From 3ecee3099eb075d2cb36892fdaa77d3fd77f18ae Mon Sep 17 00:00:00 2001 From: hoelee Date: Sun, 23 Aug 2026 04:59:43 +0800 Subject: [PATCH] feat(W1): status lifecycle (suspend/delete/resume) + shared effective-grants lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract calculateEffectiveGrants into n8n/shared/effective-grants.js (single source of truth), inlined at build time into W1/W2/W3 via gen_w*.js + inline_shared.js — removes the duplicated algorithm. - W1 now provisions with a status branch: suspended/deleted -> disable AList user + clear role scopes (soft delete) active -> create/update user+role, RECOMPUTE desired scopes (never restore stale), re-enable - Loop guard corrected: check AList user LIVE disabled state via GET /api/admin/user/get (sync_status alone breaks suspend->resume). - W2/W3 rebuilt on shared lib (identical behaviour). - Tests: test_effective_grants.js (15), test_w3_refactored.js (13), test_w1_lifecycle.js (23) — all green. - Drop superseded w2-compute.js / w3-compute.js / test_w3.js. --- docs/PROJECT_STATE.md | 69 ++-- n8n/W1-customer-provision.json | 585 +------------------------------ n8n/W2-customerproduct-sync.json | 158 +++------ n8n/W3-expiry-sync.json | 2 +- n8n/gen_w1.js | 67 ++++ n8n/gen_w2.js | 107 ++++++ n8n/gen_w3.js | 24 +- n8n/inline_shared.js | 19 + n8n/shared/effective-grants.js | 124 +++++++ n8n/test_effective_grants.js | 138 ++++++++ n8n/test_w1_lifecycle.js | 213 +++++++++++ n8n/test_w3.js | 137 -------- n8n/test_w3_refactored.js | 106 ++++++ n8n/w1-body.js | 181 ++++++++++ n8n/w2-body.js | 77 ++++ n8n/w2-compute.js | 116 ------ n8n/w3-body.js | 78 +++++ n8n/w3-compute.js | 124 ------- 18 files changed, 1216 insertions(+), 1109 deletions(-) create mode 100644 n8n/gen_w1.js create mode 100644 n8n/gen_w2.js create mode 100644 n8n/inline_shared.js create mode 100644 n8n/shared/effective-grants.js create mode 100644 n8n/test_effective_grants.js create mode 100644 n8n/test_w1_lifecycle.js delete mode 100644 n8n/test_w3.js create mode 100644 n8n/test_w3_refactored.js create mode 100644 n8n/w1-body.js create mode 100644 n8n/w2-body.js delete mode 100644 n8n/w2-compute.js create mode 100644 n8n/w3-body.js delete mode 100644 n8n/w3-compute.js diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 17b66c7..b3e720b 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,4 +1,4 @@ -# Project State (Handoff) — 2026-08-22 +# Project State (Handoff) — 2026-08-23 > 交接文档:压缩/新会话后,按此文档续做。细节见各 doc。 @@ -40,37 +40,43 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离 - 表/字段/链接列 ID 全表:`docs/DATABASE_SCHEMA.md`(已更新为 7 表) - NocoDB API token(workspace 级,来自 nc_api_tokens 表 n8n 那行):在 `docs/API_INTEGRATION.md` / SETUP.md 有记(或查 NocoDB UI) -### 2. W1 客户开通 workflow — **已实测跑通** ✅ -- 文件:`n8n/W1-customer-provision.json` -- 流程:Webhook → Extract customerId → **Load Env(step 2, 用 $env)** → Fetch 记录 → 判断 create/update → Create Role → Find Role Id → Create User(role:[roleId]) → Find User Id → Write Back -- 关键:AList user/create 传 **空 role 数组会被拒**(自动填 guest→报 "admin or guest user can not be created")。必须先 Create Role 拿自增 id,再 Create User 带 `role:[id]` -- **base_path 已硬编码 `'/'`**(不是 `/Class`,否则 JoinPath 双重前缀 → 403):commit 1ac1f17 -- **Loop guard**:新增 "Skip if already synced" IF 节点(sync_status=synced 时短路,不写回 NocoDB → 打破 webhook 循环);同时去掉 last_sync 写入(时间戳每次必变会破坏 guard) -- Load Env 已同步为 `$env` 版本(process.env 在 Code 节点不可用):commit 58c2f57 +### 2. W1 客户开通 + Status Lifecycle — **实测跑通** ✅ +- 文件:`n8n/W1-customer-provision.json`(由 `n8n/w1-body.js` + 共享函数库经 `gen_w1.js` 生成) +- 流程:Webhook → Extract customerId → Load Env → **单 Code 节点 "Provision & Apply Lifecycle"**(含 login + status 分支 + write-back) +- **Step 3 新增 status lifecycle**: + - `suspended`/`deleted` → disable AList user + 清空 role scopes(soft delete,保留 user + NocoDB 历史) + - `active` → provision(建/更新 user+role)→ **重算 desired scopes(复用共享 calculateEffectiveGrants,不恢复旧值)** → re-enable + - 其他 → no-op +- **幂等**:见 `n8n/test_w1_lifecycle.js`(23 断言全过,含 echo/suspend 复用/suspend→active resume 重算不恢复陈旧) +- 关键坑:loop guard 不能只看 `sync_status==='synced'`(suspend 也写 synced),要**查 AList user 实际 disabled 状态**(`GET /api/admin/user/get?id=`)——disabled 说明是 resume,须走完整 provision +- base_path 硬编码 `'/'`;空 role 数组先建 role 再建 user -### 3. W2 CustomerProduct 权限同步 — **已改好,需重新 import 测试** ⏳ -- 文件:`n8n/W2-customerproduct-sync.json` -- 流程:Webhook → Extract customerProductId → Load Env → **Compute Effective Grants**(登录 AList→读 customer→读全部 active CP→读 Product 的 M2M Resources→**per-Resource MAX(expires_at)** 合并→读 AList role 现状→diff→算 newScopes) → Skip if no change → AList Update Role Scopes → Write Back CP Sync Status -- **已修 bug(commit 36a6b3b)**:查询 active CP 的 where 语法从 `(customer,eq,${id})` → `(customer.Id,eq,${id})`。NocoDB link 字段用 display name 直接 eq 返回 0 条;必须用 `customer.Id` 子字段或外键列 `nc_jitu___Customers_id` -- **已修 bug**:link API 返回 `{Id,name}` 不含 `alist_path` → 新增 resById 反查(先加载全量 Resources 按 Id 索引,再按 link 结果的 Id 反查 alist_path) -- **Loop guard**:新增 "Skip if no change" IF + No-op 死端(scopes 无 diff 且已 synced 时短路);去掉 last_sync -- ⚠️ 待用户重新 import + 测试 +### 3. W2 CustomerProduct 权限同步 — **实测跑通** ✅ +- 文件:`n8n/W2-customerproduct-sync.json`(`n8n/w2-body.js` + 共享库 → `gen_w2.js`) +- 流程:Webhook → Extract customerProductId → Load Env → **Compute Effective Grants & Apply**(单 Code 节点调共享 `calculateEffectiveGrants`)→ Skip if no change → No-op +- customer link 为空时优雅跳过(`skipWriteBack:true`,不回写,补填后再触发) +- expiry 边界 `<= today`(到期当天视为过期) -### 4. W3 Expiry Sync(全量对账)— **已实现,待 import + dry-run 测试** 🆕 -- 文件:`n8n/W3-expiry-sync.json`(Schedule `*/5` → 单 Code 节点"Reconcile & Apply") -- 设计:`n8n/W3-DESIGN.md`;参考实现 `n8n/w3-compute.js`(自包含,read+write 都在 Code 内) -- **核心逻辑**:对每个有 `alist_role_id` 的 customer → desired = 有效 CP 派生路径 ∪ 有效 AccessGrants 路径(**并集,防止整写 scopes 清掉 manual 授权**)→ 过期排除 → diff → `role/update` 整体重写 → 选项 disable -- **Env 开关**:`ALIST_AUTO_DISABLE_ON_EXPIRE`(默认 false=只观察)、`W3_DRY_RUN`(true=只打印计划不写) -- **disable 护栏**:仅当 `nowEmpty && status=active && currentPaths>0`(原本有 scope 现在空)才禁用;空 role 的新客户不动(否则刚开通没买产品就被禁,W2 不会重新启用) -- **已本地验证**:`n8n/test_w3.js` mock 数据跑通 OFF/ON 两模式,断言全过(manual 授权保留、到期清除、disable 正确、空 role 客户不动) -- **loop 防护**:只写 AList user disabled + role scopes;**不**翻 NocoDB Customers.status(Customers update webhook→W1 可能 re-provision/re-enable=loop) +### 4. W3 Expiry Sync(全量对账)— **实测跑通** ✅ +- 文件:`n8n/W3-expiry-sync.json`(`n8n/w3-body.js` + 共享库 → `gen_w3.js`) +- Schedule `*/5` → 单 Code 节点 "Reconcile & Apply"(逐客户调共享 `calculateEffectiveGrants` → diff → 应用) +- 修过 Bearer 前缀 bug(AList 要裸 token)+ expiry 边界 `<= today` +- Env:`W3_DRY_RUN`、`ALIST_AUTO_DISABLE_ON_EXPIRE`(默认 off) + +### 5. 共享函数库(Step 3 核心重构)🆕 +- **`n8n/shared/effective-grants.js`** = 单一事实源:`calculateEffectiveGrants(customerId)` + `ncGet`/`alistReq`/`linkId` + - 算法:desired = 有效 CP 派生路径 **∪** 有效 AccessGrants 路径;同 path 多 product 取 **MAX(expires_at)**;permission 取 readwrite 覆盖 + - expiry 边界 `<= today`;status 过滤非 active +- **构建时内联**:`n8n/inline_shared.js` 的 `sharedSource()` 剥离 `module.exports`(`/* SHARED_EXPORT_BEGIN */` 之后),三个 `gen_w*.js` 把 body(`__SHARED_INLINE__` 占位)+ 共享源拼成 JSON +- **W1/W2/W3 零复制算法**:三处都调同一个 `calculateEffectiveGrants` +- **测试**:`test_effective_grants.js`(共享算法 15 断言)、`test_w3_refactored.js`(W3 重构回归 13 断言)、`test_w1_lifecycle.js`(W1 lifecycle 幂等 23 断言)——全过 ## 🔴 下一步(按序) -1. **用户测试 W2**(import 覆盖 → 用例 2、3 → AList 前端实测客户登录看目录)→ 报错贴 execution 错误 -1.5. **W3 测试**:import `W3-expiry-sync.json` → 设 `W3_DRY_RUN=true` 跑一次看计划 → 造过期 CP 观察 scope 移除 → 开关 auto-disable 做 M3(详见 W3-DESIGN 里程碑) -2. 验证通过后:**expiry scheduler**(每 5 分钟对账:全量 diff NocoDB↔AList,到期的移除 scope)—— 规格 §26/§37 -3. 其余 workflow(W3-W6):Product/Resource 变更 → 重算受影响 customer;手动 sync;日志;通知 -4. 本地 git 留档 → 验证稳定后推 Gitea(git.hoelee.com) +1. **重新 import 三个 workflow**(W1/W2/W3 都改了,共享库重构 + W1 status lifecycle)到 n8n,覆盖旧版本 +2. **实测 W1 status lifecycle**:NocoDB 改 customer.status=suspended → 看 AList user disabled + scopes 清空;改回 active → 看 re-enable + scopes 重算(不是恢复旧值);deleted → soft delete 不删 user +3. **(Step 4) W4 Full Reconciliation**:NocoDB desired vs AList actual 全量 drift 修复(W3 已具备基础,W4 补 reconciliation 日志 + 每日全量) +4. (Step 5 之后) 暂不做 hard delete(W6) +5. 本地 git 留档 + 验证稳定后推 Gitea(git.hoelee.com;凭据已脱敏 + 历史已重写,tag v0.3-core-sync-working 已推) ## 🐛 已沉淀的坑(勿再踩) - **AList**: @@ -112,7 +118,12 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离 ``` alist-serve-resource/ ├── docker-compose.yml / .env.example / .gitignore -├── n8n/ W1-customer-provision.json · W2-customerproduct-sync.json · SETUP.md · W2-DESIGN.md · w2-compute.js +├── n8n/ +│ W1/W2/W3-*.json (generated) · w1/w2/w3-body.js (runtime logic) +│ shared/effective-grants.js (single source of truth) +│ gen_w1/w2/w3.js + inline_shared.js (build-time inlining) +│ test_effective_grants.js · test_w1_lifecycle.js · test_w3_refactored.js +│ SETUP.md · W2-DESIGN.md · W3-DESIGN.md ├── scripts/ create_nocodb_schema.py · healthcheck.sh └── docs/ API_INTEGRATION.md · DATABASE_SCHEMA.md · INSTALL.md · CONFIGURATION.md · SECURITY.md · BACKUP_RESTORE.md · UPGRADE.md · TROUBLESHOOTING.md · PROJECT_STATE.md ``` diff --git a/n8n/W1-customer-provision.json b/n8n/W1-customer-provision.json index 1708471..61b2769 100644 --- a/n8n/W1-customer-provision.json +++ b/n8n/W1-customer-provision.json @@ -1,5 +1,5 @@ { - "name": "W1 - Customer Provision (AList User + Role)", + "name": "W1 - Customer Provision & Status Lifecycle", "nodes": [ { "parameters": { @@ -33,7 +33,7 @@ }, { "parameters": { - "jsCode": "// Step 2: read ALL env via $env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerId.\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n T_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',\n} };\n" + "jsCode": "// pass-through env + webhook fields (custom env vars readable ONLY via $env here)\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n" }, "id": "load-env", "name": "Load Env", @@ -46,414 +46,16 @@ }, { "parameters": { - "method": "GET", - "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records/{{ $json.customerId }}", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "xc-token", - "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" - } - ] - }, - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } + "jsCode": "// ============================================================================\n// W1 runtime body — Customer provision + STATUS LIFECYCLE (Step 3).\n// Rebuilt by `node n8n/gen_w1.js` (single source of truth = shared/effective-grants.js).\n//\n// Single self-contained Code node (plus webhook + id extract). Replaces the\n// old multi-node chain with the SAME provision behaviour PLUS status lifecycle.\n//\n// status suspended/deleted -> disable AList user + clear role scopes (soft\n// delete: user + NocoDB history preserved).\n// status active -> provision (create/update user+role) then\n// RECOMPUTE desired scopes via shared\n// calculateEffectiveGrants and write them, then\n// re-enable the user.\n// other status -> no AList mutation (skip).\n//\n// Write-back: if the customer is missing alist_user_id / alist_role_id we\n// PATCH them back to NocoDB (with sync_status 'synced'). The echo webhook from\n// that PATCH re-enters W1 but the loop guard below (skipWriteBack when already\n// populated + status unchanged) makes it a no-op.\n//\n// Idempotency: final state is converged per status (see above). Duplicate or\n// echo webhooks re-apply the same end state with zero net change.\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\nconst ctx = { $env, helpers: this.helpers };\n\n// 1. fresh customer record by id (from webhook extract)\nconst customerId = $json.customerId;\nconst cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\nconst status = cust.status || 'active';\nconst username = cust.username;\nif (!username) throw new Error('Customer missing username');\nconst alistUserId = cust.alist_user_id ?? null;\nconst alistRoleId = cust.alist_role_id ?? null;\n\n// loop guard: if this webhook is our own write-back echo AND the customer is\n// already fully provisioned with synced status AND status is unchanged, skip.\n// (Detected below via alreadyProvisioned + sync_status === 'synced'.)\nconst alreadyProvisioned = !!(alistUserId && alistRoleId);\n\n// 2. admin login\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// 3. loop guard: echo of our own write-back (active + already realized) -> skip.\n// IMPORTANT: sync_status==='synced' alone is NOT enough to skip, because a\n// suspended->active resume ALSO carries sync_status==='synced' (the suspend\n// path writes it) while the AList user is still disabled. We therefore check\n// the ACTUAL AList user disabled state: only skip when the user is provably\n// enabled already. If disabled -> fall through to active provision (re-enable\n// + recompute), which is exactly what resume needs.\nif (alreadyProvisioned && status === 'active' && cust.sync_status === 'synced' && alistUserId) {\n let currentlyEnabled = true;\n try {\n const uInfo = await alistReq(ctx, 'GET', `/api/admin/user/get?id=${alistUserId}`, null, token);\n if (uInfo?.data && typeof uInfo.data.disabled === 'boolean') {\n currentlyEnabled = !uInfo.data.disabled;\n }\n } catch (e) { /* user/get may 404 for some AList versions; assume enabled */ }\n if (currentlyEnabled) {\n return { json: { customerId, username, lifecycle: 'skip_echo', actions: [] } };\n }\n}\n\n// 4. STATUS BRANCH ---------------------------------------------------------\n\nconst writeBack = async (fields) => {\n // PATCH id fields back to NocoDB. Guard: only when they differ.\n const body = Object.assign({ Id: customerId }, fields);\n await ctx.helpers.httpRequest({\n method: 'PATCH',\n url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`,\n headers: { 'xc-token': $env.NOCODB_TOKEN },\n body: [body],\n json: true,\n });\n};\n\nif (status === 'suspended' || status === 'deleted') {\n const actions = [];\n if (alistUserId) {\n await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token);\n actions.push('disable_user');\n }\n if (alistRoleId) {\n const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${alistRoleId}`, null, token);\n await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: alistRoleId,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: [],\n }, token);\n actions.push('clear_scopes');\n }\n // mark synced so we don't re-fire on our own write-back (but we don't write\n // much here — only sync_status to stop the echo loop).\n await writeBack({ sync_status: 'synced', last_sync_error: null });\n return { json: { customerId, username, status, lifecycle: 'suspend_or_delete', actions } };\n}\n\nif (status !== 'active') {\n // pending / expired / disabled / unknown -> no mutation, but mark synced to\n // stop echo loop only if we are the ones writing. We do NOT write here to\n // avoid interfering while the customer is being drafted.\n return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } };\n}\n\n// status === 'active' -------------------------------------------------------\n\nconst actions = [];\nlet finalUserId = alistUserId;\nlet finalRoleId = alistRoleId;\n\n// 4a. ensure role exists\nif (!finalRoleId) {\n await alistReq(ctx, 'POST', '/api/admin/role/create', {\n name: 'customer-' + username,\n description: 'Auto role for ' + username,\n default: false,\n permission_scopes: [],\n }, token);\n const roles = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=100', null, token);\n const r = (roles?.data?.content || []).find(x => x.name === 'customer-' + username);\n if (!r) throw new Error('Created role not found: customer-' + username);\n finalRoleId = r.id;\n actions.push('create_role');\n}\n\n// 4b. ensure user exists / update; always re-enable on active\nif (!finalUserId) {\n await alistReq(ctx, 'POST', '/api/admin/user/create', {\n username,\n password: cust.password,\n base_path: '/',\n role: finalRoleId ? [finalRoleId] : [],\n permission: 16384,\n }, token);\n const users = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=100', null, token);\n const u = (users?.data?.content || []).find(x => x.username === username);\n if (!u) throw new Error('Created user not found: ' + username);\n finalUserId = u.id;\n actions.push('create_user');\n} else {\n await alistReq(ctx, 'POST', '/api/admin/user/update', {\n id: finalUserId,\n username,\n password: cust.password,\n base_path: '/',\n role: finalRoleId ? [finalRoleId] : [],\n permission: 16384,\n disabled: false,\n }, token);\n actions.push('update_user');\n}\n\n// 4c. RECOMPUTE desired scopes (never restore stale) and write them\nconst g = await calculateEffectiveGrants(ctx, customerId);\nawait alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: finalRoleId,\n name: 'customer-' + username,\n description: 'Auto role for ' + username,\n permission_scopes: g.newScopes,\n}, token);\nactions.push('apply_grants');\n\n// 5. write-back ids if newly discovered (creates the echo; landed in loop guard)\nif (!alistUserId || !alistRoleId) {\n await writeBack({ alist_user_id: finalUserId, alist_role_id: finalRoleId, sync_status: 'synced', last_sync_error: null });\n}\n\nreturn { json: {\n customerId, username, status, lifecycle: 'active_provision',\n alistUserId: finalUserId, alistRoleId: finalRoleId,\n desiredPaths: g.desiredPaths, actions,\n} };\n" }, - "id": "fetch-customer", - "name": "Fetch Latest Customer Record", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "id": "lifecycle", + "name": "Provision & Apply Lifecycle", + "type": "n8n-nodes-base.code", + "typeVersion": 2, "position": [ 440, 0 ] - }, - { - "parameters": { - "jsCode": "const cust = $input.item.json;\nconst username = cust.username; const password = cust.password;\nif (!username) throw new Error('Customer missing username');\nconst newUserId = cust.alist_user_id ?? null;\nconst newRoleId = cust.alist_role_id ?? null;\nconst isCreate = !newUserId;\n// Break loop: only write back if semantic fields would actually change.\n// If the record already has alist_user_id + role_id + sync_status=synced,\n// this webhook was fired by our own write-back (echo) -> skip.\nconst skipWriteBack = (!isCreate) && (cust.sync_status === 'synced');\nreturn { json: {\n customerId: cust.Id ?? cust.id,\n username, password,\n displayName: cust.display_name ?? username,\n basePath: '/',\n alistUserId: newUserId,\n alistRoleId: newRoleId,\n isCreate,\n skipWriteBack,\n} };\n" - }, - "id": "prepare", - "name": "Prepare Provision Payload", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 660, - 0 - ] - }, - { - "parameters": { - "method": "POST", - "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/auth/login", - "authentication": "none", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ username: $('Load Env').item.json.ALIST_ADMIN_USERNAME, password: $('Load Env').item.json.ALIST_ADMIN_PASSWORD }) }}", - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "id": "alist-login", - "name": "AList Admin Login", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 880, - 0 - ] - }, - { - "parameters": { - "jsCode": "const resp = $input.item.json;\nconst token = resp?.data?.token;\nif (!token) throw new Error('AList admin login failed: ' + JSON.stringify(resp));\nreturn { json: { token } };\n" - }, - "id": "extract-token", - "name": "Extract Admin Token", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1100, - 0 - ] - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeName": "boolean", - "ignoreCase": true - }, - "conditions": [ - { - "id": "cond-create", - "leftValue": "={{ $('Prepare Provision Payload').item.json.isCreate }}", - "rightValue": true, - "operator": { - "type": "boolean", - "operation": "equals", - "singleValue": true, - "rightType": "boolean" - } - } - ], - "combinator": "or" - }, - "options": {} - }, - "id": "branch-create-update", - "name": "Create vs Update", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 1320, - 0 - ] - }, - { - "parameters": { - "method": "POST", - "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/create", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "={{ $('Extract Admin Token').item.json.token }}" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ name: 'customer-' + $('Prepare Provision Payload').item.json.username, description: 'Auto role for ' + $('Prepare Provision Payload').item.json.username, default: false, permission_scopes: [] }) }}", - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "id": "alist-create-role", - "name": "AList Create Role", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1540, - -240 - ] - }, - { - "parameters": { - "method": "GET", - "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/list?page=1&per_page=100", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "={{ $('Extract Admin Token').item.json.token }}" - } - ] - }, - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "id": "alist-list-roles", - "name": "AList List Roles (find id)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1760, - -240 - ] - }, - { - "parameters": { - "jsCode": "const roles = $input.item.json?.data?.content ?? [];\nconst target = 'customer-' + $('Prepare Provision Payload').item.json.username;\nconst r = roles.find(x => x.name === target);\nif (!r) throw new Error('Role not found: ' + target);\nreturn { json: { alistRoleId: r.id, roleName: target } };\n" - }, - "id": "find-role-id", - "name": "Find New Role Id", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1980, - -240 - ] - }, - { - "parameters": { - "method": "POST", - "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/create", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "={{ $('Extract Admin Token').item.json.token }}" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: [$('Find New Role Id').item.json.alistRoleId], permission: 16384 }) }}", - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "id": "alist-create-user", - "name": "AList Create User", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 2200, - -240 - ] - }, - { - "parameters": { - "method": "GET", - "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/list?page=1&per_page=100", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "={{ $('Extract Admin Token').item.json.token }}" - } - ] - }, - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "id": "alist-list-users", - "name": "AList List Users (find id)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 2420, - -240 - ] - }, - { - "parameters": { - "jsCode": "const users = $input.item.json?.data?.content ?? [];\nconst target = $('Prepare Provision Payload').item.json.username;\nconst u = users.find(x => x.username === target);\nif (!u) throw new Error('Created user not found: ' + target);\nreturn { json: { alistUserId: u.id, username: target } };\n" - }, - "id": "find-user-id", - "name": "Find New User Id", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 2640, - -240 - ] - }, - { - "parameters": { - "method": "PATCH", - "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "xc-token", - "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, alist_user_id: $('Find New User Id').item.json.alistUserId, alist_role_id: $('Find New Role Id').item.json.alistRoleId, sync_status: 'synced', last_sync_error: null }]) }}", - "options": {} - }, - "id": "nocodb-writeback-create", - "name": "Write Back (Created)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 2860, - -240 - ] - }, - { - "parameters": { - "method": "POST", - "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/update", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "={{ $('Extract Admin Token').item.json.token }}" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ id: $('Prepare Provision Payload').item.json.alistUserId, username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: $('Prepare Provision Payload').item.json.alistRoleId ? [$('Prepare Provision Payload').item.json.alistRoleId] : [], permission: 16384 }) }}", - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "id": "alist-update-user", - "name": "AList Update User", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1540, - 0 - ] - }, - { - "parameters": { - "method": "PATCH", - "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "xc-token", - "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, sync_status: 'synced', last_sync_error: null }]) }}", - "options": {} - }, - "id": "nocodb-writeback-update", - "name": "Write Back (Updated)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1760, - 0 - ] - }, - { - "parameters": { - "jsCode": "// Loop guard: echo webhook already synced -> stop here, no login, no write-back.\nreturn { json: { skipped: true, customerId: $('Prepare Provision Payload').item.json.customerId } };" - }, - "id": "skip-echo-end", - "name": "Skip Echo (No-op)", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1540, - -520 - ] - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeName": "boolean", - "ignoreCase": true - }, - "conditions": [ - { - "id": "cond-skip", - "leftValue": "={{ $('Prepare Provision Payload').item.json.skipWriteBack }}", - "rightValue": true, - "operator": { - "type": "boolean", - "operation": "equals", - "singleValue": true, - "rightType": "boolean" - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "id": "if-skip", - "name": "Skip if already synced", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 1320, - 0 - ] } ], "connections": { @@ -479,179 +81,20 @@ ] ] }, - "Fetch Latest Customer Record": { - "main": [ - [ - { - "node": "Prepare Provision Payload", - "type": "main", - "index": 0 - } - ] - ] - }, - "Prepare Provision Payload": { - "main": [ - [ - { - "node": "Skip if already synced", - "type": "main", - "index": 0 - } - ] - ] - }, - "AList Admin Login": { - "main": [ - [ - { - "node": "Extract Admin Token", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract Admin Token": { - "main": [ - [ - { - "node": "Create vs Update", - "type": "main", - "index": 0 - } - ] - ] - }, - "Create vs Update": { - "main": [ - [ - { - "node": "AList Create Role", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "AList Update User", - "type": "main", - "index": 0 - } - ] - ] - }, - "AList Create Role": { - "main": [ - [ - { - "node": "AList List Roles (find id)", - "type": "main", - "index": 0 - } - ] - ] - }, - "AList List Roles (find id)": { - "main": [ - [ - { - "node": "Find New Role Id", - "type": "main", - "index": 0 - } - ] - ] - }, - "Find New Role Id": { - "main": [ - [ - { - "node": "AList Create User", - "type": "main", - "index": 0 - } - ] - ] - }, - "AList Create User": { - "main": [ - [ - { - "node": "AList List Users (find id)", - "type": "main", - "index": 0 - } - ] - ] - }, - "AList List Users (find id)": { - "main": [ - [ - { - "node": "Find New User Id", - "type": "main", - "index": 0 - } - ] - ] - }, - "Find New User Id": { - "main": [ - [ - { - "node": "Write Back (Created)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Write Back (Created)": { - "main": [] - }, - "AList Update User": { - "main": [ - [ - { - "node": "Write Back (Updated)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Write Back (Updated)": { - "main": [] - }, "Load Env": { "main": [ [ { - "node": "Fetch Latest Customer Record", - "type": "main", - "index": 0 - } - ] - ] - }, - "Skip if already synced": { - "main": [ - [ - { - "node": "Skip Echo (No-op)", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "AList Admin Login", + "node": "Provision & Apply Lifecycle", "type": "main", "index": 0 } ] ] } - } -} \ No newline at end of file + }, + "settings": { + "executionOrder": "v1" + }, + "pinData": {} +} diff --git a/n8n/W2-customerproduct-sync.json b/n8n/W2-customerproduct-sync.json index 5502c93..fb89739 100644 --- a/n8n/W2-customerproduct-sync.json +++ b/n8n/W2-customerproduct-sync.json @@ -1,10 +1,10 @@ { - "name": "W2 - CustomerProduct Sync (AList Role Scopes)", + "name": "W2 - CustomerProduct Sync (Effective Grants)", "nodes": [ { "parameters": { "httpMethod": "POST", - "path": "alist-cp-sync", + "path": "alist-customerproduct-sync", "responseMode": "onReceived", "options": {} }, @@ -16,14 +16,14 @@ 0, 0 ], - "webhookId": "cp-sync" + "webhookId": "customerproduct-sync" }, { "parameters": { - "jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no CP record Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n" + "jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no CustomerProduct Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n" }, "id": "extract-id", - "name": "Extract CP Record Id", + "name": "Extract CustomerProduct Id", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ @@ -33,7 +33,7 @@ }, { "parameters": { - "jsCode": "// Step 2: read ALL env via $env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerProductId.\nconst prev = $input.item.json;\nreturn { json: {\n customerProductId: prev.customerProductId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n T_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',\n} };\n" + "jsCode": "// pass-through env + webhook fields (Code node reads $env reliably only here)\nconst prev = $input.item.json;\nreturn { json: {\n customerProductId: prev.customerProductId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n" }, "id": "load-env", "name": "Load Env", @@ -46,10 +46,10 @@ }, { "parameters": { - "jsCode": "// W2 core computation: effective desired grants + diff vs AList.\n// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.\n// Config read via $env.\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUSTPROD = $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst T_RES = $env.NOCODB_TABLE_RESOURCES;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET',\n url: NC + path,\n headers: { 'xc-token': NC_TOKEN },\n json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method,\n url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch the CP record\nconst cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);\nconst customerId = linkId(cp.customer);\nif (!customerId) {\n return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } };\n}\n\n// 2. load all active CPs for this customer\nconst cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`);\nconst today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\nconst active = (cps.list || []).filter(r => {\n if (r.status && r.status !== 'active') return false;\n if (r.expires_at && String(r.expires_at) <= today) return false; // expired (incl. today)\n return true;\n});\n\n// 3. build resource path -> max expires_at\n// NocoDB link API returns only {Id, name} for linked rows (no alist_path),\n// so fetch all Resources once and index by Id to resolve alist_path.\nconst allRes = await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`);\nconst resById = {};\nfor (const r of (allRes.list || [])) resById[r.Id] = r;\n\nconst effective = {};\nfor (const c of active) {\n const pid = linkId(c.product);\n if (!pid) continue;\n const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);\n const exp = c.expires_at; // 'YYYY-MM-DD'\n for (const res of (resList.list || [])) {\n const rid = res.Id;\n const full = resById[rid];\n const path = full ? full.alist_path : null;\n if (!path) continue;\n const cur = effective[path];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n effective[path] = { expires: exp || null, permission: 0 };\n }\n }\n}\n\n// 4. admin login\nconst login = await alistReq('POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// customer's role id\nconst cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\n\nlet currentScopes = [];\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n currentScopes = role?.data?.permission_scopes ?? [];\n}\n\nconst desiredPaths = Object.keys(effective);\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n\nlet roleName = null, roleDesc = null;\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n roleName = role?.data?.name;\n roleDesc = role?.data?.description;\n}\n\n// --- Loop guard ---\n// Re-fetch CP sync_status to detect echo (our own write-back firing this webhook again).\nconst cpSyncStatus = cp.sync_status;\nconst noChange = (toAdd.length === 0 && toRemove.length === 0);\nconst alreadySynced = (cpSyncStatus === 'synced');\n// skip if nothing changed AND already synced (echo) — avoids rewrite loop\nconst skipWriteBack = (noChange && alreadySynced);\n\nreturn {\n json: {\n customerId,\n roleId,\n roleName,\n roleDesc,\n token,\n effective,\n desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),\n skipWriteBack,\n },\n};\n" + "jsCode": "// ============================================================================\n// W2 runtime body — enforced CustomerProduct -> AList role scopes sync.\n// Rebuilt by `node n8n/gen_w2.js` (single source of truth = shared/effective-grants.js).\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\n// n8n Code node context: ctx = { $env, helpers } — but helpers is `this.helpers`\n// inside the node. Build ctx to hand to the shared library.\nconst ctx = {\n $env,\n helpers: this.helpers,\n};\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch this CP record to find its customer link (may be null mid-draft)\nconst CP = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERPRODUCTS}/records/${custProdId}`);\nconst customerId = linkId(CP.customer);\nif (!customerId) {\n // Customer link not yet filled (record being drafted). Skip gracefully; the\n // webhook re-fires when customer is set later.\n return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } };\n}\n\n// 2. compute what the customer SHOULD have right now (shared algorithm)\nconst grants = await calculateEffectiveGrants(ctx, customerId);\n\n// 3. fetch customer admin facts (role id, alist user id)\nconst cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\nconst userId = cust.alist_user_id;\n\nif (!roleId) {\n return { json: { skipWriteBack: true, customerId, reason: 'customer not provisioned (no role id)' } };\n}\n\n// 4. admin login\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// 5. current scopes vs desired scopes -> diff\nconst role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${roleId}`, null, token);\nconst currentScopes = role?.data?.permission_scopes ?? [];\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = grants.desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !grants.desiredPaths.includes(p));\nconst changed = toAdd.length > 0 || toRemove.length > 0;\n\n// 6. apply if changed\nif (changed) {\n await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: roleId,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: grants.newScopes,\n }, token);\n}\n\nreturn {\n json: {\n customerId,\n customerProductId: custProdId,\n roleId,\n roleName: role?.data?.name,\n effective: grants.desired,\n desiredPaths: grants.desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: grants.newScopes,\n skipWriteBack: changed ? false : true,\n },\n};\n" }, - "id": "compute", - "name": "Compute Effective Grants", + "id": "compute-apply", + "name": "Compute Effective Grants & Apply", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ @@ -57,81 +57,6 @@ 0 ] }, - { - "parameters": { - "method": "POST", - "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/update", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "={{ $('Compute Effective Grants').item.json.token }}" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ id: $('Compute Effective Grants').item.json.roleId, name: $('Compute Effective Grants').item.json.roleName, description: $('Compute Effective Grants').item.json.roleDesc, permission_scopes: $('Compute Effective Grants').item.json.newScopes }) }}", - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "id": "alist-apply-role", - "name": "AList Update Role Scopes", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 660, - 0 - ] - }, - { - "parameters": { - "method": "PATCH", - "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERPRODUCTS }}/records", - "authentication": "none", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "xc-token", - "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify([{ Id: $('Extract CP Record Id').item.json.customerProductId, sync_status: 'synced', last_sync_error: null }]) }}", - "options": {} - }, - "id": "nocodb-writeback", - "name": "Write Back CP Sync Status", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 880, - 0 - ] - }, - { - "parameters": { - "jsCode": "// Loop guard: no permission change + already synced -> stop, no AList write, no write-back.\nreturn { json: { skipped: true, customerProductId: $('Compute Effective Grants').item.json.customerId } };" - }, - "id": "skip-echo-end", - "name": "Skip Echo (No-op)", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 880, - -300 - ] - }, { "parameters": { "conditions": { @@ -144,7 +69,7 @@ "conditions": [ { "id": "cond-skip", - "leftValue": "={{ $('Compute Effective Grants').item.json.skipWriteBack }}", + "leftValue": "={{ $('Compute Effective Grants & Apply').item.json.skipWriteBack }}", "rightValue": true, "operator": { "type": "boolean", @@ -166,6 +91,19 @@ 660, 0 ] + }, + { + "parameters": { + "jsCode": "return { json: { skipped: true, customerId: $('Compute Effective Grants & Apply').item.json.customerId } };" + }, + "id": "skip-echo", + "name": "Skip Echo (No-op)", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + -200 + ] } ], "connections": { @@ -173,14 +111,14 @@ "main": [ [ { - "node": "Extract CP Record Id", + "node": "Extract CustomerProduct Id", "type": "main", "index": 0 } ] ] }, - "Extract CP Record Id": { + "Extract CustomerProduct Id": { "main": [ [ { @@ -191,33 +129,22 @@ ] ] }, - "Compute Effective Grants": { - "main": [ - [ - { - "node": "Skip if no change", - "type": "main", - "index": 0 - } - ] - ] - }, - "AList Update Role Scopes": { - "main": [ - [ - { - "node": "Write Back CP Sync Status", - "type": "main", - "index": 0 - } - ] - ] - }, "Load Env": { "main": [ [ { - "node": "Compute Effective Grants", + "node": "Compute Effective Grants & Apply", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Effective Grants & Apply": { + "main": [ + [ + { + "node": "Skip if no change", "type": "main", "index": 0 } @@ -233,14 +160,11 @@ "index": 0 } ], - [ - { - "node": "AList Update Role Scopes", - "type": "main", - "index": 0 - } - ] + [] ] + }, + "Skip Echo (No-op)": { + "main": [] } }, "settings": { diff --git a/n8n/W3-expiry-sync.json b/n8n/W3-expiry-sync.json index dd0e6a2..f8bc630 100644 --- a/n8n/W3-expiry-sync.json +++ b/n8n/W3-expiry-sync.json @@ -23,7 +23,7 @@ }, { "parameters": { - "jsCode": "// W3 - Expiry Sync / full reconciliation sweep (self-contained read+write).\n// Runs every 5 min. idempotent. get current -> compare -> apply diff.\n// Reads config via $env (works in n8n Code node).\n// dryRun: set W3_DRY_RUN=true env to only PRINT the plan (no AList/NocoDB writes).\n// AUTO_DISABLE: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when desired empty.\n// Default off = first rollout observes only. When on, ONLY AList user disabled (NocoDB Customers.status\n// intentionally NOT flipped: Customers update webhook -> W1 could re-provision / re-enable = loop).\n\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst T_CUSTPROD= $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_RES = $env.NOCODB_TABLE_RESOURCES;\nconst T_GRANT = $env.NOCODB_TABLE_ACCESSGRANTS;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nconst DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';\nconst AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';\nconst today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET', url: NC + path,\n headers: { 'xc-token': NC_TOKEN }, json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method, url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body, json: true,\n });\n}\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// login once\nconst login = await alistReq('POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// index all Resources by Id -> alist_path\nconst allRes = (await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\nconst resPath = {}; allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n// all access grants (materialized + manual), grouped by customer\nconst grants = (await ncGet(`/api/v2/tables/${T_GRANT}/records?limit=10000`)).list || [];\nconst gByCust = {};\nfor (const g of grants) {\n const cid = linkId(g.customer); if (!cid) continue;\n (gByCust[cid] = gByCust[cid] || []).push(g);\n}\n\nconst customers = (await ncGet(`/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];\nconst changed = [], errors = [];\n\nfor (const cust of customers) {\n try {\n if (!cust.alist_role_id) continue; // not provisioned\n const cid = cust.Id;\n // desired = CP-derived paths (active, not expired) union effective grants\n const desired = {}; // path -> true\n const cps = (await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${cid})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && c.expires_at <= today) continue; // expired (incl. today)\n const pid = linkId(c.product); if (!pid) continue;\n const res = (await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n for (const r of res) { const p = resPath[r.Id]; if (p) desired[p] = true; }\n }\n for (const g of (gByCust[cid] || [])) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && g.expires_at <= today) continue;\n const p = resPath[linkId(g.resource)]; if (p) desired[p] = true;\n }\n const desiredPaths = Object.keys(desired).sort();\n\n const role = await alistReq('GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);\n const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);\n const toRemove = currentPaths.filter(p => !desired[p]);\n const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\n const scopesChanged = toAdd.length > 0 || toRemove.length > 0;\n const nowEmpty = desiredPaths.length === 0;\n // WF3: no remaining active grant + policy on + customer active + HAD scopes before -> disable.\n // Guard currentPaths>0: never disable a fresh customer with an empty role (avoids\n // killing a just-provisioned user before they buy anything; W2 won't re-enable them).\n const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;\n\n let applied = [];\n if (scopesChanged) {\n const newScopes = desiredPaths.map(p => ({ path: p, permission: 0 }));\n if (!DRY_RUN) await alistReq('POST', '/api/admin/role/update',\n { id: cust.alist_role_id, name: role?.data?.name, description: role?.data?.description,\n permission_scopes: newScopes }, token);\n applied.push('role_scopes');\n }\n if (shouldDisable) {\n if (!DRY_RUN && cust.alist_user_id)\n await alistReq('POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);\n applied.push('disable');\n }\n if (applied.length) {\n changed.push({ customerId: cid, username: cust.username, roleId: cust.alist_role_id,\n toAdd, toRemove, desiredPaths, applied });\n }\n } catch (e) {\n errors.push({ customerId: cust.Id, username: cust.username, error: e.message });\n }\n}\n\nreturn { json: {\n dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),\n customersScanned: customers.length, customersChanged: changed.length,\n changed, errors,\n} };\n" + "jsCode": "// ============================================================================\n// W3 runtime body — 5-min expiry sweep / full reconciliation.\n// Rebuilt by `node n8n/gen_w3.js` (single source of truth = shared/effective-grants.js).\n// dryRun: W3_DRY_RUN=true -> only print the plan (no writes).\n// autoDisable: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when a\n// previously-scoped customer's desired set becomes empty. Default off.\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\nconst ctx = { $env, helpers: this.helpers };\nconst DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';\nconst AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\n\n// admin login once for the whole sweep\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\nconst customers = (await ncGet(ctx, `/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];\nconst changed = [], errors = [];\n\nfor (const cust of customers) {\n try {\n if (!cust.alist_role_id) continue; // not provisioned yet\n const cid = cust.Id;\n\n // desired scopes via shared algorithm\n const g = await calculateEffectiveGrants(ctx, cid);\n const desiredPaths = g.desiredPaths;\n\n // current scopes from AList\n const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);\n const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);\n const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\n const toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n const scopesChanged = toAdd.length > 0 || toRemove.length > 0;\n const nowEmpty = desiredPaths.length === 0;\n\n // only disable when a customer that HAD scopes now has none (guard\n // currentPaths>0 avoids killing a fresh customer with an empty role).\n const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;\n\n const applied = [];\n if (scopesChanged) {\n if (!DRY_RUN) await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: cust.alist_role_id,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: g.newScopes,\n }, token);\n applied.push('role_scopes');\n }\n if (shouldDisable) {\n if (!DRY_RUN && cust.alist_user_id)\n await alistReq(ctx, 'POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);\n applied.push('disable');\n }\n if (applied.length) {\n changed.push({\n customerId: cid, username: cust.username, roleId: cust.alist_role_id,\n toAdd, toRemove, desiredPaths, applied,\n });\n }\n } catch (e) {\n errors.push({ customerId: cust.Id, username: cust.username, error: e.message });\n }\n}\n\nreturn { json: {\n dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),\n customersScanned: customers.length, customersChanged: changed.length,\n changed, errors,\n} };\n" }, "id": "reconcile", "name": "Reconcile & Apply (Expiry Sweep)", diff --git a/n8n/gen_w1.js b/n8n/gen_w1.js new file mode 100644 index 0000000..61706c8 --- /dev/null +++ b/n8n/gen_w1.js @@ -0,0 +1,67 @@ +// Builds n8n/W1-customer-provision.json — Customer webhook with status lifecycle. +// Single source of truth: shared/effective-grants.js (inlined) + w1-body.js. +// Run: node n8n/gen_w1.js +const fs = require('fs'); +const path = require('path'); +const { sharedSource } = require('./inline_shared'); + +let body = fs.readFileSync(path.join(__dirname, 'w1-body.js'), 'utf8'); +body = body.replace('// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)', sharedSource()); + +const workflow = { + name: 'W1 - Customer Provision & Status Lifecycle', + nodes: [ + { + parameters: { + httpMethod: 'POST', + path: 'alist-customer-provision', + responseMode: 'onReceived', + options: {} + }, + id: 'trigger-webhook', + name: 'NocoDB: Customer Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 2, + position: [0, 0], + webhookId: 'customer-provision' + }, + { + parameters: { + jsCode: "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no customer Id in webhook');\nreturn { json: { customerId: Number(id), eventType: body.type ?? '' } };\n" + }, + id: 'extract-id', + name: 'Extract Customer Record Id', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [220, 0] + }, + { + parameters: { + jsCode: "// pass-through env + webhook fields (custom env vars readable ONLY via $env here)\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n" + }, + id: 'load-env', + name: 'Load Env', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [220, 0] + }, + { + parameters: { jsCode: body }, + id: 'lifecycle', + name: 'Provision & Apply Lifecycle', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [440, 0] + } + ], + connections: { + 'NocoDB: Customer Webhook': { main: [[{ node: 'Extract Customer Record Id', type: 'main', index: 0 }]] }, + 'Extract Customer Record Id': { main: [[{ node: 'Load Env', type: 'main', index: 0 }]] }, + 'Load Env': { main: [[{ node: 'Provision & Apply Lifecycle', type: 'main', index: 0 }]] } + }, + settings: { executionOrder: 'v1' }, + pinData: {} +}; + +fs.writeFileSync(path.join(__dirname, 'W1-customer-provision.json'), JSON.stringify(workflow, null, 2) + '\n'); +console.log('wrote W1-customer-provision.json (' + body.length + ' chars body)'); diff --git a/n8n/gen_w2.js b/n8n/gen_w2.js new file mode 100644 index 0000000..ecbaf6f --- /dev/null +++ b/n8n/gen_w2.js @@ -0,0 +1,107 @@ +// Builds n8n/W2-customerproduct-sync.json — the CustomerProduct webhook. +// Single source of truth: shared/effective-grants.js (inlined) + w2-body.js. +// Run: node n8n/gen_w2.js +const fs = require('fs'); +const path = require('path'); +const { sharedSource } = require('./inline_shared'); + +let body = fs.readFileSync(path.join(__dirname, 'w2-body.js'), 'utf8'); +body = body.replace('// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)', sharedSource()); + +const workflow = { + name: 'W2 - CustomerProduct Sync (Effective Grants)', + nodes: [ + { + parameters: { + httpMethod: 'POST', + path: 'alist-customerproduct-sync', + responseMode: 'onReceived', + options: {} + }, + id: 'trigger-webhook', + name: 'NocoDB: CustomerProduct Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 2, + position: [0, 0], + webhookId: 'customerproduct-sync' + }, + { + parameters: { + jsCode: "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no CustomerProduct Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n" + }, + id: 'extract-id', + name: 'Extract CustomerProduct Id', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [220, 0] + }, + { + parameters: { + jsCode: "// pass-through env + webhook fields (Code node reads $env reliably only here)\nconst prev = $input.item.json;\nreturn { json: {\n customerProductId: prev.customerProductId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n" + }, + id: 'load-env', + name: 'Load Env', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [220, 0] + }, + { + parameters: { jsCode: body }, + id: 'compute-apply', + name: 'Compute Effective Grants & Apply', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [440, 0] + }, + { + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeName: 'boolean', ignoreCase: true }, + conditions: [ + { + id: 'cond-skip', + leftValue: "={{ $('Compute Effective Grants & Apply').item.json.skipWriteBack }}", + rightValue: true, + operator: { type: 'boolean', operation: 'equals', singleValue: true, rightType: 'boolean' } + } + ], + combinator: 'and' + }, + options: {} + }, + id: 'if-skip', + name: 'Skip if no change', + type: 'n8n-nodes-base.if', + typeVersion: 2, + position: [660, 0] + }, + { + parameters: { + jsCode: "return { json: { skipped: true, customerId: $('Compute Effective Grants & Apply').item.json.customerId } };" + }, + id: 'skip-echo', + name: 'Skip Echo (No-op)', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [660, -200] + } + ], + connections: { + 'NocoDB: CustomerProduct Webhook': { main: [[{ node: 'Extract CustomerProduct Id', type: 'main', index: 0 }]] }, + 'Extract CustomerProduct Id': { main: [[{ node: 'Load Env', type: 'main', index: 0 }]] }, + 'Load Env': { main: [[{ node: 'Compute Effective Grants & Apply', type: 'main', index: 0 }]] }, + 'Compute Effective Grants & Apply': { main: [[{ node: 'Skip if no change', type: 'main', index: 0 }]] }, + 'Skip if no change': { + main: [ + [{ node: 'Skip Echo (No-op)', type: 'main', index: 0 }], + [] // no-change path terminates silently (nothing to do) + ] + }, + 'Skip Echo (No-op)': { main: [] } + }, + settings: { executionOrder: 'v1' }, + pinData: {} +}; + +fs.writeFileSync(path.join(__dirname, 'W2-customerproduct-sync.json'), JSON.stringify(workflow, null, 2) + '\n'); +console.log('wrote W2-customerproduct-sync.json (' + body.length + ' chars body)'); diff --git a/n8n/gen_w3.js b/n8n/gen_w3.js index 42070b6..d766595 100644 --- a/n8n/gen_w3.js +++ b/n8n/gen_w3.js @@ -1,19 +1,19 @@ -// Builds n8n/W3-expiry-sync.json from w3-compute.js (single source of truth). +// Builds n8n/W3-expiry-sync.json — the 5-min expiry sweep. +// Single source of truth: shared/effective-grants.js (inlined) + w3-body.js. // Run: node n8n/gen_w3.js const fs = require('fs'); const path = require('path'); -const jsCode = fs.readFileSync(path.join(__dirname, 'w3-compute.js'), 'utf8'); +const { sharedSource } = require('./inline_shared'); + +let body = fs.readFileSync(path.join(__dirname, 'w3-body.js'), 'utf8'); +body = body.replace('// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)', sharedSource()); const workflow = { name: 'W3 - Expiry Sync (AList Role Reconciliation)', nodes: [ { parameters: { - rule: { - interval: [ - { field: 'minutes', minutesInterval: 5 } - ] - } + rule: { interval: [{ field: 'minutes', minutesInterval: 5 }] } }, id: 'trigger-schedule', name: 'Every 5 minutes', @@ -22,7 +22,7 @@ const workflow = { position: [0, 0] }, { - parameters: { jsCode }, + parameters: { jsCode: body }, id: 'reconcile', name: 'Reconcile & Apply (Expiry Sweep)', type: 'n8n-nodes-base.code', @@ -32,11 +32,7 @@ const workflow = { ], connections: { 'Every 5 minutes': { - main: [ - [ - { node: 'Reconcile & Apply (Expiry Sweep)', type: 'main', index: 0 } - ] - ] + main: [[{ node: 'Reconcile & Apply (Expiry Sweep)', type: 'main', index: 0 }]] } }, settings: { executionOrder: 'v1' }, @@ -44,4 +40,4 @@ const workflow = { }; fs.writeFileSync(path.join(__dirname, 'W3-expiry-sync.json'), JSON.stringify(workflow, null, 2) + '\n'); -console.log('wrote W3-expiry-sync.json (' + jsCode.length + ' chars jsCode)'); +console.log('wrote W3-expiry-sync.json (' + body.length + ' chars body)'); diff --git a/n8n/inline_shared.js b/n8n/inline_shared.js new file mode 100644 index 0000000..63edef0 --- /dev/null +++ b/n8n/inline_shared.js @@ -0,0 +1,19 @@ +// Shared build helper: inline shared/effective-grants.js into a workflow's +// Code node body, stripping the module.exports shim. All gen_*.js scripts use +// this so the "single source of truth" file is what actually ships. +const fs = require('fs'); +const path = require('path'); + +const SHARED_PATH = path.join(__dirname, 'shared', 'effective-grants.js'); +const EXPORT_MARKER = '/* SHARED_EXPORT_BEGIN */'; + +// Returns the shared library source WITHOUT the module.exports block — a +// plain script ready to prepend to (or include in) a Code node's jsCode. +function sharedSource() { + const src = fs.readFileSync(SHARED_PATH, 'utf8'); + const idx = src.indexOf(EXPORT_MARKER); + if (idx === -1) throw new Error('SHARED_EXPORT_BEGIN marker not found in shared source'); + return src.slice(0, idx).replace(/\s+$/, ''); +} + +module.exports = { sharedSource, SHARED_PATH }; diff --git a/n8n/shared/effective-grants.js b/n8n/shared/effective-grants.js new file mode 100644 index 0000000..ff908b4 --- /dev/null +++ b/n8n/shared/effective-grants.js @@ -0,0 +1,124 @@ +// ============================================================================ +// SHARED: calculateEffectiveGrants — single source of truth for "what scopes +// should customer X have right now", reused by W1 (active resume), W2 +// (CustomerProduct sync) and W3 (expiry sweep). +// +// This file is INLINED into each workflow's Code node at build time (see +// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained: +// no require/import, no process.exit, no top-level return outside functions. +// +// Config is read via $env (the ONLY reliable channel in n8n Code nodes for +// custom env vars — process.env is unreliable there). The caller passes a +// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n. +// +// ============================================================================ + +// --- helpers (depend on ctx, so they are functions not top-level consts) --- + +function ncUrl(ctx, path) { + return ctx.$env.NOCODB_URL + path; +} + +async function ncGet(ctx, path) { + return await ctx.helpers.httpRequest({ + method: 'GET', + url: ncUrl(ctx, path), + headers: { 'xc-token': ctx.$env.NOCODB_TOKEN }, + json: true, + }); +} + +async function alistReq(ctx, method, path, body, token) { + // NOTE: AList expects the RAW token in Authorization (no "Bearer " prefix). + return await ctx.helpers.httpRequest({ + method, + url: ctx.$env.ALIST_URL + path, + headers: token ? { Authorization: token } : {}, + body, + json: true, + }); +} + +// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on +// whether the API was asked to expand them. Normalise to a single id or null. +function linkId(v) { + if (v == null) return null; + if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null; + if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null; + return v; +} + +// --- core algorithm -------------------------------------------------------- +// +// desired scopes for a customer = union of: +// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct +// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant +// +// For (a) the SAME resource coming from MULTIPLE products keeps the MAX +// expires_at, exactly matching the "two products share a resource" semantics +// the user tested. Expiry boundary is `<= today` (expiring today is expired). + +async function calculateEffectiveGrants(ctx, customerId) { + const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS; + const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS; + const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS; + const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES; + const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS; + const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg + const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD' + + // index all Resources by Id -> alist_path (cheap, one query) + const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || []; + const resPath = {}; + allRes.forEach(r => { resPath[r.Id] = r.alist_path; }); + + const desired = {}; // path -> { expires, permission } + + // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on + // the same path. + const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || []; + for (const c of cps) { + if (c.status && c.status !== 'active') continue; + if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today) + const pid = linkId(c.product); + if (!pid) continue; + const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || []; + const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry) + for (const r of res) { + const p = resPath[r.Id]; + if (!p) continue; + const cur = desired[p]; + if (!cur || !cur.expires || (exp && exp > cur.expires)) { + desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 }; + } + } + } + + // (b) AccessGrants -> Resources (manual / materialised direct grants) + const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || []; + for (const g of grants) { + if (g.status && g.status !== 'active') continue; + if (g.expires_at && String(g.expires_at) <= today) continue; + const p = resPath[linkId(g.resource)]; + if (!p) continue; + const exp = g.expires_at; + const cur = desired[p]; + if (!cur || !cur.expires || (exp && exp > cur.expires)) { + desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 }; + } + } + + const desiredPaths = Object.keys(desired).sort(); + return { + customerId, + desired, // { path: {expires, permission} } + desiredPaths, // sorted array of paths + newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })), + }; +} + +// --- export shim: kept only when unit-tested, stripped when inlined ------- +/* SHARED_EXPORT_BEGIN */ +if (typeof module !== 'undefined' && module.exports) { + module.exports = { calculateEffectiveGrants, ncGet, alistReq, linkId, ncUrl }; +} diff --git a/n8n/test_effective_grants.js b/n8n/test_effective_grants.js new file mode 100644 index 0000000..56c2591 --- /dev/null +++ b/n8n/test_effective_grants.js @@ -0,0 +1,138 @@ +// Unit test for shared/effective-grants.js — validate calculateEffectiveGrants +// against mocked NocoDB/AList, exercising the tricky cases: +// * two products share a resource -> MAX expiry wins +// * expired CP (expires_at <= today) excluded +// * non-active CP excluded +// * manual AccessGrant unioned in, and MAX expiry across sources +// * permission readwrite override +// Run: node n8n/test_effective_grants.js +const path = require('path'); +const { calculateEffectiveGrants, linkId } = require(path.join(__dirname, 'shared', 'effective-grants.js')); + +const TODAY = new Date().toISOString().slice(0, 10); // real today for expiry edge + +// ---------------- mock data ---------------- +const RESOURCES = [ + { Id: 1, alist_path: '/Class/Python/01' }, + { Id: 2, alist_path: '/Class/Python/02' }, + { Id: 3, alist_path: '/Class/Health' }, + { Id: 4, alist_path: '/Template/Music' }, +]; +// product -> resources (M2M link) +const PRODUCT_RES = { + 1: [{ Id: 1 }, { Id: 2 }], // Product A: Python 01+02 + 2: [{ Id: 3 }], // Product B: Health + 3: [{ Id: 4 }], // Product C: Music + 4: [{ Id: 1 }], // Product D: shares Python 01 with A +}; +const CUSTOMERPRODUCTS = [ + { Id: 1, customer: 1, product: 1, status: 'active', expires_at: '2099-01-01' }, + { Id: 2, customer: 1, product: 2, status: 'active', expires_at: '2099-06-01' }, + { Id: 3, customer: 1, product: 4, status: 'active', expires_at: '2098-12-01' }, // shares path /Class/Python/01 + { Id: 4, customer: 1, product: 3, status: 'active', expires_at: TODAY }, // EXPIRING TODAY -> excluded + { Id: 5, customer: 1, product: 3, status: 'revoked', expires_at: '2099-01-01' }, // revoked -> excluded +]; +const ACCESSGRANTS = [ + { Id: 1, customer: 1, resource: 4, status: 'active', expires_at: '2099-03-01', permission: 'readwrite' }, + { Id: 2, customer: 1, resource: 3, status: 'active', expires_at: '2098-01-01', permission: 'read' }, + { Id: 3, customer: 1, resource: 2, status: 'expired', expires_at: '2020-01-01' }, // expired grant excluded +]; + +// mock ctx implementing ctx.$env + ctx.helpers.httpRequest +function makeCtx() { + const calls = []; + const ctx = { + $env: { + NOCODB_URL: 'https://nocodb.test', + NOCODB_TOKEN: 'test-token', + ALIST_URL: 'https://alist.test', + NOCODB_TABLE_CUSTOMERS: 'tc', + NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcpc', + NOCODB_TABLE_PRODUCTS: 'tp', + NOCODB_TABLE_RESOURCES: 'tr', + NOCODB_TABLE_ACCESSGRANTS: 'tag', + NOCODB_LINK_PROD_RESOURCES: 'lnk', + NOCODB_TABLE_PURCHASES: 'tpur', + }, + helpers: { + httpRequest: async (opts) => { + calls.push(opts.url); + const u = opts.url; + // Resources list + if (u.includes('/tc/records') && u.includes('limit=10000')) { + if (u.includes('/tr/records')) return { list: RESOURCES }; + } + if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES }; + // CustomerProducts where customer eq + if (u.includes('/tcpc/records?where=(customer,eq,')) { + return { list: CUSTOMERPRODUCTS }; + } + // AccessGrants where customer eq + if (u.includes('/tag/records?where=(customer,eq,')) { + return { list: ACCESSGRANTS }; + } + // Product M2M link + const m = u.match(/\/tp\/links\/lnk\/records\/(\d+)/); + if (m) { + return { list: PRODUCT_RES[Number(m[1])] || [] }; + } + throw new Error('unexpected mock URL: ' + u); + }, + }, + }; + return { ctx, calls }; +} + +let failures = 0; +function assert(name, cond, detail) { + if (cond) { console.log('PASS ' + name); } + else { failures++; console.log('FAIL ' + name + (detail ? ' -> ' + JSON.stringify(detail) : '')); } +} + +async function main() { + const { ctx } = makeCtx(); + const r = await calculateEffectiveGrants(ctx, 1); + + // desiredPaths expected: + // Product A (2099-01-01): /Class/Python/01, /Class/Python/02 + // Product B (2099-06-01): /Class/Health + // Product D (2098-12-01): /Class/Python/01 (max(2099-01-01, 2098-12-01) = 2099-01-01) + // Product C expiring TODAY -> excluded -> /Template/Music NOT from CP + // grant 1 (readwrite, 2099-03-01): /Template/Music + // grant 2 (read, 2098-01-01): /Class/Health (already present, max(2099-06-01) wins) + // grant 3 expired -> excluded + // => final paths: Python/01, Python/02, Health, Music (4 paths) + const paths = r.desiredPaths; + assert('4 desired paths', paths.length === 4, paths); + assert('has Python/01', paths.includes('/Class/Python/01'), paths); + assert('has Python/02', paths.includes('/Class/Python/02'), paths); + assert('has Health', paths.includes('/Class/Health'), paths); + assert('has Music (via manual grant)', paths.includes('/Template/Music'), paths); + + // expiry MAX across shared resource + assert('Python/01 expiry = 2099-01-01 (MAX of A 2099-01-01 and D 2098-12-01)', + r.desired['/Class/Python/01'].expires === '2099-01-01', r.desired['/Class/Python/01']); + // Health expiry = MAX(CP B 2099-06-01, grant 2098-01-01) = 2099-06-01 + assert('Health expiry = 2099-06-01 (MAX of product vs grant)', + r.desired['/Class/Health'].expires === '2099-06-01', r.desired['/Class/Health']); + // Music expiry = 2099-03-01 from manual grant, permission readwrite -> 1 + assert('Music permission = readwrite (1)', + r.desired['/Template/Music'].permission === 1, r.desired['/Template/Music']); + // Python/01 permission default 0 + assert('Python/01 permission = 0 (default)', + r.desired['/Class/Python/01'].permission === 0, r.desired['/Class/Python/01']); + + // newScopes shape + assert('newScopes length 4', r.newScopes.length === 4, r.newScopes); + assert('newScopes are {path,permission}', r.newScopes.every(s => typeof s.path === 'string' && typeof s.permission === 'number'), r.newScopes); + + // linkId normalisation + assert('linkId object', linkId({ Id: 7 }) === 7); + assert('linkId array', linkId([{ Id: 7 }]) === 7); + assert('linkId null', linkId(null) === null); + assert('linkId number', linkId(7) === 7); + + console.log(failures === 0 ? '\nALL TESTS PASSED' : `\n${failures} TEST(S) FAILED`); + process.exit(failures === 0 ? 0 : 1); +} +main(); diff --git a/n8n/test_w1_lifecycle.js b/n8n/test_w1_lifecycle.js new file mode 100644 index 0000000..b4b3172 --- /dev/null +++ b/n8n/test_w1_lifecycle.js @@ -0,0 +1,213 @@ +// Idempotency + lifecycle tests for the W1 body (status lifecycle). +// Loads the ACTUAL inlined code from W1-customer-provision.json and runs it +// against a mock ctx, driving a customer through: +// active -> suspended -> active (resume) -> deleted +// and asserting converged AList end-state each time, plus duplicate-webhook +// idempotency. +// Run: node n8n/test_w1_lifecycle.js +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W1-customer-provision.json'), 'utf8')); +const code = workflow.nodes.find(n => n.name === 'Provision & Apply Lifecycle').parameters.jsCode; + +const TODAY = new Date().toISOString().slice(0, 10); + +// ---- mutable mock state (represents AList + NocoDB) ---- +const state = { + // NocoDB customer record (the fields W1 reads) + customer: { Id: 1, username: 'alice', password: 'pw', status: 'active', alist_user_id: null, alist_role_id: null, sync_status: 'pending', last_sync_error: null }, + // AList + users: [], // {id, username, disabled, role} + roles: [], // {id, name, permission_scopes} + nextUserId: 1000, + nextRoleId: 2000, + // NocoDB CPs (for calculateEffectiveGrants) + cps: [ + { Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }, + ], + grants: [{ Id: 1, customer: 1, resource: 3, status: 'active', expires_at: '2099-01-01', permission: 'read' }], + resources: [ + { Id: 1, alist_path: '/Class/Python/01' }, + { Id: 2, alist_path: '/Class/Health' }, + { Id: 3, alist_path: '/Template/Music' }, + ], + productRes: { 2: [{ Id: 2 }] }, + // record write-back PATCHes + patched: [], +}; + +function mockHttp(opts) { + const u = opts.url; + const m = opts.method || 'GET'; + const body = opts.body || {}; + + if (u.endsWith('/api/auth/login')) return { data: { token: 'TOKEN' } }; + + // NocoDB customer GET + const cget = u.match(/\/tc\/records\/(\d+)$/); + if (cget && m === 'GET') return state.customer; + + // NocoDB customer PATCH (write-back) + if (u.includes('/tc/records') && m === 'PATCH') { + const fields = body[0] || body; + state.patched.push(Object.assign({}, fields)); + // apply to customer state + Object.assign(state.customer, fields); + return { code: 200 }; + } + + // resources list + if (u.includes('/tr/records?limit=10000')) return { list: state.resources }; + + // CP where customer eq + const cpm = u.match(/tcpc\/records\?where=\(customer,eq,(\d+)\)/); + if (cpm) return { list: state.cps.filter(c => c.customer === Number(cpm[1])) }; + + // grants where customer eq + const gm = u.match(/tag\/records\?where=\(customer,eq,(\d+)\)/); + if (gm) return { list: state.grants.filter(g => g.customer === Number(gm[1])) }; + + // product M2M + const pm = u.match(/tp\/links\/lnk\/records\/(\d+)/); + if (pm) return { list: state.productRes[Number(pm[1])] || [] }; + + // role create + if (u.endsWith('/api/admin/role/create')) { + const r = { id: state.nextRoleId++, name: body.name, description: body.description, permission_scopes: body.permission_scopes || [] }; + state.roles.push(r); + return { data: r }; + } + // role list + if (u.endsWith('/api/admin/role/list?page=1&per_page=100')) return { data: { content: state.roles } }; + // role get + const rg = u.match(/role\/get\?id=(\d+)/); + if (rg) { + const r = state.roles.find(x => x.id === Number(rg[1])); + if (!r) return { data: {} }; + return { data: { name: r.name, description: r.description, permission_scopes: r.permission_scopes } }; + } + // role update + if (u.endsWith('/api/admin/role/update')) { + const r = state.roles.find(x => x.id === body.id); + if (r) r.permission_scopes = body.permission_scopes; + return { code: 200 }; + } + // user create + if (u.endsWith('/api/admin/user/create')) { + const us = { id: state.nextUserId++, username: body.username, disabled: body.disabled ?? false, role: body.role }; + state.users.push(us); + return { data: us }; + } + // user get (loop guard checks actual disabled state) + const ug = u.match(/user\/get\?id=(\d+)/); + if (ug) { + const us = state.users.find(x => x.id === Number(ug[1])); + return { data: us || {} }; + } + // user list + if (u.endsWith('/api/admin/user/list?page=1&per_page=100')) return { data: { content: state.users } }; + // user update + if (u.endsWith('/api/admin/user/update')) { + const us = state.users.find(x => x.id === body.id); + if (us) { + if (body.disabled !== undefined) us.disabled = body.disabled; + if (body.role !== undefined) us.role = body.role; + } + return { code: 200 }; + } + + throw new Error('unexpected: ' + m + ' ' + u); +} + +async function runW1(customerId, customerStatusOverride) { + // build ctx reflecting CURRENT state.customer + if (customerStatusOverride !== undefined) state.customer.status = customerStatusOverride; + // ensure alist ids are on the customer record (W1 reads them from NocoDB) + state.customer.alist_user_id = state.customer.alist_user_id ?? null; + state.customer.alist_role_id = state.customer.alist_role_id ?? null; + + const sandbox = { + $env: { + NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', ALIST_URL: 'https://al', + NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcpc', + NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr', + NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_LINK_PROD_RESOURCES: 'lnk', + ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p', + }, + console, + }; + sandbox.helpers = { httpRequest: mockHttp }; + const wrapped = `(async function(){\nconst $json = { customerId: ${customerId} };\n${code}\n})`; + const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w1-body.js' }); + return await fn(); +} + +let fail = 0; +const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } }; + +function roleScopes(roleId) { + const r = state.roles.find(x => x.id === roleId); + return (r?.permission_scopes || []).map(s => s.path).sort(); +} +function userById(id) { return state.users.find(x => x.id === id); } + +async function main() { + // --- 1. active (fresh customer) -> provision + grants --- + console.log('\n[1] active fresh customer'); + let r = await runW1(1, 'active'); + A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json); + A('user created', state.users.length === 1, state.users); + A('role created', state.roles.length === 1, state.roles); + A('user enabled', userById(state.users[0].id).disabled === false, state.users[0]); + const uid1 = state.users[0].id; + const rid1 = state.roles[0].id; + // desired = Health (product 2) + Music (manual grant) = 2 paths + A('scopes = Health + Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1)); + // write-back happened + A('wrote alist_user_id + role_id back', state.customer.alist_user_id === uid1 && state.customer.alist_role_id === rid1, state.customer); + + // --- 2. duplicate webhook (echo) -> idempotent no-op --- + console.log('\n[2] duplicate webhook (echo, already synced)'); + r = await runW1(1, 'active'); + A('echo -> skip', r.json.lifecycle === 'skip_echo', r.json); + A('no new user/role', state.users.length === 1 && state.roles.length === 1, { u: state.users.length, r: state.roles.length }); + + // --- 3. suspended -> disable + clear scopes --- + console.log('\n[3] suspended'); + r = await runW1(1, 'suspended'); + A('lifecycle suspend_or_delete', r.json.lifecycle === 'suspend_or_delete', r.json); + A('user disabled', userById(uid1).disabled === true, userById(uid1)); + A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1)); + A('user NOT deleted (soft)', state.users.length === 1, state.users); + + // --- 4. duplicate suspend webhook -> idempotent --- + console.log('\n[4] duplicate suspend'); + const usersBefore = state.users.length; + r = await runW1(1, 'suspended'); + A('still disabled', userById(uid1).disabled === true, userById(uid1)); + A('no new users', state.users.length === usersBefore, state.users.length); + + // --- 5. active resume -> re-enable + RECOMPUTE scopes (not stale) --- + // To prove recompute-not-restore, change the CP set first: remove Health product, + // the desired should now be only Music (NOT the old Health+Music restored). + console.log('\n[5] active resume (recompute, not restore stale)'); + state.cps = []; // remove all CPs -> only manual grant remains + r = await runW1(1, 'active'); + A('lifecycle active_provision (resume)', r.json.lifecycle === 'active_provision', r.json); + A('user re-enabled', userById(uid1).disabled === false, userById(uid1)); + A('scopes recomputed = only Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Template/Music']), roleScopes(rid1)); + + // --- 6. deleted -> soft delete (disable + clear, keep user) --- + console.log('\n[6] deleted'); + r = await runW1(1, 'deleted'); + A('lifecycle suspend_or_delete', r.json.lifecycle === 'suspend_or_delete', r.json); + A('user disabled', userById(uid1).disabled === true, userById(uid1)); + A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1)); + A('user retained (soft delete)', state.users.length === 1, state.users); + + console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`); + process.exit(fail === 0 ? 0 : 1); +} +main().then(() => {}).catch(e => { console.error('RUN ERROR:', e); process.exit(1); }); diff --git a/n8n/test_w3.js b/n8n/test_w3.js deleted file mode 100644 index 9ef58d2..0000000 --- a/n8n/test_w3.js +++ /dev/null @@ -1,137 +0,0 @@ -// Standalone correctness test for w3-compute.js logic. -// Mocks this.helpers.httpRequest + $env, runs the exact n8n Code-node code, -// and asserts desired-union / diff / disable decisions. -// Run: node n8n/test_w3.js (from Windows bash: node n8n/test_w3.js) -const fs = require('fs'); -const path = require('path'); - -const src = fs.readFileSync(path.join(__dirname, 'w3-compute.js'), 'utf8'); - -// ---- fixtures (mirror NocoDB records / AList API shapes) ---- -// Resources: Id -> alist_path -const RES = { 11: '/Class/Forex', 12: '/Class/Python', 13: '/Manual/Ebook' }; -// Products: Id -> [resourceIds] -const PROD_RES = { 101: [11, 12], 102: [13] }; -// Customers: Id -> {alist_role_id, alist_user_id, status, disabled} -const CUST = { - 1: { alist_role_id: 501, alist_user_id: 901, status: 'active', disabled: false }, // qualified; has expired CP - 2: { alist_role_id: 502, alist_user_id: 902, status: 'active', disabled: false }, // all expired -> should clear+disable(if on) - 3: { alist_role_id: 503, alist_user_id: 903, status: 'active', disabled: false }, // no scopes now - 4: { alist_role_id: null, alist_user_id: 904, status: 'active', disabled: false }, // not provisioned -> skip -}; -// CustomerProducts: {Id, customer:Id, product:Id, expires_at, status} -const CPS = { - 1: [{ cid: 1, product: 101, expires_at: '2026-12-31', status: 'active' }, // live -> /Class/Forex,/Class/Python - { cid: 1, product: 102, expires_at: '2020-01-01', status: 'active' }], // expired -> dropped - 2: [{ cid: 2, product: 101, expires_at: '2020-01-01', status: 'active' }], // expired -> empty - 3: [], -}; -// AccessGrants: active manual grant for cust 1 on /Manual/Ebook -const GRANTS = { - 1: [{ resource: 13, status: 'active', expires_at: null }], -}; -// Current AList role scopes -const ROLE_SCOPES = { - 501: ['/Class/Forex', '/Stale/Old'], // has stale -> remove; needs /Class/Python,/Manual/Ebook - 502: ['/Class/Forex'], // all expired -> clear - 503: [], -}; - -// ---- mock httpRequest keyed by URL ---- -const calls = { roleUpdate: [], userUpdate: [] }; -function httpRequest({ method, url, headers, body }) { - if (url.endsWith('/api/auth/login')) return { data: { token: 'tk-' } }; - const g = (m) => new RegExp(m).exec(url); - let m; - // per-table list endpoints - if (url.includes('/tables/RES/records?limit=10000')) { - return { list: Object.keys(RES).map((Id) => ({ Id: Number(Id), alist_path: RES[Id] })) }; - } - if (url.includes('/tables/GRANT/records?limit=10000')) { - const list = []; - for (const cid of Object.keys(GRANTS)) for (const gg of GRANTS[cid]) list.push({ ...gg, customer: { Id: Number(cid) } }); - return { list }; - } - if (url.includes('/tables/CUST/records?limit=10000')) { - return { list: Object.keys(CUST).map((Id) => ({ Id: Number(Id), username: 'u' + Id, ...CUST[Id] })) }; - } - // customerproducts by customer: /tables/CP/records?where=(customer.Id,eq,N)&limit=1000 - if ((m = g('/tables/CP/records\\?where=\\(customer.Id,eq,([0-9]+)\\)&limit=1000'))) { - const cid = Number(m[1]); - return { list: (CPS[cid] || []).map((c) => ({ ...c, customer: { Id: cid } })) }; - } - // product links: /tables/PROD/links/LNK/records/{pid} - if ((m = g('/tables/PROD/links/LNK/records/([0-9]+)'))) { - const pid = Number(m[1]); - return { list: (PROD_RES[pid] || []).map((Id) => ({ Id })) }; - } - // role get - if ((m = g('/api/admin/role/get\\?id=([0-9]+)'))) { - const rid = Number(m[1]); - return { data: { id: rid, name: 'r' + rid, description: '', permission_scopes: (ROLE_SCOPES[rid] || []).map((path) => ({ path, permission: 0 })) } }; - } - if (url.endsWith('/api/admin/role/update')) { calls.roleUpdate.push(body); return { data: null }; } - if (url.endsWith('/api/admin/user/update')) { calls.userUpdate.push(body); return { data: null }; } - throw new Error('no mock for ' + method + ' ' + url); -} - -const $env = { - NOCODB_URL: 'http://nc', NOCODB_TOKEN: 't', - ALIST_URL: 'http://alist', - NOCODB_TABLE_CUSTOMERS: 'CUST', NOCODB_TABLE_CUSTOMERPRODUCTS: 'CP', - NOCODB_TABLE_PRODUCTS: 'PROD', NOCODB_TABLE_RESOURCES: 'RES', - NOCODB_TABLE_ACCESSGRANTS: 'GRANT', NOCODB_LINK_PROD_RESOURCES: 'LNK', - ALIST_ADMIN_USERNAME: 'a', ALIST_ADMIN_PASSWORD: 'p', - ALIST_AUTO_DISABLE_ON_EXPIRE: process.env.TEST_DISABLE === '1' ? 'true' : 'false', - W3_DRY_RUN: 'false', -}; -// w3-compute uses plain (non-arrow) functions -> `this` resolves to globalThis -// when called bare. Wrap in an async IIFE so the file's top-level `await` works -// (n8n Code nodes also support top-level await). The file ends with `return {json:{...}}` -// which becomes the IIFE's resolve value ( {json: payload} ). -globalThis.helpers = { httpRequest }; - -(async () => { - const runner = new Function('$env', '$json', 'return (async () => {\n' + src + '\n})();'); - const json = await runner($env, {}); - const out = json.json; // unwrap the {json: {...}} wrapper - console.log('SUMMARY:', JSON.stringify(out, null, 2)); - console.log('SUMMARY:', JSON.stringify(out, null, 2)); - - // ---- assertions ---- - const fail = (msg) => { console.error('FAIL: ' + msg); process.exitCode = 1; }; - const ok = () => {}; - const changed = out.changed; - - const c1 = changed.find((c) => c.customerId === 1); - if (!c1) fail('cust1 should change'); else { - if (!c1.toRemove.includes('/Stale/Old')) fail('cust1: stale /Stale/Old not removed'); - if (!c1.toAdd.includes('/Class/Python')) fail('cust1: /Class/Python not added'); - if (!c1.toAdd.includes('/Manual/Ebook')) fail('cust1: manual /Manual/Ebook not added'); - if (c1.toAdd.includes('/Class/Forex')) fail('cust1: /Class/Forex already present, should not be toAdd'); - if (c1.applied.includes('role_scopes') === false) ok(); - } - const c2 = changed.find((c) => c.customerId === 2); - if (!c2) fail('cust2 should change (all expired -> clear)'); else { - if (!c2.toRemove.includes('/Class/Forex')) fail('cust2: expired /Class/Forex not cleared'); - if (c2.desiredPaths.length !== 0) fail('cust2 desiredPaths should be empty'); - if (process.env.TEST_DISABLE === '1' && !c2.applied.includes('disable')) fail('cust2 should disable when auto-disable ON'); - if (process.env.TEST_DISABLE !== '1' && c2.applied.includes('disable')) fail('cust2 must NOT disable when auto-disable OFF'); - } - const c3 = changed.find((c) => c.customerId === 3); - if (c3) fail('cust3 (already empty scope) should NOT change'); - if (changed.find((c) => c.customerId === 4)) fail('cust4 (no role) must be skipped'); - - // inspect actual writes - const ru = calls.roleUpdate.find((b) => b.id === 501); - if (ru) { - const paths = (ru.permission_scopes || []).map((s) => s.path); - if (!paths.includes('/Class/Forex')) fail('write cust1 lost /Class/Forex'); - if (!paths.includes('/Manual/Ebook')) fail('write cust1 missing manual grant'); - if (paths.includes('/Stale/Old')) fail('write cust1 kept stale'); - } else fail('no role/update call for cust1'); - if (process.env.TEST_DISABLE === '1' && !calls.userUpdate.find((b) => b.id === 902)) fail('cust2 user not disabled'); - - const nFail = Object.keys(calls.roleUpdate).length; // crude; we already assert specifics - console.log(process.exitCode ? '\n>>> TEST FAILED' : '\n>>> ALL CHECKS PASSED'); -})(); diff --git a/n8n/test_w3_refactored.js b/n8n/test_w3_refactored.js new file mode 100644 index 0000000..67895b3 --- /dev/null +++ b/n8n/test_w3_refactored.js @@ -0,0 +1,106 @@ +// Verify the REFACTORED W3 body produces correct reconciliation after the +// calculateEffectiveGrants extraction into shared/. Runs the ACTUAL inlined +// code from W3-expiry-sync.json against a mock ctx. +// Run: node n8n/test_w3_refactored.js +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W3-expiry-sync.json'), 'utf8')); +const code = workflow.nodes.find(n => n.name === 'Reconcile & Apply (Expiry Sweep)').parameters.jsCode; + +const TODAY = new Date().toISOString().slice(0, 10); +const RESOURCES = [ + { Id: 1, alist_path: '/Class/Python/01' }, + { Id: 2, alist_path: '/Class/Health' }, + { Id: 3, alist_path: '/Template/Music' }, +]; +const PRODUCT_RES = { 2: [{ Id: 2 }], 3: [{ Id: 3 }], 4: [{ Id: 1 }] }; +const CUSTOMERS = [ + { Id: 1, username: 'cust1', alist_role_id: 10, alist_user_id: 100, status: 'active' }, + { Id: 2, username: 'cust2', alist_role_id: 11, alist_user_id: 101, status: 'active' }, + { Id: 3, username: 'cust3', alist_role_id: null, alist_user_id: null, status: 'active' }, +]; +const CPS = { + 1: [{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }], + 2: [{ Id: 2, customer: 2, product: 3, status: 'active', expires_at: TODAY }], +}; +const GRANTS = { + 1: [{ Id: 1, customer: 1, resource: 3, status: 'active', expires_at: '2099-01-01', permission: 'read' }], + 2: [], +}; +const ROLE_SCOPES = { + 10: [{ path: '/Class/Python/01', permission: 0 }], + 11: [{ path: '/Template/Music', permission: 0 }], +}; + +const wroteRole = [], disabledUsers = []; + +const sandbox = { + $env: { + NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', ALIST_URL: 'https://al', + NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcpc', + NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr', + NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_LINK_PROD_RESOURCES: 'lnk', + ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p', + W3_DRY_RUN: 'false', ALIST_AUTO_DISABLE_ON_EXPIRE: 'true', + wroteRole, disabledUsers, + }, + console, +}; +sandbox.helpers = { + httpRequest: async (opts) => { + const u = opts.url; + if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } }; + if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES }; + if (u.includes('/tc/records?limit=10000')) return { list: CUSTOMERS }; + const cpm = u.match(/tcpc\/records\?where=\(customer,eq,(\d+)\)/); + if (cpm) return { list: CPS[Number(cpm[1])] || [] }; + const gm = u.match(/tag\/records\?where=\(customer,eq,(\d+)\)/); + if (gm) return { list: GRANTS[Number(gm[1])] || [] }; + const pm = u.match(/tp\/links\/lnk\/records\/(\d+)/); + if (pm) return { list: PRODUCT_RES[Number(pm[1])] || [] }; + const rg = u.match(/role\/get\?id=(\d+)/); + if (rg) return { data: { permission_scopes: ROLE_SCOPES[Number(rg[1])] || [] } }; + if (u.endsWith('/api/admin/role/update')) { wroteRole.push(opts.body); return { code: 200 }; } + if (u.endsWith('/api/admin/user/update')) { disabledUsers.push(opts.body); return { code: 200 }; } + throw new Error('unexpected URL: ' + u); + }, +}; + +async function main() { + // Wrap exactly like n8n: top-level await with `this` bound to a node context + // that exposes `.helpers` and `$env`. + const wrapped = `(async function(){\n${code}\n})`; + const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w3-body.js' }); + const result = await fn(); + const changed = result.json.changed; + const errors = result.json.errors; + + let fail = 0; + const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } }; + + A('scanned 3', result.json.customersScanned === 3, result.json.customersScanned); + A('cust3 skipped', !changed.find(c => c.customerId === 3), changed); + A('no errors', errors.length === 0, errors); + const c1 = changed.find(c => c.customerId === 1); + A('cust1 changed', !!c1, changed); + if (c1) { + A('cust1 add Health', c1.toAdd.includes('/Class/Health'), c1.toAdd); + A('cust1 add Music (grant)', c1.toAdd.includes('/Template/Music'), c1.toAdd); + A('cust1 remove stale Python', c1.toRemove.includes('/Class/Python/01'), c1.toRemove); + } + const c2 = changed.find(c => c.customerId === 2); + A('cust2 changed', !!c2, changed); + if (c2) { + A('cust2 remove Music', c2.toRemove.includes('/Template/Music'), c2.toRemove); + A('cust2 add empty', c2.toAdd.length === 0, c2.toAdd); + A('cust2 disabled', c2.applied.includes('disable'), c2.applied); + } + A('2 role updates', wroteRole.length === 2, wroteRole.length); + A('1 disable', disabledUsers.length === 1, disabledUsers.length); + + console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`); + process.exit(fail === 0 ? 0 : 1); +} +main().then(() => {}).catch(e => { console.error(e); process.exit(1); }); diff --git a/n8n/w1-body.js b/n8n/w1-body.js new file mode 100644 index 0000000..edaf100 --- /dev/null +++ b/n8n/w1-body.js @@ -0,0 +1,181 @@ +// ============================================================================ +// W1 runtime body — Customer provision + STATUS LIFECYCLE (Step 3). +// Rebuilt by `node n8n/gen_w1.js` (single source of truth = shared/effective-grants.js). +// +// Single self-contained Code node (plus webhook + id extract). Replaces the +// old multi-node chain with the SAME provision behaviour PLUS status lifecycle. +// +// status suspended/deleted -> disable AList user + clear role scopes (soft +// delete: user + NocoDB history preserved). +// status active -> provision (create/update user+role) then +// RECOMPUTE desired scopes via shared +// calculateEffectiveGrants and write them, then +// re-enable the user. +// other status -> no AList mutation (skip). +// +// Write-back: if the customer is missing alist_user_id / alist_role_id we +// PATCH them back to NocoDB (with sync_status 'synced'). The echo webhook from +// that PATCH re-enters W1 but the loop guard below (skipWriteBack when already +// populated + status unchanged) makes it a no-op. +// +// Idempotency: final state is converged per status (see above). Duplicate or +// echo webhooks re-apply the same end state with zero net change. +// ============================================================================ +// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build) +// ============================================================================ + +const ctx = { $env, helpers: this.helpers }; + +// 1. fresh customer record by id (from webhook extract) +const customerId = $json.customerId; +const cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`); +const status = cust.status || 'active'; +const username = cust.username; +if (!username) throw new Error('Customer missing username'); +const alistUserId = cust.alist_user_id ?? null; +const alistRoleId = cust.alist_role_id ?? null; + +// loop guard: if this webhook is our own write-back echo AND the customer is +// already fully provisioned with synced status AND status is unchanged, skip. +// (Detected below via alreadyProvisioned + sync_status === 'synced'.) +const alreadyProvisioned = !!(alistUserId && alistRoleId); + +// 2. admin login +const login = await alistReq(ctx, 'POST', '/api/auth/login', { + username: $env.ALIST_ADMIN_USERNAME, + password: $env.ALIST_ADMIN_PASSWORD, +}, null); +const token = login?.data?.token; +if (!token) throw new Error('AList login failed'); + +// 3. loop guard: echo of our own write-back (active + already realized) -> skip. +// IMPORTANT: sync_status==='synced' alone is NOT enough to skip, because a +// suspended->active resume ALSO carries sync_status==='synced' (the suspend +// path writes it) while the AList user is still disabled. We therefore check +// the ACTUAL AList user disabled state: only skip when the user is provably +// enabled already. If disabled -> fall through to active provision (re-enable +// + recompute), which is exactly what resume needs. +if (alreadyProvisioned && status === 'active' && cust.sync_status === 'synced' && alistUserId) { + let currentlyEnabled = true; + try { + const uInfo = await alistReq(ctx, 'GET', `/api/admin/user/get?id=${alistUserId}`, null, token); + if (uInfo?.data && typeof uInfo.data.disabled === 'boolean') { + currentlyEnabled = !uInfo.data.disabled; + } + } catch (e) { /* user/get may 404 for some AList versions; assume enabled */ } + if (currentlyEnabled) { + return { json: { customerId, username, lifecycle: 'skip_echo', actions: [] } }; + } +} + +// 4. STATUS BRANCH --------------------------------------------------------- + +const writeBack = async (fields) => { + // PATCH id fields back to NocoDB. Guard: only when they differ. + const body = Object.assign({ Id: customerId }, fields); + await ctx.helpers.httpRequest({ + method: 'PATCH', + url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`, + headers: { 'xc-token': $env.NOCODB_TOKEN }, + body: [body], + json: true, + }); +}; + +if (status === 'suspended' || status === 'deleted') { + const actions = []; + if (alistUserId) { + await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token); + actions.push('disable_user'); + } + if (alistRoleId) { + const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${alistRoleId}`, null, token); + await alistReq(ctx, 'POST', '/api/admin/role/update', { + id: alistRoleId, + name: role?.data?.name, + description: role?.data?.description, + permission_scopes: [], + }, token); + actions.push('clear_scopes'); + } + // mark synced so we don't re-fire on our own write-back (but we don't write + // much here — only sync_status to stop the echo loop). + await writeBack({ sync_status: 'synced', last_sync_error: null }); + return { json: { customerId, username, status, lifecycle: 'suspend_or_delete', actions } }; +} + +if (status !== 'active') { + // pending / expired / disabled / unknown -> no mutation, but mark synced to + // stop echo loop only if we are the ones writing. We do NOT write here to + // avoid interfering while the customer is being drafted. + return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } }; +} + +// status === 'active' ------------------------------------------------------- + +const actions = []; +let finalUserId = alistUserId; +let finalRoleId = alistRoleId; + +// 4a. ensure role exists +if (!finalRoleId) { + await alistReq(ctx, 'POST', '/api/admin/role/create', { + name: 'customer-' + username, + description: 'Auto role for ' + username, + default: false, + permission_scopes: [], + }, token); + const roles = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=100', null, token); + const r = (roles?.data?.content || []).find(x => x.name === 'customer-' + username); + if (!r) throw new Error('Created role not found: customer-' + username); + finalRoleId = r.id; + actions.push('create_role'); +} + +// 4b. ensure user exists / update; always re-enable on active +if (!finalUserId) { + await alistReq(ctx, 'POST', '/api/admin/user/create', { + username, + password: cust.password, + base_path: '/', + role: finalRoleId ? [finalRoleId] : [], + permission: 16384, + }, token); + const users = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=100', null, token); + const u = (users?.data?.content || []).find(x => x.username === username); + if (!u) throw new Error('Created user not found: ' + username); + finalUserId = u.id; + actions.push('create_user'); +} else { + await alistReq(ctx, 'POST', '/api/admin/user/update', { + id: finalUserId, + username, + password: cust.password, + base_path: '/', + role: finalRoleId ? [finalRoleId] : [], + permission: 16384, + disabled: false, + }, token); + actions.push('update_user'); +} + +// 4c. RECOMPUTE desired scopes (never restore stale) and write them +const g = await calculateEffectiveGrants(ctx, customerId); +await alistReq(ctx, 'POST', '/api/admin/role/update', { + id: finalRoleId, + name: 'customer-' + username, + description: 'Auto role for ' + username, + permission_scopes: g.newScopes, +}, token); +actions.push('apply_grants'); + +// 5. write-back ids if newly discovered (creates the echo; landed in loop guard) +if (!alistUserId || !alistRoleId) { + await writeBack({ alist_user_id: finalUserId, alist_role_id: finalRoleId, sync_status: 'synced', last_sync_error: null }); +} + +return { json: { + customerId, username, status, lifecycle: 'active_provision', + alistUserId: finalUserId, alistRoleId: finalRoleId, + desiredPaths: g.desiredPaths, actions, +} }; diff --git a/n8n/w2-body.js b/n8n/w2-body.js new file mode 100644 index 0000000..6c1779d --- /dev/null +++ b/n8n/w2-body.js @@ -0,0 +1,77 @@ +// ============================================================================ +// W2 runtime body — enforced CustomerProduct -> AList role scopes sync. +// Rebuilt by `node n8n/gen_w2.js` (single source of truth = shared/effective-grants.js). +// ============================================================================ +// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build) +// ============================================================================ + +// n8n Code node context: ctx = { $env, helpers } — but helpers is `this.helpers` +// inside the node. Build ctx to hand to the shared library. +const ctx = { + $env, + helpers: this.helpers, +}; + +const custProdId = $json.customerProductId; + +// 1. re-fetch this CP record to find its customer link (may be null mid-draft) +const CP = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERPRODUCTS}/records/${custProdId}`); +const customerId = linkId(CP.customer); +if (!customerId) { + // Customer link not yet filled (record being drafted). Skip gracefully; the + // webhook re-fires when customer is set later. + return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } }; +} + +// 2. compute what the customer SHOULD have right now (shared algorithm) +const grants = await calculateEffectiveGrants(ctx, customerId); + +// 3. fetch customer admin facts (role id, alist user id) +const cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`); +const roleId = cust.alist_role_id; +const userId = cust.alist_user_id; + +if (!roleId) { + return { json: { skipWriteBack: true, customerId, reason: 'customer not provisioned (no role id)' } }; +} + +// 4. admin login +const login = await alistReq(ctx, 'POST', '/api/auth/login', { + username: $env.ALIST_ADMIN_USERNAME, + password: $env.ALIST_ADMIN_PASSWORD, +}, null); +const token = login?.data?.token; +if (!token) throw new Error('AList login failed'); + +// 5. current scopes vs desired scopes -> diff +const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${roleId}`, null, token); +const currentScopes = role?.data?.permission_scopes ?? []; +const currentPaths = currentScopes.map(s => s.path); +const toAdd = grants.desiredPaths.filter(p => !currentPaths.includes(p)); +const toRemove = currentPaths.filter(p => !grants.desiredPaths.includes(p)); +const changed = toAdd.length > 0 || toRemove.length > 0; + +// 6. apply if changed +if (changed) { + await alistReq(ctx, 'POST', '/api/admin/role/update', { + id: roleId, + name: role?.data?.name, + description: role?.data?.description, + permission_scopes: grants.newScopes, + }, token); +} + +return { + json: { + customerId, + customerProductId: custProdId, + roleId, + roleName: role?.data?.name, + effective: grants.desired, + desiredPaths: grants.desiredPaths, + currentScopes, + diff: { toAdd, toRemove }, + newScopes: grants.newScopes, + skipWriteBack: changed ? false : true, + }, +}; diff --git a/n8n/w2-compute.js b/n8n/w2-compute.js deleted file mode 100644 index 3e62d57..0000000 --- a/n8n/w2-compute.js +++ /dev/null @@ -1,116 +0,0 @@ -// W2 core computation: effective desired grants + diff vs AList. -// Uses this.helpers.httpRequest (n8n Code node) for all HTTP. -// Config read via process.env (n8n $env proxy is unreliable for custom vars). -const NC = process.env.NOCODB_URL; -const NC_TOKEN = process.env.NOCODB_TOKEN; -const ALIST = process.env.ALIST_URL; -const T_CUSTPROD = process.env.NOCODB_TABLE_CUSTOMERPRODUCTS; -const T_PROD = process.env.NOCODB_TABLE_PRODUCTS; -const T_CUST = process.env.NOCODB_TABLE_CUSTOMERS; -const LNK_PROD_RES = process.env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg - -async function ncGet(path) { - return await this.helpers.httpRequest({ - method: 'GET', - url: NC + path, - headers: { 'xc-token': NC_TOKEN }, - json: true, - }); -} -async function alistReq(method, path, body, token) { - return await this.helpers.httpRequest({ - method, - url: ALIST + path, - headers: token ? { Authorization: token } : {}, - body, - json: true, - }); -} -function linkId(v) { - if (v == null) return null; - if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null; - if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null; - return v; -} - -const custProdId = $json.customerProductId; - -// 1. re-fetch the CP record -const cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`); -const customerId = linkId(cp.customer); -if (!customerId) { - // Gracefully skip when customer link not yet filled (CP record is being drafted). - // The webhook will re-fire when customer is set later. - return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } }; -} - -// 2. load all active (and not-yet-expired) CPs for this customer -const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD' -const cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`); -const active = (cps.list || []).filter(r => { - if (r.status && r.status !== 'active') return false; - if (r.expires_at && String(r.expires_at) <= today) return false; // expired (incl. today) - return true; -}); - -// 3. build resource path -> max expires_at -const effective = {}; -for (const c of active) { - const pid = linkId(c.product); - if (!pid) continue; - const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`); - const exp = c.expires_at; // 'YYYY-MM-DD' - for (const res of (resList.list || [])) { - const path = res.alist_path; - if (!path) continue; - const cur = effective[path]; - if (!cur || !cur.expires || (exp && exp > cur.expires)) { - effective[path] = { expires: exp || null, permission: 0 }; - } - } -} - -// 4. admin login -const login = await alistReq('POST', '/api/auth/login', { - username: process.env.ALIST_ADMIN_USERNAME, - password: process.env.ALIST_ADMIN_PASSWORD, -}, null); -const token = login?.data?.token; -if (!token) throw new Error('AList login failed'); - -// customer's role id -const cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`); -const roleId = cust.alist_role_id; - -let currentScopes = []; -if (roleId) { - const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token); - currentScopes = role?.data?.permission_scopes ?? []; -} - -const desiredPaths = Object.keys(effective); -const currentPaths = currentScopes.map(s => s.path); -const toAdd = desiredPaths.filter(p => !currentPaths.includes(p)); -const toRemove = currentPaths.filter(p => !desiredPaths.includes(p)); - -let roleName = null, roleDesc = null; -if (roleId) { - const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token); - roleName = role?.data?.name; - roleDesc = role?.data?.description; -} - -return { - json: { - customerId, - roleId, - roleName, - roleDesc, - token, - effective, - desiredPaths, - currentScopes, - diff: { toAdd, toRemove }, - newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })), - }, -}; diff --git a/n8n/w3-body.js b/n8n/w3-body.js new file mode 100644 index 0000000..5420375 --- /dev/null +++ b/n8n/w3-body.js @@ -0,0 +1,78 @@ +// ============================================================================ +// W3 runtime body — 5-min expiry sweep / full reconciliation. +// Rebuilt by `node n8n/gen_w3.js` (single source of truth = shared/effective-grants.js). +// dryRun: W3_DRY_RUN=true -> only print the plan (no writes). +// autoDisable: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when a +// previously-scoped customer's desired set becomes empty. Default off. +// ============================================================================ +// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build) +// ============================================================================ + +const ctx = { $env, helpers: this.helpers }; +const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true'; +const AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true'; +const T_CUST = $env.NOCODB_TABLE_CUSTOMERS; + +// admin login once for the whole sweep +const login = await alistReq(ctx, 'POST', '/api/auth/login', { + username: $env.ALIST_ADMIN_USERNAME, + password: $env.ALIST_ADMIN_PASSWORD, +}, null); +const token = login?.data?.token; +if (!token) throw new Error('AList login failed'); + +const customers = (await ncGet(ctx, `/api/v2/tables/${T_CUST}/records?limit=10000`)).list || []; +const changed = [], errors = []; + +for (const cust of customers) { + try { + if (!cust.alist_role_id) continue; // not provisioned yet + const cid = cust.Id; + + // desired scopes via shared algorithm + const g = await calculateEffectiveGrants(ctx, cid); + const desiredPaths = g.desiredPaths; + + // current scopes from AList + const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token); + const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path); + const toAdd = desiredPaths.filter(p => !currentPaths.includes(p)); + const toRemove = currentPaths.filter(p => !desiredPaths.includes(p)); + const scopesChanged = toAdd.length > 0 || toRemove.length > 0; + const nowEmpty = desiredPaths.length === 0; + + // only disable when a customer that HAD scopes now has none (guard + // currentPaths>0 avoids killing a fresh customer with an empty role). + const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0; + + const applied = []; + if (scopesChanged) { + if (!DRY_RUN) await alistReq(ctx, 'POST', '/api/admin/role/update', { + id: cust.alist_role_id, + name: role?.data?.name, + description: role?.data?.description, + permission_scopes: g.newScopes, + }, token); + applied.push('role_scopes'); + } + if (shouldDisable) { + if (!DRY_RUN && cust.alist_user_id) + await alistReq(ctx, 'POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token); + applied.push('disable'); + } + if (applied.length) { + changed.push({ + customerId: cid, username: cust.username, roleId: cust.alist_role_id, + toAdd, toRemove, desiredPaths, applied, + }); + } + } catch (e) { + errors.push({ customerId: cust.Id, username: cust.username, error: e.message }); + } +} + +return { json: { + dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(), + customersScanned: customers.length, customersChanged: changed.length, + changed, errors, +} }; diff --git a/n8n/w3-compute.js b/n8n/w3-compute.js deleted file mode 100644 index 9fc3905..0000000 --- a/n8n/w3-compute.js +++ /dev/null @@ -1,124 +0,0 @@ -// W3 - Expiry Sync / full reconciliation sweep (self-contained read+write). -// Runs every 5 min. idempotent. get current -> compare -> apply diff. -// Reads config via $env (works in n8n Code node). -// dryRun: set W3_DRY_RUN=true env to only PRINT the plan (no AList/NocoDB writes). -// AUTO_DISABLE: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when desired empty. -// Default off = first rollout observes only. When on, ONLY AList user disabled (NocoDB Customers.status -// intentionally NOT flipped: Customers update webhook -> W1 could re-provision / re-enable = loop). - -const NC = $env.NOCODB_URL; -const NC_TOKEN = $env.NOCODB_TOKEN; -const ALIST = $env.ALIST_URL; -const T_CUST = $env.NOCODB_TABLE_CUSTOMERS; -const T_CUSTPROD= $env.NOCODB_TABLE_CUSTOMERPRODUCTS; -const T_PROD = $env.NOCODB_TABLE_PRODUCTS; -const T_RES = $env.NOCODB_TABLE_RESOURCES; -const T_GRANT = $env.NOCODB_TABLE_ACCESSGRANTS; -const LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg - -const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true'; -const AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true'; -const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD' - -async function ncGet(path) { - return await this.helpers.httpRequest({ - method: 'GET', url: NC + path, - headers: { 'xc-token': NC_TOKEN }, json: true, - }); -} -async function alistReq(method, path, body, token) { - return await this.helpers.httpRequest({ - method, url: ALIST + path, - headers: token ? { Authorization: token } : {}, - body, json: true, - }); -} -function linkId(v) { - if (v == null) return null; - if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null; - if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null; - return v; -} - -// login once -const login = await alistReq('POST', '/api/auth/login', { - username: $env.ALIST_ADMIN_USERNAME, - password: $env.ALIST_ADMIN_PASSWORD, -}, null); -const token = login?.data?.token; -if (!token) throw new Error('AList login failed'); - -// index all Resources by Id -> alist_path -const allRes = (await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`)).list || []; -const resPath = {}; allRes.forEach(r => { resPath[r.Id] = r.alist_path; }); - -// all access grants (materialized + manual), grouped by customer -const grants = (await ncGet(`/api/v2/tables/${T_GRANT}/records?limit=10000`)).list || []; -const gByCust = {}; -for (const g of grants) { - const cid = linkId(g.customer); if (!cid) continue; - (gByCust[cid] = gByCust[cid] || []).push(g); -} - -const customers = (await ncGet(`/api/v2/tables/${T_CUST}/records?limit=10000`)).list || []; -const changed = [], errors = []; - -for (const cust of customers) { - try { - if (!cust.alist_role_id) continue; // not provisioned - const cid = cust.Id; - // desired = CP-derived paths (active, not expired) union effective grants - const desired = {}; // path -> true - const cps = (await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${cid})&limit=1000`)).list || []; - for (const c of cps) { - if (c.status && c.status !== 'active') continue; - if (c.expires_at && c.expires_at <= today) continue; // expired (incl. today) - const pid = linkId(c.product); if (!pid) continue; - const res = (await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || []; - for (const r of res) { const p = resPath[r.Id]; if (p) desired[p] = true; } - } - for (const g of (gByCust[cid] || [])) { - if (g.status && g.status !== 'active') continue; - if (g.expires_at && g.expires_at <= today) continue; - const p = resPath[linkId(g.resource)]; if (p) desired[p] = true; - } - const desiredPaths = Object.keys(desired).sort(); - - const role = await alistReq('GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token); - const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path); - const toRemove = currentPaths.filter(p => !desired[p]); - const toAdd = desiredPaths.filter(p => !currentPaths.includes(p)); - const scopesChanged = toAdd.length > 0 || toRemove.length > 0; - const nowEmpty = desiredPaths.length === 0; - // WF3: no remaining active grant + policy on + customer active + HAD scopes before -> disable. - // Guard currentPaths>0: never disable a fresh customer with an empty role (avoids - // killing a just-provisioned user before they buy anything; W2 won't re-enable them). - const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0; - - let applied = []; - if (scopesChanged) { - const newScopes = desiredPaths.map(p => ({ path: p, permission: 0 })); - if (!DRY_RUN) await alistReq('POST', '/api/admin/role/update', - { id: cust.alist_role_id, name: role?.data?.name, description: role?.data?.description, - permission_scopes: newScopes }, token); - applied.push('role_scopes'); - } - if (shouldDisable) { - if (!DRY_RUN && cust.alist_user_id) - await alistReq('POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token); - applied.push('disable'); - } - if (applied.length) { - changed.push({ customerId: cid, username: cust.username, roleId: cust.alist_role_id, - toAdd, toRemove, desiredPaths, applied }); - } - } catch (e) { - errors.push({ customerId: cust.Id, username: cust.username, error: e.message }); - } -} - -return { json: { - dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(), - customersScanned: customers.length, customersChanged: changed.length, - changed, errors, -} };