// ============================================================================ // W3 runtime body — 5-min expiry sweep / full reconciliation. // Rebuilt by `node n8n/gen_w3.js` (single source of truth = shared/effective-grants.js). // dryRun: W3_DRY_RUN=true -> only print the plan (no writes). // autoDisable: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when a // previously-scoped customer's desired set becomes empty. Default off. // ============================================================================ // __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build) // ============================================================================ const ctx = { $env, helpers: this.helpers }; const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true'; const AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true'; const T_CUST = $env.NOCODB_TABLE_CUSTOMERS; // admin login once for the whole sweep const login = await alistReq(ctx, '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'); const customers = (await ncGet(ctx, `/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 yet const cid = cust.Id; // desired scopes via shared algorithm const g = await calculateEffectiveGrants(ctx, cid); const desiredPaths = g.desiredPaths; // current scopes from AList const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token); const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path); const toAdd = desiredPaths.filter(p => !currentPaths.includes(p)); const toRemove = currentPaths.filter(p => !desiredPaths.includes(p)); const scopesChanged = toAdd.length > 0 || toRemove.length > 0; const nowEmpty = desiredPaths.length === 0; // only disable when a customer that HAD scopes now has none (guard // currentPaths>0 avoids killing a fresh customer with an empty role). const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0; const applied = []; if (scopesChanged) { if (!DRY_RUN) await alistReq(ctx, 'POST', '/api/admin/role/update', { id: cust.alist_role_id, name: role?.data?.name, description: role?.data?.description, permission_scopes: g.newScopes, }, token); applied.push('role_scopes'); } if (shouldDisable) { if (!DRY_RUN && cust.alist_user_id) await alistReq(ctx, '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, } };