Skip to content

Commit 209d3e5

Browse files
committed
feat(rest): REST gates consume the shared derivation resolver (#3391 PR-2)
Route every per-object exposure gate through the spec's single derivation source of truth, so the three-state whitelist and the derived verbs are resolved identically everywhere. - Main gate (`apiAccessDenialFromEnable`): resolver-backed; `[]` → deny-all; the 405 body's `allowed` array is now the EFFECTIVE operation set (enum- ordered), not the raw whitelist. `enforceApiAccess` forwards writeMode / bulkChild opts. - Import: two-stage gate — a coarse `create ∨ update` check 405s fully-closed objects before the CSV parse; a writeMode-precise second stage (insert→create, update→update, upsert→create∧update) after prep resolves the mode. - Export: reverse-derives from `list`, and the schema-derived column header is projected to the FLS-readable set (union of masked-row keys) so export can never expose a wider column set than list. Explicit `?fields=` is honored but masked values stay empty. - Bulk: all five surfaces (createMany/updateMany/deleteMany, per-object /batch, cross-object /batch) require `bulk ∧ child`. - platform-objects: the four in-repo explicit-whitelist identity objects gain the `bulk` primitive so their Many/batch surfaces keep working. Tests: rest exposure suite (405 allowed = effective, deny-all, bulk∧child), new rest-api-derivation-gates suite, batch endpoint bulk cases, and two blocking export FLS column-projection tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH
1 parent d3ae192 commit 209d3e5

9 files changed

Lines changed: 377 additions & 33 deletions

packages/platform-objects/src/identity/sys-business-unit-member.object.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ export const SysBusinessUnitMember = ObjectSchema.create({
108108
// P2 derives these from create/update|list and reclaims the explicit
109109
// entries. Reconcile-safe: import/export are not generic write verbs, so
110110
// reconcileManagedApiMethods (managedBy:'platform') never strips them.
111-
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'],
111+
// `bulk` grants the createMany/updateMany/deleteMany + batch surfaces (bulk
112+
// ∧ child after #3391) — memberships are imported as one bulk operation.
113+
// `bulk` is a primitive — a permanent, legitimate declaration, not P2 debt.
114+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'],
112115
},
113116
});

packages/platform-objects/src/identity/sys-business-unit.object.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ export const SysBusinessUnit = ObjectSchema.create({
219219
// Data portability is expected for the org tree: fields like `external_ref`
220220
// and `effective_from/to` are designed for HRIS batch sync (#3025). Import
221221
// reuses the create/update affordances this object already grants.
222-
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'],
222+
// `bulk` grants the createMany/updateMany/deleteMany + batch surfaces, which
223+
// after #3391 require the `bulk` primitive (bulk ∧ child). `bulk` is a
224+
// primitive — a permanent, legitimate declaration, not P0/P2 debt.
225+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'],
223226
},
224227
});

