feat(W3): expiry sync / full reconciliation sweep (schedule */5) + design + tested compute
- Single Code-node sweep: desired = CP-active paths UNION active AccessGrants; expiry excluded -> role/update rewrite; optional auto-disable (env-gated, default off) - disable guard: only when had scopes before (never fresh empty-role customer) - loop guard: no NocoDB Customers.status flip (avoids W1 re-provision loop) - test_w3.js mock-verified both AUTO_DISABLE modes; gen_w3.js builds JSON from w3-compute.js
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// 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: 'Bearer ' + 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
|
||||
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,
|
||||
} };
|
||||
Reference in New Issue
Block a user