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:
+181
@@ -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,
|
||||
} };
|
||||
Reference in New Issue
Block a user