// 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 (incl. today) 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, } };