Skip to content

Commit 2cdb9a9

Browse files
committed
fix(plugin-approvals): the delegation Delegator field offers only the acting user
bindDelegationWriteGuard admits one delegator on a non-system write, the acting user, and refuses every other with 403. The field was a free sys_user lookup, so the create form offered every user. It now declares defaultValue 'current_user' and a lookupFilters entry on id equal to {current_user_id}, so the form opens on the acting user and the picker offers nobody else. The guard is unchanged. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent a43405d commit 2cdb9a9

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Delegator field of `sys_approval_delegation` offers exactly what the
5+
* delegation write guard admits.
6+
*
7+
* `bindDelegationWriteGuard` (lifecycle-hooks.ts) admits one delegator on a
8+
* non-system write — the acting user — and refuses every other with 403: a
9+
* delegation names its own author as delegator, and acting for an absent
10+
* approver is the approval-level override (the guard's docstring holds the
11+
* ruling). The field used to be a free `sys_user` lookup, so the console's
12+
* create dialog demanded a choice among every user and the server refused all
13+
* but one of them. It now declares that one value, in the object vocabulary
14+
* the create form honours:
15+
*
16+
* - `defaultValue: 'current_user'` — the create form seeds the acting user
17+
* (the token the console resolves client-side on a `sys_user` lookup), and
18+
* an insert that omits the field stores the acting user;
19+
* - `lookupFilters` on `id` = `{current_user_id}` — the picker's candidate
20+
* query, whose placeholder the engine resolves against the caller on every
21+
* read.
22+
*
23+
* Through a real `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory, real
24+
* DDL) with the guard bound, this file asserts:
25+
*
26+
* 1. the declaration, read off the object;
27+
* 2. the picker's candidate query returns the caller and nobody else, for
28+
* each caller;
29+
* 3. every candidate it offers is admitted by the guard, and a create that
30+
* leaves the field empty stores the caller;
31+
* 4. the guard is not widened: a create or an update naming anyone else is
32+
* still refused, `FORBIDDEN` / 403, and nothing is stored;
33+
* 5. `required` stays: a write with no acting user still has no delegator.
34+
*/
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { ObjectQL } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import { isCurrentUserDefaultToken } from '@objectstack/spec/data';
40+
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
41+
import { bindDelegationWriteGuard } from './lifecycle-hooks.js';
42+
43+
const OBJECT = 'sys_approval_delegation';
44+
const SYSTEM = { context: { isSystem: true } } as any;
45+
const asUser = (userId: string) => ({ context: { userId, positions: [], permissions: [] } }) as any;
46+
47+
/** The install's people: an admin covering for Priya, and two colleagues. */
48+
const USERS = [
49+
{ id: 'u_admin', name: 'Dev Admin' },
50+
{ id: 'u_priya', name: 'Priya Raman' },
51+
{ id: 'u_tomas', name: 'Tomas Okafor' },
52+
];
53+
54+
/** Just enough of `sys_user` for the picker's candidate query. */
55+
const sysUser = {
56+
name: 'sys_user',
57+
label: 'User',
58+
fields: {
59+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
60+
name: { name: 'name', label: 'Name', type: 'text' as const },
61+
},
62+
};
63+
64+
type LookupFilter = { field: string; operator: string; value: unknown };
65+
66+
const delegatorField = (): Record<string, any> =>
67+
(SysApprovalDelegation.fields as Record<string, any>).delegator_id;
68+
69+
/**
70+
* The picker's candidate `where`, from the declared `lookupFilters`. The
71+
* console lowers each `eq` entry to `{ [field]: value }` and sends it as the
72+
* `$filter` of its `sys_user` query (objectui `lookupFiltersToRecord`); every
73+
* entry here is `eq`, which the declaration test holds.
74+
*/
75+
function pickerWhere(): Record<string, unknown> {
76+
const filters = (delegatorField().lookupFilters ?? []) as LookupFilter[];
77+
const where: Record<string, unknown> = {};
78+
for (const f of filters) where[f.field] = f.value;
79+
return where;
80+
}
81+
82+
describe('sys_approval_delegation — the Delegator field offers only what the write guard admits', () => {
83+
let engine: ObjectQL;
84+
85+
beforeAll(async () => {
86+
engine = new ObjectQL();
87+
const driver = new SqlDriver({
88+
client: 'better-sqlite3',
89+
connection: { filename: ':memory:' },
90+
useNullAsDefault: true,
91+
});
92+
engine.registerDriver(driver, true);
93+
await engine.init();
94+
engine.registry.registerObject(sysUser as any, 'com.objectstack.test.delegator-picker');
95+
engine.registry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.delegator-picker');
96+
await engine.syncSchemas();
97+
bindDelegationWriteGuard(engine as any);
98+
for (const u of USERS) await engine.insert('sys_user', u, SYSTEM);
99+
});
100+
101+
afterAll(async () => {
102+
try { await engine?.destroy(); } catch { /* noop */ }
103+
});
104+
105+
it('declares the acting user as the default and as the picker\'s only candidate, and stays required', () => {
106+
const field = delegatorField();
107+
expect(field.type).toBe('lookup');
108+
expect(field.reference).toBe('sys_user');
109+
expect(field.required).toBe(true);
110+
// The token the console's create form resolves to the acting user on a
111+
// `sys_user` lookup, and the engine stamps at insert.
112+
expect(isCurrentUserDefaultToken(field.defaultValue)).toBe(true);
113+
expect(field.lookupFilters).toEqual([{ field: 'id', operator: 'eq', value: '{current_user_id}' }]);
114+
});
115+
116+
it('the registered body keeps both declarations', () => {
117+
const registered = (engine.registry.getObject(OBJECT) as any).fields.delegator_id;
118+
expect(isCurrentUserDefaultToken(registered.defaultValue)).toBe(true);
119+
expect(registered.lookupFilters).toEqual(delegatorField().lookupFilters);
120+
});
121+
122+
it.each(USERS.map((u) => [u.id]))('the picker offers %s themselves and nobody else', async (caller) => {
123+
const candidates = await engine.find('sys_user', { where: pickerWhere() } as any, asUser(caller));
124+
expect(candidates.map((r: any) => r.id)).toEqual([caller]);
125+
});
126+
127+
it('every candidate the picker offers is admitted by the guard', async () => {
128+
const candidates = await engine.find('sys_user', { where: pickerWhere() } as any, asUser('u_admin'));
129+
expect(candidates.length).toBeGreaterThan(0);
130+
for (const candidate of candidates) {
131+
const created = await engine.insert(
132+
OBJECT, { delegator_id: (candidate as any).id, delegate_id: 'u_tomas' }, asUser('u_admin'),
133+
);
134+
expect(created.delegator_id).toBe('u_admin');
135+
}
136+
});
137+
138+
it.each([
139+
['omitted', { delegate_id: 'u_tomas' }],
140+
['null', { delegator_id: null, delegate_id: 'u_tomas' }],
141+
])('a create that leaves the delegator %s stores the acting user', async (_label, data) => {
142+
const created = await engine.insert(OBJECT, { ...data }, asUser('u_admin'));
143+
const row = await engine.findOne(OBJECT, { where: { id: created.id } } as any, SYSTEM);
144+
expect(row?.delegator_id).toBe('u_admin');
145+
});
146+
147+
it('the guard is not widened: a create naming anyone else is refused FORBIDDEN / 403 and stores nothing', async () => {
148+
const before = await engine.count(OBJECT, { where: { delegator_id: 'u_priya' } } as any, SYSTEM);
149+
await expect(
150+
engine.insert(OBJECT, { delegator_id: 'u_priya', delegate_id: 'u_tomas' }, asUser('u_admin')),
151+
).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 });
152+
expect(await engine.count(OBJECT, { where: { delegator_id: 'u_priya' } } as any, SYSTEM)).toBe(before);
153+
});
154+
155+
it('the guard is not widened: an update relabelling the delegator is refused FORBIDDEN / 403', async () => {
156+
const own = await engine.insert(OBJECT, { delegate_id: 'u_tomas' }, asUser('u_admin'));
157+
await expect(
158+
engine.update(OBJECT, { id: own.id, delegator_id: 'u_priya' }, asUser('u_admin')),
159+
).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 });
160+
const row = await engine.findOne(OBJECT, { where: { id: own.id } } as any, SYSTEM);
161+
expect(row?.delegator_id).toBe('u_admin');
162+
});
163+
164+
it('`required` stays: a system write with no acting user and no delegator is refused', async () => {
165+
await expect(engine.insert(OBJECT, { delegate_id: 'u_tomas' }, SYSTEM)).rejects.toMatchObject({
166+
code: 'VALIDATION_FAILED',
167+
fields: expect.arrayContaining([expect.objectContaining({ field: 'delegator_id', code: 'required' })]),
168+
});
169+
});
170+
});

‎packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,27 @@ export const SysApprovalDelegation = ObjectSchema.create({
8686
group: 'Delegation',
8787
}),
8888

89+
// A delegation names its own author as delegator: on every non-system
90+
// write `bindDelegationWriteGuard` (lifecycle-hooks.ts) admits exactly one
91+
// value here, the acting user, and refuses any other with 403 (its
92+
// docstring holds why there is no admin exemption). So the field declares
93+
// that one value in the vocabulary the create form honours, instead of a
94+
// free `sys_user` lookup that offered every user and saw all but one
95+
// refused:
96+
// - `defaultValue: 'current_user'` — the create form opens on the acting
97+
// user, and an insert that omits the field stores the acting user
98+
// (`applyFieldDefaults`, before the guard runs; the guard stamps the
99+
// same value). `required` stays: a write with no acting user still has
100+
// no delegator, and is refused.
101+
// - `lookupFilters` — the picker's candidate query, which the engine
102+
// resolves `{current_user_id}` in for every read, so it offers the
103+
// acting user and nobody else.
104+
// Pinned in `sys-approval-delegation-delegator-picker.test.ts`.
89105
delegator_id: Field.lookup('sys_user', {
90106
label: 'Delegator',
91107
required: true,
108+
defaultValue: 'current_user',
109+
lookupFilters: [{ field: 'id', operator: 'eq', value: '{current_user_id}' }],
92110
group: 'Delegation',
93111
description: 'The user going out of office; their individually-routed approver slots are rerouted while active.',
94112
}),

0 commit comments

Comments
 (0)