From 12b155952bdf8943552310ace3c7ef46d8f00723 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:29:20 +0000 Subject: [PATCH 1/5] wip(formula): register `can` receiver-only + permission data on EvalContext Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude --- packages/formula/src/cel-engine.ts | 27 +++- packages/formula/src/eval-permissions.ts | 86 +++++++++++ packages/formula/src/index.ts | 12 +- packages/formula/src/stdlib.ts | 148 ++++++++++++++++++- packages/formula/src/types.ts | 44 ++++++ packages/spec/src/security/permission.zod.ts | 146 ++++++++++++++++++ 6 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 packages/formula/src/eval-permissions.ts diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 3bce1cda815..8f7e24d17ce 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -18,6 +18,7 @@ import type { ASTNode } from '@marcbachmann/cel-js'; import type { Expression } from '@objectstack/spec'; import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib'; +import type { PermissionBinding } from './stdlib'; import type { DialectEngine, EvalContext, EvalResult } from './types'; /** @@ -57,10 +58,22 @@ export const CEL_ENV_OPTIONS = { * * Exported (package-internal; NOT in `index.ts`) so the stdlib drift pin reads * the authoritative environment through the same constructor the engine uses. + * + * `permissionBinding` is the acting subject plus its effective object + * permissions, pinned for this one evaluation (see `PermissionBinding`). Every + * caller that is not evaluating — `compile()`, the drift pin, the + * function-existence oracle — omits it, and that is exactly right: it changes + * what `can` ANSWERS, never whether `can` EXISTS, so the set of registered + * names is identical with and without it and a publish-time verdict can never + * disagree with the runtime about which names resolve. */ -export function buildEnv(now: () => Date, timezone = 'UTC'): Environment { +export function buildEnv( + now: () => Date, + timezone = 'UTC', + permissionBinding?: PermissionBinding, +): Environment { const env = new Environment(CEL_ENV_OPTIONS); - return registerNumericCoercions(registerStdLib(env, now, timezone)); + return registerNumericCoercions(registerStdLib(env, now, timezone, permissionBinding)); } /** @@ -1727,8 +1740,16 @@ export const celEngine: DialectEngine = { const now = () => ctx.now ?? new Date(); try { - const env = buildEnv(now, ctx.timezone ?? 'UTC'); + // Scope FIRST: `can` is answered about the acting subject by IDENTITY, and + // the subject is the canonical `EvalUser` object `buildScope` mints. The + // environment therefore has to be built from the scope, not beside it — + // rebuilding a lookalike user here would give `current_user.can(…)` a + // receiver that is equal to the bound one and not the same as it. const scope = buildScope(ctx); + const env = buildEnv(now, ctx.timezone ?? 'UTC', { + subject: scope.current_user, + permissions: ctx.permissions, + }); // #3183 — coerce a date-field operand compared with `==`/`!=` against a // temporal function (`date(record.d) == today()`), so a `Field.date` string // matches the Timestamp instead of silently never equalling it. No-op (and diff --git a/packages/formula/src/eval-permissions.ts b/packages/formula/src/eval-permissions.ts new file mode 100644 index 00000000000..e09fd2e317d --- /dev/null +++ b/packages/formula/src/eval-permissions.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one door permission data comes through on its way into + * {@link EvalContext.permissions}. + * + * ## Why a door at all, when the payload already has the right shape + * + * `/auth/me/permissions` answers `{ objects, systemPermissions }` and its + * `objects` map IS the `EvalContext.permissions` shape — object name -> + * `EffectiveObjectPermission`. The conversion is therefore almost nothing, and + * that is exactly the risk: "almost nothing" is what a caller re-implements by + * hand, and a hand-built map is how the two ends drift. A map keyed on labels + * instead of object names, a map carrying the raw `ObjectPermission` of ONE + * permission set instead of the server-resolved effective entry, a map whose + * values are booleans — every one of those parses as "an object" and every one + * of them makes `can()` answer confidently and wrongly, because a permission + * verdict has no shape of its own to be checked against. + * + * So the entries are parsed with the published schema and a payload that is not + * the published shape is REFUSED, loudly, at the seam where the caller can still + * fix it — rather than a release later, on somebody's screen, as a permission + * check that silently says no. + * + * ## Cost, and where to pay it + * + * Call this ONCE per fetch of `/auth/me/permissions` and keep the result for as + * long as the response is good for. ⛔ Do not call it per evaluation: the map is + * pinned data and the engine re-reads it for free, so re-parsing per predicate + * buys nothing and pays a full schema walk of every object the subject can see. + */ + +import { EffectiveObjectPermissionSchema } from '@objectstack/spec/security'; + +import type { EvalPermissions } from './types'; + +/** + * Build {@link EvalPermissions} from the `objects` map of a + * `/auth/me/permissions` response. + * + * Accepts `unknown` on purpose — the payload usually arrives from the network, + * where "it is typed" is a claim about the caller's declaration file rather + * than about the bytes. + * + * @throws when `objects` is not a plain map of object name -> + * `EffectiveObjectPermission`. The message names the offending object and what + * the schema said about it; there is no lenient arm, no coercion and no + * partial result, because half a permission map is the failure this refuses. + */ +export function toEvalPermissions(objects: unknown): EvalPermissions { + if (objects === null || typeof objects !== 'object' || Array.isArray(objects)) { + throw new TypeError( + 'toEvalPermissions(objects): expected the `objects` map of a /auth/me/permissions ' + + `response (object name -> EffectiveObjectPermission), received ${describe(objects)}. ` + + 'Pass `response.objects`, not the whole response and not a permission set.', + ); + } + const out: Record = {}; + for (const [object, value] of Object.entries(objects as Record)) { + const parsed = EffectiveObjectPermissionSchema.safeParse(value); + if (!parsed.success) { + throw new TypeError( + `toEvalPermissions(objects): the entry for '${object}' is not an ` + + `EffectiveObjectPermission — ${parsed.error.issues[0]?.message ?? 'invalid shape'} ` + + `(at \`${object}${issuePath(parsed.error.issues[0]?.path)}\`). This map must be the ` + + 'server-resolved effective set from /auth/me/permissions, not an authored permission ' + + "set's `objects` block.", + ); + } + out[object] = parsed.data; + } + return Object.freeze(out) as EvalPermissions; +} + +/** A short, non-leaking description of a rejected payload for the error text. */ +function describe(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'an array'; + return typeof value; +} + +/** `.a.b` for a zod issue path, or `''` when the issue is on the entry itself. */ +function issuePath(path: readonly PropertyKey[] | undefined): string { + if (!path || path.length === 0) return ''; + return path.map((segment) => `.${String(segment)}`).join(''); +} diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index f68f71b3d2a..99425d2f4bf 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -55,7 +55,15 @@ export type { } from './cel-engine'; export { cronEngine } from './cron-engine'; export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine'; -export { registerStdLib, buildScope } from './stdlib'; +export { registerStdLib, buildScope, registerPermissionPredicate } from './stdlib'; +export type { PermissionBinding } from './stdlib'; +// objectui#4421 / batch #147 — the permission predicate's data door. `can` reads +// `EvalContext.permissions` and nothing else, and this is how that map is built +// from the published `/auth/me/permissions` response. Exported rather than left +// to each caller because the conversion looks trivial enough to hand-roll, and a +// hand-rolled permission map has no shape of its own to be wrong against: it +// parses, `can` answers from it, and the answer is a confident silent denial. +export { toEvalPermissions } from './eval-permissions'; export { resolveSeed, resolveSeedRecord } from './seed-eval'; export { normalizeExpression, normalizeExpressionTree } from './normalize'; // ADR-0058 — canonical CEL → FilterCondition pushdown compiler (one AST, @@ -95,4 +103,4 @@ export type { UnknownFunctionCall } from './unknown-function'; export { validateExpression, introspectScope, expectedDialect, inferExpressionType, nearestName, CEL_STDLIB_FUNCTIONS } from './validate'; export type { FieldRole, ExprInput, ExprSchemaHint, ExprValidationError, ExprValidationResult, InferredValueType } from './validate'; export type { SeedValue, SeedPrimitive } from './seed-eval'; -export type { DialectEngine, EvalContext, EvalResult, EvalError } from './types'; +export type { DialectEngine, EvalContext, EvalResult, EvalError, EvalPermissions } from './types'; diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index ab4a228097f..ba62e3a879f 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -2,19 +2,33 @@ * ObjectStack standard CEL function library. * * Registered into the per-evaluation `Environment` by the CEL engine. All - * functions are pure given a pinned `now` — that determinism is what makes - * `objectstack build` artifacts byte-stable across runs. + * functions are pure given a pinned `now` and the pinned data this module is + * handed — that determinism is what makes `objectstack build` artifacts + * byte-stable across runs. ⛔ Nothing registered here may be handed a resolver, + * a lazy getter or any other callback into the host: a function that reaches + * outside its arguments and the data pinned at registration makes the same + * source evaluate to different values on two runs of the same build. * * Function naming intentionally avoids the `os.` prefix because cel-js binds * dotted names to receiver types. Instead, the `os` namespace in CEL holds * *data* (`os.user`, `os.org`, `os.env`) supplied by the caller's - * {@link EvalContext}. + * {@link EvalContext}. The one RECEIVER-form function this library registers — + * `current_user.can(object, verb)` — uses that same binding rather than + * fighting it: see {@link registerPermissionPredicate}. */ import type { Environment } from '@marcbachmann/cel-js'; -import type { EvalContext } from './types'; +import type { EvalContext, EvalPermissions } from './types'; import { createEvalUser, type EvalUser } from '@objectstack/spec'; +// The verb vocabulary lives on the SECURITY subpath, where the permission +// contract it is seeded from lives — not on the root barrel, which exports a +// curated authoring surface and no domain tables. +import { + objectPermissionGrants, + resolveObjectPermissionVerb, + OBJECT_PERMISSION_VERB_NAMES, +} from '@objectstack/spec/security'; /** * Calendar-day parts (y/m/d) of an instant *as seen in a timezone* @@ -90,19 +104,139 @@ function addMonthsUtc(d: Date, n: number): Date { return out; } +/** + * What `current_user.can(object, verb)` is answered from: the acting subject + * this evaluation bound, and that subject's effective object permissions. + * + * Both halves are DATA, pinned when the environment is built. `subject` is the + * very object {@link buildScope} mounted under `current_user` (and its `user` / + * `ctx.user` / `os.user` aliases), carried here so the binding can tell a call + * ON the acting subject from a call on something else that happens to sit to + * the left of a dot. + */ +export interface PermissionBinding { + /** + * The canonical `EvalUser` this evaluation bound, or `undefined` when the + * evaluation carries no user at all (a parse-time environment, a system + * write). Compared by IDENTITY, never by shape. + */ + readonly subject: unknown; + /** The subject's effective object permissions, or `undefined` when none were passed. */ + readonly permissions: EvalPermissions | undefined; +} + +/** + * Register `can` — the permission predicate — as a RECEIVER method, so the one + * authored spelling is `current_user.can(object, verb)`. + * + * ## Receiver-only, deliberately + * + * cel-js binds dotted names to receiver types, and a bare `can(object, verb)` + * is NOT registered: it keeps faulting (`found no matching overload for + * 'can(dyn, dyn)'`), which is the right answer — a bare call names no subject, + * and a permission question with no subject has no meaning. The name exists in + * the environment either way, so the publish gate's function-existence verdict + * (`firstUnknownFunctionCall`) reads a bare call as a call-FORM fault rather + * than an existence one, exactly as it already does for `split`. + * + * ## Every refusal is LOUD — there is no quiet answer + * + * The binding throws, and the engine reports the throw as + * `{ ok: false, error: { kind: 'runtime' } }`, for each of: + * + * - **no permission data in the context.** ⛔ Never `true` (fail-open: an + * action shown to someone who cannot use it, and worse, a section of data + * revealed), ⛔ never a silent `false` (fail-shut: every gated element + * disappears for everyone, indistinguishable from a correct denial, and the + * author is told nothing). A context that was never given the data cannot + * tell "denied" from "nobody passed it", so it says so. + * - **a receiver that is not the acting subject.** `record.can(…)` reads as a + * question about the record and would silently be answered about the user. + * - **a verb outside the vocabulary.** `current_user.can('crm_lead', 'approve')` + * is an author asking about a capability this platform does not model; the + * refusal names the whole accepted vocabulary. + * - **a non-string object or verb.** + * + * The ONE quiet answer is a real one: an object the effective map does not + * mention is an object with no grant, and answers `false` — the same answer an + * all-`false` entry gives, because they mean the same thing. + */ +export function registerPermissionPredicate( + env: Environment, + binding: PermissionBinding | undefined, +): Environment { + return env.registerFunction( + 'dyn.can(dyn, dyn): bool', + (receiver: unknown, object: unknown, verb: unknown): boolean => { + if (typeof object !== 'string' || object.length === 0) { + throw new Error( + 'can(object, verb): `object` must be an object NAME (a non-empty string), e.g. ' + + "current_user.can('crm_lead', 'edit').", + ); + } + if (typeof verb !== 'string' || verb.length === 0) { + throw new Error( + 'can(object, verb): `verb` must be one of ' + + `${OBJECT_PERMISSION_VERB_NAMES.join(', ')} (a non-empty string).`, + ); + } + const target = resolveObjectPermissionVerb(verb); + if (!target) { + throw new Error( + `can(object, verb): \`${verb}\` is not a permission verb. The accepted verbs are ` + + `${OBJECT_PERMISSION_VERB_NAMES.join(', ')}. A capability outside that list is not ` + + 'modelled as an object permission, so no answer about it would mean anything.', + ); + } + if (binding?.subject === undefined || receiver !== binding.subject) { + throw new Error( + 'can(object, verb) answers about the ACTING SUBJECT and must be called on it: write ' + + "current_user.can('', '') (the `user` / `ctx.user` / `os.user` aliases " + + 'are the same object and work too). Calling it on anything else would answer a ' + + 'question about the current user while reading as a question about the receiver.', + ); + } + if (binding.permissions === undefined) { + throw new Error( + `can('${object}', '${verb}') cannot be answered: this evaluation context carries no ` + + 'permission data. Pass `permissions` on the EvalContext — the `objects` map of the ' + + 'published /auth/me/permissions response, object name -> EffectiveObjectPermission. ' + + 'Refusing loudly is deliberate: answering `true` would show what the subject may not ' + + 'have, and answering `false` would hide it from everyone with no way to tell that ' + + 'apart from a real denial.', + ); + } + const entry = Object.hasOwn(binding.permissions, object) + ? binding.permissions[object] + : undefined; + return objectPermissionGrants(entry, target); + }, + ); +} + /** * Register the ObjectStack standard library into a CEL environment. * * The `now` resolver is closed over so each call uses the pinned - * `EvalContext.now` (or wall-clock fallback). Implementations are kept tiny - * and dependency-free — they're the contract surface for AI authors and must - * stay legible. + * `EvalContext.now` (or wall-clock fallback); `permissionBinding` is pinned the + * same way and for the same reason — see {@link PermissionBinding}. + * Implementations are kept tiny and dependency-free — they're the contract + * surface for AI authors and must stay legible. + * + * `can` is registered UNCONDITIONALLY, binding or not: whether a name exists in + * this environment is a fact about the platform, not about one call site's + * data. A conditional registration would make the publish gate's answer depend + * on which context happened to build the environment, so an authored predicate + * could pass the gate and be unknown at runtime — the very split this binding + * exists to close. */ export function registerStdLib( env: Environment, now: () => Date, timezone = 'UTC', + permissionBinding?: PermissionBinding, ): Environment { + registerPermissionPredicate(env, permissionBinding); // `today()` / `daysFromNow()` / `daysAgo()` are calendar-day functions: they // resolve to the reference-tz calendar day expressed as a UTC-midnight Date // (ADR-0053 Phase 2 D1), never an instant carrying wall-clock time. For a diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts index 27f991bbd75..3aa07c5f602 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -13,6 +13,31 @@ */ import type { Expression } from '@objectstack/spec'; +import type { EffectiveObjectPermission } from '@objectstack/spec/security'; + +/** + * The acting subject's effective object permissions, indexed by object name — + * the {@link EvalContext.permissions} payload, and the only input `can()` reads. + * + * **It is the published `/auth/me/permissions` shape, verbatim**: the `objects` + * map of `GetEffectivePermissionsResponse`, object name → the server-resolved + * `EffectiveObjectPermission` for this subject. One contract, both ends — the + * server already publishes it, a caller carries it in here unchanged, and + * nothing re-derives, re-keys or re-shapes it on the way. + * + * **A pure DATA map, never a resolver.** ⛔ Not a callback, not a lazy getter, + * not an object carrying methods: a function here would make evaluation depend + * on something outside the context — both the "declared, never bound" shape + * that cost `os.exists` / `os.count` / `os.lookup` their place on this + * interface, and a breach of the purity invariant `stdlib.ts` documents, the + * one that keeps `objectstack build` artifacts byte-stable across runs. + * + * **Completeness is the caller's promise.** `can()` reads an ABSENT object + * entry as "no grant" — which is what an all-`false` entry means anyway — so a + * partial map does not fault, it answers `false`. Pass the whole effective set + * the endpoint returned, never a hand-picked subset. + */ +export type EvalPermissions = Readonly>; /** * Runtime context for evaluating an expression. @@ -74,6 +99,25 @@ export interface EvalContext { previous?: Record; /** Action / flow input payload. */ input?: Record; + /** + * The acting subject's effective object permissions — see + * {@link EvalPermissions} for the shape and where it comes from. + * + * Read by exactly one binding, `current_user.can(object, verb)`, and bound + * only when {@link user} is also present: `can` is a question about the + * acting subject, and this map is that subject's answer sheet. ⛔ It is NOT + * mounted as a CEL variable — an authored predicate cannot read + * `os.permissions.crm_lead.allowEdit` and reach around the verb vocabulary, + * so the closed verb table stays the only door. + * + * **Absent ≠ empty.** With no map at all `can()` THROWS + * (`ok: false, kind: 'runtime'`) rather than answering: a context that was + * never given permission data cannot distinguish "denied" from "nobody + * passed the data", and answering either way — `true` fail-open, or a silent + * `false` — turns a wiring bug into a security verdict. An EMPTY map is a + * real answer (this subject holds nothing) and evaluates to `false`. + */ + permissions?: EvalPermissions; /** Free-form bag for niche call sites; merged onto the variable scope. */ extra?: Record; } diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 7c073285347..aa4374a19d2 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -74,6 +74,152 @@ const OBJECT_PERMISSION_KEY_ALIASES: Readonly> = { modifyalldata: 'modifyAllRecords', }; +/** + * The object-permission bit a permission VERB resolves to. Only the `allow*` + * capability bits are reachable from a verb: the super-user axes + * (`viewAllRecords` / `modifyAllRecords`) are grants a verb never names, and + * the depth axes (`readScope` / `writeScope`) are not booleans at all. + */ +export type ObjectPermissionVerbTarget = + | 'allowRead' + | 'allowCreate' + | 'allowEdit' + | 'allowDelete' + | 'allowExport' + | 'allowTransfer'; + +/** + * A `can`-prefixed spelling of a bare verb already in + * {@link OBJECT_PERMISSION_KEY_ALIASES} — `canread` beside `read`, both landing + * on `allowRead`. Detected structurally (the `can`-stripped remainder is itself + * an alias onto the SAME target) rather than by a hand-kept list, so a future + * verb that merely begins with those three letters is not swallowed. + */ +function isCanPrefixedSpelling(key: string): boolean { + if (!key.startsWith('can')) return false; + const bare = key.slice(3); + return bare.length > 0 && OBJECT_PERMISSION_KEY_ALIASES[bare] === OBJECT_PERMISSION_KEY_ALIASES[key]; +} + +/** + * The permission VERB vocabulary — the closed set of verbs a predicate may name + * when it asks whether the acting subject holds a capability on an object, and + * the `allow*` bit each verb resolves to. + * + * ## Seeded, never transcribed + * + * The rows are DERIVED from {@link OBJECT_PERMISSION_KEY_ALIASES}' bare verbs — + * every alias key that lands on an `allow*` bit and is not a `can`-prefixed + * spelling of another one. Deriving rather than copying is the whole point: the + * alias table is what an author's mis-spelled permission KEY is corrected + * against, so a verb accepted here is a verb that table already recognises, and + * a row retired there (`restore` / `purge` left with the #12497 tombstones) + * leaves here in the same edit with no second place to forget. The derived set + * is pinned exactly in `permission.test.ts`; a change to the alias table that + * moves it is a decision to take, not drift to absorb. + * + * ## The one row that is NOT derived + * + * `import` → `allowCreate`. `ObjectPermission` has no `allowImport` bit and the + * alias table has no `import` row, so nothing to derive it from exists: it is + * the MAINTAINER'S OWN CHOICE, recorded in director batch #13 — importing rows + * is creating rows, and the create grant is what gates it. ⛔ It is not derived + * from ADR-0068, which contains no verb table at all and whose D4 defers + * capability-gating; citing that ADR for this row would attribute a decision to + * a document that does not carry it. + * + * ## Closed, and loudly so + * + * A verb outside this table is REFUSED by its consumer, never mapped to a + * nearest neighbour and never answered `false`: a predicate asking about a + * capability this platform does not model is an authoring mistake whose silent + * answer would be indistinguishable from a real denial. + * + * ⚠️ Read it through {@link resolveObjectPermissionVerb}, never by indexing it + * directly with author-supplied text — a plain record inherits `Object`'s own + * properties, so `OBJECT_PERMISSION_VERBS['toString']` answers with a function + * and a truthiness test on it says "granted". + */ +export const OBJECT_PERMISSION_VERBS: Readonly> = + Object.freeze({ + ...(Object.fromEntries( + Object.entries(OBJECT_PERMISSION_KEY_ALIASES).filter( + ([key, target]) => target.startsWith('allow') && !isCanPrefixedSpelling(key), + ), + ) as Record), + // Maintainer's own choice, director batch #13 — see the block above. + import: 'allowCreate', + }); + +/** + * Every verb {@link OBJECT_PERMISSION_VERBS} accepts, sorted — the list a + * consumer prints when it refuses one, so the author is told the whole + * vocabulary instead of being asked to guess again. + */ +export const OBJECT_PERMISSION_VERB_NAMES: readonly string[] = Object.freeze( + Object.keys(OBJECT_PERMISSION_VERBS).sort(), +); + +/** + * The `allow*` bit `verb` resolves to, or `undefined` when the verb is outside + * the vocabulary. + * + * The ONLY supported read of {@link OBJECT_PERMISSION_VERBS}: the own-property + * check is what keeps `toString`, `constructor` and `__proto__` from resolving + * to something truthy when the verb arrives from an authored expression. + */ +export function resolveObjectPermissionVerb(verb: string): ObjectPermissionVerbTarget | undefined { + return Object.hasOwn(OBJECT_PERMISSION_VERBS, verb) ? OBJECT_PERMISSION_VERBS[verb] : undefined; +} + +/** + * Whether one effective object-permission entry grants `target`. + * + * ## Why this is not `permission[target] === true` + * + * The super-user axes are grants, and a reader that only looks at the named + * `allow*` bit answers `false` for a caller the enforcement door lets through — + * the `declared ≠ enforced` gap, pointed the dangerous way round: a predicate + * hiding an action from the one administrator who holds the power to use it. + * The fold below is the SAME fold the runtime's own answer is built from + * (`PermissionEvaluator.checkObjectPermission` in `@objectstack/plugin-security` + * — the read bypass on `viewAllRecords || modifyAllRecords`, the write bypass on + * `modifyAllRecords` alone, `export` as `grant ∧ read`), stated once here so + * every reader of an `/auth/me/permissions` entry gives the caller the same + * verdict the server's 403 would. + * + * Three cells are deliberate rather than incidental, and each is load-bearing: + * + * - **`allowCreate` has NO super-user bypass.** "Modify All Data" widens edit, + * delete and transfer; it does not manufacture a create grant, and the + * evaluator's bypass key set (edit/delete + the mapped destructive ops) is + * what says so. + * - **`allowExport` is a CONJUNCTION, not a bit.** Export is `read ∧ grant` + * (`export ⊆ list`), so a set granting export on an object the caller cannot + * read grants nothing — and the super-user bits, which do not imply export, + * still satisfy the read half. + * - **An ABSENT entry is `false`, never an error.** An object no permission set + * mentions is an object with no grant; an effective map is allowed to omit it + * exactly as it is allowed to carry an all-`false` entry, and the two must + * read the same. + */ +export function objectPermissionGrants( + permission: EffectiveObjectPermission | undefined, + target: ObjectPermissionVerbTarget, +): boolean { + if (!permission) return false; + const modifyAll = permission.modifyAllRecords === true; + const read = permission.allowRead === true || permission.viewAllRecords === true || modifyAll; + switch (target) { + case 'allowRead': return read; + case 'allowCreate': return permission.allowCreate === true; + case 'allowEdit': return permission.allowEdit === true || modifyAll; + case 'allowDelete': return permission.allowDelete === true || modifyAll; + case 'allowTransfer': return permission.allowTransfer === true || modifyAll; + case 'allowExport': return permission.allowExport === true && read; + } +} + /** * [#12840] The inert residue the #12497 retirement left in BUILT artifacts: * every `@objectstack/spec` 17.x the released toolchain shipped still carried From ba6ff5fd8efe25f10317a7451bdbe69a59dce7dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:37:29 +0000 Subject: [PATCH 2/5] wip(formula): re-point can carriers, add permission-predicate suite Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude --- .../formula/src/permission-predicate.test.ts | 257 ++++++++++++++++++ packages/formula/src/stdlib.ts | 2 +- packages/formula/src/unknown-function.test.ts | 34 ++- packages/formula/src/validate.test.ts | 43 ++- packages/spec/src/security/permission.zod.ts | 4 +- 5 files changed, 327 insertions(+), 13 deletions(-) create mode 100644 packages/formula/src/permission-predicate.test.ts diff --git a/packages/formula/src/permission-predicate.test.ts b/packages/formula/src/permission-predicate.test.ts new file mode 100644 index 00000000000..f378e345601 --- /dev/null +++ b/packages/formula/src/permission-predicate.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { celEngine } from './cel-engine'; +import { toEvalPermissions } from './eval-permissions'; +import { firstUnknownFunctionCall } from './unknown-function'; +import { validateExpression } from './validate'; +import type { EvalContext, EvalResult } from './types'; + +/** + * `current_user.can(object, verb)` — the permission predicate (objectui#4421, + * maintainer ruling batch #147 item 5, letter A). + * + * ## The defect this file aims at + * + * Registering `can` ALONE makes the publish gate accept + * `current_user.can(object, verb)` with zero errors while nothing can evaluate + * it: the production evaluator receives `{ now, timezone, user, org, record }` + * and `EvalUser` carries no permissions. The author's predicate then publishes + * green and faults on every screen at runtime — one deduped `console.warn` as + * the only signal, the action gone for every user including the one who holds + * the grant. + * + * So the property under test is a PAIR, and neither half is optional: + * + * - a `can` predicate that is given permission data **evaluates**, and + * - a `can` predicate that is not **fails loudly, at evaluation**, with a + * message naming the missing input — never `true`, never a silent `false`, + * never a green publish followed by a quiet wrong answer. + * + * The `expectLoud` helper below is what keeps the second half honest: it + * asserts the refusal is a refusal (`ok: false`, `kind: 'runtime'`) rather than + * merely "not the answer I wanted", which a silent `false` would also satisfy. + */ + +/** The `/auth/me/permissions` `objects` payload used across these cases. */ +const ME_PERMISSIONS = { + crm_lead: { allowRead: true, allowEdit: true }, + crm_invoice: { allowRead: true }, + crm_audit: { viewAllRecords: true }, + crm_archive: { modifyAllRecords: true }, + crm_report: { allowRead: true, allowExport: true }, + crm_locked: {}, +}; + +const USER = { id: 'usr_1', positions: ['sales_rep'], organizationId: 'org_1' }; + +function evaluate(source: string, ctx: EvalContext): EvalResult { + return celEngine.evaluate({ dialect: 'cel', source }, ctx); +} + +/** Evaluate with the standard subject + the standard effective permission map. */ +function withPermissions(source: string): EvalResult { + return evaluate(source, { user: USER, permissions: toEvalPermissions(ME_PERMISSIONS) }); +} + +/** + * Assert a refusal is LOUD: a reported `runtime` fault whose message contains + * `needle`. ⛔ Never assert "the value was false" for these — a silent `false` + * is the failure mode, so a pin that accepts one pins the defect. + */ +function expectLoud(result: EvalResult, needle: string): void { + expect(result.ok, 'must REFUSE, not answer').toBe(false); + if (result.ok) return; + expect(result.error.kind).toBe('runtime'); + expect(result.error.message).toContain(needle); +} + +describe('`can` evaluates from permission data in the context', () => { + it('answers TRUE for a granted verb and FALSE for an ungranted one on the same object', () => { + // Both directions on one object: a pin that only asserted `true` would stay + // green under an implementation that answers `true` for everything. + expect(withPermissions("current_user.can('crm_lead', 'edit')")).toEqual({ ok: true, value: true }); + expect(withPermissions("current_user.can('crm_invoice', 'edit')")).toEqual({ ok: true, value: false }); + }); + + it('reads the verb through the spec vocabulary, aliases included', () => { + // `update` / `write` / `edit` all name `allowEdit`; `remove` names + // `allowDelete`; `import` names `allowCreate` — the maintainer's own choice + // (batch #13), not anything ADR-0068 says. + expect(withPermissions("current_user.can('crm_lead', 'update')")).toEqual({ ok: true, value: true }); + expect(withPermissions("current_user.can('crm_lead', 'write')")).toEqual({ ok: true, value: true }); + expect(withPermissions("current_user.can('crm_lead', 'remove')")).toEqual({ ok: true, value: false }); + expect(withPermissions("current_user.can('crm_lead', 'import')")).toEqual({ ok: true, value: false }); + }); + + it('agrees with the enforcement door about the super-user bits', () => { + // `viewAllRecords` grants read, `modifyAllRecords` grants edit/delete — the + // fold `PermissionEvaluator.checkObjectPermission` performs. A predicate + // that answered `false` here would hide an action from the one caller the + // server would have let through. + expect(withPermissions("current_user.can('crm_audit', 'read')")).toEqual({ ok: true, value: true }); + expect(withPermissions("current_user.can('crm_archive', 'delete')")).toEqual({ ok: true, value: true }); + // …and the cell that is deliberately NOT folded: "Modify All Data" widens + // edit/delete, it does not manufacture a create grant. + expect(withPermissions("current_user.can('crm_archive', 'create')")).toEqual({ ok: true, value: false }); + }); + + it('treats export as `grant ∧ read`, not as a bare bit', () => { + expect(withPermissions("current_user.can('crm_report', 'export')")).toEqual({ ok: true, value: true }); + expect(withPermissions("current_user.can('crm_lead', 'export')")).toEqual({ ok: true, value: false }); + }); + + it('answers FALSE for an object the effective map does not mention — a real answer, not a fault', () => { + // An object no permission set mentions is an object with no grant, which is + // what an all-`false` entry means too, so the two read the same. This is the + // ONE quiet answer, and it is quiet because it is a fact about the data. + expect(withPermissions("current_user.can('crm_unmentioned', 'read')")).toEqual({ ok: true, value: false }); + expect(withPermissions("current_user.can('crm_locked', 'read')")).toEqual({ ok: true, value: false }); + }); + + it('answers identically under every ADR-0068 alias of the acting subject', () => { + // `buildScope` mounts ONE EvalUser under four names; `can` compares the + // receiver by identity, so all four reach it and a predicate evaluates the + // same wherever it was authored. + for (const root of ['current_user', 'user', 'ctx.user', 'os.user']) { + expect(withPermissions(`${root}.can('crm_lead', 'edit')`), root) + .toEqual({ ok: true, value: true }); + } + }); + + it('composes into a real visibility predicate', () => { + // The authored shape the card exists for: "show this only if the user can + // edit X" — the standing capability of every mainstream platform. + expect(withPermissions("current_user.can('crm_lead', 'edit') && !current_user.can('crm_invoice', 'edit')")) + .toEqual({ ok: true, value: true }); + }); +}); + +describe('`can` refuses LOUDLY — never fail-open, never a silent false', () => { + it('throws when the context carries NO permission data (ruling ②)', () => { + // ⭐ The case the card exists to prevent. A predicate published green and + // then handed the production context — `{ user }` and nothing else — must + // say so, at evaluation, in words that name the missing input. + const r = evaluate("current_user.can('crm_lead', 'edit')", { user: USER }); + expectLoud(r, 'carries no permission data'); + expectLoud(r, '/auth/me/permissions'); + // Stated as the two things it must NOT be, because both are values a + // careless implementation returns and both are indistinguishable from a + // real answer at the call site. + expect(r).not.toEqual({ ok: true, value: true }); + expect(r).not.toEqual({ ok: true, value: false }); + }); + + it('an EMPTY map is a real answer, not the missing-data fault', () => { + // The boundary of the case above: a subject who holds nothing evaluates to + // `false`. If these two collapsed, the loud refusal would fire for every + // unprivileged caller and be trained away. + expect(evaluate("current_user.can('crm_lead', 'edit')", { user: USER, permissions: {} })) + .toEqual({ ok: true, value: false }); + }); + + it('throws on a verb outside the vocabulary, and names the whole vocabulary', () => { + const r = withPermissions("current_user.can('crm_lead', 'approve')"); + expectLoud(r, '`approve` is not a permission verb'); + expectLoud(r, 'create, delete, edit, export, import, read, remove, transfer, update, write'); + }); + + it('throws on the retired lifecycle verbs rather than answering about a tombstone', () => { + // `restore` / `purge` left the alias table with the #12497 tombstones, so + // they are outside the derived vocabulary. Answering `false` would read as + // "you lack the grant" for a bit that no longer exists. + expectLoud(withPermissions("current_user.can('crm_lead', 'restore')"), 'is not a permission verb'); + expectLoud(withPermissions("current_user.can('crm_lead', 'purge')"), 'is not a permission verb'); + }); + + it('throws on an inherited-property verb instead of resolving one', () => { + // A plain record inherits `Object`'s own properties; a truthiness test on + // `VERBS['toString']` says "granted". The own-property read is what stops it. + expectLoud(withPermissions("current_user.can('crm_lead', 'toString')"), 'is not a permission verb'); + expectLoud(withPermissions("current_user.can('crm_lead', 'constructor')"), 'is not a permission verb'); + }); + + it('throws when the receiver is not the acting subject', () => { + // `record.can(…)` reads as a question about the record. Answering it from + // the current user's permissions would be a confident wrong answer to a + // question nobody asked. + const r = evaluate("record.can('crm_lead', 'edit')", { + user: USER, + record: { id: 'r1' }, + permissions: toEvalPermissions(ME_PERMISSIONS), + }); + expectLoud(r, 'answers about the ACTING SUBJECT'); + }); + + it('throws on a non-string object or verb', () => { + expectLoud(withPermissions('current_user.can(1, 2)'), 'must be an object NAME'); + expectLoud(withPermissions("current_user.can('crm_lead', 3)"), 'must be one of'); + }); + + it('faults on an unbound `current_user` when the evaluation carries no user at all', () => { + // A system write binds no user, so the receiver does not resolve and cel-js + // refuses before the binding is reached. Loud either way — this pins WHICH + // loud, so a later change that starts answering here is visible. + const r = evaluate("current_user.can('crm_lead', 'edit')", { + permissions: toEvalPermissions(ME_PERMISSIONS), + }); + expect(r.ok).toBe(false); + expect(r.ok === false && r.error.message).toContain('current_user'); + }); +}); + +describe('`can` is registered RECEIVER-ONLY', () => { + it('the bare call keeps faulting — a subject-less permission question has no meaning', () => { + const compiled = celEngine.compile('can(object, verb)'); + expect(compiled.ok).toBe(false); + expect(compiled.ok === false && compiled.error.kind).toBe('type'); + expect(compiled.ok === false && compiled.error.message) + .toContain("found no matching overload for 'can("); + // …and at evaluation too, with the data present: the fault is the call FORM, + // not the data. + expect(withPermissions('can(object, verb)').ok).toBe(false); + }); + + it('the name EXISTS even so, so the publish gate reports a call-form fault and not an unknown name', () => { + // Same treatment the environment already gives `split`. Existence is not + // call position (#13594 ruling refinement 3). + expect(firstUnknownFunctionCall('can(object, verb)')).toBeNull(); + expect(firstUnknownFunctionCall('current_user.can(object, verb)')).toBeNull(); + }); + + it('registration does not depend on the context that built the environment', () => { + // The split this whole card exists to close: if `can` were registered only + // when permission data happened to be present, a predicate could pass the + // publish gate (which builds a data-free environment) and be an UNKNOWN name + // at runtime, or the reverse. Both environments answer the same. + expect(validateExpression('predicate', 'current_user.can(record, "read")').ok).toBe(true); + expect(withPermissions("current_user.can('crm_lead', 'read')").ok).toBe(true); + }); +}); + +describe('toEvalPermissions — the door the map comes through', () => { + it('carries the published payload through unchanged in meaning', () => { + const map = toEvalPermissions(ME_PERMISSIONS); + expect(Object.keys(map).sort()).toEqual(Object.keys(ME_PERMISSIONS).sort()); + expect(map.crm_lead.allowEdit).toBe(true); + }); + + it('refuses a payload that is not the published shape', () => { + expect(() => toEvalPermissions(null)).toThrow(/expected the `objects` map/); + expect(() => toEvalPermissions([])).toThrow(/received an array/); + // A permission-set-shaped value in an entry, the classic hand-rolled map: + // `allowEdit` as a string rather than a bool. + expect(() => toEvalPermissions({ crm_lead: { allowEdit: 'yes' } })) + .toThrow(/the entry for 'crm_lead' is not an EffectiveObjectPermission/); + expect(() => toEvalPermissions({ crm_lead: 'read' })).toThrow(/crm_lead/); + }); + + it('accepts the retired-default residue the published 17.x toolchain still emits', () => { + // A server on an older toolchain materialises `allowRestore: false` / + // `allowPurge: false` into every entry. That residue must not make a whole + // permission map unusable. + const map = toEvalPermissions({ crm_lead: { allowRead: true, allowRestore: false, allowPurge: false } }); + expect(map.crm_lead.allowRead).toBe(true); + }); +}); diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index ba62e3a879f..d7d2c4d8e3b 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -206,7 +206,7 @@ export function registerPermissionPredicate( 'apart from a real denial.', ); } - const entry = Object.hasOwn(binding.permissions, object) + const entry = Object.prototype.hasOwnProperty.call(binding.permissions, object) ? binding.permissions[object] : undefined; return objectPermissionGrants(entry, target); diff --git a/packages/formula/src/unknown-function.test.ts b/packages/formula/src/unknown-function.test.ts index b50af751435..f30aacfd600 100644 --- a/packages/formula/src/unknown-function.test.ts +++ b/packages/formula/src/unknown-function.test.ts @@ -71,10 +71,36 @@ describe('firstUnknownFunctionCall — what it REFUSES (#13594)', () => { expect(found?.detail).toContain("found no matching overload for 'dyn.nosuchmethod(string)'"); }); - it('the objectui#4421 predicate — the authored shape this ruling came from', () => { - // `current_user` is a declared SCOPE_ROOT, so the unbound-root check cannot - // structurally see this one: existence is the only check that can. - expect(firstUnknownFunctionCall('current_user.can(object, verb)')?.name).toBe('can'); + it('the objectui#4421 SHAPE still lands here — with an invented method, now that `can` is real', () => { + // RE-POINTED, not deleted (batch #147 item 5, letter A). The case pins a + // STRUCTURAL property and the property is unchanged: `current_user` is a + // declared SCOPE_ROOT, so the unbound-root check cannot see a method call + // hung off it, and existence is the only check that can. What changed is the + // EXAMPLE — `can` is registered now (receiver-only) — so the shape is + // carried by a method that really is invented. Deleting the case would have + // thrown the coverage away with the example. + expect(firstUnknownFunctionCall('current_user.canApprove(object, verb)')?.name) + .toBe('canApprove'); + }); + + it('`can` itself is REGISTERED now — receiver form resolves, bare form is a call-FORM fault', () => { + // The other half of the same re-pointing, and the reason the case above had + // to move rather than go: this is what `can` answers today. + // + // Receiver form: registered, so there is no existence verdict at all. + expect(firstUnknownFunctionCall('current_user.can(object, verb)')).toBeNull(); + // Bare form: still faults (registered receiver-only), but the NAME exists, + // so this oracle reports nothing — exactly as it already does for `split`, + // pinned in the silence table below. Existence is not call position (ruling + // refinement 3). + expect(firstUnknownFunctionCall('can(object, verb)')).toBeNull(); + // …and the control that the bare form really does still fault, so the line + // above is silence about a live fault rather than about nothing. + const bare = celEngine.compile('can(object, verb)'); + expect(bare.ok).toBe(false); + expect(bare.ok === false && bare.error.kind).toBe('type'); + expect(bare.ok === false && bare.error.message) + .toContain("found no matching overload for 'can("); }); it('a typo one edit away from a real function is still just unknown — no suggestion field', () => { diff --git a/packages/formula/src/validate.test.ts b/packages/formula/src/validate.test.ts index 3ce820f10b2..879936ab348 100644 --- a/packages/formula/src/validate.test.ts +++ b/packages/formula/src/validate.test.ts @@ -94,14 +94,27 @@ describe('validateExpression (ADR-0032)', () => { }); it('rejects an invented method on the canonical user root (#13594)', () => { - // The authored predicate that motivated the card (objectui#4421): a - // capability method on `current_user` that reads plausibly and does not - // exist. `current_user` is a declared SCOPE_ROOT, so this is NOT caught as - // an unbound root — only the unknown call catches it. - const r = validateExpression('predicate', 'current_user.can(record, "read")'); + // RE-POINTED, not deleted (batch #147 item 5, letter A). The structural + // property is unchanged — `current_user` is a declared SCOPE_ROOT, so a + // method call hung off it is NOT caught as an unbound root and only the + // unknown call catches it — but the EXAMPLE had to move: `can` is a + // registered receiver method now, so it is no longer invented. A plausible + // neighbour that still is carries the case. + const r = validateExpression('predicate', 'current_user.canApprove(record, "read")'); expect(r.ok).toBe(false); expect(r.errors[0].message).toContain('found no matching overload'); - expect(r.errors[0].message).toContain('can'); + expect(r.errors[0].message).toContain('canApprove'); + }); + + it('ACCEPTS `current_user.can(object, verb)` — the objectui#4421 predicate, now registered', () => { + // The control that makes the re-pointing above a reading rather than a + // move: the exact authored shape the card came from validates clean. + // Until this landed the validator refused it while the publish gate for + // view predicates accepted it — a gate and a runtime disagreeing about one + // name (#13594). Both say the same thing about `can` now. + const r = validateExpression('predicate', 'current_user.can(record, "read")'); + expect(r.ok).toBe(true); + expect(r.errors).toHaveLength(0); }); it('the global form rejects and the stdlib control stays clean (#13594)', () => { @@ -275,12 +288,28 @@ describe('validateExpression (ADR-0032)', () => { // three-character name, a jump from a permission verb to a numeric // function. Worse than silence: an author who takes it writes // `min(object, verb)`. - const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message; + // + // RE-POINTED from the receiver form to the BARE one (batch #147 item 5, + // letter A). `can` is registered RECEIVER-ONLY now, so + // `current_user.can(…)` type-checks and no longer reaches this arm — + // while `can(object, verb)` still faults and still lands here, which is + // precisely the case this hint's wording was written for: the name is + // not callable HERE (it is one of the receiver-only names the catalog + // does not advertise), and the catalog is still the wrong place to look + // for a neighbour. The hazard is identical and the sentence is now + // literally true instead of merely useful. + const message = validateExpression('predicate', 'can(object, verb)').errors[0].message; expect(message).toContain('`can` is not a callable name here'); expect(message).not.toMatch(/Did you mean/); expect(message).not.toMatch(/`min`/); }); + it('…and the receiver form it moved OFF is clean — the control for that move', () => { + // Without this the case above would keep passing if `can` had never been + // registered at all, which is the whole thing the re-pointing asserts. + expect(validateExpression('predicate', 'current_user.can(object, verb)').ok).toBe(true); + }); + it('leaves the shared `nearestName` budget alone — this class narrows locally', () => { // The hazard is this catalog's, not the heuristic's: field-name // suggestions keep the shared budget. If this ever stops answering diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index aa4374a19d2..0e6d6902b06 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -169,7 +169,9 @@ export const OBJECT_PERMISSION_VERB_NAMES: readonly string[] = Object.freeze( * to something truthy when the verb arrives from an authored expression. */ export function resolveObjectPermissionVerb(verb: string): ObjectPermissionVerbTarget | undefined { - return Object.hasOwn(OBJECT_PERMISSION_VERBS, verb) ? OBJECT_PERMISSION_VERBS[verb] : undefined; + return Object.prototype.hasOwnProperty.call(OBJECT_PERMISSION_VERBS, verb) + ? OBJECT_PERMISSION_VERBS[verb] + : undefined; } /** From 38f3041d3819029a0e78f5c0af5b9fd251d2f7c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:50:28 +0000 Subject: [PATCH 3/5] wip(lint,spec): re-point can carriers in lint, pin the verb vocabulary Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude --- packages/lint/src/runtime-gate.test.ts | 31 ++++- .../validate-visibility-predicates.test.ts | 52 ++++++-- .../src/validate-visibility-predicates.ts | 48 +++++-- packages/spec/src/security/permission.test.ts | 124 ++++++++++++++++++ 4 files changed, 221 insertions(+), 34 deletions(-) diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index d9d495234bf..0658364f335 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -379,20 +379,37 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# }); it('REFUSES a predicate calling a function the CEL environment does not register', () => { - // #13594 — the arm that used to walk through. `current_user.can(object, verb)` - // is the authored shape from objectui#4421: it PARSES, so the syntax arm has - // nothing to say, and `current_user` is a declared root, so the bare-ref arm - // cannot reach the call. Before the ruling this reached a tenant's runtime - // and faulted fail-CLOSED on every surface. - const { errors } = gateView(runtimeView('current_user.can(object, verb)')); + // #13594 — the arm that used to walk through. The shape is a method call on + // `current_user`: it PARSES, so the syntax arm has nothing to say, and + // `current_user` is a declared root, so the bare-ref arm cannot reach the + // call. Before the ruling this reached a tenant's runtime and faulted + // fail-CLOSED on every surface. + // + // RE-POINTED, not deleted (batch #147 item 5, letter A): the example used to + // be `current_user.can(object, verb)` from objectui#4421, and `can` is a + // registered receiver method now. `canApprove` carries the same shape and is + // still invented; the case below is the other half, and the two together are + // what says the gate narrowed by exactly one name. + const { errors } = gateView(runtimeView('current_user.canApprove(object, verb)')); const f = errors.find((e) => e.rule === 'visibility-predicate-unknown-function'); expect(f, 'the publish door must refuse an unresolvable call, not just the validator').toBeDefined(); expect(f!.severity).toBe('error'); expect(f!.path).toBe('views[0].form.sections[0].fields[0]'); - expect(f!.message).toMatch(/`can`/); + expect(f!.message).toMatch(/`canApprove`/); expect(f!.message).toMatch(/found no matching overload/); }); + it('ACCEPTS `current_user.can(object, verb)` — objectui#4421, registered and evaluable now', () => { + // The predicate the whole #13594 arm was built around. It is no longer an + // unresolvable call, and the gate must say so: a publish door that kept + // refusing it would block the capability this platform now ships, and one + // that accepted it while nothing could evaluate it was the defect. Both ends + // moved together — `@objectstack/formula` registers `can` receiver-only and + // answers it from `EvalContext.permissions`. + const { errors } = gateView(runtimeView('current_user.can(object, verb)')); + expect(errors.map((e) => e.rule)).not.toContain('visibility-predicate-unknown-function'); + }); + it('ACCEPTS a predicate whose functions all resolve — the control for the arm above', () => { // Same door, same shape, a registered function. Without this the test above // would still pass if the arm had started refusing every call. diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index bd6e2246031..2b1be02cf22 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -1247,32 +1247,58 @@ describe('visibility-predicate-unknown-function (#13594)', () => { }); }); - it('the objectui#4421 predicate is refused, and the call is what names it', () => { - // The authored predicate this ruling came from. `current_user` is a declared - // SCOPE_ROOT, so the bare-identifier rule cannot structurally reach `can` — - // it reports the rootless ARGUMENTS instead. Both findings are real and have - // different fixes, which is why they are not mutually exclusive. - const rules = validateVisibilityPredicates(formStack('current_user.can(object, verb)')) + it('an invented method on the canonical user root is refused, and the call is what names it', () => { + // RE-POINTED, not deleted (batch #147 item 5, letter A). The property is the + // rule PAIR, unchanged: `current_user` is a declared SCOPE_ROOT, so the + // bare-identifier rule cannot structurally reach the method name — it reports + // the rootless ARGUMENTS instead. Both findings are real and have different + // fixes, which is why they are not mutually exclusive. The example moved off + // `can`, which is registered now; `canApprove` is the same shape, still + // invented. + const rules = validateVisibilityPredicates(formStack('current_user.canApprove(object, verb)')) .map((f) => f.rule); expect(rules).toContain(VISIBILITY_PREDICATE_UNKNOWN_FUNCTION); expect(rules).toContain(VISIBILITY_BARE_IDENTIFIER); - expect(unknownFnFindings(formStack('current_user.can(object, verb)'))[0].message) - .toContain('`can`'); + expect(unknownFnFindings(formStack('current_user.canApprove(object, verb)'))[0].message) + .toContain('`canApprove`'); + }); + + it('…and the objectui#4421 predicate itself is NOT refused any more — `can` resolves', () => { + // The other half of the re-pointing, and the reading that makes it one: the + // existence arm narrowed by exactly one name. `current_user.can(object, verb)` + // no longer draws an unknown-function finding, because + // `@objectstack/formula` registers `can` receiver-only and answers it from + // `EvalContext.permissions` — publish acceptance and runtime evaluability + // moved together, which is the whole point of #13594's arm. + expect(unknownFnFindings(formStack('current_user.can(object, verb)'))).toEqual([]); + // The bare-identifier finding on the rootless ARGUMENTS is untouched: that + // rule's verdict is about `object` / `verb`, not about the call. + expect(validateVisibilityPredicates(formStack('current_user.can(object, verb)')).map((f) => f.rule)) + .toContain(VISIBILITY_BARE_IDENTIFIER); }); it('⛔ offers no "did you mean" suggestion, however close the typo (refinement 2)', () => { - // `nearestName('can', )` answers `min`. The ruling ships - // the engine's own wording and nothing on top of it, so a one-edit typo gets - // the same treatment as a wholly invented name. + // The ruling ships the engine's own wording and nothing on top of it, so a + // one-edit typo gets the same treatment as a wholly invented name. const findings = unknownFnFindings(formStack('isBlnk(record.x)')); expect(findings).toHaveLength(1); expect(findings[0].message).not.toMatch(/did you mean/i); expect(findings[0].hint).not.toMatch(/did you mean/i); // …and the hazard itself, stated as a case: nothing anywhere in the finding - // proposes `isBlank` (or, for `can`, `min`). + // proposes `isBlank`. expect(findings[0].message).not.toContain('`isBlank`'); expect(findings[0].hint).not.toContain('`isBlank`'); - expect(JSON.stringify(unknownFnFindings(formStack('can(record.x)')))).not.toContain('`min`'); + // The DISTANT-jump half. It used to be carried by `can(record.x)`, whose + // nearest catalog entry is `min` — two edits on a three-character name, + // across an unrelated namespace. `can` is registered now, so that source + // draws no finding at all and the assertion on it would be vacuous; the + // hazard is re-pointed onto a name that is still unknown, and the finding is + // asserted to EXIST before it is asserted to suggest nothing. + const distant = unknownFnFindings(formStack('cap(record.x)')); + expect(distant, 'the re-pointed source must still draw a finding, or this pins nothing') + .toHaveLength(1); + expect(JSON.stringify(distant)).not.toMatch(/did you mean/i); + expect(JSON.stringify(distant)).not.toContain('`max`'); }); it('the hint says NAME fault, not dialect — the #7073 / #13821 correction, kept', () => { diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index 5fdac4f8204..3e7d24bc589 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -67,10 +67,10 @@ * its own id. * - `visibility-predicate-unknown-function` (**error**, #13594) — a predicate * that PARSES perfectly and calls a function the CEL environment does not - * register (`current_user.can(object, verb)`, `totallyBogusFn(1,2)`). See the - * §Function existence block below for the ruling that put it here, for why it - * is the one `check()` verdict this file adopts, and for the four things it - * deliberately still does not report. + * register (`current_user.canApprove(object, verb)`, `totallyBogusFn(1,2)`). + * See the §Function existence block below for the ruling that put it here, for + * why it is the one `check()` verdict this file adopts, and for the four + * things it deliberately still does not report. * - `visibility-bare-identifier` (**error**, #6128 / #5149 requirement 3) — a * predicate referencing a top-level identifier that no binding root can * resolve (`status == 'active'` instead of `record.status == 'active'`). See @@ -187,6 +187,19 @@ * validator has refused unknown calls since #1877 — and the hole was here, on * the one predicate surface `validate-expressions.ts` does not walk. * + * ⚠️ **SINCE — `can` itself is no longer an example of this** (batch #147 item + * 5, letter A). `@objectstack/formula` registers `can` RECEIVER-ONLY and answers + * it from `EvalContext.permissions`, so `current_user.can(object, verb)` now + * resolves here AND evaluates at runtime — publish acceptance and runtime + * evaluability moved together, which is the property this arm exists to hold. + * The paragraph above is kept as the RECORD of what the gate was measured to do + * before the ruling, because that measurement is what the arm rests on; the + * carriers that used to spell the shape with `can` are re-pointed onto + * `canApprove` (⛔ not deleted) and read as a pair with the acceptance case + * beside each of them. A bare `can(object, verb)` still faults, and this gate + * still says nothing about it: the name exists, so that is a call-FORM fault, + * exactly like `split` in refinement 3 below. + * * ### The ruling * * Maintainer, 2026-08-31, on a censused premise (host-registered extra CEL @@ -216,9 +229,13 @@ * call this refuses is a call that WILL fault when evaluated. The old ruling's * fear — an `error`-level gate rejecting predicates that work — needs a fault * class that is data-dependent, and existence is not one. - * 4. **No suggestion is offered.** 「不给 `nearestName` 建议。」 — - * `nearestName('can', )` answers `'min'`. The engine's own - * wording ships verbatim and nothing is guessed on top of it. + * 4. **No suggestion is offered.** 「不给 `nearestName` 建议。」 — the measured + * hazard was `nearestName('can', )` answering `'min'`, two + * edits on a three-character name across an unrelated namespace. `can` is a + * registered name now, so that exact source no longer reaches this arm; the + * refinement is unchanged and the pins are re-pointed onto a name that still + * does. The engine's own wording ships verbatim and nothing is guessed on top + * of it. * * The fault this closes is the one metadata validation exists for, and the one * an AI author hits hardest: a plausible-looking function name that does not @@ -1030,13 +1047,16 @@ function checkElement( // restated per RULE PAIR instead of per predicate. // // The finding carries NO "did you mean" suggestion, and the hint says nothing - // about why — ruling refinement 2, 「不给 `nearestName` 建议。」 The measured - // hazard: `nearestName('can', )` answers `min`, two edits on - // a three-character name, jumping from a permission verb to a numeric - // function. An author who takes it (an LLM author above all, following the - // last sentence it was handed) writes `min(object, verb)` and is further from - // working than before it asked. The engine's own wording ships verbatim and - // nothing is guessed on top of it. + // about why — ruling refinement 2, 「不给 `nearestName` 建议。」 The hazard was + // measured on `can`: `nearestName('can', )` answers `min`, + // two edits on a three-character name, jumping from a permission verb to a + // numeric function. An author who takes it (an LLM author above all, following + // the last sentence it was handed) writes `min(object, verb)` and is further + // from working than before it asked. (`can` is a registered receiver method + // since batch #147 item 5, so that source no longer reaches here; the hazard + // CLASS is unchanged — short names land two edits from an unrelated catalog + // entry — and the pins are re-pointed onto one that still does.) The engine's + // own wording ships verbatim and nothing is guessed on top of it. const unknownCall: UnknownFunctionCall | null = source && !refusal ? firstUnknownFunctionCall(source) : null; if (source && unknownCall) { diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 2b547d266a2..d1f1583838f 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -8,6 +8,10 @@ import { type PermissionSet, type ObjectPermission, type FieldPermission, + OBJECT_PERMISSION_VERBS, + OBJECT_PERMISSION_VERB_NAMES, + objectPermissionGrants, + resolveObjectPermissionVerb, } from './permission.zod'; import { ObjectStackDefinitionSchema } from '../stack.zod'; @@ -1083,3 +1087,123 @@ describe('[#6698] modifyAllRecords declares its bypass AND the limit of that byp .toMatch(SURVIVING_FLOOR); }); }); + +/** + * The permission VERB vocabulary (objectui#4421, maintainer ruling batch #147 + * item 5, letter A). + * + * Two properties, and the second is why this block exists at all: + * + * 1. the table is DERIVED from `OBJECT_PERMISSION_KEY_ALIASES`' bare verbs, so + * the two can never disagree about which verbs this platform recognises; + * 2. the derived result is PINNED exactly. A derivation with no pin absorbs an + * alias-table edit silently — retire an alias and a verb leaves the closed + * vocabulary with nothing red, which is a capability quietly disappearing + * from every authored predicate that named it. + */ +describe('OBJECT_PERMISSION_VERBS — the closed verb vocabulary', () => { + it('is exactly the derived set, row for row', () => { + expect({ ...OBJECT_PERMISSION_VERBS }).toEqual({ + // Derived — the bare verbs of OBJECT_PERMISSION_KEY_ALIASES. + read: 'allowRead', + create: 'allowCreate', + edit: 'allowEdit', + update: 'allowEdit', + write: 'allowEdit', + delete: 'allowDelete', + remove: 'allowDelete', + export: 'allowExport', + transfer: 'allowTransfer', + // NOT derived — the maintainer's own choice, director batch #13. Nothing + // to derive it from exists: there is no `allowImport` bit and no `import` + // alias row. ⛔ Not ADR-0068, which contains no verb table. + import: 'allowCreate', + }); + }); + + it('withholds the `can`-prefixed spellings and the super-user aliases', () => { + // `canread` etc. are alias spellings of a verb already in the table; taking + // them too would put two names for one capability into an authored surface. + // `viewall` / `modifyall` name super-user AXES, which a verb never names. + for (const notAVerb of ['canread', 'cancreate', 'canedit', 'candelete', + 'viewall', 'viewalldata', 'modifyall', 'modifyalldata']) { + expect(resolveObjectPermissionVerb(notAVerb), notAVerb).toBeUndefined(); + } + }); + + it('withholds the retired lifecycle verbs — a verb may only name a bit the shape accepts', () => { + // `restore` / `purge` left the alias table with the #12497 tombstones. A + // vocabulary that still carried them would answer questions about bits that + // cannot be authored, and `false` would read as "you lack the grant". + expect(resolveObjectPermissionVerb('restore')).toBeUndefined(); + expect(resolveObjectPermissionVerb('purge')).toBeUndefined(); + }); + + it('resolves nothing for an inherited property', () => { + // A plain record inherits Object.prototype, so a direct index on + // author-supplied text answers `toString` with a function — truthy, and read + // by a caller as a grant. The own-property check in the resolver is the fix, + // and this is the pin that keeps a "simplification" back to `VERBS[verb]` + // from landing. + for (const inherited of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf']) { + expect(resolveObjectPermissionVerb(inherited), inherited).toBeUndefined(); + } + }); + + it('publishes the whole vocabulary, sorted, for a refusal message to name', () => { + expect([...OBJECT_PERMISSION_VERB_NAMES]).toEqual( + ['create', 'delete', 'edit', 'export', 'import', 'read', 'remove', 'transfer', 'update', 'write'], + ); + }); +}); + +/** + * `objectPermissionGrants` — reading one effective entry the way the + * enforcement door reads it. + */ +describe('objectPermissionGrants — the fold, not the bare bit', () => { + it('answers the plain bits', () => { + expect(objectPermissionGrants({ allowRead: true }, 'allowRead')).toBe(true); + expect(objectPermissionGrants({ allowRead: true }, 'allowEdit')).toBe(false); + expect(objectPermissionGrants({ allowCreate: true }, 'allowCreate')).toBe(true); + }); + + it('folds the READ bypass across BOTH super-user bits', () => { + // `PermissionEvaluator.checkObjectPermission`: `permKey === 'allowRead' && + // (viewAllRecords || modifyAllRecords)`. A reader that missed this hides a + // section from the one caller the server would have served. + expect(objectPermissionGrants({ viewAllRecords: true }, 'allowRead')).toBe(true); + expect(objectPermissionGrants({ modifyAllRecords: true }, 'allowRead')).toBe(true); + }); + + it('folds the WRITE bypass across modifyAllRecords ONLY', () => { + // "View All Data" is a read power and must never widen a write — the whole + // point of shipping the two bits separately. + expect(objectPermissionGrants({ modifyAllRecords: true }, 'allowEdit')).toBe(true); + expect(objectPermissionGrants({ modifyAllRecords: true }, 'allowDelete')).toBe(true); + expect(objectPermissionGrants({ modifyAllRecords: true }, 'allowTransfer')).toBe(true); + expect(objectPermissionGrants({ viewAllRecords: true }, 'allowEdit')).toBe(false); + expect(objectPermissionGrants({ viewAllRecords: true }, 'allowDelete')).toBe(false); + }); + + it('does NOT manufacture a create grant from a super-user bit', () => { + // The evaluator's bypass key set is edit/delete plus the mapped destructive + // ops; `allowCreate` is deliberately not in it. + expect(objectPermissionGrants({ modifyAllRecords: true }, 'allowCreate')).toBe(false); + expect(objectPermissionGrants({ viewAllRecords: true }, 'allowCreate')).toBe(false); + }); + + it('treats export as `grant ∧ read`', () => { + expect(objectPermissionGrants({ allowExport: true }, 'allowExport')).toBe(false); + expect(objectPermissionGrants({ allowExport: true, allowRead: true }, 'allowExport')).toBe(true); + // The super-user bits satisfy the read half but never the grant half. + expect(objectPermissionGrants({ allowExport: true, viewAllRecords: true }, 'allowExport')).toBe(true); + expect(objectPermissionGrants({ modifyAllRecords: true }, 'allowExport')).toBe(false); + }); + + it('reads an ABSENT entry and an all-false entry the same way', () => { + // An object no permission set mentions is an object with no grant. + expect(objectPermissionGrants(undefined, 'allowRead')).toBe(false); + expect(objectPermissionGrants({}, 'allowRead')).toBe(false); + }); +}); From 0a6c8f354d8b4aac98315624240c7c59bc07ecb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:54:56 +0000 Subject: [PATCH 4/5] chore(spec): regenerate api-surface + export-origins for the verb vocabulary Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude --- packages/spec/api-surface/security.json | 7 ++++++- packages/spec/export-origins/security.json | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/spec/api-surface/security.json b/packages/spec/api-surface/security.json index 33d72aa1fdc..69c58258d0a 100644 --- a/packages/spec/api-surface/security.json +++ b/packages/spec/api-surface/security.json @@ -40,6 +40,8 @@ "FieldPermission (type)", "FieldPermissionParsed (type)", "FieldPermissionSchema (const)", + "OBJECT_PERMISSION_VERBS (const)", + "OBJECT_PERMISSION_VERB_NAMES (const)", "OWDModel (const)", "OWDModel (type)", "ObjectAccessScope (type)", @@ -47,6 +49,7 @@ "ObjectPermission (type)", "ObjectPermissionParsed (type)", "ObjectPermissionSchema (const)", + "ObjectPermissionVerbTarget (type)", "OrgScopingEntitlement (interface)", "OrgScopingEntitlementSchema (const)", "PLATFORM_CAPABILITIES (const)", @@ -88,9 +91,11 @@ "describeAnchorForbiddenBits (function)", "describeHighPrivilegeBits (function)", "normalizeTenancyPosture (function)", + "objectPermissionGrants (function)", "permissionForm (const)", "postureEnforcesWall (function)", "postureStampsOrganization (function)", - "postureUsesUnionScope (function)" + "postureUsesUnionScope (function)", + "resolveObjectPermissionVerb (function)" ] } diff --git a/packages/spec/export-origins/security.json b/packages/spec/export-origins/security.json index 0e039a5554f..5bc04d050cc 100644 --- a/packages/spec/export-origins/security.json +++ b/packages/spec/export-origins/security.json @@ -40,12 +40,15 @@ "FieldPermission": "src/security/permission.zod.ts#FieldPermission (type)", "FieldPermissionParsed": "src/security/permission.zod.ts#FieldPermissionParsed (type)", "FieldPermissionSchema": "src/security/permission.zod.ts#FieldPermissionSchema (const)", + "OBJECT_PERMISSION_VERBS": "src/security/permission.zod.ts#OBJECT_PERMISSION_VERBS (const)", + "OBJECT_PERMISSION_VERB_NAMES": "src/security/permission.zod.ts#OBJECT_PERMISSION_VERB_NAMES (const)", "OWDModel": "src/security/sharing.zod.ts#OWDModel (type)", "ObjectAccessScope": "src/security/permission.zod.ts#ObjectAccessScope (type)", "ObjectAccessScopeSchema": "src/security/permission.zod.ts#ObjectAccessScopeSchema (const)", "ObjectPermission": "src/security/permission.zod.ts#ObjectPermission (type)", "ObjectPermissionParsed": "src/security/permission.zod.ts#ObjectPermissionParsed (type)", "ObjectPermissionSchema": "src/security/permission.zod.ts#ObjectPermissionSchema (const)", + "ObjectPermissionVerbTarget": "src/security/permission.zod.ts#ObjectPermissionVerbTarget (type)", "OrgScopingEntitlement": "src/security/tenancy-posture.ts#OrgScopingEntitlement (interface)", "OrgScopingEntitlementSchema": "src/security/tenancy-posture.ts#OrgScopingEntitlementSchema (const)", "PLATFORM_CAPABILITIES": "src/security/capabilities.ts#PLATFORM_CAPABILITIES (const)", @@ -83,9 +86,11 @@ "describeAnchorForbiddenBits": "src/security/high-privilege.ts#describeAnchorForbiddenBits (function)", "describeHighPrivilegeBits": "src/security/high-privilege.ts#describeHighPrivilegeBits (function)", "normalizeTenancyPosture": "src/security/tenancy-posture.ts#normalizeTenancyPosture (function)", + "objectPermissionGrants": "src/security/permission.zod.ts#objectPermissionGrants (function)", "permissionForm": "src/security/permission.form.ts#permissionForm (const)", "postureEnforcesWall": "src/security/tenancy-posture.ts#postureEnforcesWall (function)", "postureStampsOrganization": "src/security/tenancy-posture.ts#postureStampsOrganization (function)", - "postureUsesUnionScope": "src/security/tenancy-posture.ts#postureUsesUnionScope (function)" + "postureUsesUnionScope": "src/security/tenancy-posture.ts#postureUsesUnionScope (function)", + "resolveObjectPermissionVerb": "src/security/permission.zod.ts#resolveObjectPermissionVerb (function)" } } From 1966c84cf991796317026d08308445919135f777 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 20:01:48 +0000 Subject: [PATCH 5/5] chore: add the two changesets for the can predicate landing Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude --- .../18545-formula-can-permission-predicate.md | 32 +++++++++++++++++++ .../18545-spec-object-permission-verbs.md | 17 ++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .changeset/18545-formula-can-permission-predicate.md create mode 100644 .changeset/18545-spec-object-permission-verbs.md diff --git a/.changeset/18545-formula-can-permission-predicate.md b/.changeset/18545-formula-can-permission-predicate.md new file mode 100644 index 00000000000..9ee0d0fadfd --- /dev/null +++ b/.changeset/18545-formula-can-permission-predicate.md @@ -0,0 +1,32 @@ +--- +'@objectstack/formula': minor +--- + +Add `current_user.can(object, verb)` — the permission predicate — to the CEL engine, together with the data it is answered from. + +`Clause-②: yes` — a new callable name widens the authorable surface. Purely additive: nothing is removed, renamed or narrowed, and every expression that evaluated before evaluates the same way. + +**What you can write now** + +```cel +current_user.can('crm_lead', 'edit') +``` + +`can` is registered **receiver-only**, so it is called ON the acting subject (`current_user`, or its `user` / `ctx.user` / `os.user` aliases — the same object). A bare `can(object, verb)` is deliberately not registered and keeps faulting: a permission question with no subject has no meaning. + +The verb vocabulary is the closed table `OBJECT_PERMISSION_VERBS` in `@objectstack/spec/security` — `read`, `create`, `edit`/`update`/`write`, `delete`/`remove`, `export`, `transfer`, `import`. A verb outside it is refused loudly rather than answered `false`. The answer folds the super-user bits exactly as the enforcement door does, so a predicate and the server's 403 cannot disagree. + +**What a call site must pass** + +`EvalContext` gains `permissions` — a pure data map, object name → `EffectiveObjectPermission`, which is the `objects` map of the published `/auth/me/permissions` response, unchanged. Build it through the new `toEvalPermissions(response.objects)`, which refuses a payload that is not that shape. + +```ts +import { toEvalPermissions } from '@objectstack/formula'; + +const permissions = toEvalPermissions(mePermissions.objects); +ExpressionEngine.evaluate(predicate, { user, record, permissions }); +``` + +**With no permission data in the context, `can` THROWS** (`ok: false`, `kind: 'runtime'`) and names the missing input. It never answers `true` (which would reveal what the subject may not see) and never answers a silent `false` (which would hide a gated element from everyone, indistinguishable from a real denial). An *empty* map is a real answer and evaluates to `false`, as does an object the map does not mention. + +**Also new, all additive**: `EvalPermissions` and `PermissionBinding` types, `registerPermissionPredicate()`, and an optional fourth argument on `registerStdLib()` carrying the binding. Existing three-argument calls are unaffected. diff --git a/.changeset/18545-spec-object-permission-verbs.md b/.changeset/18545-spec-object-permission-verbs.md new file mode 100644 index 00000000000..f418973ce41 --- /dev/null +++ b/.changeset/18545-spec-object-permission-verbs.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +--- + +Publish the object-permission VERB vocabulary and the effective-entry reader from `@objectstack/spec/security`. + +`Clause-②: yes` — new exported names on a published surface. Purely additive: no export is removed, renamed or narrowed, and no schema changes shape. + +**New exports** + +- `OBJECT_PERMISSION_VERBS` — the closed verb → `allow*` bit table. Derived from the bare verbs of the object-permission key aliases (`read`, `create`, `edit`/`update`/`write`, `delete`/`remove`, `export`, `transfer`) plus one row that is not derivable and is recorded as a deliberate choice: `import` → `allowCreate`, because importing rows is creating rows. `restore` / `purge` are absent, as they are on the alias table since their bits were retired. +- `OBJECT_PERMISSION_VERB_NAMES` — the same vocabulary, sorted, for a refusal message to name in full. +- `resolveObjectPermissionVerb(verb)` — the only supported read of the table. Use it rather than indexing the record: a direct index answers `toString` with a function, which a truthiness check reads as a grant. +- `objectPermissionGrants(permission, target)` — whether one `EffectiveObjectPermission` entry grants a bit, folded the way the enforcement path folds it: `viewAllRecords` or `modifyAllRecords` grants read; `modifyAllRecords` grants edit, delete and transfer but never create; `export` is `grant ∧ read`. An absent entry and an all-`false` entry both answer `false`. +- `ObjectPermissionVerbTarget` — the `allow*` bit type a verb can resolve to. + +**Why they are published**: `@objectstack/formula`'s new `current_user.can(object, verb)` predicate reads a `/auth/me/permissions` map, and a client rendering the same capability reads the same map. One table and one fold, published once, so the predicate an author writes and the 403 the server returns cannot answer differently.