Skip to content

Commit d9941b1

Browse files
committed
feat(spec,hono): per-object effective apiOperations on /me/permissions (#3391 PR-3)
Add the response-side contract that lets the server hand the frontend the resolved effective operation set — the single channel the UI consumes, never the raw whitelist. - spec: `EffectiveObjectPermissionSchema` extends `ObjectPermissionSchema` with an optional `apiOperations` array; `GetEffectivePermissionsResponse.objects` uses it. The authoring `ObjectPermissionSchema` is deliberately NOT extended, so a permission-set author can never declare a meaningless key. - hono: `/me/permissions` now annotates each per-object entry with its effective `apiOperations` (`annotateEffectiveApiOperations`), and for a modify-all super-user seeds false-init entries for restricting objects absent from the merged map (`seedSuperUserRestrictedObjects`) so fold pulls them true. Sequence: seed → fold → clamp → annotate, each guarded (failure omits apiOperations and the client falls back to default-allow). - client: zero code — the typed surface carries the new field via the spec re-export. - changeset documenting the contract and the five behavior changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH
1 parent 209d3e5 commit d9941b1

8 files changed

Lines changed: 344 additions & 2 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/plugin-hono-server": minor
6+
"@objectstack/objectql": patch
7+
"@objectstack/platform-objects": patch
8+
---
9+
10+
feat: single-source API-method derivation — the server is the only adjudicator (#3391)
11+
12+
An object's effective API surface is now resolved from **six primitives**
13+
(`get/list/create/update/delete/bulk`) by ONE derivation table in
14+
`@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed`
15+
/ `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it:
16+
the REST data surface, the runtime HTTP/MCP dispatcher, and the
17+
`/me/permissions` annotation. The `apiMethods` whitelist is three-state —
18+
`undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and
19+
the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/
20+
export`) are DERIVED from the primitives, never declared standalone (a standalone
21+
declaration is honored one release with a registration-time deprecation warning).
22+
23+
**Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create,
24+
update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot,
25+
always on this phase); `aggregate`/`search` ⊆ list (search also needs
26+
`searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update;
27+
bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the
28+
`enable.trash` flag was retired, #2377).
29+
30+
**New response-side contract:** `EffectiveObjectPermissionSchema` extends
31+
`ObjectPermissionSchema` with an optional `apiOperations` array;
32+
`GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now
33+
hands down the per-object effective operation set. The authoring
34+
`ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes
35+
the effective set the server resolves, never the raw whitelist.
36+
37+
**Behavior changes (tightening — a `declared ≠ enforced` gap closed):**
38+
39+
1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405),
40+
matching the documented three-state contract instead of the prior fail-open
41+
"no restriction". In-repo impact is zero (every `[]` object also sets
42+
`apiEnabled: false`, so 404 precedes 405).
43+
2. The runtime dispatcher / MCP whitelist is now live. It previously read the
44+
flat shape while `getObject()` returns the flags nested under `.enable`, so
45+
the gate never fired — a silent dead gate now enforced (nested-first,
46+
flat-compatible).
47+
3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no
48+
explicit `import`/`export`) now admits import (⊆ create∨update) and export
49+
(⊆ list). Row-level FLS is shared with list; the export column header is now
50+
projected to the FLS-readable set so it can never expose a wider column set
51+
than list (previously a masked column leaked its name as an empty column).
52+
4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object
53+
`/batch`, cross-object `/batch`) now require the `bulk` primitive AND the
54+
child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects
55+
(`sys_user`, `sys_user_preference`, `sys_business_unit`,
56+
`sys_business_unit_member`) gained `bulk`; a third-party object with an
57+
explicit write whitelist that omits `bulk` will now 405 on the Many/batch
58+
routes.
59+
5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set
60+
(enum-ordered), not the raw whitelist.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
annotateEffectiveApiOperations,
6+
seedSuperUserRestrictedObjects,
7+
type ApiExposureSchemaLike,
8+
} from './hono-plugin.js';
9+
10+
/**
11+
* #3391 — the `/me/permissions` per-object map carries the server-resolved
12+
* effective API operation set (`apiOperations`). These pin the two pure helpers
13+
* (parallel to fold/clamp): the super-user seed and the annotation pass.
14+
*/
15+
describe('annotateEffectiveApiOperations (#3391)', () => {
16+
const schemaOf = (map: Record<string, ApiExposureSchemaLike>) => (name: string) => map[name];
17+
18+
it('annotates a restricting object with its effective operation set', () => {
19+
const objects: Record<string, any> = { widget: { allowRead: true } };
20+
annotateEffectiveApiOperations(objects, schemaOf({
21+
widget: { name: 'widget', enable: { apiMethods: ['get', 'list'] } },
22+
}));
23+
// list-class reads derive aggregate/search/export; enum-ordered.
24+
expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
25+
});
26+
27+
it('does NOT annotate an unrestricted object (client keeps default-allow)', () => {
28+
const objects: Record<string, any> = { widget: { allowRead: true } };
29+
annotateEffectiveApiOperations(objects, schemaOf({
30+
widget: { name: 'widget', enable: {} }, // no apiMethods → unrestricted
31+
}));
32+
expect('apiOperations' in objects.widget).toBe(false);
33+
});
34+
35+
it('annotates a deny-all object with an empty array', () => {
36+
const objects: Record<string, any> = { locked: { allowRead: true } };
37+
annotateEffectiveApiOperations(objects, schemaOf({
38+
locked: { name: 'locked', enable: { apiMethods: [] } },
39+
}));
40+
expect(objects.locked.apiOperations).toEqual([]);
41+
});
42+
43+
it('skips the wildcard entry', () => {
44+
const objects: Record<string, any> = { '*': { modifyAllRecords: true }, widget: { allowRead: true } };
45+
annotateEffectiveApiOperations(objects, schemaOf({
46+
widget: { name: 'widget', enable: { apiMethods: ['create', 'bulk'] } },
47+
}));
48+
expect('apiOperations' in objects['*']).toBe(false);
49+
expect(objects.widget.apiOperations).toContain('create');
50+
expect(objects.widget.apiOperations).toContain('bulk');
51+
});
52+
53+
it('skips when the schema is missing (client falls back)', () => {
54+
const objects: Record<string, any> = { widget: { allowRead: true } };
55+
annotateEffectiveApiOperations(objects, () => undefined);
56+
expect('apiOperations' in objects.widget).toBe(false);
57+
});
58+
59+
it('reverse-derived import/export appear in the effective set for a CRUD whitelist', () => {
60+
const objects: Record<string, any> = { deal: { allowRead: true } };
61+
annotateEffectiveApiOperations(objects, schemaOf({
62+
deal: { name: 'deal', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete'] } },
63+
}));
64+
expect(objects.deal.apiOperations).toContain('import');
65+
expect(objects.deal.apiOperations).toContain('export');
66+
expect(objects.deal.apiOperations).toContain('upsert');
67+
expect(objects.deal.apiOperations).not.toContain('restore');
68+
});
69+
});
70+
71+
describe('seedSuperUserRestrictedObjects (#3391)', () => {
72+
const schemas: ApiExposureSchemaLike[] = [
73+
{ name: 'widget', enable: { apiMethods: ['get', 'list'] } }, // restricting
74+
{ name: 'open_obj', enable: {} }, // unrestricted
75+
{ name: 'locked', enable: { apiMethods: [] } }, // deny-all (restricting)
76+
];
77+
78+
it('for a modify-all super-user, seeds false-init entries for restricting objects only', () => {
79+
const objects: Record<string, any> = { '*': { modifyAllRecords: true, viewAllRecords: true } };
80+
seedSuperUserRestrictedObjects(objects, schemas);
81+
expect(objects.widget).toEqual({ allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false });
82+
expect(objects.locked).toBeDefined();
83+
// unrestricted objects are NOT seeded (no annotation to attach)
84+
expect(objects.open_obj).toBeUndefined();
85+
});
86+
87+
it('does not seed for a viewAll-only wildcard (avoids flipping check() to explicit deny)', () => {
88+
const objects: Record<string, any> = { '*': { viewAllRecords: true } }; // no modifyAllRecords
89+
seedSuperUserRestrictedObjects(objects, schemas);
90+
expect(objects.widget).toBeUndefined();
91+
});
92+
93+
it('does not clobber an object already present in the map', () => {
94+
const objects: Record<string, any> = {
95+
'*': { modifyAllRecords: true },
96+
widget: { allowRead: true, allowEdit: true }, // pre-existing explicit entry
97+
};
98+
seedSuperUserRestrictedObjects(objects, schemas);
99+
expect(objects.widget).toEqual({ allowRead: true, allowEdit: true });
100+
});
101+
102+
it('end-to-end: seed → annotate yields apiOperations for a super-user on a restricting object', () => {
103+
// A super-user whose only grant is the wildcard, with no explicit widget entry.
104+
const objects: Record<string, any> = { '*': { modifyAllRecords: true } };
105+
seedSuperUserRestrictedObjects(objects, schemas);
106+
annotateEffectiveApiOperations(objects, (name) => schemas.find((s) => s.name === name));
107+
expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
108+
expect(objects.locked.apiOperations).toEqual([]);
109+
});
110+
});

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import {
99
RestServerConfig,
1010
} from '@objectstack/spec/api';
1111
import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN } from '@objectstack/spec';
12+
import {
13+
resolveEffectiveApiMethods,
14+
effectiveOperationsArray,
15+
type EnableLike,
16+
} from '@objectstack/spec/data';
1217
import type { ExecutionContext } from '@objectstack/spec/kernel';
1318
import { HonoHttpServer, HonoCorsOptions } from './adapter';
1419
import { cors } from 'hono/cors';
@@ -283,6 +288,68 @@ export function clampManagedObjectWrites(
283288
}
284289
}
285290

