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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user