AList expects bare token (not 'Bearer <token>'). With 'Bearer' prefix, all role/get calls returned non-data responses -> currentPaths degraded to [] -> W3 kept re-applying same scopes every run (false toAdd) and never detected expired scopes (false toRemove). This is the root cause of W3 not showing hoe's expiry removal.
56 lines
6.6 KiB
JSON
56 lines
6.6 KiB
JSON
{
|
|
"name": "W3 - Expiry Sync (AList Role Reconciliation)",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"rule": {
|
|
"interval": [
|
|
{
|
|
"field": "minutes",
|
|
"minutesInterval": 5
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"id": "trigger-schedule",
|
|
"name": "Every 5 minutes",
|
|
"type": "n8n-nodes-base.scheduleTrigger",
|
|
"typeVersion": 1.2,
|
|
"position": [
|
|
0,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"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\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"
|
|
},
|
|
"id": "reconcile",
|
|
"name": "Reconcile & Apply (Expiry Sweep)",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
220,
|
|
0
|
|
]
|
|
}
|
|
],
|
|
"connections": {
|
|
"Every 5 minutes": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Reconcile & Apply (Expiry Sweep)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
},
|
|
"pinData": {}
|
|
}
|