packages/platform-objects/src/identity/sys-user-preference.object.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ export const SysUserPreference = ObjectSchema.create({
115115
trackHistory: false,
116116
searchable: false,
117117
apiEnabled: true,
118-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
118+
// `bulk` grants the createMany/updateMany/deleteMany + batch surfaces, which
119+
// after #3391 require the `bulk` primitive (bulk ∧ child). `bulk` is a
120+
// primitive — a permanent, legitimate declaration.
121+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
119122
},
120123
});

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -758,8 +758,11 @@ export const SysUser = ObjectSchema.create({
758758
// actions), so they are not exposed. `update` stays: it is the ONE
759759
// generic write opened on an identity table (ADR-0092 D4), server-side
760760
// clamped to the profile-field whitelist ({name, image}) by the guard —
761-
// `userActions.edit: true` above declares the affordance.
762-
apiMethods: ['get', 'list', 'update'],
761+
// `userActions.edit: true` above declares the affordance. `bulk` grants the
762+
// updateMany surface (bulk ∧ update after #3391); paired with the sole
763+
// `update` write, only bulk-update is admitted (createMany/deleteMany still
764+
// 405). `bulk` is a primitive — a permanent, legitimate declaration.
765+
apiMethods: ['get', 'list', 'update', 'bulk'],
763766
},
764767

765768
// Email uniqueness is enforced by the unique index above (and better-auth's

packages/rest/src/export-integration.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,70 @@ describe('export route — real engine + protocol integration', () => {
316316
expect(dataRows[0][2]).toBe('是');
317317
});
318318
});
319+
320+
// ===========================================================================
321+
// #3391 blocking test: export column projection ≡ list's field-level security.
322+
// The derivation contract opens export from `list`, so export MUST NOT expose a
323+
// wider column set than list. The read middleware DELETES unreadable keys, so a
324+
// schema-derived export header would otherwise leak the *names* of FLS-hidden
325+
// columns as empty cells. These two tests are the "阻塞性测试" — they fail
326+
// (red) against the pre-#3391 header (= full schema) and pass after the fix.
327+
// ===========================================================================
328+
describe('export route — FLS column projection (#3391 blocking)', () => {
329+
// Simulate field-level security with the exact delete-key semantics the
330+
// security FieldMasker uses: strip `title` from every task row on read.
331+
const maskTitle = (engine: any) =>
332+
engine.registerHook(
333+
'afterFind',
334+
(ctx: any) => { if (Array.isArray(ctx.result)) for (const r of ctx.result) { if (r) delete r.title; } },
335+
{ object: 'task' },
336+
);
337+
338+
it('schema-derived header is narrowed to the masked-readable column set (CSV + JSON)', async () => {
339+
const { engine, protocol, route } = await boot();
340+
maskTitle(engine);
341+
342+
// The list key set under the SAME read path (afterFind runs on find too).
343+
const listRes: any = await protocol.findData({ object: 'task', query: {} });
344+
const listRows: any[] = listRes.records ?? listRes.data ?? [];
345+
const listKeys = new Set<string>();
346+
for (const r of listRows) for (const k of Object.keys(r)) listKeys.add(k);
347+
expect(listKeys.has('title')).toBe(false); // masked out of list too
348+
349+
// CSV: the masked column's header (标题) must be gone — no empty leak column.
350+
const csv = makeRes();
351+
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res);
352+
const header = parseCsv(csv.chunks.join(''))[0];
353+
expect(header).not.toContain('标题');
354+
expect(header).toEqual(['ID', '完成', '优先级', '截止', '负责人']);
355+
356+
// JSON: every exported row's key set ⊆ the list key set; title never present.
357+
const json = makeRes();
358+
await route.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, json.res);
359+
const arr = JSON.parse(json.chunks.join(''));
360+
expect(arr.length).toBe(2);
361+
for (const row of arr) {
362+
for (const k of Object.keys(row)) expect(listKeys.has(k)).toBe(true);
363+
expect('title' in row).toBe(false);
364+
}
365+
});
366+
367+
it('explicit ?fields= keeps a requested masked column but never emits its value', async () => {
368+
const { engine, route } = await boot();
369+
maskTitle(engine);
370+
371+
// An explicit request is honored (projection does NOT narrow it), but the
372+
// masked value must always render as an empty cell — same as list `$select`.
373+
const csv = makeRes();
374+
await route.handler(
375+
{ params: { object: 'task' }, query: { format: 'csv', fields: 'id,title' } } as any,
376+
csv.res,
377+
);
378+
const rows = parseCsv(csv.chunks.join(''));
379+
expect(rows[0]).toEqual(['ID', '标题']); // header kept as requested
380+
for (const r of rows.slice(1)) {
381+
expect(r[0]).not.toBe(''); // id present
382+
expect(r[1] ?? '').toBe(''); // title masked → always empty, never a value
383+
}
384+
});
385+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #3391 — the REST per-object exposure gate consumes the spec's single
4+
// derivation source of truth (`resolveEffectiveApiMethods` /
5+
// `isApiOperationAllowed`). This suite pins the gate DECISION
6+
// (`apiAccessDenialFromEnable`) against the derivation contract: reverse-derived
7+
// import/export, writeMode-precise import, deny-all, bulk∧child, and the
8+
// explicit-legacy compatibility path. Route-level plumbing (createMany/batch,
9+
// import two-stage, export projection) is covered by rest.test.ts,
10+
// rest-batch-endpoint.test.ts, and export-integration.test.ts.
11+
12+
import { describe, it, expect } from 'vitest';
13+
import { apiAccessDenialFromEnable } from './rest-server';
14+
15+
/** True when the gate allows the operation (no denial object). */
16+
function allowed(enable: any, op: string, opts?: any): boolean {
17+
return apiAccessDenialFromEnable(enable, 'obj', op, opts) === null;
18+
}
19+
/** The denial (status/body) or null. */
20+
function denial(enable: any, op: string, opts?: any) {
21+
return apiAccessDenialFromEnable(enable, 'obj', op, opts);
22+
}
23+
24+
describe('REST gate — three-state (#3391)', () => {
25+
it('no enable / unrestricted → everything allowed', () => {
26+
expect(allowed(undefined, 'create')).toBe(true);
27+
expect(allowed({}, 'delete')).toBe(true);
28+
expect(allowed({ apiEnabled: true }, 'export')).toBe(true);
29+
});
30+
31+
it('apiEnabled:false → 404', () => {
32+
const d = denial({ apiEnabled: false }, 'get');
33+
expect(d?.status).toBe(404);
34+
expect(d?.body.code).toBe('OBJECT_API_DISABLED');
35+
});
36+
37+
it('empty whitelist [] → deny-all 405', () => {
38+
const d = denial({ apiMethods: [] }, 'get');
39+
expect(d?.status).toBe(405);
40+
expect(d?.body.allowed).toEqual([]);
41+
expect(allowed({ apiMethods: [] }, 'list')).toBe(false);
42+
});
43+
});
44+
45+
describe('REST gate — reverse-derived import/export (#3391)', () => {
46+
// The sys_business_unit shape: a plain CRUD whitelist with NO explicit
47+
// import/export must still admit import (⊆ create∨update) and export (⊆ list).
48+
const crud = { apiMethods: ['get', 'list', 'create', 'update', 'delete'] };
49+
50+
it('CRUD whitelist admits import (reverse-derived from create/update)', () => {
51+
expect(allowed(crud, 'import')).toBe(true);
52+
expect(allowed(crud, 'import', { writeMode: 'insert' })).toBe(true);
53+
expect(allowed(crud, 'import', { writeMode: 'update' })).toBe(true);
54+
expect(allowed(crud, 'import', { writeMode: 'upsert' })).toBe(true);
55+
});
56+
57+
it('CRUD whitelist admits export (reverse-derived from list)', () => {
58+
expect(allowed(crud, 'export')).toBe(true);
59+
});
60+
61+
it("['get','list'] admits export but not import", () => {
62+
const ro = { apiMethods: ['get', 'list'] };
63+
expect(allowed(ro, 'export')).toBe(true);
64+
const d = denial(ro, 'import');
65+
expect(d?.status).toBe(405);
66+
// 405 body carries the effective set (list-derived reads), never `import`.
67+
expect(d?.body.allowed).toContain('export');
68+
expect(d?.body.allowed).not.toContain('import');
69+
});
70+
});
71+
72+
describe('REST gate — import writeMode precision (#3391)', () => {
73+
it("['create'] admits insert-mode import, rejects update-mode", () => {
74+
const createOnly = { apiMethods: ['get', 'list', 'create'] };
75+
expect(allowed(createOnly, 'import', { writeMode: 'insert' })).toBe(true);
76+
expect(allowed(createOnly, 'import', { writeMode: 'update' })).toBe(false);
77+
// upsert needs create ∧ update → rejected
78+
expect(allowed(createOnly, 'import', { writeMode: 'upsert' })).toBe(false);
79+
});
80+
81+
it("['update'] admits update-mode import, rejects insert-mode", () => {
82+
const updateOnly = { apiMethods: ['get', 'list', 'update'] };
83+
expect(allowed(updateOnly, 'import', { writeMode: 'update' })).toBe(true);
84+
expect(allowed(updateOnly, 'import', { writeMode: 'insert' })).toBe(false);
85+
});
86+
87+
it('upsert-mode import needs both create and update', () => {
88+
expect(allowed({ apiMethods: ['create', 'update'] }, 'import', { writeMode: 'upsert' })).toBe(true);
89+
expect(allowed({ apiMethods: ['create'] }, 'import', { writeMode: 'upsert' })).toBe(false);
90+
});
91+
});
92+
93+
describe('REST gate — explicit legacy wins (deprecated, #3391)', () => {
94+
it("explicit ['import'] admits import in every writeMode", () => {
95+
const e = { apiMethods: ['import'] };
96+
expect(allowed(e, 'import')).toBe(true);
97+
expect(allowed(e, 'import', { writeMode: 'insert' })).toBe(true);
98+
expect(allowed(e, 'import', { writeMode: 'update' })).toBe(true);
99+
});
100+
});
101+
102+
describe('REST gate — bulk ∧ child (#3391)', () => {
103+
it("['create'] (no bulk) → createMany child gate rejects", () => {
104+
expect(allowed({ apiMethods: ['create'] }, 'bulk', { bulkChild: 'create' })).toBe(false);
105+
});
106+
107+
it("['create','bulk'] → createMany child gate admits", () => {
108+
expect(allowed({ apiMethods: ['create', 'bulk'] }, 'bulk', { bulkChild: 'create' })).toBe(true);
109+
});
110+
111+
it("['bulk'] (no create) → createMany child gate rejects (child op missing)", () => {
112+
const d = denial({ apiMethods: ['bulk'] }, 'bulk', { bulkChild: 'create' });
113+
expect(d?.status).toBe(405);
114+
});
115+
116+
it('batch upsert child needs bulk ∧ create ∧ update', () => {
117+
expect(allowed({ apiMethods: ['bulk', 'create', 'update'] }, 'bulk', { bulkChild: 'upsert' })).toBe(true);
118+
expect(allowed({ apiMethods: ['bulk', 'create'] }, 'bulk', { bulkChild: 'upsert' })).toBe(false);
119+
});
120+
});
121+
122+
describe('REST gate — action alias normalization (#3391)', () => {
123+
it('runtime action names map onto canonical operations', () => {
124+
const ro = { apiMethods: ['get', 'list'] };
125+
expect(allowed(ro, 'query')).toBe(true); // query → list
126+
expect(allowed(ro, 'find')).toBe(true); // find → list
127+
expect(allowed(ro, 'batch', { bulkChild: 'create' })).toBe(false); // batch → bulk
128+
});
129+
});

