Files
alist-serve-resource/n8n/shared/effective-grants.js
T
hoelee 21e3a3b45f fix(shared): link-field where query must use (customer.Id,eq,N) not (customer,eq,N)
- calculateEffectiveGrants queried CustomerProducts/AccessGrants with
  (customer,eq,N), which matches the link field's DISPLAY NAME and always
  returns 0 rows -> desiredPaths always [] -> W1/W2/W3 write empty scopes.
- Regression from the shared-lib refactor (3ecee30): W2 had already fixed
  this in 36a6b3b but the shared extract reverted to the old syntax.
- Correct syntax (customer.Id,eq,N) verified against live NocoDB for
  customer 6 -> /Class/Health, /Class/Other, /Template/Music.
- Updated all three test mocks to match the corrected where clause.
2026-08-23 06:55:04 +08:00

128 lines
5.3 KiB
JavaScript

// ============================================================================
// 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.
// NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always
// returns 0 rows. Must use the `customer.Id` sub-field syntax (or the
// foreign-key column `nc_jitu___Customers_id`).
const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,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.Id,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 };
}