291+
/** The API-exposure-relevant slice of a registered object schema. */
292+
export interface ApiExposureSchemaLike {
293+
name?: string;
294+
enable?: EnableLike | null;
295+
}
296+
297+
/**
298+
* [#3391] Seed false-initialized per-object entries for a MODIFY-ALL super-user,
299+
* for every registered object whose `apiMethods` whitelist tightens exposure.
300+
*
301+
* A super-user's grant is usually the `'*'` wildcard, not explicit per-object
302+
* entries — so restricting objects never appear in the merged `objects` map and
303+
* would miss their `apiOperations` annotation. Seeding a `{allow*: false}` entry
304+
* lets {@link foldWildcardSuperUser} pull it true (super-user reads/writes
305+
* everything) and lets {@link annotateEffectiveApiOperations} attach the effective
306+
* set. Runs BEFORE fold.
307+
*
308+
* Guarded to `modifyAllRecords` super-users ONLY: for a viewAll-only caller,
309+
* materializing a `false` entry would flip the client's `check('edit')` from
310+
* "undefined → default-allow" to "explicit false → deny" — a scope-exceeding
311+
* behavior change. A modify-all caller is folded to `true` anyway, so seeding is
312+
* harmless there. Unrestricted objects are skipped (they carry no annotation).
313+
*/
314+
export function seedSuperUserRestrictedObjects(
315+
objects: Record<string, any>,
316+
allSchemas: readonly ApiExposureSchemaLike[],
317+
): void {
318+
if (objects?.['*']?.modifyAllRecords !== true) return;
319+
for (const schema of allSchemas) {
320+
const name = schema?.name;
321+
if (!name || name === '*' || objects[name]) continue;
322+
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined);
323+
if (eff.mode === 'unrestricted') continue; // only restricting objects
324+
objects[name] = { allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false };
325+
}
326+
}
327+
328+
/**
329+
* [#3391] Annotate each per-object `/me/permissions` entry with the SERVER's
330+
* effective API operation set (`apiOperations`), mutating the map in place.
331+
*
332+
* This is the single "effective" channel the frontend consumes — it renders the
333+
* operations the server hands down here, never the raw `apiMethods` whitelist.
334+
* Only objects whose whitelist actually tightens exposure are annotated (a
335+
* `deny-all` object gets an empty array; an unrestricted object gets nothing, so
336+
* the client keeps its default-allow behavior). Runs AFTER fold + clamp so the
337+
* annotation sits alongside the final CRUD affordances.
338+
*/
339+
export function annotateEffectiveApiOperations(
340+
objects: Record<string, any>,
341+
schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined,
342+
): void {
343+
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
344+
if (obj === '*' || !acc) continue;
345+
const schema = schemaOf(obj);
346+
if (!schema) continue; // schema missing → no annotation (client falls back)
347+
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined);
348+
if (eff.mode === 'unrestricted') continue; // open object → no annotation
349+
acc.apiOperations = effectiveOperationsArray(eff);
350+
}
351+
}
352+
286353
export class HonoServerPlugin implements Plugin {
287354
name = 'com.objectstack.server.hono';
288355
type = 'server';
@@ -1131,11 +1198,36 @@ export class HonoServerPlugin implements Plugin {
11311198
// (sys_user → edit). Together these remove both the false-negative
11321199
// (admin sees sys_user editable) and the false-positive (admin does
11331200
// NOT see sys_member editable, matching the guard).
1201+
// [#3391] For a modify-all super-user, seed restricting objects
1202+
// absent from the merged map so fold pulls them true and annotate
1203+
// can attach their effective apiOperations. Guarded — a failure
1204+
// here must never drop the whole response.
1205+
try {
1206+
const allSchemas: ApiExposureSchemaLike[] = (() => {
1207+
try { return (ql as any)?.registry?.getAllObjects?.() ?? []; }
1208+
catch { return []; }
1209+
})();
1210+
seedSuperUserRestrictedObjects(objects, allSchemas);
1211+
} catch (e: any) {
1212+
ctx.logger.warn('[hono] effective apiOperations seed failed', { err: e?.message });
1213+
}
11341214
foldWildcardSuperUser(objects);
11351215
clampManagedObjectWrites(objects, (name) => {
11361216
try { return ql?.getSchema?.(name) as ManagedSchemaLike | undefined; }
11371217
catch { return undefined; }
11381218
});
1219+
// [#3391] Annotate the per-object effective API operation set —
1220+
// the single channel the frontend consumes for effective ops.
1221+
// Guarded: on failure we simply omit apiOperations and the client
1222+
// falls back to its default-allow behavior.
1223+
try {
1224+
annotateEffectiveApiOperations(objects, (name) => {
1225+
try { return ql?.getSchema?.(name) as ApiExposureSchemaLike | undefined; }
1226+
catch { return undefined; }
1227+
});
1228+
} catch (e: any) {
1229+
ctx.logger.warn('[hono] effective apiOperations annotate failed', { err: e?.message });
1230+
}
11391231
return c.json({
11401232
authenticated: true,
11411233
userId: execCtx.userId,

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,7 @@
11831183
"security/AuthzPosture",
11841184
"security/CapabilityDeclaration",
11851185
"security/CriteriaSharingRule",
1186+
"security/EffectiveObjectPermission",
11861187
"security/ExplainDecision",
11871188
"security/ExplainLayer",
11881189
"security/ExplainMatchedRule",

packages/spec/src/api/protocol.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ describe('ObjectStack Protocol', () => {
201201
objects: { account: { allowRead: true } },
202202
systemPermissions: ['manage_users', 'view_reports'],
203203
}).success).toBe(true);
204+
// #3391: the effective response carries per-object apiOperations.
205+
const withOps = GetEffectivePermissionsResponseSchema.safeParse({
206+
objects: { account: { allowRead: true, apiOperations: ['get', 'list', 'export'] } },
207+
systemPermissions: [],
208+
});
209+
expect(withOps.success).toBe(true);
210+
if (withOps.success) {
211+
expect((withOps.data.objects.account as any).apiOperations).toEqual(['get', 'list', 'export']);
212+
}
204213
});
205214

206215
it('validates Workflow operations', () => {

packages/spec/src/api/protocol.zod.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
AnalyticsMetadataResponseSchema
1515
} from './analytics.zod';
1616
import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod';
17-
import { ObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod';
17+
import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod';
1818
import { StateMachineSchema } from '../automation/state-machine.zod';
1919
import { ActionDescriptorSchema } from '../automation/node-executor.zod';
2020
import { TranslationDataSchema } from '../system/translation.zod';
@@ -665,7 +665,10 @@ export const GetObjectPermissionsResponseSchema = lazySchema(() => z.object({
665665
export const GetEffectivePermissionsRequestSchema = lazySchema(() => z.object({}));
666666

667667
export const GetEffectivePermissionsResponseSchema = lazySchema(() => z.object({
668-
objects: z.record(z.string(), ObjectPermissionSchema).describe('Effective object permissions keyed by object name'),
668+
// [#3391] The effective response carries the server-resolved API operation set
669+
// per object (`apiOperations`) — the single "effective" channel the frontend
670+
// consumes. Authoring `ObjectPermissionSchema` stays unextended.
671+
objects: z.record(z.string(), EffectiveObjectPermissionSchema).describe('Effective object permissions keyed by object name'),
669672
systemPermissions: z.array(z.string()).describe('Effective system-level permissions'),
670673
}));
671674

packages/spec/src/security/permission.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
22
import {
33
PermissionSetSchema,
44
ObjectPermissionSchema,
5+
EffectiveObjectPermissionSchema,
56
FieldPermissionSchema,
67
AdminScopeSchema,
78
type PermissionSet,
@@ -98,6 +99,46 @@ describe('ObjectPermissionSchema', () => {
9899
});
99100
});
100101

102+
describe('EffectiveObjectPermissionSchema (#3391 response-side)', () => {
103+
it('carries every ObjectPermission field plus optional apiOperations', () => {
104+
const parsed = EffectiveObjectPermissionSchema.parse({
105+
allowRead: true,
106+
allowCreate: true,
107+
apiOperations: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
108+
});
109+
expect(parsed.allowRead).toBe(true);
110+
expect(parsed.apiOperations).toEqual(['get', 'list', 'create', 'update', 'delete', 'bulk']);
111+
});
112+
113+
it('apiOperations is optional (absent = client default-allow)', () => {
114+
const parsed = EffectiveObjectPermissionSchema.parse({ allowRead: true });
115+
expect(parsed.apiOperations).toBeUndefined();
116+
expect(parsed.allowRead).toBe(true);
117+
});
118+
119+
it('rejects an apiOperations value outside the ApiMethod enum', () => {
120+
expect(
121+
EffectiveObjectPermissionSchema.safeParse({ allowRead: true, apiOperations: ['frobnicate'] }).success,
122+
).toBe(false);
123+
});
124+
125+
it('accepts the derived legacy operations (import/export/aggregate/…)', () => {
126+
expect(
127+
EffectiveObjectPermissionSchema.safeParse({
128+
allowRead: true,
129+
apiOperations: ['get', 'list', 'aggregate', 'search', 'export', 'import', 'upsert'],
130+
}).success,
131+
).toBe(true);
132+
});
133+
134+
it('does not leak apiOperations onto the authoring ObjectPermissionSchema', () => {
135+
// The authoring schema stays unextended — a stray apiOperations key is
136+
// stripped (or rejected) there, never a valid authoring field.
137+
const parsed = ObjectPermissionSchema.parse({ allowRead: true, apiOperations: ['get'] } as any);
138+
expect((parsed as any).apiOperations).toBeUndefined();
139+
});
140+
});
141+
101142
describe('FieldPermissionSchema', () => {
102143
it('should default readable to true', () => {
103144
const result = FieldPermissionSchema.parse({});

0 commit comments

Comments
 (0)