Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/api-methods-derivation-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": minor
"@objectstack/runtime": minor
"@objectstack/rest": minor
"@objectstack/plugin-hono-server": minor
"@objectstack/objectql": patch
"@objectstack/platform-objects": patch
---

feat: single-source API-method derivation — the server is the only adjudicator (#3391)

An object's effective API surface is now resolved from **six primitives**
(`get/list/create/update/delete/bulk`) by ONE derivation table in
`@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed`
/ `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it:
the REST data surface, the runtime HTTP/MCP dispatcher, and the
`/me/permissions` annotation. The `apiMethods` whitelist is three-state —
`undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and
the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/
export`) are DERIVED from the primitives, never declared standalone (a standalone
declaration is honored one release with a registration-time deprecation warning).

**Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create,
update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot,
always on this phase); `aggregate`/`search` ⊆ list (search also needs
`searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update;
bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the
`enable.trash` flag was retired, #2377).

**New response-side contract:** `EffectiveObjectPermissionSchema` extends
`ObjectPermissionSchema` with an optional `apiOperations` array;
`GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now
hands down the per-object effective operation set. The authoring
`ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes
the effective set the server resolves, never the raw whitelist.

**Behavior changes (tightening — a `declared ≠ enforced` gap closed):**

1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405),
matching the documented three-state contract instead of the prior fail-open
"no restriction". In-repo impact is zero (every `[]` object also sets
`apiEnabled: false`, so 404 precedes 405).
2. The runtime dispatcher / MCP whitelist is now live. It previously read the
flat shape while `getObject()` returns the flags nested under `.enable`, so
the gate never fired — a silent dead gate now enforced (nested-first,
flat-compatible).
3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no
explicit `import`/`export`) now admits import (⊆ create∨update) and export
(⊆ list). Row-level FLS is shared with list; the export column header is now
projected to the FLS-readable set so it can never expose a wider column set
than list (previously a masked column leaked its name as an empty column).
4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object
`/batch`, cross-object `/batch`) now require the `bulk` primitive AND the
child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects
(`sys_user`, `sys_user_preference`, `sys_business_unit`,
`sys_business_unit_member`) gained `bulk`; a third-party object with an
explicit write whitelist that omits `bulk` will now 405 on the Many/batch
routes.
5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set
(enum-ordered), not the raw whitelist.
26 changes: 24 additions & 2 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ Refined with enterprise data lifecycle controls:
## TypeScript Usage

```typescript
import { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
import type { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
import { AdminScope, EffectiveObjectPermission, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
import type { AdminScope, EffectiveObjectPermission, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';

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


---

## EffectiveObjectPermission

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **allowCreate** | `boolean` | ✅ | Create permission |
| **allowRead** | `boolean` | ✅ | Read permission |
| **allowEdit** | `boolean` | ✅ | Edit permission |
| **allowDelete** | `boolean` | ✅ | Delete permission |
| **allowTransfer** | `boolean` | ✅ | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
| **allowRestore** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
| **allowPurge** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
| **viewAllRecords** | `boolean` | ✅ | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | ✅ | Modify All Data (Bypass Sharing) |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
| **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org |
| **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. |


---

## FieldPermission
Expand Down
46 changes: 45 additions & 1 deletion packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, computeFQN, parseFQN } from './registry';
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnDeprecatedExplicitApiMethods, computeFQN, parseFQN } from './registry';

describe('SchemaRegistry', () => {
let registry: SchemaRegistry;
Expand Down Expand Up @@ -846,3 +846,47 @@ describe('reconcileManagedApiMethods', () => {
expect(stored.enable.apiMethods).toEqual(['get', 'list']);
});
});

// ==========================================
// warnDeprecatedExplicitApiMethods — #3391
// One-shot deprecation warning for standalone LEGACY apiMethods values (the
// derived 8). Pure observation — never mutates the schema.
// ==========================================
describe('warnDeprecatedExplicitApiMethods (#3391)', () => {
it('warns when a whitelist declares a derived legacy value (import/export)', () => {
const warn = vi.fn();
warnDeprecatedExplicitApiMethods(
{ name: 'crm_deal_a', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'] } } as any,
{ warn },
);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('crm_deal_a');
expect(warn.mock.calls[0][0]).toContain('import');
expect(warn.mock.calls[0][0]).toContain('export');
expect(warn.mock.calls[0][0]).toContain('#3391');
});

it('stays silent for a pure-primitive whitelist', () => {
const warn = vi.fn();
warnDeprecatedExplicitApiMethods(
{ name: 'crm_deal_b', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] } } as any,
{ warn },
);
expect(warn).not.toHaveBeenCalled();
});

it('stays silent for an undefined / empty whitelist', () => {
const warn = vi.fn();
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_c', enable: {} } as any, { warn });
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_d', enable: { apiMethods: [] } } as any, { warn });
expect(warn).not.toHaveBeenCalled();
});

it('warns only once per object name (hot path stays free)', () => {
const warn = vi.fn();
const schema: any = { name: 'crm_deal_once', enable: { apiMethods: ['list', 'aggregate'] } };
warnDeprecatedExplicitApiMethods(schema, { warn });
warnDeprecatedExplicitApiMethods(schema, { warn });
expect(warn).toHaveBeenCalledTimes(1);
});
});
54 changes: 53 additions & 1 deletion packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/** Objects already warned about explicit legacy `apiMethods` (once per object). */
const warnedDeprecatedApiMethods = new Set<string>();

/**
* Registration-time deprecation warning for standalone LEGACY `apiMethods`
* values (#3391). The 8 legacy verbs (`upsert`/`aggregate`/`history`/`search`/
* `restore`/`purge`/`import`/`export`) are DERIVED from the six primitives; an
* object that declares one explicitly is honored verbatim for one release
* ("explicit wins"), then the value is removed in the enum-shrink (P2 of #3391).
*
* Emitted once per object (not per request — the hot path stays free), mirroring
* the {@link reconcileManagedApiMethods} warning format and pointing at P2. Pure
* observation: it never mutates the schema.
*/
export function warnDeprecatedExplicitApiMethods(
schema: ServiceObject,
opts?: { warn?: (msg: string) => void },
): void {
const methods = (schema as any).enable?.apiMethods;
if (!Array.isArray(methods) || methods.length === 0) return;
const legacy = methods.filter((m: string) => (LEGACY_API_METHODS as readonly string[]).includes(m));
if (legacy.length === 0) return;
const name = String((schema as any).name ?? '');
if (warnedDeprecatedApiMethods.has(name)) return;
warnedDeprecatedApiMethods.add(name);
const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
warn(
`[Registry] Object "${name}" declares derived legacy apiMethods value(s) ` +
`[${legacy.join(', ')}] in enable.apiMethods — these derive from the six ` +
`primitives (get/list/create/update/delete/bulk) and are honored verbatim ` +
`for now, but standalone declaration is deprecated and will be removed in ` +
`the enum-shrink (P2 of #3391). Declare the underlying primitives instead ` +
`(e.g. ['create','update'] grants upsert/import; ['list'] grants ` +
`aggregate/export/search).`,
);
}