packages/rest/src/rest-batch-endpoint.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,39 @@ describe('POST {basePath}/batch — cross-object transactional batch', () => {
186186
expect(ql.transaction).not.toHaveBeenCalled();
187187
});
188188

189-
it('allows an operation that IS in the apiMethods whitelist', async () => {
189+
it('allows an operation that IS in the apiMethods whitelist (with bulk)', async () => {
190+
// #3391: the cross-object batch is a bulk surface — the object must grant the
191+
// `bulk` primitive AND the child write (bulk ∧ create).
190192
const ql = makeQl();
191-
const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['create', 'read'] } }] });
193+
const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['create', 'bulk'] } }] });
192194
const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: { amount: 1 } }] });
193195
expect(res.statusCode).toBe(200);
194196
expect(ql.transaction).toHaveBeenCalledTimes(1);
195197
});
196198

199+
it('blocks a batch write when the object grants the child but not the bulk primitive (405, #3391)', async () => {
200+
// create is whitelisted but `bulk` is not → the batch/Many surface is closed.
201+
const ql = makeQl();
202+
const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['create'] } }] });
203+
const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: { amount: 1 } }] });
204+
expect(res.statusCode).toBe(405);
205+
expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED');
206+
// the 405 body carries the EFFECTIVE operation set, not the raw whitelist
207+
expect(res.body.allowed).toContain('create');
208+
expect(res.body.allowed).not.toContain('bulk');
209+
expect(ql.transaction).not.toHaveBeenCalled();
210+
});
211+
212+
it('blocks a batch write when the object grants bulk but not the child (405, #3391)', async () => {
213+
// bulk is present but the object cannot create → bulk ∧ create fails.
214+
const ql = makeQl();
215+
const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['bulk', 'update'] } }] });
216+
const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: { amount: 1 } }] });
217+
expect(res.statusCode).toBe(405);
218+
expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED');
219+
expect(ql.transaction).not.toHaveBeenCalled();
220+
});
221+
197222
// ── request validation ────────────────────────────────────────────────────
198223

199224
it('rejects a non-atomic request (this endpoint is always atomic)', async () => {

0 commit comments

Comments
 (0)