- Non-active statuses (pending/suspended/expired/deleted) now uniformly: disable AList user + clear role scopes (soft, keeps user/role/CP/history); no-op when the customer has no AList user yet (pure NocoDB label). - Resume transitions (X -> active) recompute effective grants from current CustomerProducts + expiry, never restore stale scopes. - businessChanged() now also treats after-insert (new row, no previous) as a business change so fresh customers are processed on first webhook. - Lifecycle test covers the full matrix: 10 scenarios, all green. - AccessGrants still not materialized (deferred to W4 per decision). - Customers.status stays account-level, outside permission calc.
247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
// ============================================================================
|
|
// 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.
|
|
//
|
|
// LIFECYCLE MATRIX (account-level state, 2026-08-23):
|
|
//
|
|
// status active (no user yet) -> provision (create role + user), recompute
|
|
// effective grants, enable user.
|
|
// status active (has user) -> re-enable + RECOMPUTE effective grants
|
|
// (never restore stale scopes), apply.
|
|
//
|
|
// status pending/suspended/expired/deleted:
|
|
// - has user/role -> disable AList user + clear role scopes (soft; keep
|
|
// user + role + CustomerProducts + history).
|
|
// - no user yet -> no-op (pure NocoDB label; do NOT create AList user).
|
|
//
|
|
// All resume transitions (pending/suspended/expired/deleted -> active) recompute
|
|
// from current CustomerProducts + Product Resources + expiry; never restore old
|
|
// scopes, and never mutate CustomerProducts automatically.
|
|
//
|
|
// SELF-WRITE-BACK SUPPRESSION (loop guard, 2026-08-23 rewrite):
|
|
// We distinguish an ADMIN business change from our OWN write-back echo using
|
|
// the NocoDB v2 webhook's old/new rows (`previous_rows` vs `rows`), NOT the
|
|
// brittle `sync_status === 'synced'` check.
|
|
//
|
|
// businessChanged = a BUSINESS field moved (status / username / password /
|
|
// display_name / email / notes) OR `sync_required` is set.
|
|
// echo = only our bookkeeping fields moved (alist_user_id,
|
|
// alist_role_id, sync_status, sync_required=false,
|
|
// last_sync, last_sync_error).
|
|
//
|
|
// We always write back with `sync_required: false` (and clear it), so our own
|
|
// echo re-enters with businessChanged === false -> skip immediately, before
|
|
// any AList mutation. An admin flipping status (or setting sync_required) is
|
|
// the ONLY thing that proceeds past the guard — exactly once.
|
|
//
|
|
// Value-change write-back guard: writeBack() re-reads the record and only
|
|
// PATCHes fields that would actually CHANGE. Writing the same value produces
|
|
// no PATCH -> no webhook -> no loop, even if NocoDB webhooks fire on any field.
|
|
//
|
|
// 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;
|
|
|
|
// NocoDB v2 webhook payload carries the pre/post rows. The extract node already
|
|
// surfaced customerId; here we re-read the record for the authoritative
|
|
// CURRENT field values (source of truth = NocoDB), and separately inspect the
|
|
// event's old/new rows to decide business-change vs echo.
|
|
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;
|
|
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 — distinguish admin business change from our own echo.
|
|
// The extract node passes through the raw rows so we can diff. Fall back to
|
|
// "always proceed" if the payload rows are missing (better a redundant
|
|
// idempotent provision than a silently dropped admin change).
|
|
const oldRow = ($json.previousRow && Object.keys($json.previousRow).length) ? $json.previousRow : null;
|
|
const newRow = ($json.newRow && Object.keys($json.newRow).length) ? $json.newRow : null;
|
|
|
|
// Business fields an admin edits to MEAN a change. Bookkeeping fields (ids,
|
|
// sync_*, last_sync*) are what WE write and must never by themselves count as
|
|
// a business change.
|
|
const BUSINESS_FIELDS = ['status', 'username', 'password', 'display_name', 'email', 'notes'];
|
|
|
|
function businessChanged() {
|
|
// sync_required explicitly set by admin => force reprocess (one-shot override).
|
|
if (cust.sync_required === true || cust.sync_required === 1) return true;
|
|
|
|
// After-INSERT: there is a new row but no previous row. This is a fresh
|
|
// customer creation -> treat as a business change (provision / deactivate).
|
|
if (newRow && !oldRow) return true;
|
|
|
|
// If we have old+new rows, compare only business fields.
|
|
if (oldRow && newRow) {
|
|
for (const f of BUSINESS_FIELDS) {
|
|
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
|
|
}
|
|
// Any business field present in new but matching old, or bookkeeping-only
|
|
// delta => echo. Also guard against a field we didn't list changing: if a
|
|
// business field existed in the row and moved, catch it generically.
|
|
for (const f of Object.keys(newRow)) {
|
|
// skip pure bookkeeping fields
|
|
if (['alist_user_id','alist_role_id','sync_status','sync_required','last_sync','last_sync_error','Id','UpdatedAt','CreatedAt'].includes(f)) continue;
|
|
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Both rows missing (unknown shape): fall back to provisioning only when
|
|
// provision is genuinely incomplete OR status changed from what AList has.
|
|
// To stay safe against loops, treat "no info" as skip-UNLESS-sync_required,
|
|
// which admin can set explicitly.
|
|
return false;
|
|
}
|
|
|
|
if (!businessChanged()) {
|
|
// Our own write-back echo (or a bookkeeping-only save). Refuse to re-apply.
|
|
// Nothing is written back here, so no further webhook is generated.
|
|
return { json: { customerId, username, status, lifecycle: 'skip_writeback_echo', actions: [] } };
|
|
}
|
|
|
|
// 4. STATUS BRANCH ---------------------------------------------------------
|
|
|
|
// writeBack with a VALUE-CHANGE guard: re-read the live record and PATCH only
|
|
// fields whose value actually differs. If nothing differs -> no PATCH -> no
|
|
// webhook -> loop is physically broken at the source.
|
|
const writeBack = async (fields) => {
|
|
const live = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
|
|
const changes = {};
|
|
for (const k of Object.keys(fields)) {
|
|
const want = fields[k];
|
|
const have = live[k];
|
|
if (want !== have) changes[k] = want;
|
|
}
|
|
if (Object.keys(changes).length === 0) return; // no-op: nothing changed
|
|
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: [Object.assign({ Id: customerId }, changes)],
|
|
json: true,
|
|
});
|
|
};
|
|
|
|
if (status !== 'active') {
|
|
// pending / suspended / expired / deleted / unknown -> deactivate.
|
|
// - has user/role: disable AList user + clear role scopes (soft).
|
|
// - no user yet: no-op (pure label; do NOT create AList user).
|
|
if (!alistUserId && !alistRoleId) {
|
|
return { json: { customerId, username, status, lifecycle: 'noop_not_provisioned', actions: [] } };
|
|
}
|
|
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');
|
|
}
|
|
// value-change guarded write-back; echo re-entry sees businessChanged()===false.
|
|
await writeBack({ sync_status: 'synced', sync_required: false, last_sync_error: null });
|
|
return { json: { customerId, username, status, lifecycle: 'deactivate', 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 + sync_status + clear sync_required (value-change guarded).
|
|
// The echo re-entry sees businessChanged()===false -> skip_writeback_echo.
|
|
await writeBack({
|
|
alist_user_id: alistUserId || finalUserId,
|
|
alist_role_id: alistRoleId || finalRoleId,
|
|
sync_status: 'synced',
|
|
sync_required: false,
|
|
last_sync_error: null,
|
|
});
|
|
|
|
return { json: {
|
|
customerId, username, status, lifecycle: 'active_provision',
|
|
alistUserId: finalUserId, alistRoleId: finalRoleId,
|
|
desiredPaths: g.desiredPaths, actions,
|
|
} };
|