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
+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 };
}