AList expects bare token (not 'Bearer <token>'). With 'Bearer' prefix, all role/get calls returned non-data responses -> currentPaths degraded to [] -> W3 kept re-applying same scopes every run (false toAdd) and never detected expired scopes (false toRemove). This is the root cause of W3 not showing hoe's expiry removal.
125 lines
5.5 KiB
JavaScript
125 lines
5.5 KiB
JavaScript
// 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: 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,
|
|
} };
|