feat(W1): status lifecycle (suspend/delete/resume) + shared effective-grants lib

- Extract calculateEffectiveGrants into n8n/shared/effective-grants.js
  (single source of truth), inlined at build time into W1/W2/W3 via
  gen_w*.js + inline_shared.js — removes the duplicated algorithm.
- W1 now provisions with a status branch:
  suspended/deleted -> disable AList user + clear role scopes (soft delete)
  active -> create/update user+role, RECOMPUTE desired scopes (never restore
  stale), re-enable
- Loop guard corrected: check AList user LIVE disabled state via
  GET /api/admin/user/get (sync_status alone breaks suspend->resume).
- W2/W3 rebuilt on shared lib (identical behaviour).
- Tests: test_effective_grants.js (15), test_w3_refactored.js (13),
  test_w1_lifecycle.js (23) — all green.
- Drop superseded w2-compute.js / w3-compute.js / test_w3.js.
This commit is contained in:
2026-08-23 04:59:43 +08:00
parent 870a702f7b
commit 3ecee3099e
18 changed files with 1216 additions and 1109 deletions
+181
View File
@@ -0,0 +1,181 @@
// ============================================================================
// W1 runtime body — Customer provision + STATUS LIFECYCLE (Step 3).
// Rebuilt by `node n8n/gen_w1.js` (single source of truth = shared/effective-grants.js).
//
// Single self-contained Code node (plus webhook + id extract). Replaces the
// old multi-node chain with the SAME provision behaviour PLUS status lifecycle.
//
// status suspended/deleted -> disable AList user + clear role scopes (soft
// delete: user + NocoDB history preserved).
// status active -> provision (create/update user+role) then
// RECOMPUTE desired scopes via shared
// calculateEffectiveGrants and write them, then
// re-enable the user.
// other status -> no AList mutation (skip).
//
// Write-back: if the customer is missing alist_user_id / alist_role_id we
// PATCH them back to NocoDB (with sync_status 'synced'). The echo webhook from
// that PATCH re-enters W1 but the loop guard below (skipWriteBack when already
// populated + status unchanged) makes it a no-op.
//
// Idempotency: final state is converged per status (see above). Duplicate or
// echo webhooks re-apply the same end state with zero net change.
// ============================================================================
// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)
// ============================================================================
const ctx = { $env, helpers: this.helpers };
// 1. fresh customer record by id (from webhook extract)
const customerId = $json.customerId;
const cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
const status = cust.status || 'active';
const username = cust.username;
if (!username) throw new Error('Customer missing username');
const alistUserId = cust.alist_user_id ?? null;
const alistRoleId = cust.alist_role_id ?? null;
// loop guard: if this webhook is our own write-back echo AND the customer is
// already fully provisioned with synced status AND status is unchanged, skip.
// (Detected below via alreadyProvisioned + sync_status === 'synced'.)
const alreadyProvisioned = !!(alistUserId && alistRoleId);
// 2. admin login
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');
// 3. loop guard: echo of our own write-back (active + already realized) -> skip.
// IMPORTANT: sync_status==='synced' alone is NOT enough to skip, because a
// suspended->active resume ALSO carries sync_status==='synced' (the suspend
// path writes it) while the AList user is still disabled. We therefore check
// the ACTUAL AList user disabled state: only skip when the user is provably
// enabled already. If disabled -> fall through to active provision (re-enable
// + recompute), which is exactly what resume needs.
if (alreadyProvisioned && status === 'active' && cust.sync_status === 'synced' && alistUserId) {
let currentlyEnabled = true;
try {
const uInfo = await alistReq(ctx, 'GET', `/api/admin/user/get?id=${alistUserId}`, null, token);
if (uInfo?.data && typeof uInfo.data.disabled === 'boolean') {
currentlyEnabled = !uInfo.data.disabled;
}
} catch (e) { /* user/get may 404 for some AList versions; assume enabled */ }
if (currentlyEnabled) {
return { json: { customerId, username, lifecycle: 'skip_echo', actions: [] } };
}
}
// 4. STATUS BRANCH ---------------------------------------------------------
const writeBack = async (fields) => {
// PATCH id fields back to NocoDB. Guard: only when they differ.
const body = Object.assign({ Id: customerId }, fields);
await ctx.helpers.httpRequest({
method: 'PATCH',
url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`,
headers: { 'xc-token': $env.NOCODB_TOKEN },
body: [body],
json: true,
});
};
if (status === 'suspended' || status === 'deleted') {
const actions = [];
if (alistUserId) {
await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token);
actions.push('disable_user');
}
if (alistRoleId) {
const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${alistRoleId}`, null, token);
await alistReq(ctx, 'POST', '/api/admin/role/update', {
id: alistRoleId,
name: role?.data?.name,
description: role?.data?.description,
permission_scopes: [],
}, token);
actions.push('clear_scopes');
}
// mark synced so we don't re-fire on our own write-back (but we don't write
// much here — only sync_status to stop the echo loop).
await writeBack({ sync_status: 'synced', last_sync_error: null });
return { json: { customerId, username, status, lifecycle: 'suspend_or_delete', actions } };
}
if (status !== 'active') {
// pending / expired / disabled / unknown -> no mutation, but mark synced to
// stop echo loop only if we are the ones writing. We do NOT write here to
// avoid interfering while the customer is being drafted.
return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } };
}
// status === 'active' -------------------------------------------------------
const actions = [];
let finalUserId = alistUserId;
let finalRoleId = alistRoleId;
// 4a. ensure role exists
if (!finalRoleId) {
await alistReq(ctx, 'POST', '/api/admin/role/create', {
name: 'customer-' + username,
description: 'Auto role for ' + username,
default: false,
permission_scopes: [],
}, token);
const roles = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=100', null, token);
const r = (roles?.data?.content || []).find(x => x.name === 'customer-' + username);
if (!r) throw new Error('Created role not found: customer-' + username);
finalRoleId = r.id;
actions.push('create_role');
}
// 4b. ensure user exists / update; always re-enable on active
if (!finalUserId) {
await alistReq(ctx, 'POST', '/api/admin/user/create', {
username,
password: cust.password,
base_path: '/',
role: finalRoleId ? [finalRoleId] : [],
permission: 16384,
}, token);
const users = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=100', null, token);
const u = (users?.data?.content || []).find(x => x.username === username);
if (!u) throw new Error('Created user not found: ' + username);
finalUserId = u.id;
actions.push('create_user');
} else {
await alistReq(ctx, 'POST', '/api/admin/user/update', {
id: finalUserId,
username,
password: cust.password,
base_path: '/',
role: finalRoleId ? [finalRoleId] : [],
permission: 16384,
disabled: false,
}, token);
actions.push('update_user');
}
// 4c. RECOMPUTE desired scopes (never restore stale) and write them
const g = await calculateEffectiveGrants(ctx, customerId);
await alistReq(ctx, 'POST', '/api/admin/role/update', {
id: finalRoleId,
name: 'customer-' + username,
description: 'Auto role for ' + username,
permission_scopes: g.newScopes,
}, token);
actions.push('apply_grants');
// 5. write-back ids if newly discovered (creates the echo; landed in loop guard)
if (!alistUserId || !alistRoleId) {
await writeBack({ alist_user_id: finalUserId, alist_role_id: finalRoleId, sync_status: 'synced', last_sync_error: null });
}
return { json: {
customerId, username, status, lifecycle: 'active_provision',
alistUserId: finalUserId, alistRoleId: finalRoleId,
desiredPaths: g.desiredPaths, actions,
} };