- 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.
175 lines
13 KiB
JSON
175 lines
13 KiB
JSON
{
|
|
"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": "// ============================================================================\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-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
|
|
}
|
|
],
|
|
[]
|
|
]
|
|
},
|
|
"Skip Echo (No-op)": {
|
|
"main": []
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
},
|
|
"pinData": {}
|
|
}
|