diff --git a/.changeset/18783-server-can-option-visibility.md b/.changeset/18783-server-can-option-visibility.md new file mode 100644 index 00000000000..2de3df5c867 --- /dev/null +++ b/.changeset/18783-server-can-option-visibility.md @@ -0,0 +1,39 @@ +--- +'@objectstack/objectql': minor +'@objectstack/plugin-security': minor +'@objectstack/core': minor +'@objectstack/plugin-hono-server': patch +--- + +feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783) + +A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants: + +```ts +stage: Field.select({ + label: 'Stage', + options: [ + { value: 'open', label: 'Open' }, + { value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + ], +}), +``` + +`@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone. + +**What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected. + +**Where the map comes from — one producer.** + +- `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine. +- `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from. +- `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build). + +**Failure stance.** + +- If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants". +- With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam. + +**Known gap, not changed here.** `can()` reads only the per-object entries of the map, and `/auth/me/permissions` lists an object for a `'*'` wildcard grant only when that grant carries a super-user bit. So a subject whose access to an object comes only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — gets `false` from `current_user.can('', …)`, although the data plane admits the write. Before this release such a gate was never enforced for anyone; after it, that population is refused on a `can`-gated option. Any client that answers `can()` from the same `/auth/me/permissions` map gets the same `false`. + +**No spec key, route or config key is added or removed.** diff --git a/packages/core/src/security/effective-object-permissions.test.ts b/packages/core/src/security/effective-object-permissions.test.ts new file mode 100644 index 00000000000..c4bd07386d4 --- /dev/null +++ b/packages/core/src/security/effective-object-permissions.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18783] `buildEffectiveObjectPermissions` — the ONE function behind the + * `/auth/me/permissions` `objects` slot and `ISecurityService.getEffectiveObjectPermissions`. + * + * The four folds it composes keep their own pin batteries where they were + * written (plugin-hono-server's `fold-wildcard-superuser.test.ts` and + * `effective-api-operations.test.ts`, which exercise them through that + * package's unchanged re-exports). What is pinned HERE is the composition: + * the merge rule, the order, the guards, and that the result aliases nothing. + */ + +import { describe, it, expect } from 'vitest'; +import { buildEffectiveObjectPermissions } from './effective-object-permissions.js'; + +describe('buildEffectiveObjectPermissions', () => { + it('merges most-permissively: `true` wins, otherwise the first defined value stands', () => { + const map: any = buildEffectiveObjectPermissions([ + { objects: { deal: { allowRead: true, allowEdit: false } } }, + { objects: { deal: { allowEdit: true, allowDelete: false } } }, + { objects: { deal: { allowDelete: true, allowCreate: false } } }, + { objects: undefined }, + null, + ]); + expect(map.deal).toEqual({ allowRead: true, allowEdit: true, allowDelete: true, allowCreate: false }); + }); + + it('builds FRESH entries — nothing in the map aliases an input set', () => { + const entry = { allowRead: true }; + const sets = [{ objects: { deal: entry } }]; + const map: any = buildEffectiveObjectPermissions(sets); + expect(map.deal).toEqual(entry); + expect(map.deal).not.toBe(entry); + map.deal.allowEdit = true; + expect(entry).toEqual({ allowRead: true }); + }); + + it('seeds, then folds, then clamps, then annotates — in that order', () => { + const schemas: Record = { + report: { name: 'report', enable: { apiMethods: ['get', 'list'] } }, + sys_member: { name: 'sys_member', managedBy: 'better-auth' }, + }; + const map: any = buildEffectiveObjectPermissions( + [{ objects: { '*': { viewAllRecords: true, modifyAllRecords: true }, sys_member: { allowRead: true } } }], + { allSchemas: () => Object.values(schemas), schemaOf: (n) => schemas[n] }, + ); + // Seed → fold: an entry nobody named, pulled true by the super-user bits. + expect(map.report).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }); + // Fold → clamp: the guard has the last word on a managed object's writes. + expect(map.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }); + // Annotate runs last, over the final entries. + expect(Array.isArray(map.report.apiOperations)).toBe(true); + }); + + it('a throwing schema source degrades the annotations, never the map', () => { + const warns: string[] = []; + const map: any = buildEffectiveObjectPermissions( + [{ objects: { deal: { allowRead: true } } }], + { + allSchemas: () => { throw new Error('registry down'); }, + schemaOf: () => { throw new Error('registry down'); }, + logger: { warn: (m) => warns.push(m) }, + }, + ); + expect(map).toEqual({ deal: { allowRead: true } }); + }); + + it('with no schema source at all it is the bare merge plus the fold', () => { + const map: any = buildEffectiveObjectPermissions([ + { objects: { '*': { modifyAllRecords: true }, deal: { allowRead: false } } }, + ]); + expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }); + expect(map.deal.apiOperations).toBeUndefined(); + }); +}); diff --git a/packages/core/src/security/effective-object-permissions.ts b/packages/core/src/security/effective-object-permissions.ts new file mode 100644 index 00000000000..5732cd3c7ae --- /dev/null +++ b/packages/core/src/security/effective-object-permissions.ts @@ -0,0 +1,375 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The EFFECTIVE object-permission map — object name -> `EffectiveObjectPermission` + * — computed from a subject's resolved permission sets. ONE function, two + * consumers: + * + * - `/auth/me/permissions` (`@objectstack/plugin-hono-server`) serves it as the + * `objects` slot of its response; + * - `ISecurityService.getEffectiveObjectPermissions` (`@objectstack/plugin-security`) + * returns it, and the engine hands it to `current_user.can(object, verb)` as + * `EvalContext.permissions` (#18783). + * + * ## Why here, and why ONE function + * + * The contract member's docblock requires the two to be byte-for-byte the same + * answer, "computed once rather than twice": the second consumer (`can()`) + * cannot tell a wrong map from a right one — an entry the map omits reads as + * "no grant", indistinguishable from a measured denial. A second copy of this + * merge would be exactly the hand-built map `@objectstack/formula`'s + * `toEvalPermissions` names as a confident silent denial waiting to happen. + * + * It lives in `@objectstack/core` because both consumers already depend on this + * package and on nothing else they share: `plugin-hono-server` must never take a + * runtime dependency on `plugin-security` (it is optional in the stacks those + * endpoints serve), and `plugin-security` has no business importing a transport. + * The folds below used to live in `plugin-hono-server`'s + * `current-user-endpoints.ts`, which still re-exports them under the same names. + * + * Every function here is PURE over its inputs (the map is mutated in place and + * returned; nothing else is read or written) — the set RESOLUTION is the + * caller's, from the one resolver `ISecurityService.resolvePermissionSetsForContext`. + */ + +import { + resolveEffectiveApiMethods, + effectiveOperationsArray, + type EnableLike, +} from '@objectstack/spec/data'; +import type { EffectiveObjectPermission } from '@objectstack/spec/security'; + +/** + * Does the `'*'` entry carry the super-user READ bypass? + * + * ONE reading of that question for this whole file — {@link foldWildcardSuperUser} + * asks it to decide whose `allowRead` it pulls true, and + * {@link seedSuperUserRestrictedObjects} asks it to decide whom it seeds for, so + * the seed can never materialise an entry for a principal the fold leaves false. + * It is the same bypass the server itself applies: `PermissionEvaluator`'s + * `wildcardSuperUser()` treats `viewAllRecords` and `modifyAllRecords` alike for + * read (`allowRead` short-circuits on either), and only the modify bit reaches + * the write axis. + */ +function wildcardGrantsSuperRead(objects: Record): boolean { + const wild = objects?.['*']; + return wild?.viewAllRecords === true || wild?.modifyAllRecords === true; +} + +/** + * Fold the `'*'` wildcard super-user grant into every per-object entry of a + * `/me/permissions` `objects` map, mutating it in place. + * + * The endpoint merges each resolved permission set's explicit `objects` entries + * most-permissively per key, but treats `'*'` and named objects as independent + * keys — so a wildcard "Modify/View All Data" grant is never propagated into a + * per-object entry another set explicitly denied. That makes the client's + * per-object FLS STRICTER than the server's actual enforcement + * (`PermissionEvaluator.checkObjectPermission`, which returns allow as soon as + * ANY set grants — including via the `'*'` modifyAll/viewAll super-user bypass, + * with no deny-wins). The mismatch surfaces for a platform admin + * (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds + * `organization_admin` (which denies writes on identity tables): the client + * would see `sys_user.allowEdit:false` and disable a form the server accepts + * (verified: `PATCH /data/sys_user {name}` → 200). ADR-0124 D1 makes the + * server the authoritative gate, and D4 makes this direction explicit: what + * the client is told must be derived from the server's actual effective + * enforcement, never from an independent reading of the declarations. + * + * The super-user grant covers private/managed objects on the server, so folding + * it here is exactly as broad as real enforcement — never broader. + */ +export function foldWildcardSuperUser(objects: Record): void { + const wild = objects?.['*']; + if (!wild) return; + const superRead = wildcardGrantsSuperRead(objects); + const superWrite = wild.modifyAllRecords === true; + if (!superRead && !superWrite) return; + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + if (superRead) acc.allowRead = true; + if (superWrite) { + acc.allowEdit = true; + acc.allowCreate = true; + acc.allowDelete = true; + } + } +} + +/** Minimal schema shape the managed-write clamp needs. */ +export interface ManagedSchemaLike { + managedBy?: string; + userActions?: { + // create/edit/delete all accept the object form + // ({ enabled, visibleWhen, disabledWhen }) — #2614 gave it to the row + // pair, #7692 to `create`. Only the object-level `enabled` matters + // here: the predicates are UI gating, not a permission grant. + create?: boolean | { enabled?: boolean }; + edit?: boolean | { enabled?: boolean }; + delete?: boolean | { enabled?: boolean }; + } | null; +} + +/** True only when a userActions flag (bare boolean or object form) explicitly opts the write in. */ +function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean { + return v === true || (typeof v === 'object' && v !== null && v.enabled === true); +} + +/** + * Buckets whose user-context generic writes are guarded fail-closed at the + * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), + * `engine-owned` / `append-only` by plugin-security's engine-owned write guard + * (ADR-0103). `config` / `platform` / `system-data` have no such guard — their + * permission-set result stands. + * + * `system` was listed here until #3355 renamed it to the writable-default + * `system-data`, which joins `config` / `platform` on the unclamped side. That + * matters more here than it looks: this clamp reads `userActions` DIRECTLY rather + * than the resolved affordances, so clamping a bucket whose members legitimately + * dropped their now-redundant `userActions` block would report `allowEdit: false` + * for tables the engine happily writes — the exact false-NEGATIVE this function + * exists to avoid, merely inverted. + */ +const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'engine-owned', 'append-only']); + +/** + * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side + * enforcement layer that permission sets don't model: the engine write guards. + * They fail-closed reject USER-CONTEXT insert/update/delete on every managed + * object whose resolved affordances forbid the verb — `better-auth` + * (ADR-0092 D2) and `engine-owned`/`append-only` (ADR-0103) — except where the + * object opted the write affordance in via `userActions.{create,edit,delete}` + * (e.g. sys_user opens `edit` for its profile fields). + * + * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` + * for a platform admin on tables the guard actually blocks (sys_member, + * sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the + * false-negative the fold fixes. The real effective answer for a user-context + * caller is `permission-set grant ∩ guard policy`, and the guard policy for a + * guarded object is exactly its resolved CRUD affordance. `config`/`platform`/`system-data` + * objects are NOT clamped — no guard covers them, so their permission-set result + * stands (an admin CAN write them via the data API, and the hint must not + * under-report that). + */ +export function clampManagedObjectWrites( + objects: Record, + schemaOf: (objectName: string) => ManagedSchemaLike | undefined, +): void { + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + const schema = schemaOf(obj); + if (!schema?.managedBy || !GUARDED_WRITE_BUCKETS.has(schema.managedBy)) continue; + const ua = schema.userActions ?? {}; + if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false; + // `create` reads through the same opt-in helper as edit/delete since + // #7692 widened it to the object form — a bare `ua.create !== true` + // would clamp away a legitimate `{ enabled: true, visibleWhen: … }`. + if (!isWriteOptedIn(ua.create)) acc.allowCreate = false; + if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false; + } +} + +/** 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 wildcard 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. + * + * [#18990] Admitted by {@link wildcardGrantsSuperRead} — the READ bypass, so + * BOTH super-user classes are seeded, and a plain wildcard grant carrying + * neither bypass bit still is not. This pass used to be guarded to + * `modifyAllRecords` alone, on the reading that materializing a `false` entry + * for a viewAll-only caller would flip the client's `check('edit')` from + * "undefined → default-allow" to "explicit false → deny". It does flip it, and + * that flip is the POINT: the seed only ever touches objects with no explicit + * entry (`objects[name]` below), and on those a viewAll-only principal really + * can only read — so "explicit false" for edit is what is TRUE about it, while + * the silence it replaces left the client rendering write and Export + * affordances the server answers `403`. `allowRead` is pulled true by the same + * fold for the same reason: `viewAllRecords` is a read bypass server-side too + * (`PermissionEvaluator.checkObjectPermission`), so the entry is exactly as + * broad as real enforcement, never broader. + * + * [#18931] A schema is skipped only when it needs NO annotation, which is the + * predicate {@link annotateEffectiveApiOperations} itself applies: unrestricted + * AND the export axis leaves `export` in place. Resolving WITHOUT the export + * slot made this pass disagree with annotate's — an unrestricted object whose + * `export` the axis withholds got no entry, annotate (which iterates existing + * entries only) never saw it, and `/me/permissions` stayed silent for exactly + * the population #8681 created: a wildcard-only admin holding no `allowExport`. + * The client's `apiOperations` is then `undefined`, its default-allow path + * renders an Export button, and the click is refused `403 EXPORT_NOT_PERMITTED`. + */ +export function seedSuperUserRestrictedObjects( + objects: Record, + allSchemas: readonly ApiExposureSchemaLike[], +): void { + if (!wildcardGrantsSuperRead(objects)) return; + // [#18931] The export slot annotate will read for an entry seeded here. A + // seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser` + // does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to + // exactly this wildcard bit — the two passes cannot diverge again. + const userExportAllowed = objects['*']?.allowExport === true; + for (const schema of allSchemas) { + const name = schema?.name; + if (!name || name === '*' || objects[name]) continue; + const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); + // Same skip predicate as annotate: there is nothing to say about an + // unrestricted object that keeps its full operation closure. + if (eff.mode === 'unrestricted' && userExportAllowed) continue; + 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. + * An object is annotated whenever its effective set is narrower than the + * client's default-allow assumption (a `deny-all` object gets an empty array; + * [#3544] an unrestricted object gets one too whenever the export axis withholds + * `export`). Only an unrestricted object that keeps its full closure gets + * nothing, because for it default-allow is already the right answer. Runs AFTER + * fold + clamp so the annotation sits alongside the final CRUD affordances, and + * {@link seedSuperUserRestrictedObjects} applies the same predicate so a + * wildcard-only principal has an entry here to annotate ([#18931]). + */ +export function annotateEffectiveApiOperations( + objects: Record, + schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined, +): void { + // [#3544] The `'*'` entry's export grant is the FALLBACK for objects that do + // not carry one of their own. The merge keeps `'*'` and named objects as + // independent keys, but the server evaluator does not: its + // `resolveObjectPermission` falls back to the wildcard whenever a set has no + // explicit entry for the object, so an admin set granting export wholesale + // via `'*': { allowExport: true }` really does grant it per-object. Reading + // the wildcard here keeps the button the client shows and the request the + // server accepts in agreement — the same class of client/server divergence + // `foldWildcardSuperUser` exists to close, on the export axis. + const wildExport = objects?.['*']?.allowExport; + 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) + // [#3544] User-level export axis: `export` derives from `list ∧ this + // grant`. OPT-IN — only an explicit `true` (on the object entry, else + // inherited from `'*'`) allows export; unset and `false` both withhold + // it, and the super-user bits do NOT imply it. + const exportBit = acc.allowExport ?? wildExport; + const userExportAllowed = exportBit === true; + const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); + // Annotate when the object tightens via `apiMethods`, OR when the export + // axis removes `export` from an otherwise-open object (so the client + // hides the Export button). An unrestricted object with export still + // allowed needs no annotation — the client keeps its default-allow path. + if (eff.mode === 'unrestricted' && userExportAllowed) continue; + acc.apiOperations = effectiveOperationsArray(eff); + } +} + +/** + * The schema reads {@link buildEffectiveObjectPermissions} needs, as accessors + * over whatever engine the caller holds. Every accessor is optional and every + * read is GUARDED: a registry that throws, or an engine that is not wired at + * all, degrades the annotations exactly as `/auth/me/permissions` always has + * (no seed, no clamp, no `apiOperations`) — it never drops the map. + */ +export interface EffectiveObjectPermissionsSchemaSource { + /** Every registered object schema — read by the super-user seed. */ + allSchemas?: () => readonly ApiExposureSchemaLike[] | null | undefined; + /** One object's schema — read by the managed-write clamp and the `apiOperations` annotation. */ + schemaOf?: (objectName: string) => (ManagedSchemaLike & ApiExposureSchemaLike) | null | undefined; + /** Where a failed seed / annotation pass is reported. */ + logger?: { warn?: (message: string, meta?: Record) => void }; +} + +/** One resolved permission set, as far as this merge reads it. */ +export interface EffectiveObjectPermissionsInputSet { + objects?: Record | null; +} + +/** + * [#18783] The effective object-permission map for a subject whose permission + * sets are `sets` — the `objects` slot of `/auth/me/permissions` and the answer + * of `ISecurityService.getEffectiveObjectPermissions`, from this ONE function. + * + * In order, exactly as the endpoint has always composed it: + * + * 1. the most-permissive merge of every set's explicit `objects` entries — + * same semantics as `PermissionEvaluator.getFieldPermissions`, for ALL + * objects in one pass (`'*'` is merged as an ordinary key); + * 2. {@link seedSuperUserRestrictedObjects} — guarded: a failure is reported + * and the map is kept; + * 3. {@link foldWildcardSuperUser}; + * 4. {@link clampManagedObjectWrites}; + * 5. {@link annotateEffectiveApiOperations} — guarded like (2). + * + * Every entry is a FRESH object: nothing in the returned map aliases a + * permission set, so a caller may freeze or serialise it freely. + * + * It throws only where the merge itself cannot read a set (an `objects` value + * that is not an object) — the set RESOLUTION, and its failure stance, stay + * with the caller. + */ +export function buildEffectiveObjectPermissions( + sets: ReadonlyArray, + source: EffectiveObjectPermissionsSchemaSource = {}, +): Record { + const objects: Record = {}; + for (const ps of sets) { + if (!ps?.objects) continue; + for (const [obj, perm] of Object.entries(ps.objects)) { + const acc = objects[obj] ?? {}; + for (const [k, v] of Object.entries(perm as any)) { + if (v === true) acc[k] = true; + else if (acc[k] === undefined) acc[k] = v; + } + objects[obj] = acc; + } + } + const schemaOf = (name: string): (ManagedSchemaLike & ApiExposureSchemaLike) | undefined => { + try { return source.schemaOf?.(name) ?? undefined; } catch { return undefined; } + }; + // [#3391] For a wildcard super-user — [#18990] either bypass bit, not + // modify-all alone — seed restricting objects absent from the merged map so + // fold pulls what it pulls and annotate can attach their effective + // apiOperations. Guarded — a failure here must never drop the whole map. + try { + const allSchemas = (() => { + try { return source.allSchemas?.() ?? []; } catch { return [] as ApiExposureSchemaLike[]; } + })(); + seedSuperUserRestrictedObjects(objects, allSchemas); + } catch (e: any) { + source.logger?.warn?.('[effective-permissions] apiOperations seed failed', { err: e?.message }); + } + // Make the per-object map reflect the server's ACTUAL effective enforcement + // = permission-set grant ∩ identity write guard (ADR-0057 D10, cited as an + // attribution, #9628): (1) fold the `'*'` super-user grant into every object + // so an admin's wildcard is not shadowed by another set's explicit deny; + // (2) re-clamp guarded managed objects by their write affordance. + foldWildcardSuperUser(objects); + clampManagedObjectWrites(objects, schemaOf); + // [#3391] Annotate the per-object effective API operation set. Guarded: on + // failure `apiOperations` is simply omitted and a client falls back to its + // default-allow behaviour. + try { + annotateEffectiveApiOperations(objects, schemaOf); + } catch (e: any) { + source.logger?.warn?.('[effective-permissions] apiOperations annotate failed', { err: e?.message }); + } + return objects as Record; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index aae66a5e0f7..9beb63b0209 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -145,6 +145,20 @@ export { type ResolveLocalizationInput, } from './resolve-authz-context.js'; +// [#18783] The EFFECTIVE object-permission map — ONE function behind both the +// `/auth/me/permissions` `objects` slot and `ISecurityService.getEffectiveObjectPermissions` +// (the map `current_user.can()` reads). The four folds moved here from +// plugin-hono-server, which re-exports them under the same names. +export { + buildEffectiveObjectPermissions, + foldWildcardSuperUser, + clampManagedObjectWrites, + seedSuperUserRestrictedObjects, + annotateEffectiveApiOperations, + type ManagedSchemaLike, + type ApiExposureSchemaLike, +} from './effective-object-permissions.js'; + // #6216 (maintainer ruling 2026-08-08, Option A) — the SINGLE ExecutionContext // assembly shared by every transport entry point, with the anonymous face as // two NAMED entries (fail-closed default / explicit guest) instead of drift. diff --git a/packages/objectql/src/engine-option-permission-predicate.test.ts b/packages/objectql/src/engine-option-permission-predicate.test.ts new file mode 100644 index 00000000000..622f77c0983 --- /dev/null +++ b/packages/objectql/src/engine-option-permission-predicate.test.ts @@ -0,0 +1,325 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18783] The ENGINE half of `current_user.can(object, verb)` in an option's + * `visibleWhen`, driven through the real engine and a real driver. + * + * The rule-validator suite hands `permissions` in by hand, which pins the + * evaluator and nothing about how the map reaches it. Everything below is a + * call-site fact (PD #10: check the CALL SITE, bulk paths included): + * + * - the map comes from the registered resolver — the security plugin's + * `ISecurityService.getEffectiveObjectPermissions` — and a `can` gate is + * ENFORCED with it on insert, by-id update, bulk update and `validate()`; + * - it is resolved at most ONCE per write (N rows, one resolution), never + * kept across writes (a revoked grant is seen on the very next write); + * - a write whose gates never call `can` never asks — so it cannot be refused + * by a resolution it did not depend on (the ruling's control); + * - no resolver ⇒ NO permission data: the gate stays loudly unevaluable and + * the value is admitted with the warn naming the missing input — ⛔ not a + * denial; + * - a resolver that THROWS fails the write CLOSED with its own error, and a + * map that is not the published shape is refused the same way. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { ValidationError } from './validation/record-validator.js'; + +import '@objectstack/spec'; +import '@objectstack/formula'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + const cond = v as any; + if (cond && typeof cond === 'object' && !Array.isArray(cond)) { + if ('$in' in cond) return Array.isArray(cond.$in) && cond.$in.includes(row?.[k]); + if ('$eq' in cond) return row?.[k] === cond.$eq; + } + return row?.[k] === cond; + }); + }; + const writes: Array<{ op: string; object: string; data: any }> = []; + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // Hold the caller's bound (`check:objectql-double-limit`). + const bounded = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + const fields: string[] | undefined = ast?.fields; + if (!fields) return bounded; + return bounded.map((r) => { + const out: any = {}; + for (const f of fields) if (r[f] !== undefined) out[f] = r[f]; + return out; + }); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + writes.push({ op: 'create', object, data }); + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + writes.push({ op: 'update', object, data }); + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async updateMany(object: string, _ast: any, data: Record) { + writes.push({ op: 'updateMany', object, data }); + return 0; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor, writes }; +} + +/** A logger whose `warn` lines the cases read back; everything else is swallowed. */ +function captureLogger() { + const warns: Array<{ msg: string; meta: any }> = []; + const noop = () => {}; + const logger: any = { + debug: noop, info: noop, error: noop, trace: noop, fatal: noop, + warn: (msg: string, meta?: any) => warns.push({ msg, meta }), + child: () => logger, + }; + return { warns, logger }; +} + +/** A resolver that records every ask. `answer` may change between writes. */ +function countingResolver(initial: unknown | ((ctx: unknown) => unknown)) { + const asks: unknown[] = []; + let answer = initial; + const fn = async (ctx: unknown) => { + asks.push(ctx); + return typeof answer === 'function' ? (answer as (c: unknown) => unknown)(ctx) : answer; + }; + return { fn, asks, set: (next: unknown) => { answer = next; } }; +} + +const ACTING = { userId: 'u1', positions: ['sales_rep'] } as any; +/** The `objects` slot of `/auth/me/permissions` for two subjects. */ +const MAY_EDIT = { crm_account: { allowRead: true, allowEdit: true } }; +const READ_ONLY = { crm_account: { allowRead: true } }; + +describe('#18783 — the engine answers `can` in option visibleWhen from the security service', () => { + let engine: ObjectQL; + let d: ReturnType; + let log: ReturnType; + + beforeEach(async () => { + log = captureLogger(); + engine = new ObjectQL({ logger: log.logger }); + d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'crm_case', + fields: { + subject: { type: 'text' }, + stage: { + type: 'select', + options: [ + { value: 'open' }, + // The authored shape the ruling names: gated on the subject's GRANT. + { value: 'escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + // The control: gated, but not on `can`. + { value: 'vip', visibleWhen: "'vip_desk' in current_user.positions" }, + ], + }, + }, + } as any, 'test-package'); + }); + + const created = () => d.writes.filter((w) => w.op === 'create' && w.object === 'crm_case'); + + async function refusal(p: Promise): Promise { + try { + await p; + } catch (err) { + return err; + } + throw new Error('expected the write to be refused'); + } + + it('REFUSES a `can`-gated option on insert for a subject whose map withholds the verb', async () => { + const r = countingResolver(READ_ONLY); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + + const err = await refusal(engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: ACTING } as any)); + expect(err).toBeInstanceOf(ValidationError); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields).toEqual([expect.objectContaining({ field: 'stage', code: 'invalid_option' })]); + expect(created()).toHaveLength(0); + // Asked with the write's own acting subject. + expect(r.asks).toHaveLength(1); + expect(r.asks[0]).toMatchObject({ userId: 'u1' }); + }); + + it('ADMITS it for a subject whose map grants the verb', async () => { + engine.registerEffectiveObjectPermissionsResolver(countingResolver(MAY_EDIT).fn); + await expect( + engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: ACTING } as any), + ).resolves.toBeTruthy(); + expect(created()).toHaveLength(1); + expect(log.warns.filter((w) => /visibleWhen/.test(w.msg))).toHaveLength(0); + }); + + it('control: a write whose gates never call `can` never asks — even a resolver that would THROW', async () => { + const r = countingResolver(() => { throw new Error('must not be asked'); }); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + + await expect(engine.insert('crm_case', { subject: 's', stage: 'open' }, { context: ACTING } as any)).resolves.toBeTruthy(); + await expect( + engine.insert('crm_case', { subject: 's', stage: 'vip' }, { context: { userId: 'u2', positions: ['vip_desk'] } } as any), + ).resolves.toBeTruthy(); + // …and the non-`can` gate is still enforced exactly as before. + await expect(engine.insert('crm_case', { subject: 's', stage: 'vip' }, { context: ACTING } as any)).rejects.toBeInstanceOf(ValidationError); + expect(r.asks).toHaveLength(0); + }); + + it('NO resolver ⇒ no permission data: loudly unevaluable and ADMITTED, never a silent denial', async () => { + await expect( + engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: ACTING } as any), + ).resolves.toBeTruthy(); + const gate = log.warns.filter((w) => w.meta?.field === 'stage'); + expect(gate).toHaveLength(1); + expect(gate[0].meta).toMatchObject({ value: 'escalated', reason: 'predicate-fault' }); + expect(gate[0].meta.error.message).toContain('carries no permission data'); + }); + + it('a resolver that THROWS fails the write CLOSED with its own error, untouched', async () => { + const boom = Object.assign(new Error('permission store unreachable'), { code: 'AUTHZ_STORE_UNAVAILABLE', status: 503 }); + engine.registerEffectiveObjectPermissionsResolver(async () => { throw boom; }); + + const err = await refusal(engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: ACTING } as any)); + expect(err).toBe(boom); + expect(err.code).toBe('AUTHZ_STORE_UNAVAILABLE'); + expect(err.status).toBe(503); + // ⛔ Not read as "no grants" — that would be a refusal dressed as a decision. + expect(err).not.toBeInstanceOf(ValidationError); + expect(created()).toHaveLength(0); + }); + + it('a map that is not the published shape is refused the same way (formula\'s door)', async () => { + engine.registerEffectiveObjectPermissionsResolver(async () => ({ crm_account: true })); + const err = await refusal(engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: ACTING } as any)); + expect(err).toBeInstanceOf(TypeError); + expect(String(err.message)).toContain("the entry for 'crm_account' is not an EffectiveObjectPermission"); + expect(created()).toHaveLength(0); + }); + + it('a system write (no acting user) never asks — `can` would be about nobody', async () => { + const r = countingResolver(MAY_EDIT); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + await expect(engine.insert('crm_case', { subject: 's', stage: 'escalated' })).resolves.toBeTruthy(); + await expect( + engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: { isSystem: true } } as any), + ).resolves.toBeTruthy(); + expect(r.asks).toHaveLength(0); + }); + + it('ONE resolution for a batch insert of N rows', async () => { + const r = countingResolver(MAY_EDIT); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + const rows = Array.from({ length: 7 }, (_, i) => ({ subject: `s${i}`, stage: 'escalated' })); + await engine.insert('crm_case', rows as any, { context: ACTING } as any); + expect(created()).toHaveLength(7); + expect(r.asks).toHaveLength(1); + }); + + for (const N of [1, 25]) { + it(`ONE resolution for a bulk update across ${N} matched row(s), and the gate holds per row`, async () => { + for (let i = 0; i < N; i++) d.storeFor('crm_case').set(`c${i}`, { id: `c${i}`, subject: 'bulk', stage: 'open' }); + + const granted = countingResolver(MAY_EDIT); + engine.registerEffectiveObjectPermissionsResolver(granted.fn); + await engine.update('crm_case', { stage: 'escalated' }, { where: { subject: 'bulk' }, multi: true, context: ACTING } as any); + expect(d.writes.filter((w) => w.op === 'updateMany')).toHaveLength(1); + expect(granted.asks).toHaveLength(1); + + const withheld = countingResolver(READ_ONLY); + engine.registerEffectiveObjectPermissionsResolver(withheld.fn); + const err = await refusal( + engine.update('crm_case', { stage: 'escalated' }, { where: { subject: 'bulk' }, multi: true, context: ACTING } as any), + ); + expect(err).toBeInstanceOf(ValidationError); + expect(err.fields[0]).toMatchObject({ field: 'stage', code: 'invalid_option' }); + expect(d.writes.filter((w) => w.op === 'updateMany')).toHaveLength(1); + expect(withheld.asks).toHaveLength(1); + }); + } + + it('by-id update: the gate is enforced on the PATCH, with one resolution', async () => { + engine.registerEffectiveObjectPermissionsResolver(countingResolver(MAY_EDIT).fn); + const made = await engine.insert('crm_case', { subject: 's', stage: 'open' }, { context: ACTING } as any) as any; + + const r = countingResolver(READ_ONLY); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + const err = await refusal( + engine.update('crm_case', { stage: 'escalated' }, { where: { id: made.id }, context: ACTING } as any), + ); + expect(err).toBeInstanceOf(ValidationError); + expect(err.fields).toEqual([expect.objectContaining({ field: 'stage', code: 'invalid_option' })]); + expect(r.asks).toHaveLength(1); + expect(d.writes.filter((w) => w.op === 'update')).toHaveLength(0); + }); + + it('never cached across writes: a grant revoked between two writes is refused on the second', async () => { + const r = countingResolver(MAY_EDIT); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + await expect( + engine.insert('crm_case', { subject: 'a', stage: 'escalated' }, { context: ACTING } as any), + ).resolves.toBeTruthy(); + + r.set(READ_ONLY); // the grant is revoked between the two requests + await expect( + engine.insert('crm_case', { subject: 'b', stage: 'escalated' }, { context: ACTING } as any), + ).rejects.toBeInstanceOf(ValidationError); + expect(r.asks).toHaveLength(2); + }); + + it('validate() previews the write with the SAME map, resolved once', async () => { + const r = countingResolver(READ_ONLY); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + const preview = await engine.validate( + 'crm_case', [{ subject: 'a', stage: 'escalated' }, { subject: 'b', stage: 'open' }], + { mode: 'insert', context: ACTING } as any, + ); + expect(preview.results?.[0]?.valid).toBe(false); + expect(preview.results?.[0]?.errors).toEqual([expect.objectContaining({ field: 'stage', code: 'invalid_option' })]); + expect(preview.results?.[1]?.valid).toBe(true); + expect(r.asks).toHaveLength(1); + + r.set(MAY_EDIT); + const admitted = await engine.validate('crm_case', { subject: 'a', stage: 'escalated' }, { mode: 'insert', context: ACTING } as any); + expect(admitted.results?.[0]?.valid).toBe(true); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 277a986d82c..1b4bd88376d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -193,7 +193,7 @@ import { pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec import { SchemaRegistry, computeFQN, type ArtifactInstallScope } from './registry.js'; import { expandSearchToFilter } from './search-filter.js'; import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; -import { ExpressionEngine } from '@objectstack/formula'; +import { ExpressionEngine, toEvalPermissions, type EvalPermissions } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { isAggregatedViewContainer, @@ -214,7 +214,7 @@ import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; -import { collectPredicateRelationships, evaluateValidationRules, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; +import { collectPredicateRelationships, evaluateValidationRules, optionVisibilityReadsPermissions, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; // [#14088] The before-phase write recorder — the provenance channel the static // `readonly` strip needs to tell a hook's write from a caller's echo of the // SAME value. Armed and sealed in `update()`; the module owns the argument for @@ -4157,6 +4157,80 @@ export class ObjectQL implements IObjectQLEngine { this.logger.debug('Registered write-gate probe for validate() relationship resolution'); } + /** + * [#18783] Where the acting subject's EFFECTIVE object permissions come from — + * the map `current_user.can(object, verb)` in a per-option `visibleWhen` is + * answered from. Registered by the security plugin (the one producer, + * `ISecurityService.getEffectiveObjectPermissions`), the same way it + * registers {@link registerWriteGateProbe}; last registration wins. + * + * Unregistered is a DEFINED state, not a fault: the engine then passes NO + * permission data — ⛔ never an empty map and never one it merged itself — so + * a `can` predicate stays loudly unevaluable, exactly as the contract member + * prescribes for an absent method. See {@link resolveOptionPermissions} for + * when it is asked and what a throw does. + */ + private _effectiveObjectPermissionsResolver?: (context: unknown) => Promise; + + /** Wire the effective object-permission source (#18783). Last registration wins. */ + registerEffectiveObjectPermissionsResolver(fn: (context: unknown) => Promise): void { + this._effectiveObjectPermissionsResolver = fn; + this.logger.debug('Registered effective object-permission resolver for option visibleWhen can()'); + } + + /** + * [#18783] The permission map one write's option gates are evaluated with — + * resolved at most ONCE for the whole write (a batch insert, a by-id update, + * an N-row bulk update, a `validate()` preview), never per row and never per + * predicate, and never kept past the call: the contract member is + * request-scoped, and a map held across writes would serve a grant that may + * since have been revoked. + * + * Asked ONLY when the write can need it — a resolver is registered, the write + * has an acting user (`current_user` is otherwise unbound and `can` asks + * about nobody), and some payload PICKS an option whose `visibleWhen` calls + * `can` ({@link optionVisibilityReadsPermissions}, the evaluator's own + * picker). Every other write pays nothing, and cannot be refused by a + * resolution it never depended on. + * + * The answer goes through `toEvalPermissions`, formula's one door into + * `EvalContext.permissions`, so a map that is not the published shape is + * refused here rather than answering `can()` confidently wrong. + * + * Returns a per-payload accessor, the shape {@link resolvePredicateRelated} + * hands back: + * + * - a payload that picks no `can`-gated option gets `undefined`; + * - one that does gets the map; + * - when the resolution THREW (or the map was refused), one that does gets + * the throw, re-raised untouched: the write fails CLOSED, with the + * resolution's own error. ⛔ It is never read as "no grants", which would + * publish a failure as a measured denial of everything. + */ + private async resolveOptionPermissions( + schema: unknown, + payloads: ReadonlyArray | null | undefined>, + context: ExecutionContext | undefined, + ): Promise<(payload: Record | null | undefined) => EvalPermissions | undefined> { + const none = (): undefined => undefined; + const resolver = this._effectiveObjectPermissionsResolver; + if (!resolver || !this.buildEvalUser(context)) return none; + const fields = (schema as { fields?: Parameters[0] } | null | undefined)?.fields; + const needs = (payload: Record | null | undefined): boolean => + optionVisibilityReadsPermissions(fields, payload); + if (!payloads.some(needs)) return none; + let permissions: EvalPermissions; + try { + permissions = toEvalPermissions(await resolver(context)); + } catch (err) { + return (payload) => { + if (needs(payload)) throw err; + return undefined; + }; + } + return (payload) => (needs(payload) ? permissions : undefined); + } + /** * [#11968] The engine-seam write epoch — the invalidation substrate of the @@ -11269,6 +11343,10 @@ export class ObjectQL implements IObjectQLEngine { const previewRelatedForRow = mayWrite ? await this.resolvePredicateRelated(schemaForValidation, rows, options?.context) : () => undefined; + // [#18783] The preview answers a `can`-gated option with the SAME map the + // write would — resolved once for the whole set, like every posture input + // above. A resolution failure rejects the preview, as it fails the write. + const previewPermissionsFor = await this.resolveOptionPermissions(schemaForValidation, rows, options?.context); const results: NonNullable = rows.map((row) => { const warnings: ValidateDataIssue[] = []; @@ -11299,6 +11377,7 @@ export class ObjectQL implements IObjectQLEngine { evaluateValidationRules(schemaForValidation as any, row, mode, { logger: this.logger, currentUser, skipStateMachine, messages, related: previewRelatedForRow(row), + permissions: previewPermissionsFor(row), }); } catch (e) { if (e instanceof ValidationError) { @@ -12000,12 +12079,20 @@ export class ObjectQL implements IObjectQLEngine { // when no rule traverses. Read under SYSTEM authority, bounded by the // projection — see `resolvePredicateRelated`. const insertRelatedForRow = await this.resolvePredicateRelated(schemaForValidation, rows, opCtx.context); + // [#18783] The subject's effective object permissions, for an option + // gated on `current_user.can(…)`: ONE resolution for the whole batch, + // and none at all unless a row picks such an option. A resolution + // failure refuses exactly the rows that needed it (per-row under + // partial mode, like every other row error here). + const insertPermissionsFor = await this.resolveOptionPermissions( + schemaForValidation, rows.filter((_, i) => rowErrors[i] === undefined), opCtx.context, + ); for (let i = 0; i < rows.length; i++) { if (rowErrors[i] !== undefined) continue; try { normalizeMultiValueFields(schemaForValidation, rows[i]); validateRecord(schemaForValidation, rows[i], 'insert', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation }); - evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: insertRelatedForRow(rows[i]) }); + evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: insertRelatedForRow(rows[i]), permissions: insertPermissionsFor(rows[i]) }); await this.assertReferencesResolve( schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx, ); @@ -13483,7 +13570,12 @@ export class ObjectQL implements IObjectQLEngine { // POST-strip merged view `evaluateValidationRules` evaluates. const updateView = { ...(priorRecord ?? {}), ...(hookContext.input.data as Record) }; const relatedForUpdate = (await this.resolvePredicateRelated(updateSchema, [updateView], opCtx.context))(updateView); - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: roWhenParent, previousParent: roWhenPreviousParent, related: relatedForUpdate }); + // [#18783] The map an option gated on `current_user.can(…)` is + // answered from — resolved only when the PATCH picks one, and a + // resolution failure fails this write closed right here. + const updatePayload = hookContext.input.data as Record; + const permissionsForUpdate = (await this.resolveOptionPermissions(updateSchema, [updatePayload], opCtx.context))(updatePayload); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: roWhenParent, previousParent: roWhenPreviousParent, related: relatedForUpdate, permissions: permissionsForUpdate }); // [#4441] A repoint is as capable of dangling as an initial link. await this.assertReferencesResolve( updateSchema, hookContext.input.data as Record, @@ -13757,10 +13849,14 @@ export class ObjectQL implements IObjectQLEngine { opCtx.context, ) : undefined; + // [#18783] ONE permission-map resolution for the whole matched + // set — the patch is shared, so either every row picks a + // `can`-gated option or none does. Never per row. + const bulkPermissions = (await this.resolveOptionPermissions(updateSchema, [bulkPatch], opCtx.context))(bulkPatch); if (rulesNeedRows) { for (const row of priorRows ?? []) { try { - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: parentForRow?.(row), previousParent: previousParentForRow?.(row), related: bulkRelatedForRow?.({ ...(row ?? {}), ...bulkPatch }) }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: parentForRow?.(row), previousParent: previousParentForRow?.(row), related: bulkRelatedForRow?.({ ...(row ?? {}), ...bulkPatch }), permissions: bulkPermissions }); } catch (err) { if (err instanceof ValidationError && row?.id != null) { throw new ValidationError(err.fields.map((f) => ({ ...f, message: `${f.message} (record ${String(row.id)})` }))); @@ -13776,7 +13872,7 @@ export class ObjectQL implements IObjectQLEngine { // every such object down the per-row branch above, where the // binding is supplied. This branch only ever runs for the // rule families that never read a header. - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, permissions: bulkPermissions }); } // [#4441] The bulk call site too — a guard wired into single-id // writes only is still a hole one call site over (AGENTS.md diff --git a/packages/objectql/src/validation/rule-validator.option-visibility.test.ts b/packages/objectql/src/validation/rule-validator.option-visibility.test.ts index e7332f3f1e7..736be33223d 100644 --- a/packages/objectql/src/validation/rule-validator.option-visibility.test.ts +++ b/packages/objectql/src/validation/rule-validator.option-visibility.test.ts @@ -10,7 +10,8 @@ * and role/context gating. Broken/unbound predicates fail-open. */ import { describe, it, expect } from 'vitest'; -import { evaluateValidationRules, needsPriorRecord } from './rule-validator.js'; +import { toEvalPermissions } from '@objectstack/formula'; +import { evaluateValidationRules, needsPriorRecord, optionVisibilityReadsPermissions } from './rule-validator.js'; import { ValidationError } from './record-validator.js'; // country → province cascade + a role-gated tier option. @@ -377,3 +378,159 @@ describe('needsPriorRecord accounts for option visibleWhen', () => { ); }); }); + +/** + * [#18783] `current_user.can(object, verb)` in an option's `visibleWhen` — the + * permission predicate, answered on the SERVER. + * + * `@objectstack/formula` answers `can` from `EvalContext.permissions` and + * refuses LOUDLY when none was passed. Until this card nothing on the write + * path passed one, so an author who gated an option on the subject's grants got + * the fail-open branch on every authenticated write: the gate was never + * enforced, one `warn` per write. The engine now hands the evaluator the + * subject's effective object-permission map as `permissions` (resolved once per + * write from `ISecurityService.getEffectiveObjectPermissions`, see the engine + * suite `engine-option-permission-predicate.test.ts`); these pin the evaluator's + * half of that contract. + */ +describe('per-option visibleWhen — the permission predicate `can` (#18783)', () => { + const canSchema = { + fields: { + stage: { + type: 'select', + options: [ + { value: 'open' }, + { value: 'escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + { value: 'vip', visibleWhen: "'vip_desk' in current_user.positions" }, + ], + }, + }, + }; + const USER = { id: 'u1', positions: ['sales_rep'] }; + /** The `/auth/me/permissions` `objects` shape, through the one door formula publishes. */ + const MAY_EDIT = toEvalPermissions({ crm_account: { allowRead: true, allowEdit: true } }); + const READ_ONLY = toEvalPermissions({ crm_account: { allowRead: true } }); + + function capture() { + const warns: Array<{ msg: string; meta: any }> = []; + return { warns, logger: { warn: (msg: string, meta?: any) => warns.push({ msg, meta }) } }; + } + + it('REFUSES a `can`-gated option for a subject whose effective map withholds the verb', () => { + // Red before #18783: with no `permissions` reaching the evaluator the + // predicate faulted and the fail-open branch admitted the value. + const { warns, logger } = capture(); + let caught: any; + try { + evaluateValidationRules(canSchema, { stage: 'escalated' }, 'insert', { + currentUser: USER, permissions: READ_ONLY, logger, + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ValidationError); + expect(caught.code).toBe('VALIDATION_FAILED'); + expect(caught.fields).toEqual([ + expect.objectContaining({ field: 'stage', code: 'invalid_option' }), + ]); + // A clean FALSE is a decision, not a diagnostic. + expect(warns).toHaveLength(0); + }); + + it('ADMITS it for a subject whose effective map grants the verb', () => { + const { warns, logger } = capture(); + expect(() => + evaluateValidationRules(canSchema, { stage: 'escalated' }, 'insert', { + currentUser: USER, permissions: MAY_EDIT, logger, + }), + ).not.toThrow(); + expect(warns).toHaveLength(0); + }); + + it('answers on the merged record on UPDATE exactly as on insert', () => { + expect(() => + evaluateValidationRules(canSchema, { stage: 'escalated' }, 'update', { + previous: { stage: 'open' }, currentUser: USER, permissions: READ_ONLY, + }), + ).toThrow(ValidationError); + expect(() => + evaluateValidationRules(canSchema, { stage: 'escalated' }, 'update', { + previous: { stage: 'open' }, currentUser: USER, permissions: MAY_EDIT, + }), + ).not.toThrow(); + }); + + it('control: a predicate WITHOUT `can` answers the same with or without the map', () => { + for (const permissions of [undefined, READ_ONLY, MAY_EDIT]) { + expect(() => + evaluateValidationRules(canSchema, { stage: 'vip' }, 'insert', { + currentUser: USER, permissions, + }), + String(permissions && Object.keys(permissions)), + ).toThrow(ValidationError); + expect(() => + evaluateValidationRules(canSchema, { stage: 'vip' }, 'insert', { + currentUser: { id: 'u2', positions: ['vip_desk'] }, permissions, + }), + ).not.toThrow(); + } + }); + + it('NO permission data ⇒ still loudly unevaluable (the named-input warn), NOT a silent denial', () => { + // The member-absent state: the engine passes no map at all, never `{}`. + // The evaluator's fail-open branch keeps the value and says why, naming the + // input the context lacked — the refusal formula writes for exactly this. + const { warns, logger } = capture(); + expect(() => + evaluateValidationRules(canSchema, { stage: 'escalated' }, 'insert', { currentUser: USER, logger }), + ).not.toThrow(); + expect(warns).toHaveLength(1); + expect(warns[0].meta).toMatchObject({ field: 'stage', value: 'escalated', reason: 'predicate-fault' }); + expect(warns[0].meta.error.message).toContain('carries no permission data'); + }); + + it('an EMPTY map is a real answer — it refuses, it does not read as "no data"', () => { + expect(() => + evaluateValidationRules(canSchema, { stage: 'escalated' }, 'insert', { + currentUser: USER, permissions: toEvalPermissions({}), + }), + ).toThrow(ValidationError); + }); + + describe('optionVisibilityReadsPermissions — which writes need the map at all', () => { + it('is true only when a PICKED option\'s predicate calls `can`', () => { + expect(optionVisibilityReadsPermissions(canSchema.fields, { stage: 'escalated' })).toBe(true); + // Picked, gated, but no `can` in its predicate. + expect(optionVisibilityReadsPermissions(canSchema.fields, { stage: 'vip' })).toBe(false); + // Picked, ungated. + expect(optionVisibilityReadsPermissions(canSchema.fields, { stage: 'open' })).toBe(false); + // Not written at all — an unchanged persisted value is not re-judged. + expect(optionVisibilityReadsPermissions(canSchema.fields, { note: 'x' })).toBe(false); + expect(optionVisibilityReadsPermissions(undefined, { stage: 'escalated' })).toBe(false); + }); + + it('reads every element of a multi-value pick and every ADR-0068 alias of the subject', () => { + const multi = { + tags: { + type: 'multiselect', + options: [ + { value: 'a' }, + { value: 'b', visibleWhen: "user.can('crm_account', 'read') || record.x == 1" }, + ], + }, + }; + expect(optionVisibilityReadsPermissions(multi, { tags: ['a'] })).toBe(false); + expect(optionVisibilityReadsPermissions(multi, { tags: ['a', 'b'] })).toBe(true); + }); + + it('reads the parsed AST, not the text: a quoted `can(` is not a call', () => { + const quoted = { + note_kind: { + type: 'select', + options: [{ value: 'q', visibleWhen: "record.title == 'you can(not) do this'" }], + }, + }; + expect(optionVisibilityReadsPermissions(quoted, { note_kind: 'q' })).toBe(false); + }); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 972d5fb26e5..819160847a1 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -193,7 +193,7 @@ */ import { ExpressionEngine, collectCelRootIdentifiers, analyzeRelationshipTraversals, findTraversalConflicts } from '@objectstack/formula'; -import type { RelationshipTraversalAnalysis } from '@objectstack/formula'; +import type { EvalPermissions, RelationshipTraversalAnalysis } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES, referenceTargetOf, resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { recordAdvisoryHit } from '@objectstack/core'; @@ -355,6 +355,22 @@ export interface EvaluateRulesOptions { * and fail-open (see {@link evaluateOptionVisibility}). */ currentUser?: { id?: string; roles?: string[]; organizationId?: string | null; [k: string]: unknown } | null; + /** + * [#18783] The acting subject's EFFECTIVE object permissions — the map + * `current_user.can(object, verb)` in a per-option `visibleWhen` is answered + * from (`EvalContext.permissions`). Only the engine can resolve it (it is + * `ISecurityService.getEffectiveObjectPermissions`, the `objects` slot of + * `/auth/me/permissions`), so it resolves it ONCE per write and hands it over, + * the division of labour `parent` and `related` follow. + * + * ⛔ `undefined` is NOT an empty map. It means "no permission data": a `can` + * predicate then stays loudly unevaluable (the fail-open branch of + * {@link evaluateOptionVisibility}, naming the missing input). An empty map is + * a REAL answer — this subject holds nothing, every `can()` is `false`. + * The engine passes one only for a write that picks an option whose predicate + * calls `can` ({@link optionVisibilityReadsPermissions}). + */ + permissions?: EvalPermissions; /** * [#4977] The master-detail header this write's field `requiredWhen` * predicates read as `parent` — the SAME binding, resolved by the SAME engine @@ -2758,9 +2774,11 @@ function toExpression(cond: string | Expression): Expression { * user. `ctx` and `os` are listed at bare-root granularity because that is what * {@link collectCelRootIdentifiers} reports — and that is exact at THIS call * site rather than merely conservative: {@link evaluateOptionVisibility} - * evaluates with `{ record, previous, user }` and nothing else, so `buildScope` - * has no `org`/`env` to mount `os` from and no other source for `ctx`. Both - * roots are therefore bound here if and only if a user is. + * evaluates with `{ record, previous, user, permissions }` and nothing else, so + * `buildScope` has no `org`/`env` to mount `os` from and no other source for + * `ctx` (`permissions` mounts no root at all — it reaches `can` through the + * environment, #18783). Both roots are therefore bound here if and only if a + * user is. */ const USER_SCOPE_ROOTS: readonly string[] = ['current_user', 'user', 'ctx', 'os']; @@ -2819,6 +2837,14 @@ function readsUserRoot(cond: string | Expression): boolean { * write. Authorization gating therefore depends on the engine binding * `current_user` on authenticated writes. * + * [#18783] A grant-gated option — `current_user.can('crm_account', 'edit')` — + * additionally needs the subject's effective object permissions, which the + * engine resolves once per write and passes as `permissions`. With the map the + * predicate evaluates and a clean FALSE refuses like any other; without it + * `can` refuses loudly inside the evaluator and the value takes the fail-open + * branch below with that refusal as its `error`. A resolution FAILURE never + * reaches here: the engine fails the write closed before evaluating. + * * The admission is deliberate and unchanged. What the fail-open branch does NOT * do any more is describe two different facts with one sentence. A system write * — a declarative seed, an in-process job, anything with no acting user — can @@ -2839,13 +2865,75 @@ function evaluateOptionVisibility( merged: Record, previous: Record | undefined, currentUser: EvaluateRulesOptions['currentUser'], + permissions: EvaluateRulesOptions['permissions'], errors: FieldValidationError[], logger: EvaluateRulesOptions['logger'], messages: ValidationMessageContext | undefined, ): void { if (!fields) return; const user = (currentUser ?? undefined) as any; + for (const { name, def, value, opt } of pickedGatedOptions(fields, data)) { + const res = ExpressionEngine.evaluate(toExpression(opt.visibleWhen!), { + record: merged, + previous, + user, + // [#18783] What `current_user.can(object, verb)` is answered from. Passed + // through as the engine resolved it: `undefined` leaves `can` loudly + // unevaluable (the fault lands in the fail-open branch below, naming the + // missing input), never a quiet `false` — see `EvaluateRulesOptions`. + permissions, + }); + if (!res.ok) { + // Which of the two fail-open cases is this? "No acting user to bind" + // needs BOTH facts — the write carries no user AND the predicate asks + // for one. Either alone misfiles: a system write whose predicate names + // no user root and faults on a typo'd field is a real broken gate, and + // an authenticated caller's fault is the case this log exists for. + const noActingUser = user === undefined; + if (noActingUser && readsUserRoot(opt.visibleWhen!)) { + logger?.warn?.( + `option visibleWhen for '${name}=${String(value)}' not evaluated: no acting user to bind current_user (system write) — allowed through`, + { field: name, value: String(value), reason: 'no-acting-user', error: res.error }, + ); + } else { + logger?.warn?.( + `option visibleWhen for '${name}=${String(value)}' failed to evaluate ` + + `(${noActingUser ? 'system write' : 'authenticated caller'}) — allowed through; ` + + `the option's gate was NOT enforced on this write. Check the predicate.`, + { field: name, value: String(value), reason: 'predicate-fault', error: res.error }, + ); + } + continue; // fail-open + } + if (res.value === false) { + errors.push(buildFieldError({ + field: name, + code: 'invalid_option', + def, + value: String(value), + messageKey: 'option_unavailable', + }, messages)); + } + } +} + +/** + * The gated options a write PICKS — one `{ field, value, option }` per picked + * value whose matched option carries a `visibleWhen`. + * + * ONE reading of "which options does this write pick", shared by + * {@link evaluateOptionVisibility} (which judges them) and + * {@link optionVisibilityReadsPermissions} (which tells the engine whether the + * write needs the permission map at all), so the two can never select + * differently: a pick the evaluator judges without the map it needed is a `can` + * gate silently not enforced. + */ +function* pickedGatedOptions( + fields: Record, + data: Record, +): Generator<{ name: string; def: ConditionalFieldDef; value: unknown; opt: ConditionalFieldOption }> { for (const [name, def] of Object.entries(fields)) { + // Only WRITTEN fields — an unchanged persisted value is left alone. if (!fieldHasOptionVisibility(def) || !(name in data)) continue; const raw = data[name]; if (raw === undefined || raw === null || raw === '') continue; @@ -2858,46 +2946,74 @@ function evaluateOptionVisibility( // Unknown value (not in options) or an ungated option → nothing to enforce // here; an out-of-set value is the enum validator's concern, not ours. if (!opt || opt.visibleWhen == null) continue; - const res = ExpressionEngine.evaluate(toExpression(opt.visibleWhen), { - record: merged, - previous, - user, - }); - if (!res.ok) { - // Which of the two fail-open cases is this? "No acting user to bind" - // needs BOTH facts — the write carries no user AND the predicate asks - // for one. Either alone misfiles: a system write whose predicate names - // no user root and faults on a typo'd field is a real broken gate, and - // an authenticated caller's fault is the case this log exists for. - const noActingUser = user === undefined; - if (noActingUser && readsUserRoot(opt.visibleWhen)) { - logger?.warn?.( - `option visibleWhen for '${name}=${String(value)}' not evaluated: no acting user to bind current_user (system write) — allowed through`, - { field: name, value: String(value), reason: 'no-acting-user', error: res.error }, - ); - } else { - logger?.warn?.( - `option visibleWhen for '${name}=${String(value)}' failed to evaluate ` - + `(${noActingUser ? 'system write' : 'authenticated caller'}) — allowed through; ` - + `the option's gate was NOT enforced on this write. Check the predicate.`, - { field: name, value: String(value), reason: 'predicate-fault', error: res.error }, - ); - } - continue; // fail-open - } - if (res.value === false) { - errors.push(buildFieldError({ - field: name, - code: 'invalid_option', - def, - value: String(value), - messageKey: 'option_unavailable', - }, messages)); - } + yield { name, def, value, opt }; } } } +/** Parsed-call memo for {@link readsPermissionPredicate} — same fixed sources as {@link userRootCache}. */ +const permissionPredicateCache = new Map(); + +/** + * Does this predicate CALL the permission predicate — a receiver call named + * `can`, i.e. `current_user.can(object, verb)` or an ADR-0068 alias of it? + * + * Read off the canonical parse ({@link parseCelToAst}), never off the text: a + * string literal that happens to contain `can(` is not a call, and a regex over + * the source would resolve the whole permission map for it. Only the RECEIVER + * form counts because it is the only form formula registers; a bare + * `can(object, verb)` faults whatever the context carries, so the map would buy + * it nothing. + * + * A non-CEL dialect, or a source that does not parse, answers `false`: the + * evaluator runs the same parser and cannot evaluate what this cannot read, so + * the map could not change its outcome. + */ +function readsPermissionPredicate(cond: string | Expression): boolean { + const expr = toExpression(cond); + if (expr.dialect !== 'cel') return false; + const source = typeof expr.source === 'string' ? expr.source : ''; + if (!source) return false; + const cached = permissionPredicateCache.get(source); + if (cached !== undefined) return cached; + const answer = callsReceiverMethod(parseCelToAst(source), 'can'); + permissionPredicateCache.set(source, answer); + return answer; +} + +/** Walk a cel-js AST for a receiver call (`op: 'rcall'`, `args[0]` = method name) named `method`. */ +function callsReceiverMethod(node: unknown, method: string): boolean { + if (Array.isArray(node)) return node.some((child) => callsReceiverMethod(child, method)); + if (typeof node !== 'object' || node === null) return false; + const { op, args } = node as { op?: unknown; args?: unknown }; + if (typeof op !== 'string') return false; + if (op === 'rcall' && Array.isArray(args) && args[0] === method) return true; + return callsReceiverMethod(args, method); +} + +/** + * [#18783] Does this write need the acting subject's effective object + * permissions — does it PICK an option whose `visibleWhen` calls + * `current_user.can(…)`? + * + * The engine asks this before resolving the map, so the map is resolved only + * for a write it can change the verdict of: a write whose gates never mention + * `can` pays nothing, and — the part that matters more — cannot be refused by a + * resolution failure it never depended on. Selection is + * {@link pickedGatedOptions}, the evaluator's own, so the two agree on every + * pick by construction. + */ +export function optionVisibilityReadsPermissions( + fields: Record | undefined | null, + data: Record | undefined | null, +): boolean { + if (!fields || !data) return false; + for (const { opt } of pickedGatedOptions(fields, data)) { + if (readsPermissionPredicate(opt.visibleWhen!)) return true; + } + return false; +} + /** * The object's name, for an advisory group's key. Read off the schema rather * than taken as a parameter: every caller already passes the registry's own @@ -3103,7 +3219,7 @@ export function evaluateValidationRules( // choice value whose option `visibleWhen` resolves cleanly to FALSE against the // merged record + `current_user`. Complements the client-side hiding, which is // not a security boundary. - evaluateOptionVisibility(fields, data, merged, previous, opts.currentUser, errors, opts.logger, opts.messages); + evaluateOptionVisibility(fields, data, merged, previous, opts.currentUser, opts.permissions, errors, opts.logger, opts.messages); // [#13889] Does this write move any BUSINESS field? A system write-back that // touches only platform-injected columns does not, so re-running the object's diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts new file mode 100644 index 00000000000..66a4a117a01 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#18783] `/auth/me/permissions`' `objects` slot is `buildEffectiveObjectPermissions` +// (`@objectstack/core`) over the resolved sets and the request's engine — the +// ONE function `ISecurityService.getEffectiveObjectPermissions` also answers +// from (plugin-security's `get-effective-object-permissions.test.ts` pins that +// half). Together the two pins are the byte-equality the contract member +// promises: "the `objects` slot of the published /auth/me/permissions +// response … the same answer computed once rather than twice". A local merge +// reintroduced here — the second copy that would let the console and the +// server's own `current_user.can()` disagree — turns this red. +// +// The fixture makes every step of the composition fire (merge, super-user +// seed, fold, managed-write clamp, apiOperations annotation), because an +// equality over a map none of them touched would pin nothing. + +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { buildEffectiveObjectPermissions } from '@objectstack/core'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; + +const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; +const USER = 'usr_admin'; + +/** The sets the resolver hands back — a super-user wildcard beside an explicit deny, and a plain grant. */ +const RESOLVED = [ + { + name: 'ops_admin', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + sys_member: { allowRead: true, allowEdit: false }, + }, + fields: {}, + }, + { name: 'sales', objects: { deal: { allowRead: true, allowEdit: false } }, fields: {} }, +]; + +/** Registered schemas: plain, better-auth-managed, and one whose `apiMethods` tighten exposure. */ +const SCHEMAS: Record = { + deal: { name: 'deal' }, + sys_member: { name: 'sys_member', managedBy: 'better-auth' }, + report: { name: 'report', enable: { apiMethods: ['get', 'list'] } }, +}; + +const ql = { + find: async () => [], + registry: { getAllApps: () => [], getAllObjects: () => Object.values(SCHEMAS) }, + getSchema: (name: string) => SCHEMAS[name], +}; + +function mount() { + const services: Record = { + auth: { + api: { + getSession: async () => ({ + user: { id: USER, email: 'admin@example.com' }, + session: { activeOrganizationId: 'org_1' }, + }), + }, + }, + objectql: ql, + metadata: { list: async () => [] as unknown[] }, + security: { resolvePermissionSetsForContext: async () => RESOLVED }, + }; + const app = new Hono(); + registerCurrentUserEndpoints({ + rawApp: app, + ctx: { + logger: { debug() {}, warn() {} }, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name] as T; + }, + }, + }); + return app; +} + +describe('[#18783] /auth/me/permissions `objects` is the one effective-map function', () => { + it('serves buildEffectiveObjectPermissions over the resolved sets, byte for byte', async () => { + const body: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json(); + const expected = buildEffectiveObjectPermissions(RESOLVED, { + allSchemas: () => ql.registry.getAllObjects(), + schemaOf: (name) => ql.getSchema(name), + }); + expect(JSON.stringify(body.objects)).toBe(JSON.stringify(expected)); + + // …and the fixture really exercised every step, so the equality means something. + expect(body.objects.deal).toMatchObject({ allowEdit: true }); // fold over an explicit deny + expect(body.objects.sys_member).toMatchObject({ allowEdit: false }); // clamp over the fold + expect(body.objects.report).toMatchObject({ allowRead: true }); // seed for a super-user + expect(Array.isArray(body.objects.report.apiOperations)).toBe(true); // annotation + }); + + it('keeps the rest of the envelope on its own merges', async () => { + const body: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json(); + expect(body.permissionSets).toEqual(['ops_admin', 'sales']); + expect(body.fields).toEqual({}); + expect(body.systemPermissions).toEqual([]); + expect(body.tabPermissions).toEqual({}); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index c2983567946..4b3beda8c28 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -40,15 +40,13 @@ import { assembleExecutionContext, + buildEffectiveObjectPermissions, IDataEngine, resolveLocalizationContext, resolveUserAuthzGrants, + type ApiExposureSchemaLike, + type ManagedSchemaLike, } from '@objectstack/core'; -import { - resolveEffectiveApiMethods, - effectiveOperationsArray, - type EnableLike, -} from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import type { @@ -238,62 +236,21 @@ function allPathsMounted(rawApp: any, paths: readonly string[]): boolean { } /** - * Does the `'*'` entry carry the super-user READ bypass? - * - * ONE reading of that question for this whole file — {@link foldWildcardSuperUser} - * asks it to decide whose `allowRead` it pulls true, and - * {@link seedSuperUserRestrictedObjects} asks it to decide whom it seeds for, so - * the seed can never materialise an entry for a principal the fold leaves false. - * It is the same bypass the server itself applies: `PermissionEvaluator`'s - * `wildcardSuperUser()` treats `viewAllRecords` and `modifyAllRecords` alike for - * read (`allowRead` short-circuits on either), and only the modify bit reaches - * the write axis. + * The permission-map folds `/auth/me/permissions` applies — MOVED to + * `@objectstack/core` (`security/effective-object-permissions.ts`, #18783) so + * the endpoint and `ISecurityService.getEffectiveObjectPermissions` compute the + * `objects` map with ONE function ({@link buildEffectiveObjectPermissions}) + * rather than two copies that agree today. Re-exported here under the same + * names, so importing them from `@objectstack/plugin-hono-server` is unchanged. */ -function wildcardGrantsSuperRead(objects: Record): boolean { - const wild = objects?.['*']; - return wild?.viewAllRecords === true || wild?.modifyAllRecords === true; -} - -/** - * Fold the `'*'` wildcard super-user grant into every per-object entry of a - * `/me/permissions` `objects` map, mutating it in place. - * - * The endpoint merges each resolved permission set's explicit `objects` entries - * most-permissively per key, but treats `'*'` and named objects as independent - * keys — so a wildcard "Modify/View All Data" grant is never propagated into a - * per-object entry another set explicitly denied. That makes the client's - * per-object FLS STRICTER than the server's actual enforcement - * (`PermissionEvaluator.checkObjectPermission`, which returns allow as soon as - * ANY set grants — including via the `'*'` modifyAll/viewAll super-user bypass, - * with no deny-wins). The mismatch surfaces for a platform admin - * (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds - * `organization_admin` (which denies writes on identity tables): the client - * would see `sys_user.allowEdit:false` and disable a form the server accepts - * (verified: `PATCH /data/sys_user {name}` → 200). ADR-0124 D1 makes the - * server the authoritative gate, and D4 makes this direction explicit: what - * the client is told must be derived from the server's actual effective - * enforcement, never from an independent reading of the declarations. - * - * The super-user grant covers private/managed objects on the server, so folding - * it here is exactly as broad as real enforcement — never broader. - */ -export function foldWildcardSuperUser(objects: Record): void { - const wild = objects?.['*']; - if (!wild) return; - const superRead = wildcardGrantsSuperRead(objects); - const superWrite = wild.modifyAllRecords === true; - if (!superRead && !superWrite) return; - for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { - if (obj === '*' || !acc) continue; - if (superRead) acc.allowRead = true; - if (superWrite) { - acc.allowEdit = true; - acc.allowCreate = true; - acc.allowDelete = true; - } - } -} - +export { + foldWildcardSuperUser, + clampManagedObjectWrites, + seedSuperUserRestrictedObjects, + annotateEffectiveApiOperations, + type ManagedSchemaLike, + type ApiExposureSchemaLike, +} from '@objectstack/core'; /** * The permission-set fields the two handlers merge out of a resolution. @@ -311,25 +268,6 @@ interface ResolvedPermissionSetLike { tabPermissions?: unknown; } -/** Minimal schema shape the managed-write clamp needs. */ -export interface ManagedSchemaLike { - managedBy?: string; - userActions?: { - // create/edit/delete all accept the object form - // ({ enabled, visibleWhen, disabledWhen }) — #2614 gave it to the row - // pair, #7692 to `create`. Only the object-level `enabled` matters - // here: the predicates are UI gating, not a permission grant. - create?: boolean | { enabled?: boolean }; - edit?: boolean | { enabled?: boolean }; - delete?: boolean | { enabled?: boolean }; - } | null; -} - -/** True only when a userActions flag (bare boolean or object form) explicitly opts the write in. */ -function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean { - return v === true || (typeof v === 'object' && v !== null && v.enabled === true); -} - /** * [#7616] The ONE permission-set resolution both handlers below use — * `ISecurityService.resolvePermissionSetsForContext`, reached through the @@ -399,172 +337,6 @@ function permissionSetResolver( return (context) => resolve.call(security, context); } -/** - * Buckets whose user-context generic writes are guarded fail-closed at the - * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), - * `engine-owned` / `append-only` by plugin-security's engine-owned write guard - * (ADR-0103). `config` / `platform` / `system-data` have no such guard — their - * permission-set result stands. - * - * `system` was listed here until #3355 renamed it to the writable-default - * `system-data`, which joins `config` / `platform` on the unclamped side. That - * matters more here than it looks: this clamp reads `userActions` DIRECTLY rather - * than the resolved affordances, so clamping a bucket whose members legitimately - * dropped their now-redundant `userActions` block would report `allowEdit: false` - * for tables the engine happily writes — the exact false-NEGATIVE this function - * exists to avoid, merely inverted. - */ -const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'engine-owned', 'append-only']); - -/** - * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side - * enforcement layer that permission sets don't model: the engine write guards. - * They fail-closed reject USER-CONTEXT insert/update/delete on every managed - * object whose resolved affordances forbid the verb — `better-auth` - * (ADR-0092 D2) and `engine-owned`/`append-only` (ADR-0103) — except where the - * object opted the write affordance in via `userActions.{create,edit,delete}` - * (e.g. sys_user opens `edit` for its profile fields). - * - * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` - * for a platform admin on tables the guard actually blocks (sys_member, - * sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the - * false-negative the fold fixes. The real effective answer for a user-context - * caller is `permission-set grant ∩ guard policy`, and the guard policy for a - * guarded object is exactly its resolved CRUD affordance. `config`/`platform`/`system-data` - * objects are NOT clamped — no guard covers them, so their permission-set result - * stands (an admin CAN write them via the data API, and the hint must not - * under-report that). - */ -export function clampManagedObjectWrites( - objects: Record, - schemaOf: (objectName: string) => ManagedSchemaLike | undefined, -): void { - for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { - if (obj === '*' || !acc) continue; - const schema = schemaOf(obj); - if (!schema?.managedBy || !GUARDED_WRITE_BUCKETS.has(schema.managedBy)) continue; - const ua = schema.userActions ?? {}; - if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false; - // `create` reads through the same opt-in helper as edit/delete since - // #7692 widened it to the object form — a bare `ua.create !== true` - // would clamp away a legitimate `{ enabled: true, visibleWhen: … }`. - if (!isWriteOptedIn(ua.create)) acc.allowCreate = false; - if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false; - } -} - -/** 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 wildcard 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. - * - * [#18990] Admitted by {@link wildcardGrantsSuperRead} — the READ bypass, so - * BOTH super-user classes are seeded, and a plain wildcard grant carrying - * neither bypass bit still is not. This pass used to be guarded to - * `modifyAllRecords` alone, on the reading that materializing a `false` entry - * for a viewAll-only caller would flip the client's `check('edit')` from - * "undefined → default-allow" to "explicit false → deny". It does flip it, and - * that flip is the POINT: the seed only ever touches objects with no explicit - * entry (`objects[name]` below), and on those a viewAll-only principal really - * can only read — so "explicit false" for edit is what is TRUE about it, while - * the silence it replaces left the client rendering write and Export - * affordances the server answers `403`. `allowRead` is pulled true by the same - * fold for the same reason: `viewAllRecords` is a read bypass server-side too - * (`PermissionEvaluator.checkObjectPermission`), so the entry is exactly as - * broad as real enforcement, never broader. - * - * [#18931] A schema is skipped only when it needs NO annotation, which is the - * predicate {@link annotateEffectiveApiOperations} itself applies: unrestricted - * AND the export axis leaves `export` in place. Resolving WITHOUT the export - * slot made this pass disagree with annotate's — an unrestricted object whose - * `export` the axis withholds got no entry, annotate (which iterates existing - * entries only) never saw it, and `/me/permissions` stayed silent for exactly - * the population #8681 created: a wildcard-only admin holding no `allowExport`. - * The client's `apiOperations` is then `undefined`, its default-allow path - * renders an Export button, and the click is refused `403 EXPORT_NOT_PERMITTED`. - */ -export function seedSuperUserRestrictedObjects( - objects: Record, - allSchemas: readonly ApiExposureSchemaLike[], -): void { - if (!wildcardGrantsSuperRead(objects)) return; - // [#18931] The export slot annotate will read for an entry seeded here. A - // seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser` - // does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to - // exactly this wildcard bit — the two passes cannot diverge again. - const userExportAllowed = objects['*']?.allowExport === true; - for (const schema of allSchemas) { - const name = schema?.name; - if (!name || name === '*' || objects[name]) continue; - const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); - // Same skip predicate as annotate: there is nothing to say about an - // unrestricted object that keeps its full operation closure. - if (eff.mode === 'unrestricted' && userExportAllowed) continue; - 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. - * An object is annotated whenever its effective set is narrower than the - * client's default-allow assumption (a `deny-all` object gets an empty array; - * [#3544] an unrestricted object gets one too whenever the export axis withholds - * `export`). Only an unrestricted object that keeps its full closure gets - * nothing, because for it default-allow is already the right answer. Runs AFTER - * fold + clamp so the annotation sits alongside the final CRUD affordances, and - * {@link seedSuperUserRestrictedObjects} applies the same predicate so a - * wildcard-only principal has an entry here to annotate ([#18931]). - */ -export function annotateEffectiveApiOperations( - objects: Record, - schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined, -): void { - // [#3544] The `'*'` entry's export grant is the FALLBACK for objects that do - // not carry one of their own. The merge keeps `'*'` and named objects as - // independent keys, but the server evaluator does not: its - // `resolveObjectPermission` falls back to the wildcard whenever a set has no - // explicit entry for the object, so an admin set granting export wholesale - // via `'*': { allowExport: true }` really does grant it per-object. Reading - // the wildcard here keeps the button the client shows and the request the - // server accepts in agreement — the same class of client/server divergence - // `foldWildcardSuperUser` exists to close, on the export axis. - const wildExport = objects?.['*']?.allowExport; - 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) - // [#3544] User-level export axis: `export` derives from `list ∧ this - // grant`. OPT-IN — only an explicit `true` (on the object entry, else - // inherited from `'*'`) allows export; unset and `false` both withhold - // it, and the super-user bits do NOT imply it. - const exportBit = acc.allowExport ?? wildExport; - const userExportAllowed = exportBit === true; - const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); - // Annotate when the object tightens via `apiMethods`, OR when the export - // axis removes `export` from an otherwise-open object (so the client - // hides the Export button). An unrestricted object with export still - // allowed needs no annotation — the client keeps its default-allow path. - if (eff.mode === 'unrestricted' && userExportAllowed) continue; - acc.apiOperations = effectiveOperationsArray(eff); - } -} - /** * Build the session → `ExecutionContext` resolver the current-user endpoints * need. @@ -1046,26 +818,16 @@ export function registerCurrentUserEndpoints( // to no access, which is what the body below then reports. const resolved: ResolvedPermissionSetLike[] = await resolvePermissionSets(execCtx) .catch(() => []); - // Most-permissive merge of `objects` and `fields` across - // all resolved permission sets — same semantics as + // Most-permissive merge of `fields` across all resolved + // permission sets — same semantics as // PermissionEvaluator.getFieldPermissions but for ALL - // objects in a single pass. - const objects: Record = {}; + // objects in a single pass. (`objects` is merged below, by the + // one function the security service answers from.) const fields: Record = {}; const systemPermissions = new Set(); const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; const tabPermissions: Record = {}; for (const ps of resolved) { - if (ps?.objects) { - for (const [obj, perm] of Object.entries(ps.objects)) { - const acc = objects[obj] ?? {}; - for (const [k, v] of Object.entries(perm as any)) { - if (v === true) acc[k] = true; - else if (acc[k] === undefined) acc[k] = v; - } - objects[obj] = acc; - } - } if (ps?.fields) { for (const [key, perm] of Object.entries(ps.fields)) { const acc = fields[key] ?? { readable: false, editable: false }; @@ -1090,51 +852,23 @@ export function registerCurrentUserEndpoints( } } } - // Make the client's per-object FLS reflect the server's ACTUAL - // effective enforcement = permission-set grant ∩ identity write - // guard (server enforces, client is courtesy — cited as ADR-0057 - // D10, an attribution, #9628). (1) Fold the `'*'` super-user grant into - // every object so an admin's wildcard is not shadowed by another - // set's explicit deny; (2) re-clamp `better-auth` managed objects - // by their write affordance, since the guard (ADR-0092 D2) blocks - // user-context writes there except where the object opted in - // (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 wildcard super-user — [#18990] either bypass bit, - // not modify-all alone — seed restricting objects absent from the - // merged map so fold pulls what it pulls and annotate can attach - // their effective apiOperations. Guarded — a failure here must - // never drop the whole response. - try { + // [#18783] The `objects` slot is the EFFECTIVE object-permission + // map, and it is computed by the ONE function + // `ISecurityService.getEffectiveObjectPermissions` answers from — + // so what the console is told here and the map the server's own + // `current_user.can()` reads are the same answer, byte for byte, + // for the same sets. The merge → seed → fold → clamp → annotate + // order, and why each step exists (ADR-0124 D1/D4, ADR-0092 D2, + // #3391, #18990), is documented once, on + // `buildEffectiveObjectPermissions` in `@objectstack/core`. + const objects = buildEffectiveObjectPermissions(resolved, { // The contract's registry view returns `unknown[]` (schema - // shape is engine-local); narrow to the slice this seeding - // reads, as the callers of getSchema below already do. - const allSchemas = (() => { - try { return (ql?.registry?.getAllObjects?.() ?? []) as ApiExposureSchemaLike[]; } - catch { return [] as ApiExposureSchemaLike[]; } - })(); - 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; } + // shape is engine-local); narrow to the slice the seed reads. + allSchemas: () => ql?.registry?.getAllObjects?.() as ApiExposureSchemaLike[] | undefined, + schemaOf: (name) => + ql?.getSchema?.(name) as (ManagedSchemaLike & ApiExposureSchemaLike) | undefined, + logger: ctx.logger, }); - // [#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/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts new file mode 100644 index 00000000000..33e6cf5ae3f --- /dev/null +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18783] `ISecurityService.getEffectiveObjectPermissions` — the effective + * object-permission map, and the one producer behind both `/auth/me/permissions` + * and the map `current_user.can()` reads on the write path. + * + * Every case resolves the service the way a cross-package consumer does — off + * the `ctx.registerService('security', …)` call, as a `Partial` (the contract's + * availability rule) — never off the plugin instance, so what is pinned is the + * member a consumer can actually REACH. + * + * What the contract member's docblock requires, clause by clause: + * + * - byte-for-byte the `objects` slot of `/auth/me/permissions` — pinned as + * equality with `buildEffectiveObjectPermissions` (`@objectstack/core`) over + * the same resolution and the same engine, the function the endpoint builds + * that slot with (the endpoint's own half is pinned in plugin-hono-server's + * `current-user-endpoints-effective-objects.test.ts`); + * - the WHOLE map — seeded, folded, clamped and annotated, not a slice; + * - it THROWS on resolution failure and ⛔ never answers `{}`; + * - request-scoped: resolved per ask, never cached across requests. + * + * And the wiring half: the SAME method is what the plugin registers on the + * engine, and an engine without the seam is reported, not silently skipped. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { buildEffectiveObjectPermissions } from '@objectstack/core'; +import type { ISecurityService } from '@objectstack/spec/contracts'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +/** The metadata-declared baseline every member resolves additively. */ +const MEMBER_DEFAULT: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { deal: { allowRead: true }, sys_member: { allowRead: true } }, + fields: {}, + systemPermissions: [], + tabPermissions: {}, +} as any; + +/** + * A DB-authored super-user set: a `'*'` wildcard carrying both bypass bits + * (and, per #8681, no `allowExport`), plus an explicit write-deny on a + * better-auth object — the shape that makes all four folds fire. + */ +const OPS_ADMIN_ROW = { + name: 'ops_admin', + label: 'Ops Admin', + object_permissions: JSON.stringify({ + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + sys_member: { allowRead: true, allowEdit: false }, + }), + field_permissions: JSON.stringify({}), + system_permissions: JSON.stringify([]), + tab_permissions: JSON.stringify({}), +}; + +/** A DB-authored plain grant — no wildcard, no bypass. */ +const SALES_ROW = { + name: 'sales', + label: 'Sales', + object_permissions: JSON.stringify({ deal: { allowRead: true, allowEdit: true } }), + field_permissions: JSON.stringify({}), + system_permissions: JSON.stringify([]), + tab_permissions: JSON.stringify({}), +}; + +/** Registered schemas: one plain, one better-auth-managed, one whose `apiMethods` tighten exposure. */ +const SCHEMAS: Record = { + deal: { name: 'deal', label: 'Deal', fields: { id: { name: 'id' } } }, + sys_member: { name: 'sys_member', label: 'Member', managedBy: 'better-auth', fields: { id: { name: 'id' } } }, + report: { name: 'report', label: 'Report', enable: { apiMethods: ['get', 'list'] }, fields: { id: { name: 'id' } } }, +}; + +/** `where` matcher: scalar equality plus the `$in` form the resolver sends; any other operator REFUSES. */ +function matches(row: Record, where: Record | undefined): boolean { + return Object.entries(where ?? {}).every(([key, cond]) => { + if (key.startsWith('$')) throw new Error(`fake engine: unsupported operator ${key}`); + const value = row[key] ?? null; + if (cond && typeof cond === 'object' && Array.isArray((cond as { $in?: unknown }).$in)) { + return ((cond as { $in: unknown[] }).$in).includes(value); + } + if (cond && typeof cond === 'object') throw new Error(`fake engine: unsupported condition on ${key}`); + return value === (cond ?? null); + }); +} + +function bootPlugin(opts: { dbRows?: Array>; engineSeam?: boolean } = {}) { + const dbRows = opts.dbRows ?? []; + const permissionSetReads: string[][] = []; + const registered: Array<(context: unknown) => Promise> = []; + const ql: any = { + registerMiddleware: () => {}, + registry: { getAllObjects: () => Object.values(SCHEMAS) }, + getSchema: (name: string) => SCHEMAS[name] ?? null, + find: async (object: string, query: any) => { + const tables: Record>> = { sys_permission_set: dbRows }; + if (object === 'sys_permission_set') permissionSetReads.push(query?.where?.name?.$in ?? []); + const rows = (tables[object] ?? []).filter((r) => matches(r, query?.where)); + // Hold the caller's bound (`check:objectql-double-limit`). + return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; + }, + }; + if (opts.engineSeam !== false) { + ql.registerEffectiveObjectPermissionsResolver = (fn: (context: unknown) => Promise) => registered.push(fn); + } + const metadata: any = { + get: async (_type: string, name: string) => SCHEMAS[name] ?? null, + list: async () => [MEMBER_DEFAULT], + }; + const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any); + return { plugin, ctx, ql, permissionSetReads, registered }; +} + +async function locate(opts?: Parameters[0]) { + const booted = bootPlugin(opts); + await booted.plugin.init(booted.ctx); + await booted.plugin.start(booted.ctx); + const svc = booted.ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial; + return { ...booted, svc }; +} + +/** The endpoint's computation, stated the way `/auth/me/permissions` states it: its sets, its engine. */ +function endpointObjects(sets: unknown, ql: any) { + return buildEffectiveObjectPermissions(sets as any, { + allSchemas: () => ql.registry.getAllObjects(), + schemaOf: (name) => ql.getSchema(name), + }); +} + +const ADMIN = { userId: 'u_admin', permissions: ['ops_admin'] } as any; +const REP = { userId: 'u_rep', permissions: ['sales'] } as any; + +describe('[#18783] getEffectiveObjectPermissions — reachable, and the endpoint\'s answer', () => { + it('is exposed on the REGISTERED literal, where a cross-package consumer feature-detects it', async () => { + const { svc } = await locate(); + expect(typeof svc.getEffectiveObjectPermissions).toBe('function'); + }); + + it('is BYTE-EQUAL to the /auth/me/permissions computation over the same resolution', async () => { + const { svc, ql } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW] }); + for (const context of [ADMIN, REP, { userId: 'u_member' }]) { + const sets = await svc.resolvePermissionSetsForContext!(context); + const member = await svc.getEffectiveObjectPermissions!(context); + expect(JSON.stringify(member), context.userId).toBe(JSON.stringify(endpointObjects(sets, ql))); + } + }); + + it('is the WHOLE map — merged, seeded, folded, clamped and annotated, not a slice', async () => { + const { svc } = await locate({ dbRows: [OPS_ADMIN_ROW] }); + const map: any = await svc.getEffectiveObjectPermissions!(ADMIN); + // Fold: the wildcard super-user reaches an object another set named explicitly. + expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowDelete: true }); + // Seed: a restricting object no set names still gets an entry for a super-user… + expect(map.report).toMatchObject({ allowRead: true, allowEdit: true }); + // …annotated with its effective operation set. + expect(Array.isArray(map.report.apiOperations)).toBe(true); + // Clamp: the better-auth guard wins over the fold on a write the object never opted in. + expect(map.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }); + // The wildcard itself is carried, as the endpoint serves it. + expect(map['*']).toMatchObject({ modifyAllRecords: true }); + }); + + it('is frozen at the top level and aliases no permission set', async () => { + const { svc } = await locate({ dbRows: [SALES_ROW] }); + const sets: any[] = await svc.resolvePermissionSetsForContext!(REP); + const map: any = await svc.getEffectiveObjectPermissions!(REP); + expect(Object.isFrozen(map)).toBe(true); + for (const set of sets) { + for (const entry of Object.values(set.objects ?? {})) { + expect(Object.values(map)).not.toContain(entry); + } + } + }); + + it('an EMPTY map is a real answer: a caller that resolves no set gets `{}`', async () => { + const { svc } = await locate(); + // No principal ⇒ no additive baseline ⇒ nothing resolves (the middleware's own answer). + await expect(svc.getEffectiveObjectPermissions!({ positions: [], permissions: [] } as any)).resolves.toEqual({}); + }); +}); + +describe('[#18783] failure stance and scope', () => { + it('a resolution failure THROWS, untouched — it never degrades to `{}`', async () => { + const { svc, plugin } = await locate(); + const boom = Object.assign(new Error('permission-set resolution failed'), { status: 503 }); + vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockRejectedValueOnce(boom); + await expect(svc.getEffectiveObjectPermissions!(REP)).rejects.toBe(boom); + }); + + it('is request-scoped: one resolution per request context, a fresh one for the next request', async () => { + const { svc, permissionSetReads } = await locate({ dbRows: [SALES_ROW] }); + // The loads that resolve THIS subject's grant (boot-time reads name nothing). + const salesLoads = () => permissionSetReads.filter((names) => names.includes('sales')).length; + const before = salesLoads(); + const request1 = { ...REP }; + await svc.getEffectiveObjectPermissions!(request1); + await svc.getEffectiveObjectPermissions!(request1); + // Within one request the plugin's per-context memo answers the second ask. + expect(salesLoads() - before).toBe(1); + // A new request is a new context object — resolved again, never served from the last one. + await svc.getEffectiveObjectPermissions!({ ...REP }); + expect(salesLoads() - before).toBe(2); + }); + + it('a grant revoked between two requests is gone from the second map', async () => { + const rows: Array> = [SALES_ROW]; + const { svc } = await locate({ dbRows: rows }); + const before: any = await svc.getEffectiveObjectPermissions!({ ...REP }); + expect(before.deal.allowEdit).toBe(true); + rows.length = 0; // the `sales` set is taken away + const after: any = await svc.getEffectiveObjectPermissions!({ ...REP }); + expect(after.deal?.allowEdit).not.toBe(true); + }); +}); + +describe('[#18783] the engine is handed the same producer', () => { + it('registers ONE resolver on the engine, and it answers exactly what the service answers', async () => { + const { svc, registered } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW] }); + expect(registered).toHaveLength(1); + for (const context of [ADMIN, REP]) { + const viaEngine = await registered[0](context); + const viaService = await svc.getEffectiveObjectPermissions!(context); + expect(JSON.stringify(viaEngine)).toBe(JSON.stringify(viaService)); + } + }); + + it('an engine without the seam is REPORTED at start — absence is loud, not silent', async () => { + const { ctx, registered } = await locate({ engineSeam: false }); + expect(registered).toHaveLength(0); + const lines = ctx.logger.warn.mock.calls.map((c: any[]) => String(c[0])); + expect(lines.some((l: string) => l.includes('registerEffectiveObjectPermissionsResolver'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 278b25f19ee..844f8f7923c 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, POSTURE_LADDER, isRowActive } from '@objectstack/core'; -import type { PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; +import { Plugin, PluginContext, POSTURE_LADDER, isRowActive, buildEffectiveObjectPermissions } from '@objectstack/core'; +import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import type { AnchorBindingContext } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; @@ -1330,6 +1330,31 @@ export class SecurityPlugin implements Plugin { ); } + // [#18783] Hand the engine the subject's EFFECTIVE object permissions, so + // `current_user.can(object, verb)` in an option's `visibleWhen` is answered + // on the write path instead of failing open. The same method the + // registered `security` literal exposes as `getEffectiveObjectPermissions` + // (below) — one map, one producer. The engine asks it at most once per + // write, and only for a write that picks an option whose predicate calls + // `can`; a throw fails that write CLOSED there. + if (typeof (ql as any).registerEffectiveObjectPermissionsResolver === 'function') { + (ql as any).registerEffectiveObjectPermissionsResolver( + (context: unknown): Promise>> => + this.getEffectiveObjectPermissions(context), + ); + } else { + // Absence must be loud, and it is functional, not durability: the engine + // is older than this plugin, so an option gated on `current_user.can(…)` + // stays unevaluable there (admitted, one warn per write) exactly as it + // was before the seam existed. The remedy is the version bump. + ctx.logger.warn( + '[security] this ObjectQL exposes no effective-permission seam ' + + '(registerEffectiveObjectPermissionsResolver), so an option visibleWhen that calls ' + + 'current_user.can(object, verb) cannot be answered on the write path and is admitted ' + + 'unenforced. Upgrade @objectstack/objectql to a version that offers the seam.', + ); + } + // [#11968] Bind the invalidation epoch to the ENGINE's seam when the wired // engine exposes one. Resolved here, once, rather than probed per request: // the plugin DI graph is static after start, and a per-request probe would @@ -1789,6 +1814,11 @@ export class SecurityPlugin implements Plugin { // same code rather than two that agree today. resolvePermissionSetsForContext: (context?: any): Promise => this.resolvePermissionSetsForContext(context), + // [#18783] The EFFECTIVE object-permission map — the `objects` slot of + // `/auth/me/permissions`, from the same function that endpoint uses + // (`buildEffectiveObjectPermissions`). Exposed on the literal for the + // reason above: a cross-package consumer can only reach it here. + getEffectiveObjectPermissions: (context?: any) => this.getEffectiveObjectPermissions(context), // [ADR-0090 D6] First-class access explanation. Same code paths as // the middleware (resolution/evaluator/RLS compiler) — explained by // construction. Explaining ANOTHER user requires `manage_users`. @@ -5864,6 +5894,46 @@ export class SecurityPlugin implements Plugin { ]); } + /** + * [#18783] `ISecurityService.getEffectiveObjectPermissions` — the subject's + * EFFECTIVE object-permission map, object name -> `EffectiveObjectPermission`. + * + * It is `buildEffectiveObjectPermissions` (`@objectstack/core`) over + * {@link resolvePermissionSetsForContext}'s answer and this plugin's engine: + * the ONE function `/auth/me/permissions` builds its `objects` slot with, so + * the endpoint and this method are the same answer for the same sets rather + * than two merges that agree today. Every clause of the contract member: + * + * - **The WHOLE map** — no object parameter; every object the resolution + * mentions plus the entries the super-user seed adds. + * - **Throws on resolution failure, never `{}`** — the throw is the set + * resolution's own, propagated untouched, so whatever it carries (a code, + * a status) reaches the caller intact. An empty map comes back only when + * the subject really holds nothing. + * - **Request-scoped** — computed per call and never cached here. The set + * resolution under it is the plugin's existing per-context memo (keyed on + * the request's context object and retired by the write epoch), so a + * second ask within one request pays no second resolution, and a new + * request — a new context — never sees an old grant. + * + * Frozen at the top level: the map is pinned data for its consumers + * (`toEvalPermissions` copies it again on the way into a predicate), and no + * entry aliases a permission set. + */ + private async getEffectiveObjectPermissions( + context?: any, + ): Promise>> { + const sets = await this.resolvePermissionSetsForContext(context); + const ql = this.ql; + return Object.freeze(buildEffectiveObjectPermissions(sets, { + // The registry view is engine-local (`unknown[]` on the contract); the + // seed reads only `name` / `enable` off each entry. + allSchemas: () => ql?.registry?.getAllObjects?.(), + schemaOf: (name: string) => ql?.getSchema?.(name), + logger: this.logger, + })); + } + /** * Resolve the effective permission sets for an execution context — positions + * explicit permission sets, with the configured baseline applied both as an diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 22ac24a2b8d..b82288eb87d 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -76,9 +76,9 @@ }, "allowExport": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#enforceExportPermission (caller-level 403 gate on the bulk-egress route, fail-closed when the security service cannot answer); packages/plugins/plugin-security/src/security-plugin.ts#canExport (→ checkObjectPermission('export'), posture-unresolvable → deny); packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#annotateEffectiveApiOperations (`const exportBit = acc.allowExport ?? wildExport` — the per-object bit and the `'*'` wildcard, for the /me/permissions projection the frontend renders)", - "note": "#3544 — user-level export axis over read. Re-verified 2026-07-30: enforcement is SERVER-side, not only the projection — the export route calls enforceExportPermission (403), separate from the object-level 405; the annotate path is the display half. Optional/no-default = backward-compatible opt-out (unset inherits read); `false` denies export while keeping read. 2026-08-25: the annotate pointer was REPOINTED — `annotateEffectiveApiOperations` moved out of hono-plugin.ts into current-user-endpoints.ts, the same repos-internal code movement that rotted systemPermissions and tabPermissions. The old citation carried no line, so the #11210 line bound could not see it; the key-mention signal is what found it. 2026-08-28: RE-ANCHORED (#13003) — the `:493-502` half was still ACCURATE (`:502` names the read), so this leg is a grammar migration; the two path-only legs are upgraded to anchors in the same pass, which is what the 2026-08-25 repoint recorded as impossible to falsify (\"the old citation carried no line, so the #11210 line bound could not see it\"). Re-closed by hand against c459da6bc." + "verifiedAt": "2026-09-25", + "evidence": "packages/rest/src/rest-server.ts#enforceExportPermission (caller-level 403 gate on the bulk-egress route, fail-closed when the security service cannot answer); packages/plugins/plugin-security/src/security-plugin.ts#canExport (→ checkObjectPermission('export'), posture-unresolvable → deny); packages/core/src/security/effective-object-permissions.ts#annotateEffectiveApiOperations (`const exportBit = acc.allowExport ?? wildExport` — the per-object bit and the `'*'` wildcard, for the /me/permissions projection the frontend renders, composed by `buildEffectiveObjectPermissions`)", + "note": "#3544 — user-level export axis over read. Re-verified 2026-07-30: enforcement is SERVER-side, not only the projection — the export route calls enforceExportPermission (403), separate from the object-level 405; the annotate path is the display half. Optional/no-default = backward-compatible opt-out (unset inherits read); `false` denies export while keeping read. 2026-08-25: the annotate pointer was REPOINTED — `annotateEffectiveApiOperations` moved out of hono-plugin.ts into current-user-endpoints.ts, the same repos-internal code movement that rotted systemPermissions and tabPermissions. The old citation carried no line, so the #11210 line bound could not see it; the key-mention signal is what found it. 2026-08-28: RE-ANCHORED (#13003) — the `:493-502` half was still ACCURATE (`:502` names the read), so this leg is a grammar migration; the two path-only legs are upgraded to anchors in the same pass, which is what the 2026-08-25 repoint recorded as impossible to falsify (\"the old citation carried no line, so the #11210 line bound could not see it\"). Re-closed by hand against c459da6bc. 2026-09-25: REPOINTED again (#18783) — `annotateEffectiveApiOperations` moved out of plugin-hono-server's current-user-endpoints.ts into @objectstack/core's security/effective-object-permissions.ts, where `buildEffectiveObjectPermissions` composes it for both /auth/me/permissions and ISecurityService.getEffectiveObjectPermissions; plugin-hono-server re-exports the name, so the old file no longer names `allowExport` at all (0 mentions, 7 in the new file) and the key-mention signal reported it UNANCHORED. Re-closed by hand: the annotate leg still reads the per-object bit before the `'*'` fallback, `enforceExportPermission` still answers 403 EXPORT_NOT_PERMITTED off `security.canExport`, and `canExport` still asks `checkObjectPermission('export', …)`." }, "allowTransfer": { "status": "live",