- 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.
128 lines
5.3 KiB
JavaScript
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 };
|
|
}
|