Skip to content

Commit ad4af62

Browse files
authored
feat: single-source API-method derivation contract (#3391 P1) (#3498)
Server is the sole adjudicator of each object's effective API operation set, resolved from six primitives (get/list/create/update/delete/bulk) by one derivation table in @objectstack/spec/data; REST gate, runtime dispatcher gate, and /me/permissions annotation all consume it. - spec: api-derivation module (resolveEffectiveApiMethods/isApiOperationAllowed/effectiveOperationsArray/API_METHOD_DERIVATION) + EffectiveObjectPermissionSchema - runtime: api-exposure rewrite — fixes the dead dispatcher/MCP gate (nested enable), []=deny-all - rest: gates consume the shared resolver — import writeMode precision, export FLS column projection, bulk∧child, 405 allowed=effective - hono: /me/permissions annotates per-object apiOperations - objectql: registration-time deprecation warning for legacy apiMethods values Closes part of #3391 (P1).
1 parent 6c94288 commit ad4af62

28 files changed

Lines changed: 1578 additions & 86 deletions
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.

content/docs/references/security/permission.mdx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ Refined with enterprise data lifecycle controls:
2424
## TypeScript Usage
2525

2626
```typescript
27-
import { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
28-
import type { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
27+
import { AdminScope, EffectiveObjectPermission, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
28+
import type { AdminScope, EffectiveObjectPermission, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
2929

3030
// Validate data
3131
const result = AdminScope.parse(data);
@@ -47,6 +47,28 @@ const result = AdminScope.parse(data);
4747
| **assignablePermissionSets** | `string[]` || Allowlist of permission-set names the delegate may hand out |
4848

4949

50+
---
51+
52+
## EffectiveObjectPermission
53+
54+
### Properties
55+
56+
| Property | Type | Required | Description |
57+
| :--- | :--- | :--- | :--- |
58+
| **allowCreate** | `boolean` || Create permission |
59+
| **allowRead** | `boolean` || Read permission |
60+
| **allowEdit** | `boolean` || Edit permission |
61+
| **allowDelete** | `boolean` || Delete permission |
62+
| **allowTransfer** | `boolean` || [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
63+
| **allowRestore** | `boolean` || [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
64+
| **allowPurge** | `boolean` || [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
65+
| **viewAllRecords** | `boolean` || View All Data (Bypass Sharing) |
66+
| **modifyAllRecords** | `boolean` || Modify All Data (Bypass Sharing) |
67+
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
68+
| **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org |
69+
| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. |
70+
71+
5072
---
5173

5274
## FieldPermission

packages/objectql/src/registry.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
2-
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, computeFQN, parseFQN } from './registry';
2+
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnDeprecatedExplicitApiMethods, computeFQN, parseFQN } from './registry';
33

44
describe('SchemaRegistry', () => {
55
let registry: SchemaRegistry;
@@ -846,3 +846,47 @@ describe('reconcileManagedApiMethods', () => {
846846
expect(stored.enable.apiMethods).toEqual(['get', 'list']);
847847
});
848848
});
849+
850+
// ==========================================
851+
// warnDeprecatedExplicitApiMethods — #3391
852+
// One-shot deprecation warning for standalone LEGACY apiMethods values (the
853+
// derived 8). Pure observation — never mutates the schema.
854+
// ==========================================
855+
describe('warnDeprecatedExplicitApiMethods (#3391)', () => {
856+
it('warns when a whitelist declares a derived legacy value (import/export)', () => {
857+
const warn = vi.fn();
858+
warnDeprecatedExplicitApiMethods(
859+
{ name: 'crm_deal_a', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'] } } as any,
860+
{ warn },
861+
);
862+
expect(warn).toHaveBeenCalledTimes(1);
863+
expect(warn.mock.calls[0][0]).toContain('crm_deal_a');
864+
expect(warn.mock.calls[0][0]).toContain('import');
865+
expect(warn.mock.calls[0][0]).toContain('export');
866+
expect(warn.mock.calls[0][0]).toContain('#3391');
867+
});
868+
869+
it('stays silent for a pure-primitive whitelist', () => {
870+
const warn = vi.fn();
871+
warnDeprecatedExplicitApiMethods(
872+
{ name: 'crm_deal_b', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] } } as any,
873+
{ warn },
874+
);
875+
expect(warn).not.toHaveBeenCalled();
876+
});
877+
878+
it('stays silent for an undefined / empty whitelist', () => {
879+
const warn = vi.fn();
880+
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_c', enable: {} } as any, { warn });
881+
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_d', enable: { apiMethods: [] } } as any, { warn });
882+
expect(warn).not.toHaveBeenCalled();
883+
});
884+
885+
it('warns only once per object name (hot path stays free)', () => {
886+
const warn = vi.fn();
887+
const schema: any = { name: 'crm_deal_once', enable: { apiMethods: ['list', 'aggregate'] } };
888+
warnDeprecatedExplicitApiMethods(schema, { warn });
889+
warnDeprecatedExplicitApiMethods(schema, { warn });
890+
expect(warn).toHaveBeenCalledTimes(1);
891+
});
892+
});

packages/objectql/src/registry.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled } from '@objectstack/spec/data';
3+
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS } from '@objectstack/spec/data';
44
import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
55
import { provisionSearchCompanion } from './search-companion.js';
66
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
@@ -389,6 +389,16 @@ export function applySystemFields(
389389
* Generic-write `apiMethods` verbs mapped to the {@link resolveCrudAffordances}
390390
* flag each one needs. Read verbs (`get`/`list`/`search`/`history`/…) are
391391
* always permitted, so they are absent here and never stripped.
392+
*
393+
* ⚠️ Two orthogonal axes — do NOT merge this with the API-tightening table.
394+
* This table (verb → *affordance*) is the UI-intent axis: it strips write verbs
395+
* a managed bucket does not *offer* from the whitelist. The verb → *primitive*
396+
* derivation that decides what the automatic API *admits* lives in
397+
* `@objectstack/spec/data` `API_METHOD_DERIVATION` / `resolveEffectiveApiMethods`
398+
* (#3391). The identical-shaped `WRITE_OP_AFFORDANCE` in plugin-security
399+
* `system-write-guard.ts` is the runtime enforcement of this same UI-intent
400+
* axis. Unifying the three is deferred to the enum-shrink (P2 of #3391); until
401+
* then keep the axes separate — see ADR-0103.
392402
*/
393403
const MANAGED_WRITE_VERB_AFFORDANCE: Record<string, 'create' | 'edit' | 'delete'> = {
394404
create: 'create',
@@ -463,6 +473,43 @@ export function reconcileManagedApiMethods(
463473
};
464474
}
465475

476+
/** Objects already warned about explicit legacy `apiMethods` (once per object). */
477+
const warnedDeprecatedApiMethods = new Set<string>();
478+
479+
/**
480+
* Registration-time deprecation warning for standalone LEGACY `apiMethods`
481+
* values (#3391). The 8 legacy verbs (`upsert`/`aggregate`/`history`/`search`/
482+
* `restore`/`purge`/`import`/`export`) are DERIVED from the six primitives; an
483+
* object that declares one explicitly is honored verbatim for one release
484+
* ("explicit wins"), then the value is removed in the enum-shrink (P2 of #3391).
485+
*
486+
* Emitted once per object (not per request — the hot path stays free), mirroring
487+
* the {@link reconcileManagedApiMethods} warning format and pointing at P2. Pure
488+
* observation: it never mutates the schema.
489+
*/
490+
export function warnDeprecatedExplicitApiMethods(
491+
schema: ServiceObject,
492+
opts?: { warn?: (msg: string) => void },
493+
): void {
494+
const methods = (schema as any).enable?.apiMethods;
495+
if (!Array.isArray(methods) || methods.length === 0) return;
496+
const legacy = methods.filter((m: string) => (LEGACY_API_METHODS as readonly string[]).includes(m));
497+
if (legacy.length === 0) return;
498+
const name = String((schema as any).name ?? '');
499+
if (warnedDeprecatedApiMethods.has(name)) return;
500+
warnedDeprecatedApiMethods.add(name);
501+
const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
502+
warn(
503+
`[Registry] Object "${name}" declares derived legacy apiMethods value(s) ` +
504+
`[${legacy.join(', ')}] in enable.apiMethods — these derive from the six ` +
505+
`primitives (get/list/create/update/delete/bulk) and are honored verbatim ` +
506+
`for now, but standalone declaration is deprecated and will be removed in ` +
507+
`the enum-shrink (P2 of #3391). Declare the underlying primitives instead ` +
508+
`(e.g. ['create','update'] grants upsert/import; ['list'] grants ` +
509+
`aggregate/export/search).`,
510+
);
511+
}
512+
466513
/**
467514
* Platform namespaces that multiple packages may legitimately share, so the
468515
* install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on
@@ -714,6 +761,11 @@ export class SchemaRegistry {
714761
// every non-`better-auth` object.
715762
schema = reconcileManagedApiMethods(schema);
716763

764+
// [#3391] One-shot deprecation warning for standalone LEGACY apiMethods
765+
// values (the derived 8) — honored this release, removed in the enum-shrink.
766+
// Runs after reconcile so we warn about what actually ships.
767+
warnDeprecatedExplicitApiMethods(schema);
768+
717769
// [ADR-0079] Object-materialization seam — DESIGNATE-ONLY primary-title
718770
// provisioning. Runs AFTER `applySystemFields` (so any designated field
719771
// co-exists with the injected system columns) and ONLY for owned objects

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

0 commit comments

Comments
 (0)