feat(W1): status lifecycle (suspend/delete/resume) + shared effective-grants lib

- 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.
This commit is contained in:
2026-08-23 04:59:43 +08:00
parent 870a702f7b
commit 3ecee3099e
18 changed files with 1216 additions and 1109 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+67
View File
@@ -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)');
+107
View File
@@ -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)');
+10 -14
View File
@@ -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)');
+19
View File
@@ -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 };
+124
View File
@@ -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 };
}
+138
View File
@@ -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();
+213
View File
@@ -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); });
-137
View File
@@ -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-<TEST>' } };
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');
})();
+106
View File
@@ -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); });
+181
View File
@@ -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,
} };
+77
View File
@@ -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,
},
};
-116
View File
@@ -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 })),
},
};
+78
View File
@@ -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,
} };
-124
View File
@@ -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,
} };