/**
* Platform namespaces that multiple packages may legitimately share, so the
* install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on
Expand Down Expand Up @@ -714,6 +761,11 @@ export class SchemaRegistry {
// every non-`better-auth` object.
schema = reconcileManagedApiMethods(schema);

// [#3391] One-shot deprecation warning for standalone LEGACY apiMethods
// values (the derived 8) — honored this release, removed in the enum-shrink.
// Runs after reconcile so we warn about what actually ships.
warnDeprecatedExplicitApiMethods(schema);

// [ADR-0079] Object-materialization seam — DESIGNATE-ONLY primary-title
// provisioning. Runs AFTER `applySystemFields` (so any designated field
// co-exists with the injected system columns) and ONLY for owned objects
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export const SysBusinessUnitMember = ObjectSchema.create({
// P2 derives these from create/update|list and reclaims the explicit
// entries. Reconcile-safe: import/export are not generic write verbs, so
// reconcileManagedApiMethods (managedBy:'platform') never strips them.
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'],
// `bulk` grants the createMany/updateMany/deleteMany + batch surfaces (bulk
// ∧ child after #3391) — memberships are imported as one bulk operation.
// `bulk` is a primitive — a permanent, legitimate declaration, not P2 debt.
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ export const SysBusinessUnit = ObjectSchema.create({
// Data portability is expected for the org tree: fields like `external_ref`
// and `effective_from/to` are designed for HRIS batch sync (#3025). Import
// reuses the create/update affordances this object already grants.
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'],
// `bulk` grants the createMany/updateMany/deleteMany + batch surfaces, which
// after #3391 require the `bulk` primitive (bulk ∧ child). `bulk` is a
// primitive — a permanent, legitimate declaration, not P0/P2 debt.
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export const SysUserPreference = ObjectSchema.create({
trackHistory: false,
searchable: false,
apiEnabled: true,
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
// `bulk` grants the createMany/updateMany/deleteMany + batch surfaces, which
// after #3391 require the `bulk` primitive (bulk ∧ child). `bulk` is a
// primitive — a permanent, legitimate declaration.
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
},
});
7 changes: 5 additions & 2 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,8 +758,11 @@ export const SysUser = ObjectSchema.create({
// actions), so they are not exposed. `update` stays: it is the ONE
// generic write opened on an identity table (ADR-0092 D4), server-side
// clamped to the profile-field whitelist ({name, image}) by the guard —
// `userActions.edit: true` above declares the affordance.
apiMethods: ['get', 'list', 'update'],
// `userActions.edit: true` above declares the affordance. `bulk` grants the
// updateMany surface (bulk ∧ update after #3391); paired with the sole
// `update` write, only bulk-update is admitted (createMany/deleteMany still
// 405). `bulk` is a primitive — a permanent, legitimate declaration.
apiMethods: ['get', 'list', 'update', 'bulk'],
},

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