diff --git a/.changeset/api-methods-derivation-contract.md b/.changeset/api-methods-derivation-contract.md new file mode 100644 index 0000000000..5234c32bde --- /dev/null +++ b/.changeset/api-methods-derivation-contract.md @@ -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. diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index e57bffcd93..9acb07b910 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -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); @@ -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 diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 68943ffed8..17bef33436 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -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; @@ -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); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 53a3718e42..05f4401d1e 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -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'; @@ -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 = { create: 'create', @@ -463,6 +473,43 @@ export function reconcileManagedApiMethods( }; } +/** Objects already warned about explicit legacy `apiMethods` (once per object). */ +const warnedDeprecatedApiMethods = new Set(); + +/** + * 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 @@ -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 diff --git a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts index 15493dac4c..4bdb19f0d5 100644 --- a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -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'], }, }); diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 35a6d47db6..43c2fd9ea9 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -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'], }, }); diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index dfe77d8788..70bfbe3413 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -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'], }, }); diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index a6a4ea06ce..382db980e8 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -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 diff --git a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts new file mode 100644 index 0000000000..f00e02a6a1 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + annotateEffectiveApiOperations, + seedSuperUserRestrictedObjects, + type ApiExposureSchemaLike, +} from './hono-plugin.js'; + +/** + * #3391 — the `/me/permissions` per-object map carries the server-resolved + * effective API operation set (`apiOperations`). These pin the two pure helpers + * (parallel to fold/clamp): the super-user seed and the annotation pass. + */ +describe('annotateEffectiveApiOperations (#3391)', () => { + const schemaOf = (map: Record) => (name: string) => map[name]; + + it('annotates a restricting object with its effective operation set', () => { + const objects: Record = { widget: { allowRead: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + widget: { name: 'widget', enable: { apiMethods: ['get', 'list'] } }, + })); + // list-class reads derive aggregate/search/export; enum-ordered. + expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); + }); + + it('does NOT annotate an unrestricted object (client keeps default-allow)', () => { + const objects: Record = { widget: { allowRead: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + widget: { name: 'widget', enable: {} }, // no apiMethods → unrestricted + })); + expect('apiOperations' in objects.widget).toBe(false); + }); + + it('annotates a deny-all object with an empty array', () => { + const objects: Record = { locked: { allowRead: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + locked: { name: 'locked', enable: { apiMethods: [] } }, + })); + expect(objects.locked.apiOperations).toEqual([]); + }); + + it('skips the wildcard entry', () => { + const objects: Record = { '*': { modifyAllRecords: true }, widget: { allowRead: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + widget: { name: 'widget', enable: { apiMethods: ['create', 'bulk'] } }, + })); + expect('apiOperations' in objects['*']).toBe(false); + expect(objects.widget.apiOperations).toContain('create'); + expect(objects.widget.apiOperations).toContain('bulk'); + }); + + it('skips when the schema is missing (client falls back)', () => { + const objects: Record = { widget: { allowRead: true } }; + annotateEffectiveApiOperations(objects, () => undefined); + expect('apiOperations' in objects.widget).toBe(false); + }); + + it('reverse-derived import/export appear in the effective set for a CRUD whitelist', () => { + const objects: Record = { deal: { allowRead: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + deal: { name: 'deal', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete'] } }, + })); + expect(objects.deal.apiOperations).toContain('import'); + expect(objects.deal.apiOperations).toContain('export'); + expect(objects.deal.apiOperations).toContain('upsert'); + expect(objects.deal.apiOperations).not.toContain('restore'); + }); +}); + +describe('seedSuperUserRestrictedObjects (#3391)', () => { + const schemas: ApiExposureSchemaLike[] = [ + { name: 'widget', enable: { apiMethods: ['get', 'list'] } }, // restricting + { name: 'open_obj', enable: {} }, // unrestricted + { name: 'locked', enable: { apiMethods: [] } }, // deny-all (restricting) + ]; + + it('for a modify-all super-user, seeds false-init entries for restricting objects only', () => { + const objects: Record = { '*': { modifyAllRecords: true, viewAllRecords: true } }; + seedSuperUserRestrictedObjects(objects, schemas); + expect(objects.widget).toEqual({ allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false }); + expect(objects.locked).toBeDefined(); + // unrestricted objects are NOT seeded (no annotation to attach) + expect(objects.open_obj).toBeUndefined(); + }); + + it('does not seed for a viewAll-only wildcard (avoids flipping check() to explicit deny)', () => { + const objects: Record = { '*': { viewAllRecords: true } }; // no modifyAllRecords + seedSuperUserRestrictedObjects(objects, schemas); + expect(objects.widget).toBeUndefined(); + }); + + it('does not clobber an object already present in the map', () => { + const objects: Record = { + '*': { modifyAllRecords: true }, + widget: { allowRead: true, allowEdit: true }, // pre-existing explicit entry + }; + seedSuperUserRestrictedObjects(objects, schemas); + expect(objects.widget).toEqual({ allowRead: true, allowEdit: true }); + }); + + it('end-to-end: seed → annotate yields apiOperations for a super-user on a restricting object', () => { + // A super-user whose only grant is the wildcard, with no explicit widget entry. + const objects: Record = { '*': { modifyAllRecords: true } }; + seedSuperUserRestrictedObjects(objects, schemas); + annotateEffectiveApiOperations(objects, (name) => schemas.find((s) => s.name === name)); + expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); + expect(objects.locked.apiOperations).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 7e5256da41..07f593ea82 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -9,6 +9,11 @@ import { RestServerConfig, } from '@objectstack/spec/api'; import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN } from '@objectstack/spec'; +import { + resolveEffectiveApiMethods, + effectiveOperationsArray, + type EnableLike, +} from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { HonoHttpServer, HonoCorsOptions } from './adapter'; import { cors } from 'hono/cors'; @@ -283,6 +288,68 @@ export function clampManagedObjectWrites( } } +/** The API-exposure-relevant slice of a registered object schema. */ +export interface ApiExposureSchemaLike { + name?: string; + enable?: EnableLike | null; +} + +/** + * [#3391] Seed false-initialized per-object entries for a MODIFY-ALL super-user, + * for every registered object whose `apiMethods` whitelist tightens exposure. + * + * A super-user's grant is usually the `'*'` wildcard, not explicit per-object + * entries — so restricting objects never appear in the merged `objects` map and + * would miss their `apiOperations` annotation. Seeding a `{allow*: false}` entry + * lets {@link foldWildcardSuperUser} pull it true (super-user reads/writes + * everything) and lets {@link annotateEffectiveApiOperations} attach the effective + * set. Runs BEFORE fold. + * + * Guarded to `modifyAllRecords` super-users ONLY: for a viewAll-only caller, + * materializing a `false` entry would flip the client's `check('edit')` from + * "undefined → default-allow" to "explicit false → deny" — a scope-exceeding + * behavior change. A modify-all caller is folded to `true` anyway, so seeding is + * harmless there. Unrestricted objects are skipped (they carry no annotation). + */ +export function seedSuperUserRestrictedObjects( + objects: Record, + allSchemas: readonly ApiExposureSchemaLike[], +): void { + if (objects?.['*']?.modifyAllRecords !== true) return; + for (const schema of allSchemas) { + const name = schema?.name; + if (!name || name === '*' || objects[name]) continue; + const eff = resolveEffectiveApiMethods(schema.enable ?? undefined); + if (eff.mode === 'unrestricted') continue; // only restricting objects + objects[name] = { allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false }; + } +} + +/** + * [#3391] Annotate each per-object `/me/permissions` entry with the SERVER's + * effective API operation set (`apiOperations`), mutating the map in place. + * + * This is the single "effective" channel the frontend consumes — it renders the + * operations the server hands down here, never the raw `apiMethods` whitelist. + * Only objects whose whitelist actually tightens exposure are annotated (a + * `deny-all` object gets an empty array; an unrestricted object gets nothing, so + * the client keeps its default-allow behavior). Runs AFTER fold + clamp so the + * annotation sits alongside the final CRUD affordances. + */ +export function annotateEffectiveApiOperations( + objects: Record, + schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined, +): void { + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + const schema = schemaOf(obj); + if (!schema) continue; // schema missing → no annotation (client falls back) + const eff = resolveEffectiveApiMethods(schema.enable ?? undefined); + if (eff.mode === 'unrestricted') continue; // open object → no annotation + acc.apiOperations = effectiveOperationsArray(eff); + } +} + export class HonoServerPlugin implements Plugin { name = 'com.objectstack.server.hono'; type = 'server'; @@ -1131,11 +1198,36 @@ export class HonoServerPlugin implements Plugin { // (sys_user → edit). Together these remove both the false-negative // (admin sees sys_user editable) and the false-positive (admin does // NOT see sys_member editable, matching the guard). + // [#3391] For a modify-all super-user, seed restricting objects + // absent from the merged map so fold pulls them true and annotate + // can attach their effective apiOperations. Guarded — a failure + // here must never drop the whole response. + try { + const allSchemas: ApiExposureSchemaLike[] = (() => { + try { return (ql as any)?.registry?.getAllObjects?.() ?? []; } + catch { return []; } + })(); + seedSuperUserRestrictedObjects(objects, allSchemas); + } catch (e: any) { + ctx.logger.warn('[hono] effective apiOperations seed failed', { err: e?.message }); + } foldWildcardSuperUser(objects); clampManagedObjectWrites(objects, (name) => { try { return ql?.getSchema?.(name) as ManagedSchemaLike | undefined; } catch { return undefined; } }); + // [#3391] Annotate the per-object effective API operation set — + // the single channel the frontend consumes for effective ops. + // Guarded: on failure we simply omit apiOperations and the client + // falls back to its default-allow behavior. + try { + annotateEffectiveApiOperations(objects, (name) => { + try { return ql?.getSchema?.(name) as ApiExposureSchemaLike | undefined; } + catch { return undefined; } + }); + } catch (e: any) { + ctx.logger.warn('[hono] effective apiOperations annotate failed', { err: e?.message }); + } return c.json({ authenticated: true, userId: execCtx.userId, diff --git a/packages/plugins/plugin-security/src/system-write-guard.ts b/packages/plugins/plugin-security/src/system-write-guard.ts index abf007cf76..15316c107a 100644 --- a/packages/plugins/plugin-security/src/system-write-guard.ts +++ b/packages/plugins/plugin-security/src/system-write-guard.ts @@ -52,6 +52,12 @@ export const ENGINE_OWNED_BUCKETS: ReadonlySet = new Set(['system', 'eng * Read ops (`find`/`findOne`/`count`/`aggregate`/…) are absent and always pass. * Aligned with the `DelegatedAdminGate` governed-operation set and the registry's * `MANAGED_WRITE_VERB_AFFORDANCE`. + * + * ⚠️ This is the UI-intent axis (verb → *affordance*), NOT the API-tightening + * axis. What the automatic API *admits* is decided by the verb → *primitive* + * derivation in `@objectstack/spec/data` (`resolveEffectiveApiMethods`, #3391) — + * a separate table on a separate axis (ADR-0103). Merging the two is deferred to + * the enum-shrink (P2 of #3391); keep them distinct until then. */ const WRITE_OP_AFFORDANCE: Record = { insert: 'create', diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 72e08b36a1..23b50dc716 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -316,3 +316,70 @@ describe('export route — real engine + protocol integration', () => { expect(dataRows[0][2]).toBe('是'); }); }); + +// =========================================================================== +// #3391 blocking test: export column projection ≡ list's field-level security. +// The derivation contract opens export from `list`, so export MUST NOT expose a +// wider column set than list. The read middleware DELETES unreadable keys, so a +// schema-derived export header would otherwise leak the *names* of FLS-hidden +// columns as empty cells. These two tests are the "阻塞性测试" — they fail +// (red) against the pre-#3391 header (= full schema) and pass after the fix. +// =========================================================================== +describe('export route — FLS column projection (#3391 blocking)', () => { + // Simulate field-level security with the exact delete-key semantics the + // security FieldMasker uses: strip `title` from every task row on read. + const maskTitle = (engine: any) => + engine.registerHook( + 'afterFind', + (ctx: any) => { if (Array.isArray(ctx.result)) for (const r of ctx.result) { if (r) delete r.title; } }, + { object: 'task' }, + ); + + it('schema-derived header is narrowed to the masked-readable column set (CSV + JSON)', async () => { + const { engine, protocol, route } = await boot(); + maskTitle(engine); + + // The list key set under the SAME read path (afterFind runs on find too). + const listRes: any = await protocol.findData({ object: 'task', query: {} }); + const listRows: any[] = listRes.records ?? listRes.data ?? []; + const listKeys = new Set(); + for (const r of listRows) for (const k of Object.keys(r)) listKeys.add(k); + expect(listKeys.has('title')).toBe(false); // masked out of list too + + // CSV: the masked column's header (标题) must be gone — no empty leak column. + const csv = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res); + const header = parseCsv(csv.chunks.join(''))[0]; + expect(header).not.toContain('标题'); + expect(header).toEqual(['ID', '完成', '优先级', '截止', '负责人']); + + // JSON: every exported row's key set ⊆ the list key set; title never present. + const json = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, json.res); + const arr = JSON.parse(json.chunks.join('')); + expect(arr.length).toBe(2); + for (const row of arr) { + for (const k of Object.keys(row)) expect(listKeys.has(k)).toBe(true); + expect('title' in row).toBe(false); + } + }); + + it('explicit ?fields= keeps a requested masked column but never emits its value', async () => { + const { engine, route } = await boot(); + maskTitle(engine); + + // An explicit request is honored (projection does NOT narrow it), but the + // masked value must always render as an empty cell — same as list `$select`. + const csv = makeRes(); + await route.handler( + { params: { object: 'task' }, query: { format: 'csv', fields: 'id,title' } } as any, + csv.res, + ); + const rows = parseCsv(csv.chunks.join('')); + expect(rows[0]).toEqual(['ID', '标题']); // header kept as requested + for (const r of rows.slice(1)) { + expect(r[0]).not.toBe(''); // id present + expect(r[1] ?? '').toBe(''); // title masked → always empty, never a value + } + }); +}); diff --git a/packages/rest/src/rest-api-derivation-gates.test.ts b/packages/rest/src/rest-api-derivation-gates.test.ts new file mode 100644 index 0000000000..342f575ef1 --- /dev/null +++ b/packages/rest/src/rest-api-derivation-gates.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #3391 — the REST per-object exposure gate consumes the spec's single +// derivation source of truth (`resolveEffectiveApiMethods` / +// `isApiOperationAllowed`). This suite pins the gate DECISION +// (`apiAccessDenialFromEnable`) against the derivation contract: reverse-derived +// import/export, writeMode-precise import, deny-all, bulk∧child, and the +// explicit-legacy compatibility path. Route-level plumbing (createMany/batch, +// import two-stage, export projection) is covered by rest.test.ts, +// rest-batch-endpoint.test.ts, and export-integration.test.ts. + +import { describe, it, expect } from 'vitest'; +import { apiAccessDenialFromEnable } from './rest-server'; + +/** True when the gate allows the operation (no denial object). */ +function allowed(enable: any, op: string, opts?: any): boolean { + return apiAccessDenialFromEnable(enable, 'obj', op, opts) === null; +} +/** The denial (status/body) or null. */ +function denial(enable: any, op: string, opts?: any) { + return apiAccessDenialFromEnable(enable, 'obj', op, opts); +} + +describe('REST gate — three-state (#3391)', () => { + it('no enable / unrestricted → everything allowed', () => { + expect(allowed(undefined, 'create')).toBe(true); + expect(allowed({}, 'delete')).toBe(true); + expect(allowed({ apiEnabled: true }, 'export')).toBe(true); + }); + + it('apiEnabled:false → 404', () => { + const d = denial({ apiEnabled: false }, 'get'); + expect(d?.status).toBe(404); + expect(d?.body.code).toBe('OBJECT_API_DISABLED'); + }); + + it('empty whitelist [] → deny-all 405', () => { + const d = denial({ apiMethods: [] }, 'get'); + expect(d?.status).toBe(405); + expect(d?.body.allowed).toEqual([]); + expect(allowed({ apiMethods: [] }, 'list')).toBe(false); + }); +}); + +describe('REST gate — reverse-derived import/export (#3391)', () => { + // The sys_business_unit shape: a plain CRUD whitelist with NO explicit + // import/export must still admit import (⊆ create∨update) and export (⊆ list). + const crud = { apiMethods: ['get', 'list', 'create', 'update', 'delete'] }; + + it('CRUD whitelist admits import (reverse-derived from create/update)', () => { + expect(allowed(crud, 'import')).toBe(true); + expect(allowed(crud, 'import', { writeMode: 'insert' })).toBe(true); + expect(allowed(crud, 'import', { writeMode: 'update' })).toBe(true); + expect(allowed(crud, 'import', { writeMode: 'upsert' })).toBe(true); + }); + + it('CRUD whitelist admits export (reverse-derived from list)', () => { + expect(allowed(crud, 'export')).toBe(true); + }); + + it("['get','list'] admits export but not import", () => { + const ro = { apiMethods: ['get', 'list'] }; + expect(allowed(ro, 'export')).toBe(true); + const d = denial(ro, 'import'); + expect(d?.status).toBe(405); + // 405 body carries the effective set (list-derived reads), never `import`. + expect(d?.body.allowed).toContain('export'); + expect(d?.body.allowed).not.toContain('import'); + }); +}); + +describe('REST gate — import writeMode precision (#3391)', () => { + it("['create'] admits insert-mode import, rejects update-mode", () => { + const createOnly = { apiMethods: ['get', 'list', 'create'] }; + expect(allowed(createOnly, 'import', { writeMode: 'insert' })).toBe(true); + expect(allowed(createOnly, 'import', { writeMode: 'update' })).toBe(false); + // upsert needs create ∧ update → rejected + expect(allowed(createOnly, 'import', { writeMode: 'upsert' })).toBe(false); + }); + + it("['update'] admits update-mode import, rejects insert-mode", () => { + const updateOnly = { apiMethods: ['get', 'list', 'update'] }; + expect(allowed(updateOnly, 'import', { writeMode: 'update' })).toBe(true); + expect(allowed(updateOnly, 'import', { writeMode: 'insert' })).toBe(false); + }); + + it('upsert-mode import needs both create and update', () => { + expect(allowed({ apiMethods: ['create', 'update'] }, 'import', { writeMode: 'upsert' })).toBe(true); + expect(allowed({ apiMethods: ['create'] }, 'import', { writeMode: 'upsert' })).toBe(false); + }); +}); + +describe('REST gate — explicit legacy wins (deprecated, #3391)', () => { + it("explicit ['import'] admits import in every writeMode", () => { + const e = { apiMethods: ['import'] }; + expect(allowed(e, 'import')).toBe(true); + expect(allowed(e, 'import', { writeMode: 'insert' })).toBe(true); + expect(allowed(e, 'import', { writeMode: 'update' })).toBe(true); + }); +}); + +describe('REST gate — bulk ∧ child (#3391)', () => { + it("['create'] (no bulk) → createMany child gate rejects", () => { + expect(allowed({ apiMethods: ['create'] }, 'bulk', { bulkChild: 'create' })).toBe(false); + }); + + it("['create','bulk'] → createMany child gate admits", () => { + expect(allowed({ apiMethods: ['create', 'bulk'] }, 'bulk', { bulkChild: 'create' })).toBe(true); + }); + + it("['bulk'] (no create) → createMany child gate rejects (child op missing)", () => { + const d = denial({ apiMethods: ['bulk'] }, 'bulk', { bulkChild: 'create' }); + expect(d?.status).toBe(405); + }); + + it('batch upsert child needs bulk ∧ create ∧ update', () => { + expect(allowed({ apiMethods: ['bulk', 'create', 'update'] }, 'bulk', { bulkChild: 'upsert' })).toBe(true); + expect(allowed({ apiMethods: ['bulk', 'create'] }, 'bulk', { bulkChild: 'upsert' })).toBe(false); + }); +}); + +describe('REST gate — action alias normalization (#3391)', () => { + it('runtime action names map onto canonical operations', () => { + const ro = { apiMethods: ['get', 'list'] }; + expect(allowed(ro, 'query')).toBe(true); // query → list + expect(allowed(ro, 'find')).toBe(true); // find → list + expect(allowed(ro, 'batch', { bulkChild: 'create' })).toBe(false); // batch → bulk + }); +}); diff --git a/packages/rest/src/rest-batch-endpoint.test.ts b/packages/rest/src/rest-batch-endpoint.test.ts index 906e766c94..e64ffb736e 100644 --- a/packages/rest/src/rest-batch-endpoint.test.ts +++ b/packages/rest/src/rest-batch-endpoint.test.ts @@ -186,14 +186,39 @@ describe('POST {basePath}/batch — cross-object transactional batch', () => { expect(ql.transaction).not.toHaveBeenCalled(); }); - it('allows an operation that IS in the apiMethods whitelist', async () => { + it('allows an operation that IS in the apiMethods whitelist (with bulk)', async () => { + // #3391: the cross-object batch is a bulk surface — the object must grant the + // `bulk` primitive AND the child write (bulk ∧ create). const ql = makeQl(); - const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['create', 'read'] } }] }); + const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['create', 'bulk'] } }] }); const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: { amount: 1 } }] }); expect(res.statusCode).toBe(200); expect(ql.transaction).toHaveBeenCalledTimes(1); }); + it('blocks a batch write when the object grants the child but not the bulk primitive (405, #3391)', async () => { + // create is whitelisted but `bulk` is not → the batch/Many surface is closed. + const ql = makeQl(); + const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['create'] } }] }); + const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: { amount: 1 } }] }); + expect(res.statusCode).toBe(405); + expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + // the 405 body carries the EFFECTIVE operation set, not the raw whitelist + expect(res.body.allowed).toContain('create'); + expect(res.body.allowed).not.toContain('bulk'); + expect(ql.transaction).not.toHaveBeenCalled(); + }); + + it('blocks a batch write when the object grants bulk but not the child (405, #3391)', async () => { + // bulk is present but the object cannot create → bulk ∧ create fails. + const ql = makeQl(); + const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['bulk', 'update'] } }] }); + const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: { amount: 1 } }] }); + expect(res.statusCode).toBe(405); + expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(ql.transaction).not.toHaveBeenCalled(); + }); + // ── request validation ──────────────────────────────────────────────────── it('rejects a non-atomic request (this endpoint is always atomic)', async () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3aeee818d0..4d56fae6a9 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -11,6 +11,12 @@ import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpoints import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; +import { + resolveEffectiveApiMethods, + isApiOperationAllowed, + effectiveOperationsArray, + DATA_ACTION_TO_API_OPERATION, +} from '@objectstack/spec/data'; /** * The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462 @@ -449,18 +455,32 @@ function applyDroppedFieldsHeader(res: any, result: unknown): void { else if (typeof res?.setHeader === 'function') res.setHeader('X-ObjectStack-Dropped-Fields', header); } +/** Extra context for a gate check: import `writeMode` precision / bulk∧child. */ +interface ApiAccessOpts { + writeMode?: string; + bulkChild?: string; +} + /** * Pure per-object API-exposure check: given an object's `enable` block, decide * whether `operation` is denied on the *external* REST surface (ADR-0049 / - * #1889). Returns the `{ status, body }` to send, or `null` when allowed. - * Shared by the single-record routes (`enforceApiAccess`) and the cross-object - * batch route so both honour the SAME gate. A missing/loose `enable` block is - * default-allow — objects with no `enable` behave exactly as before. + * #1889 / #3391). Returns the `{ status, body }` to send, or `null` when + * allowed. Shared by the single-record routes (`enforceApiAccess`) and the + * cross-object batch route so both honour the SAME gate. + * + * #3391: the decision comes from the spec's single derivation source of truth + * (`resolveEffectiveApiMethods` / `isApiOperationAllowed`), so the three-state + * whitelist (`undefined` unrestricted / `[]` deny-all / subset) and the derived + * verbs (import⊆create∨update, export⊆list, bulk∧child, …) are resolved + * identically everywhere. The 405 body's `allowed` array is the EFFECTIVE + * operation set (enum-ordered), the single "effective" channel the frontend + * consumes — never the raw whitelist. */ -function apiAccessDenialFromEnable( +export function apiAccessDenialFromEnable( enable: any, objectName: string, operation: string, + opts?: ApiAccessOpts, ): { status: number; body: Record } | null { if (!enable) return null; if (enable.apiEnabled === false) { @@ -473,18 +493,20 @@ function apiAccessDenialFromEnable( }, }; } - if (Array.isArray(enable.apiMethods) && enable.apiMethods.length > 0 && !enable.apiMethods.includes(operation)) { - return { - status: 405, - body: { - error: `API operation '${operation}' is not allowed on object '${objectName}'`, - code: 'OBJECT_API_METHOD_NOT_ALLOWED', - object: objectName, - allowed: enable.apiMethods, - }, - }; - } - return null; + const eff = resolveEffectiveApiMethods(enable); + // Unrestricted (no whitelist) → default-allow, exactly as before. + if (eff.mode === 'unrestricted') return null; + const canonical = DATA_ACTION_TO_API_OPERATION[operation] ?? operation; + if (isApiOperationAllowed(eff, canonical, opts)) return null; + return { + status: 405, + body: { + error: `API operation '${operation}' is not allowed on object '${objectName}'`, + code: 'OBJECT_API_METHOD_NOT_ALLOWED', + object: objectName, + allowed: effectiveOperationsArray(eff), + }, + }; } /** Platform object backing async import jobs (see sys-import-job.object.ts). */ @@ -1108,13 +1130,14 @@ export class RestServer { p: RestProtocol, environmentId: string | undefined, operation: string, + opts?: ApiAccessOpts, ): Promise { const objectName = req?.params?.object; if (!objectName) return false; const items = await this.loadObjectItems(p, environmentId); const obj = items.find((o: any) => o?.name === objectName); if (!obj) return false; // unknown object → let the data path 404 - const denial = apiAccessDenialFromEnable(obj.enable, objectName, operation); + const denial = apiAccessDenialFromEnable(obj.enable, objectName, operation, opts); if (denial) { res.status(denial.status).json(denial.body); return true; @@ -3736,6 +3759,14 @@ export class RestServer { } const { rows, writeMode, dryRun } = prep.prepared; + // [#3391] Import gate — stage 2 (precise). Stage 1 above is a + // coarse `create ∨ update` check that 405s fully-closed objects + // BEFORE the (potentially large) CSV parse. Now that the write + // mode is known, re-gate precisely: insert→create, update→update, + // upsert→create∧update. Catches e.g. an object that grants only + // `create` receiving an update-mode import. + if (await this.enforceApiAccess(req, res, p, environmentId, 'import', { writeMode })) return; + // Delegate the per-row coercion + upsert loop to the shared // runner (also used by the async import-job worker). const summary = await runImport({ @@ -3820,6 +3851,11 @@ export class RestServer { } const prepared = prep.prepared; + // [#3391] Import gate — stage 2 (precise), see the synchronous + // route: now that writeMode is resolved, re-gate precisely + // (insert→create, update→update, upsert→create∧update). + if (await this.enforceApiAccess(req, res, p, environmentId, 'import', { writeMode: prepared.writeMode })) return; + const jobId = newImportJobId(); const createdAt = new Date().toISOString(); const createdBy = String((context as any)?.userId ?? (context as any)?.user?.id ?? '') || undefined; @@ -4222,6 +4258,11 @@ export class RestServer { // Resolve fields: explicit param > schema fields > derived from first row. let fields: string[] | undefined; + // Whether `fields` (the export columns) were derived from the object + // schema rather than an explicit `?fields=` request. Only schema-derived + // headers are narrowed to the FLS-readable set (#3391); an explicit + // request is honored as asked (values still masked to empty). + let fieldsFromSchema = false; if (typeof q.fields === 'string' && q.fields.length > 0) { fields = q.fields.split(',').map((s: string) => s.trim()).filter(Boolean); } else if (Array.isArray(q.fields)) { @@ -4263,7 +4304,7 @@ export class RestServer { metaMap = buildFieldMetaMap(schema); if (!fields || fields.length === 0) { const names = [...metaMap.keys()]; - if (names.length > 0) fields = names; + if (names.length > 0) { fields = names; fieldsFromSchema = true; } } } catch { /* fall back to first-row derivation + raw values */ } @@ -4324,6 +4365,29 @@ export class RestServer { fields = Object.keys(rows[0] ?? {}); } + // [#3391] Column projection ≡ list's field-level security. + // The read middleware (FieldMasker) DELETES unreadable keys from + // each row, so a schema-derived header would still leak the + // *names* of FLS-hidden columns as empty cells. Narrow the + // schema-derived header to the keys actually present across the + // first masked chunk (their ∩ with schema fields is implicit — + // `fields` already came from `metaMap.keys()`), so export headers + // match list's readable columns. Explicit `?fields=` requests are + // left untouched (values still masked to empty, as with list + // `$select`); a fully empty first chunk leaves the header as-is + // (same as today — no worse). + if (fieldsFromSchema && firstChunk && fields && fields.length > 0) { + const readable = new Set(); + for (const row of rows) { + if (row && typeof row === 'object') { + for (const k of Object.keys(row)) readable.add(k); + } + } + if (readable.size > 0) { + fields = fields.filter((f) => readable.has(f)); + } + } + if (format === 'csv') { const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap); res.write(text); @@ -6255,7 +6319,10 @@ export class RestServer { checked.add(key); const obj = byName.get(op.object); if (!obj) continue; // unknown object → surfaced by the op inside the tx - const denial = apiAccessDenialFromEnable(obj.enable, op.object, op.action); + // [#3391] Cross-object batch is a bulk surface: gate each op + // as `bulk ∧ child(op.action)` — the object must grant the + // `bulk` primitive AND the specific write it performs. + const denial = apiAccessDenialFromEnable(obj.enable, op.object, 'bulk', { bulkChild: op.action }); if (denial) { res.status(denial.status).json(denial.body); return; } } } @@ -6327,7 +6394,9 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'bulk')) return; + // [#3391] bulk ∧ child(body.operation) — the object must grant + // the `bulk` primitive AND the batched write kind. + if (await this.enforceApiAccess(req, res, p, environmentId, 'bulk', { bulkChild: req.body?.operation })) return; const result = await p.batchData!({ object: req.params.object, request: req.body, @@ -6358,7 +6427,8 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return; + // [#3391] bulk ∧ create — createMany requires the `bulk` primitive. + if (await this.enforceApiAccess(req, res, p, environmentId, 'bulk', { bulkChild: 'create' })) return; const result = await p.createManyData!({ object: req.params.object, records: req.body || [], @@ -6389,7 +6459,8 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return; + // [#3391] bulk ∧ update — updateMany requires the `bulk` primitive. + if (await this.enforceApiAccess(req, res, p, environmentId, 'bulk', { bulkChild: 'update' })) return; const result = await p.updateManyData!({ object: req.params.object, ...req.body, @@ -6420,7 +6491,8 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; - if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return; + // [#3391] bulk ∧ delete — deleteMany requires the `bulk` primitive. + if (await this.enforceApiAccess(req, res, p, environmentId, 'bulk', { bulkChild: 'delete' })) return; const result = await p.deleteManyData!({ object: req.params.object, ...req.body, diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 53fee51568..8258e313bd 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2746,15 +2746,54 @@ describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => { expect(protocol.createData).not.toHaveBeenCalled(); }); - it('apiMethods whitelist → disallowed op returns 405', async () => { + it('apiMethods whitelist → disallowed op returns 405 with the EFFECTIVE set (#3391)', async () => { const { rest, protocol } = setup({ apiMethods: ['get', 'list'] }); const res = await invoke(rest, 'POST', LIST, { object: 'widget' }, { a: 1 }); expect(res.statusCode).toBe(405); expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); - expect(res.body.allowed).toEqual(['get', 'list']); + // #3391: `allowed` is now the derived EFFECTIVE operation set (enum-ordered), + // not the raw whitelist — a `list` grant derives aggregate/search/export. + expect(res.body.allowed).toEqual(['get', 'list', 'aggregate', 'search', 'export']); expect(protocol.createData).not.toHaveBeenCalled(); }); + it('empty whitelist [] + apiEnabled:true → deny-all 405 (flipped by #3391)', async () => { + const { rest, protocol } = setup({ apiMethods: [], apiEnabled: true }); + const res = await invoke(rest, 'GET', LIST, { object: 'widget' }); + expect(res.statusCode).toBe(405); + expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(res.body.allowed).toEqual([]); + expect(protocol.findData).not.toHaveBeenCalled(); + }); + + it('createMany requires the bulk primitive: [create] alone → 405 (#3391)', async () => { + const { rest, protocol } = setup({ apiMethods: ['get', 'list', 'create'] }); + const res = await invoke(rest, 'POST', '/api/v1/data/:object/createMany', { object: 'widget' }, []); + expect(res.statusCode).toBe(405); + expect(protocol.createManyData).not.toHaveBeenCalled(); + }); + + it('createMany passes when [create, bulk] granted (bulk ∧ create, #3391)', async () => { + const { rest, protocol } = setup({ apiMethods: ['get', 'list', 'create', 'bulk'] }); + const res = await invoke(rest, 'POST', '/api/v1/data/:object/createMany', { object: 'widget' }, []); + expect(res.statusCode).toBe(201); + expect(protocol.createManyData).toHaveBeenCalledTimes(1); + }); + + it('createMany with [bulk] but no create → 405 (child op not granted, #3391)', async () => { + const { rest, protocol } = setup({ apiMethods: ['get', 'list', 'bulk'] }); + const res = await invoke(rest, 'POST', '/api/v1/data/:object/createMany', { object: 'widget' }, []); + expect(res.statusCode).toBe(405); + expect(protocol.createManyData).not.toHaveBeenCalled(); + }); + + it('reverse-derived: a CRUD whitelist (no explicit bulk/import) still allows single create', async () => { + const { rest, protocol } = setup({ apiMethods: ['get', 'list', 'create', 'update', 'delete'] }); + const res = await invoke(rest, 'POST', LIST, { object: 'widget' }, { a: 1 }); + expect(res.statusCode).not.toBe(405); + expect(protocol.createData).toHaveBeenCalledTimes(1); + }); + it('apiMethods whitelist → allowed op passes through to the engine', async () => { const { rest, protocol } = setup({ apiMethods: ['get', 'list'] }); const res = await invoke(rest, 'GET', LIST, { object: 'widget' }); diff --git a/packages/runtime/src/api-exposure.test.ts b/packages/runtime/src/api-exposure.test.ts index 1e1de82886..7548d56461 100644 --- a/packages/runtime/src/api-exposure.test.ts +++ b/packages/runtime/src/api-exposure.test.ts @@ -50,12 +50,69 @@ describe('checkApiExposure (#1889)', () => { expect(d.status).toBe(405); }); - it('an empty whitelist is treated as no restriction', () => { - expect(checkApiExposure({ apiMethods: [] }, 'create').allowed).toBe(true); + it('an empty whitelist is deny-all (405) — flipped by #3391', () => { + // Previously treated as "no restriction" (fail-open); the documented + // three-state contract makes `[]` mean "expose nothing". + const d = checkApiExposure({ apiMethods: [] }, 'create'); + expect(d.allowed).toBe(false); + expect(d.status).toBe(405); + // every operation is closed + expect(checkApiExposure({ apiMethods: [] }, 'query').allowed).toBe(false); + expect(checkApiExposure({ apiMethods: [] }, 'get').allowed).toBe(false); }); it('does not gate actions with no ApiMethod mapping', () => { expect(checkApiExposure({ apiMethods: ['list'] }, 'somethingCustom').allowed).toBe(true); }); + + it('surfaces the effective operation set on a 405 denial', () => { + const d = checkApiExposure({ apiMethods: ['get', 'list'] }, 'create'); + expect(d.allowed).toBe(false); + expect(d.allowedOperations).toContain('get'); + expect(d.allowedOperations).toContain('list'); + // list-class derived reads are exposed too + expect(d.allowedOperations).toContain('aggregate'); + expect(d.allowedOperations).toContain('export'); + expect(d.allowedOperations).not.toContain('create'); + }); + }); + + describe('nested enable shape (getObject) — regression for #3391', () => { + // meta.getObject() returns the whole schema with the exposure flags under + // `.enable`. The previous flat-only reader ignored them → a dead gate. + it('reads apiEnabled from the nested enable block (404)', () => { + const d = checkApiExposure({ name: 'x', enable: { apiEnabled: false } } as any, 'get'); + expect(d.allowed).toBe(false); + expect(d.status).toBe(404); + }); + + it('reads apiMethods from the nested enable block (405)', () => { + const def = { name: 'x', enable: { apiMethods: ['list', 'get'] } } as any; + expect(checkApiExposure(def, 'query').allowed).toBe(true); + const d = checkApiExposure(def, 'create'); + expect(d.allowed).toBe(false); + expect(d.status).toBe(405); + }); + + it('nested empty whitelist is deny-all', () => { + const def = { name: 'x', enable: { apiMethods: [], apiEnabled: true } } as any; + expect(checkApiExposure(def, 'get').allowed).toBe(false); + expect(checkApiExposure(def, 'query').allowed).toBe(false); + }); + }); + + describe('derivation-backed operations (#3391)', () => { + it('import derives from create/update (writeMode-precise)', () => { + const createOnly = { apiMethods: ['create'] }; + expect(checkApiExposure(createOnly, 'import', { writeMode: 'insert' }).allowed).toBe(true); + expect(checkApiExposure(createOnly, 'import', { writeMode: 'update' }).allowed).toBe(false); + }); + + it('bulk requires the bulk primitive AND the child op', () => { + const bulkCreate = { apiMethods: ['create', 'bulk'] }; + expect(checkApiExposure(bulkCreate, 'batch', { bulkChild: 'create' }).allowed).toBe(true); + const createOnly = { apiMethods: ['create'] }; + expect(checkApiExposure(createOnly, 'batch', { bulkChild: 'create' }).allowed).toBe(false); + }); }); }); diff --git a/packages/runtime/src/api-exposure.ts b/packages/runtime/src/api-exposure.ts index d74d9167f0..47ae638bc7 100644 --- a/packages/runtime/src/api-exposure.ts +++ b/packages/runtime/src/api-exposure.ts @@ -1,26 +1,49 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Object-level API exposure gate (ADR-0049, #1889). + * Object-level API exposure gate (ADR-0049, #1889, #3391). * * Objects declare `apiEnabled` (default true) and an optional `apiMethods` - * whitelist, but the HTTP/MCP data dispatch previously ignored both — an object - * could not actually be hidden from the API, nor could its allowed operations - * be restricted. This module decides, for a given data action, whether the - * object's declared exposure permits it. + * whitelist. The HTTP/MCP data dispatch decides, for a given data action, + * whether the object's declared exposure permits it. Both fields live in the + * object's `enable` capability block. * - * Both fields are *additive restrictions* over a default-allow surface - * (`apiEnabled` defaults true; absent `apiMethods` means "all operations"). - * Therefore an unresolvable object definition fails OPEN here — that matches - * the schema defaults and avoids breaking traffic when metadata is briefly - * unavailable. The gate is a no-op for system/internal contexts (callers pass - * `isSystem` and skip this check entirely). + * ## What #3391 changed here + * + * 1. **Nested-first shape.** `meta.getObject()` returns the full object schema + * with `apiEnabled`/`apiMethods` under `.enable`. The previous version read + * them off the *flat* top level, so the dispatcher/MCP whitelist never fired + * (a silent dead gate). We now read `def.enable` first, falling back to the + * flat shape for legacy/test doubles. + * 2. **Shared derivation.** The action→operation mapping and the three-state + * whitelist semantics are no longer duplicated here — they come from the + * spec's single source of truth (`@objectstack/spec/data` + * `resolveEffectiveApiMethods` / `isApiOperationAllowed`). + * 3. **`[]` = deny-all.** An empty whitelist now means "expose nothing" (405), + * matching the documented three-state contract, instead of the prior + * fail-open "no restriction". + * + * An unresolvable object definition still fails OPEN — that matches the schema + * defaults (`apiEnabled` true, no `apiMethods`) and avoids breaking traffic when + * metadata is briefly unavailable. The gate is a no-op for system/internal + * contexts (callers pass `isSystem` and skip this check entirely). */ -/** The exposure-relevant slice of an object definition. */ +import { + resolveEffectiveApiMethods, + isApiOperationAllowed, + effectiveOperationsArray, + DATA_ACTION_TO_API_OPERATION, + type EnableLike, +} from '@objectstack/spec/data'; + +/** The exposure-relevant slice of an object definition (flat or nested). */ export interface ObjectApiDef { apiEnabled?: boolean; apiMethods?: string[] | null; + /** Real `getObject()` shape — the exposure flags live under `enable`. */ + enable?: EnableLike | null; + [key: string]: unknown; } export interface ApiExposureDecision { @@ -28,48 +51,58 @@ export interface ApiExposureDecision { /** HTTP status to return when denied (404 hides, 405 = method not allowed). */ status?: number; reason?: string; + /** The effective operation set (enum-ordered) — surfaced on a 405 denial. */ + allowedOperations?: string[]; } /** - * Map an internal `callData` action onto the spec `ApiMethod` vocabulary - * (`object.zod.ts` → `ApiMethod`). Actions with no mapping are not gated by - * `apiMethods` (they still respect `apiEnabled`). + * Resolve the `enable` capability block from either the nested `getObject()` + * shape (`{ enable: {...} }`) or a flat legacy/test shape (`{ apiEnabled, ... }`). + * Nested wins when present. */ -const ACTION_TO_API_METHOD: Record = { - create: 'create', - get: 'get', - update: 'update', - delete: 'delete', - query: 'list', - find: 'list', - // Aggregation is a list-class read: an object whose whitelist excludes - // `list` must not leak row statistics through GROUP BY either. - aggregate: 'list', - batch: 'bulk', -}; +function resolveEnableBlock(def: ObjectApiDef): EnableLike { + if (def.enable && typeof def.enable === 'object') return def.enable; + return def as EnableLike; +} -export function checkApiExposure(def: ObjectApiDef | null | undefined, action: string): ApiExposureDecision { +/** + * Decide whether a data `action` is permitted for `def`'s declared exposure. + * + * @param def Object definition (nested `getObject` shape or flat). + * @param action Runtime data action (`create`/`get`/`query`/`find`/`aggregate`/ + * `batch`/…); normalized to a canonical operation internally. + * @param opts Optional `writeMode` (import precision) / `bulkChild` (bulk∧child). + */ +export function checkApiExposure( + def: ObjectApiDef | null | undefined, + action: string, + opts?: { writeMode?: string; bulkChild?: string }, +): ApiExposureDecision { // Unresolvable definition → fall open to the schema defaults. if (!def) return { allowed: true }; + const enable = resolveEnableBlock(def); + // `apiEnabled: false` hides the object from the API entirely → 404. - if (def.apiEnabled === false) { + if (enable.apiEnabled === false) { return { allowed: false, status: 404, reason: 'object is not exposed via the API' }; } - // `apiMethods` whitelist (when present and non-empty) restricts operations. - const whitelist = def.apiMethods; - if (Array.isArray(whitelist) && whitelist.length > 0) { - const method = ACTION_TO_API_METHOD[action]; - // Only gate actions that map to a known ApiMethod; unmapped actions pass. - if (method && !whitelist.includes(method)) { - return { - allowed: false, - status: 405, - reason: `API operation '${method}' is not allowed for this object`, - }; - } - } + const eff = resolveEffectiveApiMethods(enable); + // Unrestricted (no whitelist) → allow everything, exactly as before. + if (eff.mode === 'unrestricted') return { allowed: true }; + + // Normalize the runtime action onto a canonical ApiMethod operation. An + // action with no mapping is not gated by `apiMethods` (respects `apiEnabled` + // only), preserving the prior behavior for custom actions. + const operation = DATA_ACTION_TO_API_OPERATION[action] ?? action; + + if (isApiOperationAllowed(eff, operation, opts)) return { allowed: true }; - return { allowed: true }; + return { + allowed: false, + status: 405, + reason: `API operation '${operation}' is not allowed for this object`, + allowedOperations: effectiveOperationsArray(eff), + }; } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 5b7557a6b8..8ba9b71df9 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -161,6 +161,9 @@ ], "./data": [ "ALL_OPERATORS (const)", + "API_METHOD_DERIVATION (const)", + "API_METHOD_ORDER (const)", + "API_PRIMITIVES (const)", "Address (type)", "AddressSchema (const)", "AddressValueSchema (const)", @@ -175,6 +178,8 @@ "AnalyticsQuery (type)", "AnalyticsQuerySchema (const)", "ApiMethod (type)", + "ApiMethodsMode (type)", + "ApiPrimitive (type)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", "BaseEngineOptions (type)", @@ -206,6 +211,7 @@ "CurrencyConfigSchema (const)", "CurrencyValue (type)", "CurrencyValueSchema (const)", + "DATA_ACTION_TO_API_OPERATION (const)", "DATE_MACRO_ALIAS_TOKENS (const)", "DATE_MACRO_DESCRIPTIONS (const)", "DATE_MACRO_INSTANT_TOKENS (const)", @@ -287,6 +293,8 @@ "DroppedFieldsEventSchema (const)", "ESignatureConfig (type)", "ESignatureConfigSchema (const)", + "EffectiveApiMethods (interface)", + "EnableLike (interface)", "EngineAggregateOptions (type)", "EngineAggregateOptionsSchema (const)", "EngineCountOptions (type)", @@ -362,8 +370,10 @@ "JoinNodeSchema (const)", "JoinStrategy (const)", "JoinType (const)", + "LEGACY_API_METHODS (const)", "LIFECYCLE_DURATION_REGEX (const)", "LOGICAL_OPERATORS (const)", + "LegacyApiMethod (type)", "Lifecycle (type)", "LifecycleClass (type)", "LifecycleClassSchema (const)", @@ -421,6 +431,7 @@ "ObjectRequiredPermissionsSchema (const)", "ObjectSchema (const)", "ObjectTitleCompleteness (interface)", + "OperationCheckOptions (interface)", "OperatorKey (type)", "PerOperationRequiredPermissions (type)", "PerOperationRequiredPermissionsSchema (const)", @@ -450,6 +461,7 @@ "RenderedAutonumber (interface)", "ReplicationConfig (type)", "ReplicationConfigSchema (const)", + "ResolveApiOptions (interface)", "ResolveRecordDisplayNameOptions (interface)", "ResolvedHook (type)", "RowCrudActionOverride (type)", @@ -534,13 +546,17 @@ "deriveFieldGroupLayout (function)", "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", + "effectiveOperationsArray (function)", "fieldForm (const)", "hasDynamicTokens (function)", "hookForm (const)", + "isApiOperationAllowed (function)", + "isApiPrimitive (function)", "isCompatible (function)", "isDateMacroToken (function)", "isFilterAST (function)", "isIncoherentAggregate (function)", + "isLegacyApiMethod (function)", "isMultiValueField (function)", "isTenancyDisabled (function)", "isTitleEligible (function)", @@ -555,6 +571,7 @@ "renderAutonumber (function)", "resolveCrudAffordances (function)", "resolveDisplayField (function)", + "resolveEffectiveApiMethods (function)", "resolveRecordDisplayName (function)", "sequenceWidth (function)", "suggestFieldType (function)", @@ -3905,6 +3922,8 @@ "CapabilityDeclarationSchema (const)", "CriteriaSharingRule (type)", "CriteriaSharingRuleSchema (const)", + "EffectiveObjectPermission (type)", + "EffectiveObjectPermissionSchema (const)", "ExplainDecision (type)", "ExplainDecisionSchema (const)", "ExplainLayer (type)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 4e39f46797..42995ddac5 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1183,6 +1183,7 @@ "security/AuthzPosture", "security/CapabilityDeclaration", "security/CriteriaSharingRule", + "security/EffectiveObjectPermission", "security/ExplainDecision", "security/ExplainLayer", "security/ExplainMatchedRule", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 3fe240f063..d4ebf053d4 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -153,8 +153,8 @@ }, "apiMethods": { "status": "live", - "evidence": "packages/rest/src/rest-server.ts", - "note": "enforced by enforceApiAccess — operations outside the whitelist → 405." + "evidence": "packages/spec/src/data/api-derivation.ts, packages/rest/src/rest-server.ts, packages/runtime/src/api-exposure.ts", + "note": "three-state whitelist over the six primitives (get/list/create/update/delete/bulk): undefined = unrestricted, [] = deny-all (fully closed), subset = the derived closure. resolveEffectiveApiMethods (the single derivation table) is consumed by the REST gate (405 outside the effective set) and the runtime dispatcher gate; the legacy 8 values (upsert/aggregate/history/search/restore/purge/import/export) are DERIVED, not declared standalone (#3391)." }, "trackHistory": { "status": "live", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index ed9219386e..d8dd372f4a 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -201,6 +201,15 @@ describe('ObjectStack Protocol', () => { objects: { account: { allowRead: true } }, systemPermissions: ['manage_users', 'view_reports'], }).success).toBe(true); + // #3391: the effective response carries per-object apiOperations. + const withOps = GetEffectivePermissionsResponseSchema.safeParse({ + objects: { account: { allowRead: true, apiOperations: ['get', 'list', 'export'] } }, + systemPermissions: [], + }); + expect(withOps.success).toBe(true); + if (withOps.success) { + expect((withOps.data.objects.account as any).apiOperations).toEqual(['get', 'list', 'export']); + } }); it('validates Workflow operations', () => { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 1845f9657a..134d6a35b0 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -14,7 +14,7 @@ import { AnalyticsMetadataResponseSchema } from './analytics.zod'; import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; -import { ObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; +import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; import { StateMachineSchema } from '../automation/state-machine.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; import { TranslationDataSchema } from '../system/translation.zod'; @@ -665,7 +665,10 @@ export const GetObjectPermissionsResponseSchema = lazySchema(() => z.object({ export const GetEffectivePermissionsRequestSchema = lazySchema(() => z.object({})); export const GetEffectivePermissionsResponseSchema = lazySchema(() => z.object({ - objects: z.record(z.string(), ObjectPermissionSchema).describe('Effective object permissions keyed by object name'), + // [#3391] The effective response carries the server-resolved API operation set + // per object (`apiOperations`) — the single "effective" channel the frontend + // consumes. Authoring `ObjectPermissionSchema` stays unextended. + objects: z.record(z.string(), EffectiveObjectPermissionSchema).describe('Effective object permissions keyed by object name'), systemPermissions: z.array(z.string()).describe('Effective system-level permissions'), })); diff --git a/packages/spec/src/data/api-derivation.test.ts b/packages/spec/src/data/api-derivation.test.ts new file mode 100644 index 0000000000..40b0c6550e --- /dev/null +++ b/packages/spec/src/data/api-derivation.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + resolveEffectiveApiMethods, + isApiOperationAllowed, + effectiveOperationsArray, + DATA_ACTION_TO_API_OPERATION, + API_PRIMITIVES, + LEGACY_API_METHODS, +} from './api-derivation'; + +describe('api-derivation (#3391)', () => { + describe('three-state mode', () => { + it('undefined apiMethods → unrestricted', () => { + const eff = resolveEffectiveApiMethods({}); + expect(eff.mode).toBe('unrestricted'); + expect(isApiOperationAllowed(eff, 'create')).toBe(true); + expect(isApiOperationAllowed(eff, 'delete')).toBe(true); + expect(isApiOperationAllowed(eff, 'export')).toBe(true); + }); + + it('undefined enable block → unrestricted', () => { + const eff = resolveEffectiveApiMethods(undefined); + expect(eff.mode).toBe('unrestricted'); + expect(isApiOperationAllowed(eff, 'bulk')).toBe(true); + }); + + it('empty array → deny-all (flipped semantics, #3391)', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: [] }); + expect(eff.mode).toBe('deny-all'); + for (const p of API_PRIMITIVES) expect(isApiOperationAllowed(eff, p)).toBe(false); + expect(isApiOperationAllowed(eff, 'import')).toBe(false); + expect(isApiOperationAllowed(eff, 'export')).toBe(false); + expect(effectiveOperationsArray(eff)).toEqual([]); + }); + + it('subset → restricted', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['get', 'list'] }); + expect(eff.mode).toBe('restricted'); + expect(isApiOperationAllowed(eff, 'get')).toBe(true); + expect(isApiOperationAllowed(eff, 'list')).toBe(true); + expect(isApiOperationAllowed(eff, 'create')).toBe(false); + expect(isApiOperationAllowed(eff, 'delete')).toBe(false); + }); + }); + + describe('primitive gating', () => { + it('grants only the declared primitives', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['get', 'list', 'update'] }); + expect(isApiOperationAllowed(eff, 'get')).toBe(true); + expect(isApiOperationAllowed(eff, 'update')).toBe(true); + expect(isApiOperationAllowed(eff, 'create')).toBe(false); + expect(isApiOperationAllowed(eff, 'bulk')).toBe(false); + }); + }); + + describe('derivation table — each legacy verb', () => { + it('upsert = create ∧ update', () => { + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['create', 'update'] }), 'upsert')).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['create'] }), 'upsert')).toBe(false); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['update'] }), 'upsert')).toBe(false); + }); + + it('export = list (this phase, userExportAllowed defaults true)', () => { + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['list'] }), 'export')).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['get'] }), 'export')).toBe(false); + }); + + it('export gated off when userExportAllowed=false', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['list'] }, { userExportAllowed: false }); + expect(isApiOperationAllowed(eff, 'export')).toBe(false); + // list itself is unaffected + expect(isApiOperationAllowed(eff, 'list')).toBe(true); + }); + + it('aggregate = list', () => { + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['list'] }), 'aggregate')).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['get'] }), 'aggregate')).toBe(false); + }); + + it('search = list ∧ searchable !== false', () => { + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['list'] }), 'search')).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['list'], searchable: false }), 'search')).toBe(false); + // default (searchable undefined) counts as enabled + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['list'], searchable: true }), 'search')).toBe(true); + }); + + it('history = get ∧ trackHistory === true', () => { + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['get'], trackHistory: true }), 'history')).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['get'] }), 'history')).toBe(false); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['get'], trackHistory: false }), 'history')).toBe(false); + }); + + it('restore/purge never derive (trash flag retired, #2377)', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['get', 'list', 'create', 'update', 'delete'] }); + expect(isApiOperationAllowed(eff, 'restore')).toBe(false); + expect(isApiOperationAllowed(eff, 'purge')).toBe(false); + // even fully-unrestricted objects do not expose restore/purge + const open = resolveEffectiveApiMethods({}); + expect(isApiOperationAllowed(open, 'restore')).toBe(false); + expect(isApiOperationAllowed(open, 'purge')).toBe(false); + }); + }); + + describe('import — coarse vs writeMode-precise', () => { + it('coarse: any of create/update grants import', () => { + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['create'] }), 'import')).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['update'] }), 'import')).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['list'] }), 'import')).toBe(false); + }); + + it('writeMode=insert needs create', () => { + const create = resolveEffectiveApiMethods({ apiMethods: ['create'] }); + const update = resolveEffectiveApiMethods({ apiMethods: ['update'] }); + expect(isApiOperationAllowed(create, 'import', { writeMode: 'insert' })).toBe(true); + expect(isApiOperationAllowed(update, 'import', { writeMode: 'insert' })).toBe(false); + }); + + it('writeMode=update needs update', () => { + const create = resolveEffectiveApiMethods({ apiMethods: ['create'] }); + const update = resolveEffectiveApiMethods({ apiMethods: ['update'] }); + expect(isApiOperationAllowed(create, 'import', { writeMode: 'update' })).toBe(false); + expect(isApiOperationAllowed(update, 'import', { writeMode: 'update' })).toBe(true); + }); + + it('writeMode=upsert needs create ∧ update', () => { + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['create', 'update'] }), 'import', { writeMode: 'upsert' })).toBe(true); + expect(isApiOperationAllowed(resolveEffectiveApiMethods({ apiMethods: ['create'] }), 'import', { writeMode: 'upsert' })).toBe(false); + }); + }); + + describe('explicit legacy wins (deprecated compatibility)', () => { + it('honors a standalone explicit import even without create/update', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['import'] }); + expect(isApiOperationAllowed(eff, 'import')).toBe(true); + // even precise writeMode checks pass when explicitly declared + expect(isApiOperationAllowed(eff, 'import', { writeMode: 'update' })).toBe(true); + }); + + it('honors a standalone explicit export even without list', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['export'] }); + expect(isApiOperationAllowed(eff, 'export')).toBe(true); + }); + }); + + describe('bulk ∧ child', () => { + it('requires the bulk primitive AND the child op', () => { + const withBulk = resolveEffectiveApiMethods({ apiMethods: ['create', 'bulk'] }); + const noBulk = resolveEffectiveApiMethods({ apiMethods: ['create'] }); + expect(isApiOperationAllowed(withBulk, 'bulk', { bulkChild: 'create' })).toBe(true); + // has bulk but not create → child fails + const bulkOnly = resolveEffectiveApiMethods({ apiMethods: ['bulk'] }); + expect(isApiOperationAllowed(bulkOnly, 'bulk', { bulkChild: 'create' })).toBe(false); + // has create but not bulk → bulk primitive missing + expect(isApiOperationAllowed(noBulk, 'bulk', { bulkChild: 'create' })).toBe(false); + }); + + it('bulk child upsert needs bulk ∧ create ∧ update', () => { + const full = resolveEffectiveApiMethods({ apiMethods: ['create', 'update', 'bulk'] }); + expect(isApiOperationAllowed(full, 'bulk', { bulkChild: 'upsert' })).toBe(true); + const partial = resolveEffectiveApiMethods({ apiMethods: ['create', 'bulk'] }); + expect(isApiOperationAllowed(partial, 'bulk', { bulkChild: 'upsert' })).toBe(false); + }); + }); + + describe('action alias table', () => { + it('maps runtime action vocabulary to canonical operations', () => { + expect(DATA_ACTION_TO_API_OPERATION.query).toBe('list'); + expect(DATA_ACTION_TO_API_OPERATION.find).toBe('list'); + expect(DATA_ACTION_TO_API_OPERATION.batch).toBe('bulk'); + expect(DATA_ACTION_TO_API_OPERATION.get).toBe('get'); + }); + }); + + describe('unknown/custom operations', () => { + it('are not gated by apiMethods (pass through)', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['list'] }); + expect(isApiOperationAllowed(eff, 'somethingCustom')).toBe(true); + }); + }); + + describe('effectiveOperationsArray — deterministic enum order', () => { + it('serializes in ApiMethod declaration order', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: ['list', 'create'] }); + const arr = effectiveOperationsArray(eff); + // enum order: get, list, create, update, delete, upsert, bulk, aggregate, ... + // for ['list','create']: list, create present; derived: aggregate, search (list), import, export, upsert? (needs update) no + expect(arr).toContain('list'); + expect(arr).toContain('create'); + expect(arr).toContain('aggregate'); + expect(arr).toContain('import'); + expect(arr).toContain('export'); + expect(arr).not.toContain('update'); + expect(arr).not.toContain('upsert'); + expect(arr).not.toContain('restore'); + // deterministic order: list precedes create in the enum + expect(arr.indexOf('list')).toBeLessThan(arr.indexOf('create')); + }); + + it('unrestricted exposes all primitives + derivable legacy (minus restore/purge)', () => { + const arr = effectiveOperationsArray(resolveEffectiveApiMethods({ searchable: true, trackHistory: true })); + for (const p of API_PRIMITIVES) expect(arr).toContain(p); + expect(arr).toContain('upsert'); + expect(arr).toContain('aggregate'); + expect(arr).toContain('search'); + expect(arr).toContain('history'); + expect(arr).toContain('import'); + expect(arr).toContain('export'); + expect(arr).not.toContain('restore'); + expect(arr).not.toContain('purge'); + }); + }); + + it('legacy list and primitive list are disjoint and cover the enum', () => { + const overlap = LEGACY_API_METHODS.filter((m) => (API_PRIMITIVES as readonly string[]).includes(m)); + expect(overlap).toEqual([]); + }); +}); diff --git a/packages/spec/src/data/api-derivation.ts b/packages/spec/src/data/api-derivation.ts new file mode 100644 index 0000000000..d7912b8d9a --- /dev/null +++ b/packages/spec/src/data/api-derivation.ts @@ -0,0 +1,351 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * API-method derivation — the spec's ONE source of truth for turning an + * object's `enable.apiMethods` whitelist into the effective set of operations + * the automatic API exposes (issue #3391, design in #3026). + * + * ## The contract (one sentence) + * + * The server is the only adjudicator: each object's effective operation set is + * resolved from **six primitives** (`get/list/create/update/delete/bulk`) by a + * single derivation table here — `undefined` = fully open, `[]` = fully closed, + * a subset = tightened — and every gate (REST, dispatcher, `/me/permissions` + * annotation, managed-write clamp) consumes THIS table. The frontend renders + * only the effective result the server hands down; it never reads the raw + * `apiMethods` nor derives anything itself. + * + * ## Two orthogonal axes + * + * This module is the **API-tightening axis** (verb → *primitive*): what the + * automatic API will admit. It is deliberately NOT the same as the UI-intent + * axis (verb → *affordance*) that `resolveCrudAffordances` / + * `MANAGED_WRITE_VERB_AFFORDANCE` (objectql `registry.ts`) and + * `WRITE_OP_AFFORDANCE` (plugin-security `system-write-guard.ts`) implement. + * Merging the two would blur an authoring-intent (UX affordance) concern into a + * security (API exposure) concern; they stay separate tables. See ADR-0103. + * + * ## Three-state semantics of `apiMethods` + * + * | value | mode | meaning | + * |--------------|----------------|-------------------------------------------| + * | `undefined` | `unrestricted` | every operation is exposed (default-open) | + * | `[]` | `deny-all` | no operation is exposed (fully closed) | + * | `[..subset]` | `restricted` | only the derived closure of the subset | + * + * ## Derived verbs are never declared standalone + * + * The legacy 8 values (`upsert/aggregate/history/search/restore/purge/import/ + * export`) are DERIVED from the primitives — an author who whitelists + * `['create','update']` gets `upsert`/`import` for free; one who whitelists + * `['list']` gets `aggregate`/`export`/`search` for free. A standalone legacy + * value in a whitelist is honored verbatim for one release ("explicit wins", + * with a registration-time deprecation warning) but is slated for removal in + * the enum-shrink (P2 of #3391). + */ + +import { ApiMethod } from './object.zod'; + +/** + * The six irreducible API primitives. Every effective operation is derived + * from a subset of these — they are the only values an author needs to declare. + */ +export const API_PRIMITIVES = ['get', 'list', 'create', 'update', 'delete', 'bulk'] as const; +export type ApiPrimitive = (typeof API_PRIMITIVES)[number]; + +/** + * The eight legacy values that are DERIVED from primitives. Declaring one + * standalone is deprecated (still honored one release; see + * `warnDeprecatedExplicitApiMethods` in objectql `registry.ts`). + */ +export const LEGACY_API_METHODS = [ + 'upsert', 'aggregate', 'history', 'search', 'restore', 'purge', 'import', 'export', +] as const; +export type LegacyApiMethod = (typeof LEGACY_API_METHODS)[number]; + +const PRIMITIVE_SET: ReadonlySet = new Set(API_PRIMITIVES); +const LEGACY_SET: ReadonlySet = new Set(LEGACY_API_METHODS); + +export function isApiPrimitive(v: string): v is ApiPrimitive { + return PRIMITIVE_SET.has(v); +} +export function isLegacyApiMethod(v: string): v is LegacyApiMethod { + return LEGACY_SET.has(v); +} + +/** + * The exposure-relevant slice of an object's `enable` block. Kept loose so both + * the runtime (which may hand a flat legacy shape) and authored schemas satisfy + * it. Only `apiMethods` and the feature flags (`searchable`, `trackHistory`) + * participate in derivation. + */ +export interface EnableLike { + apiEnabled?: boolean; + apiMethods?: readonly ApiMethod[] | readonly string[] | null; + searchable?: boolean; + trackHistory?: boolean; + [key: string]: unknown; +} + +/** A single derivation rule: which primitives a legacy verb needs, plus a flag gate. */ +interface DerivationRule { + /** ALL listed primitives must be granted (default relation). */ + all?: readonly ApiPrimitive[]; + /** ANY listed primitive suffices — used for the coarse `import` judgement. */ + any?: readonly ApiPrimitive[]; + /** Schema feature flag that must also hold, e.g. `searchable`/`trackHistory`. */ + flag?: (enable: EnableLike) => boolean; +} + +/** + * The derivation table — the spec's single source of truth. + * + * - `import` is `any: [create, update]` as a COARSE gate; the precise judgement + * is refined by `writeMode` in {@link isApiOperationAllowed} + * (insert→create, update→update, upsert→create∧update). + * - `export` is `list`, additionally gated by the user-level export slot + * (`ResolveApiOptions.userExportAllowed`, always `true` this phase — the real + * permission bit is a follow-up, wiring it changes no contract here). + * - `restore`/`purge` map to `delete` but their flag is permanently `false`: + * `enable.trash` was retired (#2377/ADR-0049) with no runtime consumer, so + * there is no soft-delete state to restore/purge. They return as live derived + * verbs only if/when a real recycle bin ships (#1893). + */ +export const API_METHOD_DERIVATION: Record = { + upsert: { all: ['create', 'update'] }, + import: { any: ['create', 'update'] }, + export: { all: ['list'] }, + aggregate: { all: ['list'] }, + search: { all: ['list'], flag: (e) => e.searchable !== false }, + history: { all: ['get'], flag: (e) => e.trackHistory === true }, + restore: { all: ['delete'], flag: () => false }, + purge: { all: ['delete'], flag: () => false }, +}; + +/** + * Alias table normalizing the two runtime vocabularies onto the canonical + * {@link ApiMethod} operation names: + * - runtime `callData` actions (`query`/`find`→`list`, `batch`→`bulk`); + * - REST operation literals (already canonical, listed for completeness). + * + * Actions with no entry are passed through unchanged and, if unrecognized by + * the resolver, treated as ungated (custom actions were never gated by + * `apiMethods`). + */ +export const DATA_ACTION_TO_API_OPERATION: Record = { + // runtime callData actions + get: 'get', + query: 'list', + find: 'list', + list: 'list', + create: 'create', + update: 'update', + delete: 'delete', + batch: 'bulk', + bulk: 'bulk', + // legacy / derived operation literals (identity) + upsert: 'upsert', + aggregate: 'aggregate', + history: 'history', + search: 'search', + restore: 'restore', + purge: 'purge', + import: 'import', + export: 'export', +}; + +/** Options that widen/narrow derivation at resolve time. */ +export interface ResolveApiOptions { + /** + * User-level export permission slot. `export` derives from `list` AND this + * flag. Always `true` this phase (there is no user-level export permission + * bit yet); wiring a real bit in is a zero-contract change (#3391 follow-up). + */ + userExportAllowed?: boolean; +} + +export type ApiMethodsMode = 'unrestricted' | 'restricted' | 'deny-all'; + +/** + * The resolved, effective view of an object's API exposure. Carries both the + * granted primitives and the explicitly-declared legacy values so callers can + * distinguish "derived" from "author asked for it" (needed by import `writeMode` + * precision and bulk∧child), and a pre-computed `operations` closure for + * serialization to the 405 body / `/me/permissions`. + */ +export interface EffectiveApiMethods { + mode: ApiMethodsMode; + /** Granted primitives (subset of {@link API_PRIMITIVES}). */ + primitives: ReadonlySet; + /** Legacy values the author declared explicitly (honored verbatim, deprecated). */ + explicitLegacy: ReadonlySet; + /** Full effective operation closure (primitives ∪ derived ∪ explicit legacy). */ + operations: ReadonlySet; + /** The user-level export slot captured at resolve time. */ + userExportAllowed: boolean; +} + +/** Whether a legacy verb is derivable from the given primitives + flags. */ +function isLegacyDerivable( + legacy: LegacyApiMethod, + primitives: ReadonlySet, + enable: EnableLike, + userExportAllowed: boolean, +): boolean { + const rule = API_METHOD_DERIVATION[legacy]; + if (rule.flag && !rule.flag(enable)) return false; + if (legacy === 'export' && !userExportAllowed) return false; + if (rule.all) return rule.all.every((p) => primitives.has(p)); + if (rule.any) return rule.any.some((p) => primitives.has(p)); + return false; +} + +/** Compute the full effective operation closure for a resolved state. */ +function computeOperations( + mode: ApiMethodsMode, + primitives: ReadonlySet, + explicitLegacy: ReadonlySet, + enable: EnableLike, + userExportAllowed: boolean, +): Set { + const ops = new Set(); + if (mode === 'deny-all') return ops; + for (const p of primitives) ops.add(p); + for (const l of explicitLegacy) ops.add(l); // explicit wins + for (const legacy of LEGACY_API_METHODS) { + if (ops.has(legacy)) continue; + if (isLegacyDerivable(legacy, primitives, enable, userExportAllowed)) ops.add(legacy); + } + return ops; +} + +/** + * Resolve an object's `enable` block into its effective API exposure. Pure and + * silent (the deprecation warning for explicit legacy values fires only at + * registration time — see objectql `registry.ts`). + * + * @param enable The object's `enable` capability block (or `undefined`). + * @param opts Resolve-time options (e.g. the user-level export slot). + */ +export function resolveEffectiveApiMethods( + enable?: EnableLike | null, + opts?: ResolveApiOptions, +): EffectiveApiMethods { + const userExportAllowed = opts?.userExportAllowed !== false; + const e: EnableLike = enable ?? {}; + const raw = e.apiMethods; + + // undefined / non-array → unrestricted (default-open, every operation). + if (raw == null || !Array.isArray(raw)) { + const primitives = new Set(API_PRIMITIVES); + const explicitLegacy = new Set(); + const operations = computeOperations('unrestricted', primitives, explicitLegacy, e, userExportAllowed); + return { mode: 'unrestricted', primitives, explicitLegacy, operations, userExportAllowed }; + } + + // [] → deny-all (fully closed). This is the flipped semantics of #3391: an + // empty whitelist means "expose nothing", not "no restriction". + if (raw.length === 0) { + return { + mode: 'deny-all', + primitives: new Set(), + explicitLegacy: new Set(), + operations: new Set(), + userExportAllowed, + }; + } + + // subset → restricted. Partition the declared values into granted primitives + // and explicitly-declared legacy values. + const primitives = new Set(); + const explicitLegacy = new Set(); + for (const m of raw) { + const v = String(m); + if (isApiPrimitive(v)) primitives.add(v); + else if (isLegacyApiMethod(v)) explicitLegacy.add(v); + } + const operations = computeOperations('restricted', primitives, explicitLegacy, e, userExportAllowed); + return { mode: 'restricted', primitives, explicitLegacy, operations, userExportAllowed }; +} + +/** Extra context for a single operation check. */ +export interface OperationCheckOptions { + /** + * Import write mode (`insert`/`update`/`upsert`), used to refine the coarse + * `import` derivation into a precise one. Ignored for non-import operations. + */ + writeMode?: string; + /** + * For a bulk request, the child operation being batched (`create`/`update`/ + * `delete`, or `upsert`). When set on a `bulk` check, the object must grant + * the `bulk` primitive AND the child operation must itself be allowed. + */ + bulkChild?: string; +} + +/** + * Decide whether a single operation is allowed for a resolved effective state. + * + * @param eff The resolved effective methods (from {@link resolveEffectiveApiMethods}). + * @param operation A canonical {@link ApiMethod} name (normalize runtime action + * names through {@link DATA_ACTION_TO_API_OPERATION} first). + * @param opts `writeMode` (import precision) / `bulkChild` (bulk∧child). + */ +export function isApiOperationAllowed( + eff: EffectiveApiMethods, + operation: string, + opts?: OperationCheckOptions, +): boolean { + if (eff.mode === 'deny-all') return false; + const unrestricted = eff.mode === 'unrestricted'; + + // bulk ∧ child: the object must grant the `bulk` primitive, and the batched + // child operation must itself be allowed. + if (operation === 'bulk' && opts?.bulkChild) { + if (!unrestricted && !eff.primitives.has('bulk')) return false; + return isApiOperationAllowed(eff, opts.bulkChild, { writeMode: opts.writeMode }); + } + + if (isApiPrimitive(operation)) { + return unrestricted || eff.primitives.has(operation); + } + + if (isLegacyApiMethod(operation)) { + // Explicitly-declared legacy value → honored verbatim (deprecated). + if (eff.explicitLegacy.has(operation)) return true; + // import: refine the coarse any-of into a writeMode-precise judgement. + // (Unrestricted grants every primitive, so precision is moot → allowed.) + if (operation === 'import' && opts?.writeMode && !unrestricted) { + const wm = opts.writeMode; + if (wm === 'insert') return eff.primitives.has('create'); + if (wm === 'update') return eff.primitives.has('update'); + if (wm === 'upsert') return eff.primitives.has('create') && eff.primitives.has('update'); + // unknown writeMode → fall through to the derived answer below. + } + // Otherwise the derived answer. `operations` already folds in the schema + // flags (searchable/trackHistory) and the permanently-off trash flag, so + // restore/purge stay closed even for an unrestricted object — the gate + // decision and the serialized effective set never disagree. + return eff.operations.has(operation); + } + + // Unknown/custom operation → not gated by apiMethods (matches prior behavior: + // actions with no ApiMethod mapping still respect `apiEnabled` only). + return true; +} + +/** + * The canonical serialization order for effective operations — the declaration + * order of the {@link ApiMethod} enum. Used for the deterministic 405 `allowed` + * array and the `/me/permissions` `apiOperations` field. + */ +export const API_METHOD_ORDER: readonly ApiMethod[] = ApiMethod.options; + +/** + * Serialize an effective state's operation closure into a stable, enum-ordered + * array — the single "effective set" the server hands down (405 body, + * `/me/permissions`). The frontend consumes this, never the raw whitelist. + */ +export function effectiveOperationsArray(eff: EffectiveApiMethods): ApiMethod[] { + return API_METHOD_ORDER.filter((m) => eff.operations.has(m)); +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index b43ea0c6ed..d97a763f13 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -4,6 +4,9 @@ export * from './query.zod'; export * from './filter.zod'; export * from './date-macros.zod'; export * from './object.zod'; +// API-method derivation — the single source of truth turning an object's +// `enable.apiMethods` whitelist into its effective operation set (#3391). +export * from './api-derivation'; export * from './field.zod'; // Field runtime value-shape contract (ADR-0104 D1) export * from './field-value.zod'; diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index c0fb8582e2..46aea3ccf7 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { PermissionSetSchema, ObjectPermissionSchema, + EffectiveObjectPermissionSchema, FieldPermissionSchema, AdminScopeSchema, type PermissionSet, @@ -98,6 +99,46 @@ describe('ObjectPermissionSchema', () => { }); }); +describe('EffectiveObjectPermissionSchema (#3391 response-side)', () => { + it('carries every ObjectPermission field plus optional apiOperations', () => { + const parsed = EffectiveObjectPermissionSchema.parse({ + allowRead: true, + allowCreate: true, + apiOperations: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }); + expect(parsed.allowRead).toBe(true); + expect(parsed.apiOperations).toEqual(['get', 'list', 'create', 'update', 'delete', 'bulk']); + }); + + it('apiOperations is optional (absent = client default-allow)', () => { + const parsed = EffectiveObjectPermissionSchema.parse({ allowRead: true }); + expect(parsed.apiOperations).toBeUndefined(); + expect(parsed.allowRead).toBe(true); + }); + + it('rejects an apiOperations value outside the ApiMethod enum', () => { + expect( + EffectiveObjectPermissionSchema.safeParse({ allowRead: true, apiOperations: ['frobnicate'] }).success, + ).toBe(false); + }); + + it('accepts the derived legacy operations (import/export/aggregate/…)', () => { + expect( + EffectiveObjectPermissionSchema.safeParse({ + allowRead: true, + apiOperations: ['get', 'list', 'aggregate', 'search', 'export', 'import', 'upsert'], + }).success, + ).toBe(true); + }); + + it('does not leak apiOperations onto the authoring ObjectPermissionSchema', () => { + // The authoring schema stays unextended — a stray apiOperations key is + // stripped (or rejected) there, never a valid authoring field. + const parsed = ObjectPermissionSchema.parse({ allowRead: true, apiOperations: ['get'] } as any); + expect((parsed as any).apiOperations).toBeUndefined(); + }); +}); + describe('FieldPermissionSchema', () => { it('should default readable to true', () => { const result = FieldPermissionSchema.parse({}); diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 8509d9d422..eba1727799 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { RowLevelSecurityPolicySchema } from './rls.zod'; +import { ApiMethod } from '../data/object.zod'; /** * Entity (Object) Level Permissions @@ -84,6 +85,31 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({ writeScope: ObjectAccessScopeSchema.optional().describe('[ADR-0057 D1] Write depth: own|unit|unit_and_below|org'), })); +/** + * RESPONSE-side extension of {@link ObjectPermissionSchema} carrying the + * server-resolved effective API operation set for one object (#3391). + * + * This lives on the *response* surface only (e.g. `/me/permissions`, + * `GetEffectivePermissionsResponse`) — the authoring `ObjectPermissionSchema` + * is deliberately NOT extended, so a permission-set author can never declare a + * meaningless `apiOperations` key (the server is the only adjudicator; the + * frontend consumes the effective set it hands down, never a raw whitelist). + * + * The field is named `apiOperations` (not `operations`) to avoid colliding with + * the view schema's `operations`. It is the enum-ordered closure produced by + * `@objectstack/spec/data` `effectiveOperationsArray`, present only for objects + * whose `apiMethods` whitelist actually tightens exposure; absent = the client + * falls back to its default-allow behavior (old backend / unrestricted object). + */ +export const EffectiveObjectPermissionSchema = lazySchema(() => + (ObjectPermissionSchema as unknown as z.ZodObject).extend({ + apiOperations: z.array(ApiMethod).optional().describe( + '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.', + ), + }), +); +export type EffectiveObjectPermission = z.infer; + /** * [ADR-0090 D12] Delegated-administration scope. *