Skip to content

Commit 611402f

Browse files
os-zhuangclaude
andauthored
fix(approvals): guard self-service OOO delegation writes + i18n (#3255)
Follow-up hardening on the merged OOO delegation feature (#3235). Security — delegator forge guard: sys_approval_delegation is apiEnabled CRUD, but as a system object it gets no owner_id anchor and defaults to a `public` sharing model, so an unguarded member could create a delegation naming SOMEONE ELSE as delegator and reroute that victim's individually-routed approvals to themselves. bindDelegationWrite- Guard (plugin-approvals beforeInsert/beforeUpdate, mirroring the ADR-0092 identity write-guard) forces a normal user's writes to name themselves as delegator: system context bypasses, admins (roles include 'admin') may set any delegator, everyone else is stamped-to-self on insert and rejected on a foreign delegator. Row ownership on update/delete is already covered by member_default's wildcard `created_by == current_user.id` RLS. i18n: Register sys_approval_delegation in the plugin's i18n extract config and add its object/field/view translations. zh-CN fully translated; en/ja-JP/es-ES carry the English baseline pending translation. (Blocks hand-added to avoid a full re-extract dropping existing enum/view keys.) Tests: 9 new guard cases (self-create, forge reject on insert/update, stamp on omit, unauthenticated reject, system + admin bypass, batch). plugin-approvals 123 passed. Claude-Session: https://claude.ai/code/session_016ypkQikZ55oWUHUnMebwXA Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d5a892 commit 611402f

8 files changed

Lines changed: 333 additions & 4 deletions

File tree

packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
import { defineStack } from '@objectstack/spec';
1616
import { SysApprovalRequest } from '../src/sys-approval-request.object.js';
1717
import { SysApprovalAction } from '../src/sys-approval-action.object.js';
18+
import { SysApprovalDelegation } from '../src/sys-approval-delegation.object.js';
1819
import { enObjects } from '../src/translations/en.objects.generated.js';
1920
import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
2021
import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
2122
import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
2223

2324
export default defineStack({
2425
name: 'plugin-approvals-i18n-extract',
25-
objects: [SysApprovalRequest, SysApprovalAction] as any,
26+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalDelegation] as any,
2627
translations: [
2728
{ en: { objects: enObjects } },
2829
{ 'zh-CN': { objects: zhCNObjects } },

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import { describe, it, expect, beforeEach } from 'vitest';
1313
import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
14-
import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
14+
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
1515

1616
interface FakeRow { [k: string]: any }
1717

@@ -1223,3 +1223,67 @@ describe('ApprovalService — out-of-office delegation (#1322)', () => {
12231223
expect(req.pending_approvers).toEqual(['bob']);
12241224
});
12251225
});
1226+
1227+
// ── Delegation self-service write guard (#1322 follow-up) ─────────────
1228+
//
1229+
// sys_approval_delegation is apiEnabled CRUD; a member must not be able to
1230+
// forge a delegation for someone else (delegator_id = victim) and reroute the
1231+
// victim's approvals. The guard forces delegator_id == acting user for normal
1232+
// writes; system/admin contexts bypass. Row-ownership on update/delete is the
1233+
// platform's created_by RLS (not exercised here).
1234+
describe('sys_approval_delegation write guard (#1322)', () => {
1235+
const DEL = 'sys_approval_delegation';
1236+
let engine: ReturnType<typeof makeFakeEngine>;
1237+
1238+
beforeEach(() => {
1239+
engine = makeFakeEngine();
1240+
bindDelegationWriteGuard(engine as any);
1241+
});
1242+
1243+
const fireInsert = (data: any, session: any) =>
1244+
(engine as any).fire('beforeInsert', { object: DEL, input: { data }, session });
1245+
const fireUpdate = (data: any, session: any) =>
1246+
(engine as any).fire('beforeUpdate', { object: DEL, input: { id: data?.id ?? 'd1', data }, session });
1247+
const member = (userId?: string) => ({ isSystem: false, roles: [], ...(userId ? { userId } : {}) });
1248+
1249+
it('allows a member to create their own delegation', async () => {
1250+
await expect(fireInsert({ delegator_id: 'u1', delegate_id: 'u2' }, member('u1'))).resolves.toBeUndefined();
1251+
});
1252+
1253+
it('rejects a member forging a delegation for someone else', async () => {
1254+
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
1255+
});
1256+
1257+
it('stamps the caller as delegator when omitted on insert', async () => {
1258+
const data: any = { delegate_id: 'u2' };
1259+
await fireInsert(data, member('u1'));
1260+
expect(data.delegator_id).toBe('u1');
1261+
});
1262+
1263+
it('rejects an unauthenticated non-system insert', async () => {
1264+
await expect(fireInsert({ delegate_id: 'u2' }, member())).rejects.toThrow(/FORBIDDEN/);
1265+
});
1266+
1267+
it('bypasses the guard for system context', async () => {
1268+
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, { isSystem: true })).resolves.toBeUndefined();
1269+
});
1270+
1271+
it('lets an admin set the delegator to anyone', async () => {
1272+
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u2' }, { isSystem: false, roles: ['admin'], userId: 'admin1' })).resolves.toBeUndefined();
1273+
});
1274+
1275+
it('rejects a member relabelling delegator on update', async () => {
1276+
await expect(fireUpdate({ id: 'd1', delegator_id: 'victim' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
1277+
});
1278+
1279+
it('allows a member update that does not touch delegator_id', async () => {
1280+
await expect(fireUpdate({ id: 'd1', valid_until: '2026-06-01T00:00:00Z' }, member('u1'))).resolves.toBeUndefined();
1281+
});
1282+
1283+
it('rejects a batch insert if any row names a foreign delegator', async () => {
1284+
await expect(fireInsert(
1285+
[{ delegator_id: 'u1', delegate_id: 'u2' }, { delegator_id: 'victim', delegate_id: 'u3' }],
1286+
member('u1'),
1287+
)).rejects.toThrow(/FORBIDDEN/);
1288+
});
1289+
});

packages/plugins/plugin-approvals/src/approvals-plugin.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
ESCALATION_SCAN_INTERVAL_MS,
1414
type ApprovalEngine,
1515
} from './approval-service.js';
16-
import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
16+
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
1717
import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
1818

1919
export interface ApprovalsPluginOptions {
@@ -124,12 +124,16 @@ export class ApprovalsServicePlugin implements Plugin {
124124
});
125125

126126
// Record lock: block edits to a record while it has a pending request.
127+
// Delegation write-guard: a self-service OOO delegation may only name the
128+
// acting user as delegator (#1322 follow-up). Both bind under the same
129+
// package id, so unbindAllHooks clears them together.
127130
if (!this.options.disableAutoHooks) {
128131
try {
129132
unbindAllHooks(engine);
130133
bindApprovalLockHook(engine, ctx.logger);
134+
bindDelegationWriteGuard(engine, ctx.logger);
131135
} catch (err: any) {
132-
ctx.logger.warn?.('[approvals] failed to bind record-lock hook', { error: err?.message });
136+
ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
133137
}
134138
}
135139

packages/plugins/plugin-approvals/src/lifecycle-hooks.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,70 @@ export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogg
109109
logger?.info?.('[approvals] record-lock hook bound');
110110
}
111111

112+
/** The self-service out-of-office delegation object (#1322). */
113+
export const DELEGATION_OBJECT = 'sys_approval_delegation';
114+
115+
/**
116+
* Self-service write guard for `sys_approval_delegation` (#1322 follow-up).
117+
*
118+
* The object is `apiEnabled` CRUD so a user can declare their own out-of-office
119+
* delegation. But it is a system object: it gets no auto `owner_id` anchor and
120+
* (with no `sharingModel`) defaults to a `public` sharing model, so an
121+
* unguarded member could **forge a delegation for someone else**
122+
* (`delegator_id = victim`) and reroute the victim's individually-routed
123+
* approvals to themselves. This guard forces a normal user's writes to name
124+
* themselves as the delegator:
125+
*
126+
* - **system** context (service / seed / import) → bypass;
127+
* - **admin** (`roles` includes `'admin'`) → may set `delegator_id` to anyone;
128+
* - otherwise `delegator_id` must equal the acting user — an absent delegator
129+
* on insert is stamped to the caller, a foreign delegator is rejected.
130+
*
131+
* Row-level ownership on update/delete (you can only touch a delegation you
132+
* created) is already enforced by `member_default`'s wildcard
133+
* `created_by == current_user.id` RLS; this guard adds the delegator-identity
134+
* check that RLS alone can't express. Mirrors the ADR-0092 identity write-guard
135+
* shape and the security plugin's `owner_id` anchor guard, scoped to this one
136+
* object.
137+
*/
138+
export function bindDelegationWriteGuard(engine: MinimalEngine, logger?: MinimalLogger): void {
139+
const makeGuard = (isInsert: boolean) => async (ctx: any) => {
140+
const session = (ctx?.session ?? {}) as any;
141+
if (session.isSystem) return; // service / seed / import
142+
const roles = (session.roles ?? []) as unknown[];
143+
if (Array.isArray(roles) && roles.includes('admin')) return; // admin may act for anyone
144+
const userId = session.userId != null ? String(session.userId) : '';
145+
const data = ctx?.input?.data;
146+
const rows = Array.isArray(data) ? data : (data && typeof data === 'object' ? [data] : []);
147+
const deny = (): never => {
148+
const err: any = new Error(
149+
'FORBIDDEN: you may only manage out-of-office delegations where you are the delegator'
150+
+ (userId ? ` ('${userId}')` : ''),
151+
);
152+
err.code = 'FORBIDDEN';
153+
err.statusCode = 403;
154+
throw err;
155+
};
156+
for (const row of rows) {
157+
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
158+
const has = Object.prototype.hasOwnProperty.call(row, 'delegator_id');
159+
const supplied = has ? String((row as any).delegator_id ?? '') : '';
160+
if (isInsert && (!has || supplied === '')) {
161+
// Self-service: stamp the caller as delegator when omitted (the schema's
162+
// `required` is the fallback if the engine doesn't persist the stamp).
163+
if (!userId) deny();
164+
(row as any).delegator_id = userId;
165+
continue;
166+
}
167+
// A foreign delegator on insert (forge) or update (relabel/hijack) → deny.
168+
if (has && supplied !== userId) deny();
169+
}
170+
};
171+
engine.registerHook('beforeInsert', makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
172+
engine.registerHook('beforeUpdate', makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
173+
logger?.info?.('[approvals] delegation write-guard bound');
174+
}
175+
112176
/** Unregister every hook the lock module registered. */
113177
export function unbindAllHooks(engine: MinimalEngine): number {
114178
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);

packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,54 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
152152
label: "All"
153153
}
154154
}
155+
},
156+
sys_approval_delegation: {
157+
label: "Approval Delegation",
158+
pluralLabel: "Approval Delegations",
159+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
160+
fields: {
161+
id: {
162+
label: "Delegation ID"
163+
},
164+
delegator_id: {
165+
label: "Delegator",
166+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
167+
},
168+
delegate_id: {
169+
label: "Delegate",
170+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
171+
},
172+
valid_from: {
173+
label: "Valid From",
174+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job."
175+
},
176+
valid_until: {
177+
label: "Valid Until",
178+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
179+
},
180+
reason: {
181+
label: "Reason",
182+
help: "Why the delegation exists (e.g. \"Annual leave 5/26\u20135/30\"). Recorded on the substitution audit row."
183+
},
184+
organization_id: {
185+
label: "Organization",
186+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
187+
},
188+
created_at: {
189+
label: "Created At"
190+
},
191+
updated_at: {
192+
label: "Updated At"
193+
}
194+
},
195+
_views: {
196+
active: {
197+
label: "Active",
198+
emptyState: {
199+
title: "No delegations",
200+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
201+
}
202+
}
203+
}
155204
}
156205
};

packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,54 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
152152
label: "Todas"
153153
}
154154
}
155+
},
156+
sys_approval_delegation: {
157+
label: "Approval Delegation",
158+
pluralLabel: "Approval Delegations",
159+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
160+
fields: {
161+
id: {
162+
label: "Delegation ID"
163+
},
164+
delegator_id: {
165+
label: "Delegator",
166+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
167+
},
168+
delegate_id: {
169+
label: "Delegate",
170+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
171+
},
172+
valid_from: {
173+
label: "Valid From",
174+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job."
175+
},
176+
valid_until: {
177+
label: "Valid Until",
178+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
179+
},
180+
reason: {
181+
label: "Reason",
182+
help: "Why the delegation exists (e.g. \"Annual leave 5/26\u20135/30\"). Recorded on the substitution audit row."
183+
},
184+
organization_id: {
185+
label: "Organization",
186+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
187+
},
188+
created_at: {
189+
label: "Created At"
190+
},
191+
updated_at: {
192+
label: "Updated At"
193+
}
194+
},
195+
_views: {
196+
active: {
197+
label: "Active",
198+
emptyState: {
199+
title: "No delegations",
200+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
201+
}
202+
}
203+
}
155204
}
156205
};

packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,54 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
152152
label: "すべて"
153153
}
154154
}
155+
},
156+
sys_approval_delegation: {
157+
label: "Approval Delegation",
158+
pluralLabel: "Approval Delegations",
159+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
160+
fields: {
161+
id: {
162+
label: "Delegation ID"
163+
},
164+
delegator_id: {
165+
label: "Delegator",
166+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
167+
},
168+
delegate_id: {
169+
label: "Delegate",
170+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
171+
},
172+
valid_from: {
173+
label: "Valid From",
174+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job."
175+
},
176+
valid_until: {
177+
label: "Valid Until",
178+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
179+
},
180+
reason: {
181+
label: "Reason",
182+
help: "Why the delegation exists (e.g. \"Annual leave 5/26\u20135/30\"). Recorded on the substitution audit row."
183+
},
184+
organization_id: {
185+
label: "Organization",
186+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
187+
},
188+
created_at: {
189+
label: "Created At"
190+
},
191+
updated_at: {
192+
label: "Updated At"
193+
}
194+
},
195+
_views: {
196+
active: {
197+
label: "Active",
198+
emptyState: {
199+
title: "No delegations",
200+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
201+
}
202+
}
203+
}
155204
}
156205
};

0 commit comments

Comments
 (0)