diff --git a/.changeset/24414-capability-predicate.md b/.changeset/24414-capability-predicate.md new file mode 100644 index 00000000000..3e5398750d9 --- /dev/null +++ b/.changeset/24414-capability-predicate.md @@ -0,0 +1,21 @@ +--- +'@objectstack/formula': minor +'@objectstack/spec': patch +--- + +feat(formula): `current_user.hasCapability(name)` — a page predicate can ask whether the viewer holds a system capability, from the same list the action gate reads + +Clause-②: yes (widening) + +- **The predicate.** The CEL standard library registers `hasCapability` beside `can`, receiver-only, so the one spelling is `current_user.hasCapability('')` (the `user` / `ctx.user` / `os.user` aliases work too). It answers `true` when the name is in the acting subject's held system capabilities and `false` when it is not. A name no action declares is just a string: there is no registry lookup. +- **The data.** `EvalContext` gains `systemPermissions` (type `EvalSystemPermissions`, a list of capability names): the `systemPermissions` array of the published `/auth/me/permissions` response, unchanged — the list an action's `requiredPermissions` is checked against (ADR-0066 D4). `PermissionBinding` gains the same field, optional. Pass it beside `permissions`: + + ```ts + celEngine.evaluate(expr, { user, permissions: toEvalPermissions(me.objects), systemPermissions: me.systemPermissions }); + ``` + +- **Loud, like `can`.** With no `systemPermissions` on the context the call fails (`ok: false`, `kind: 'runtime'`) rather than answering; an empty list is a real answer and evaluates to `false`. A non-list, a non-string or empty name, or a receiver that is not the acting subject fails the same way. A bare `hasCapability(name)` does not resolve. +- **The authoring gate.** The visibility-predicate rule's function-existence check reads the evaluation environment, so a page `visibleWhen` calling `current_user.hasCapability(…)` now passes `os validate`, `os lint` and `os build`, where before it was refused as an unknown function (`visibility-predicate-unknown-function`). An invented sibling (`hasCapabilities`) is still refused. +- **`can` points at it.** `current_user.can(object, verb)` with a verb outside the object-permission vocabulary still fails, and its message now names `current_user.hasCapability('')` for a capability question. +- **Docs.** The page component `visibleWhen` describe names `current_user.hasCapability('')` as the way to gate a component on a capability: `visibleWhen` hides, `requiredPermissions` refuses. +- **Hosts.** A host that evaluates page predicates supplies the list for the predicate to answer; one that does not makes it fail, and a failing page `visibleWhen` renders the component. diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index b535d8fda78..17f781a5859 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -311,7 +311,7 @@ View filter rule | **style** | `Record` | optional | Inline styles or utility classes | | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -483,7 +483,7 @@ Sort field and direction pair | **style** | `Record` | optional | Inline styles or utility classes | | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/packages/formula/src/capability-predicate.test.ts b/packages/formula/src/capability-predicate.test.ts new file mode 100644 index 00000000000..795239b0ac8 --- /dev/null +++ b/packages/formula/src/capability-predicate.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { celEngine } from './cel-engine'; +import { firstUnknownFunctionCall } from './unknown-function'; +import { validateExpression } from './validate'; +import type { EvalContext, EvalResult } from './types'; + +/** + * `current_user.hasCapability(name)` — the capability predicate (cloud#3036 + * ruling A: the action's declaration is the one source of truth, so a page + * predicate must be able to ask the question the action gate asks). + * + * The property under test is the same PAIR `permission-predicate.test.ts` pins + * for `can`: given the subject's capability list the predicate evaluates, and + * without it the predicate fails LOUDLY — never `true`, never a silent `false`. + */ + +/** The `/auth/me/permissions` `systemPermissions` payload used across these cases. */ +const SYSTEM_PERMISSIONS = ['manage_users', 'view_setup']; + +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); +} + +function withCapabilities(source: string): EvalResult { + return evaluate(source, { user: USER, systemPermissions: SYSTEM_PERMISSIONS }); +} + +/** A refusal is a reported `runtime` fault naming `needle` — ⛔ never a quiet `false`. */ +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('`hasCapability` answers from the subject\'s held capabilities', () => { + it('answers TRUE for a held capability and FALSE for one not held', () => { + // Both directions: a pin asserting only `true` stays green under an + // implementation that answers `true` for everything. + expect(withCapabilities("current_user.hasCapability('manage_users')")).toEqual({ ok: true, value: true }); + expect(withCapabilities("current_user.hasCapability('manage_billing')")).toEqual({ ok: true, value: false }); + }); + + it('treats a name no action declares as just a string — no registry lookup', () => { + // Held ⇒ true, not held ⇒ false, whatever the platform's action catalog + // says: the list is the subject's, and nothing else is consulted. + const undeclared = 'zz_no_action_declares_this'; + expect(evaluate(`current_user.hasCapability('${undeclared}')`, { + user: USER, systemPermissions: [undeclared], + })).toEqual({ ok: true, value: true }); + expect(withCapabilities(`current_user.hasCapability('${undeclared}')`)).toEqual({ ok: true, value: false }); + }); + + it('answers FALSE from an EMPTY list — a real answer, not a fault', () => { + expect(evaluate("current_user.hasCapability('manage_users')", { user: USER, systemPermissions: [] })) + .toEqual({ ok: true, value: false }); + }); + + it('answers identically under every ADR-0068 alias of the acting subject', () => { + for (const root of ['current_user', 'user', 'ctx.user', 'os.user']) { + expect(withCapabilities(`${root}.hasCapability('manage_users')`), root).toEqual({ ok: true, value: true }); + } + }); + + it('composes with `can` and the rest of a predicate', () => { + expect(evaluate( + "current_user.hasCapability('view_setup') && !current_user.hasCapability('manage_billing')", + { user: USER, systemPermissions: SYSTEM_PERMISSIONS }, + )).toEqual({ ok: true, value: true }); + }); +}); + +describe('`hasCapability` refuses LOUDLY', () => { + it('THROWS when the context carries no capability list — never a silent false', () => { + expectLoud( + evaluate("current_user.hasCapability('manage_users')", { user: USER }), + 'carries no capability list', + ); + }); + + it('THROWS on a capability list that is not a list of names', () => { + expectLoud( + evaluate("current_user.hasCapability('manage_users')", { + user: USER, + systemPermissions: { manage_users: true } as unknown as string[], + }), + 'is not a list of capability names', + ); + }); + + it('THROWS when called on anything but the acting subject', () => { + expectLoud( + evaluate("record.hasCapability('manage_users')", { + user: USER, systemPermissions: SYSTEM_PERMISSIONS, record: { id: 'r1' }, + }), + 'must be called on it', + ); + }); + + it('faults on an unbound `current_user` when the evaluation carries no user, even with a list', () => { + // cel-js refuses the unresolved receiver before the binding is reached — + // loud either way, as for `can`; this pins WHICH loud. + const r = evaluate("current_user.hasCapability('manage_users')", { systemPermissions: SYSTEM_PERMISSIONS }); + expect(r.ok).toBe(false); + expect(r.ok === false && r.error.message).toContain('current_user'); + }); + + it('THROWS on a non-string or empty name', () => { + expectLoud(withCapabilities('current_user.hasCapability(1)'), 'must be a system capability NAME'); + expectLoud(withCapabilities("current_user.hasCapability('')"), 'must be a system capability NAME'); + }); + + it('a bare `hasCapability(name)` names no subject and does not resolve', () => { + const result = withCapabilities("hasCapability('manage_users')"); + expect(result.ok).toBe(false); + }); +}); + +describe('`hasCapability` exists for the publish gates', () => { + it('is a registered name — the function-existence oracle reports nothing', () => { + expect(firstUnknownFunctionCall("current_user.hasCapability('manage_users')")).toBeNull(); + // Control: an invented sibling on the same receiver IS reported, so the + // null above is a reading, not a blank run. + expect(firstUnknownFunctionCall("current_user.hasCapabilities('manage_users')")?.name) + .toBe('hasCapabilities'); + }); + + it('validates clean as a boolean predicate', () => { + expect(validateExpression('predicate', "current_user.hasCapability('manage_users')").ok).toBe(true); + }); + + it('points an out-of-vocabulary `can` verb at the capability predicate', () => { + expectLoud( + evaluate("current_user.can('crm_lead', 'approve')", { user: USER, permissions: {} }), + "current_user.hasCapability('')", + ); + }); +}); diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 6fa1738be71..5845a5aac34 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -61,10 +61,11 @@ export const CEL_ENV_OPTIONS = { * 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 + * permissions and held system capabilities, 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` / `hasCapability` ANSWER, never + * whether they EXIST, 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. */ @@ -1805,6 +1806,7 @@ export const celEngine: DialectEngine = { const env = buildEnv(now, ctx.timezone ?? 'UTC', { subject: scope.current_user, permissions: ctx.permissions, + systemPermissions: ctx.systemPermissions, }); // #3183 — coerce a date-field operand compared with `==`/`!=` against a // temporal function (`date(record.d) == today()`), so a `Field.date` string diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index dcd4cc37b6d..aeae3a5e2cb 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -64,7 +64,7 @@ export type { } from './cel-engine'; export { cronEngine } from './cron-engine'; export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine'; -export { registerStdLib, buildScope, registerPermissionPredicate } from './stdlib'; +export { registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate } 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 @@ -158,4 +158,4 @@ export type { ExpressionSourceKind, } from './expression-refusal'; export type { SeedValue, SeedPrimitive } from './seed-eval'; -export type { DialectEngine, EvalContext, EvalResult, EvalError, EvalPermissions } from './types'; +export type { DialectEngine, EvalContext, EvalResult, EvalError, EvalPermissions, EvalSystemPermissions } from './types'; diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index 7257b407c84..83bc57b0907 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -12,14 +12,15 @@ * 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}. The one RECEIVER-form function this library registers — - * `current_user.can(object, verb)` — uses that same binding rather than - * fighting it: see {@link registerPermissionPredicate}. + * {@link EvalContext}. The two RECEIVER-form functions this library registers — + * `current_user.can(object, verb)` and `current_user.hasCapability(name)` — use + * that same binding rather than fighting it: see + * {@link registerPermissionPredicate} and {@link registerCapabilityPredicate}. */ import type { Environment } from '@marcbachmann/cel-js'; -import type { EvalContext, EvalPermissions } from './types'; +import type { EvalContext, EvalPermissions, EvalSystemPermissions } 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 @@ -151,6 +152,12 @@ export interface PermissionBinding { readonly subject: unknown; /** The subject's effective object permissions, or `undefined` when none were passed. */ readonly permissions: EvalPermissions | undefined; + /** + * The subject's held system capabilities — the `systemPermissions` list of + * the published `/auth/me/permissions` response — or `undefined` when none + * were passed. Read by `current_user.hasCapability(name)` and nothing else. + */ + readonly systemPermissions?: EvalSystemPermissions | undefined; } /** @@ -213,7 +220,8 @@ export function registerPermissionPredicate( 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.', + 'modelled as an object permission, so no answer about it would mean anything. To ask ' + + "whether the subject holds a system capability, write current_user.hasCapability('').", ); } if (binding?.subject === undefined || receiver !== binding.subject) { @@ -242,6 +250,80 @@ export function registerPermissionPredicate( ); } +/** + * Register `hasCapability` — the capability predicate — as a RECEIVER method, + * so the one authored spelling is `current_user.hasCapability(name)`. + * + * It asks the question the action gate asks (ADR-0066 D4): does the acting + * subject hold this system capability? The answer is read from the subject's + * held capabilities — the `systemPermissions` list `/auth/me/permissions` + * publishes, the same list an action's `requiredPermissions` is checked + * against — so a page node gated with it and an action gated on the same name + * agree for every viewer. That is what makes the action's declaration the one + * source of truth: a page predicate names the capability the action declares, + * never a proxy for it (`can('', 'edit')` is a different question and + * answers differently for a subject who holds one and not the other). + * + * `visible` hides and `requiredPermissions` refuses (ADR-0066 D4): a predicate + * written with this function decides what is SHOWN, and the server stays the + * authority on what may be DONE. + * + * A name is just a string. ⛔ There is no registry lookup: a name no action + * declares is answered like any other — `true` when the subject holds it, + * `false` when it does not — because the list is the subject's, not the + * platform's catalog. + * + * Receiver-only and LOUD on exactly the terms `can` is (see + * {@link registerPermissionPredicate}): a bare call faults as a call-FORM + * fault, a receiver that is not the acting subject throws, a non-string or + * empty name throws, and a context carrying no capability list THROWS — ⛔ + * never `true` (fail-open), ⛔ never a silent `false` (every gated node gone, + * indistinguishable from a real denial). An EMPTY list is a real answer and + * evaluates to `false`. + */ +export function registerCapabilityPredicate( + env: Environment, + binding: PermissionBinding | undefined, +): Environment { + return env.registerFunction( + 'dyn.hasCapability(dyn): bool', + (receiver: unknown, name: unknown): boolean => { + if (typeof name !== 'string' || name.length === 0) { + throw new Error( + 'hasCapability(name): `name` must be a system capability NAME (a non-empty string), ' + + "e.g. current_user.hasCapability('manage_users').", + ); + } + if (binding?.subject === undefined || receiver !== binding.subject) { + throw new Error( + 'hasCapability(name) answers about the ACTING SUBJECT and must be called on it: write ' + + "current_user.hasCapability('') (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.', + ); + } + const held = binding.systemPermissions; + if (held === undefined) { + throw new Error( + `hasCapability('${name}') cannot be answered: this evaluation context carries no ` + + 'capability list. Pass `systemPermissions` on the EvalContext — the `systemPermissions` ' + + 'list of the published /auth/me/permissions response. 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.', + ); + } + if (!Array.isArray(held) || held.some((entry) => typeof entry !== 'string')) { + throw new Error( + `hasCapability('${name}') cannot be answered: the context's \`systemPermissions\` is not ` + + 'a list of capability names. Pass the `systemPermissions` array of the published ' + + '/auth/me/permissions response unchanged.', + ); + } + return held.includes(name); + }, + ); +} + /** * Register the ObjectStack standard library into a CEL environment. * @@ -251,9 +333,9 @@ export function registerPermissionPredicate( * 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 + * `can` and `hasCapability` are 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. @@ -265,6 +347,7 @@ export function registerStdLib( permissionBinding?: PermissionBinding, ): Environment { registerPermissionPredicate(env, permissionBinding); + registerCapabilityPredicate(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 3aa07c5f602..632ca44ca51 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -39,6 +39,21 @@ import type { EffectiveObjectPermission } from '@objectstack/spec/security'; */ export type EvalPermissions = Readonly>; +/** + * The acting subject's held system capabilities — the + * {@link EvalContext.systemPermissions} payload, and the only input + * `hasCapability()` reads. + * + * **It is the published `/auth/me/permissions` shape, verbatim**: the + * `systemPermissions` array of `GetEffectivePermissionsResponse`, the same list + * the action gate checks an action's `requiredPermissions` against (ADR-0066 + * D4). A caller carries it in here unchanged. + * + * **A pure DATA list, never a resolver**, for the reason {@link EvalPermissions} + * gives. + */ +export type EvalSystemPermissions = ReadonlyArray; + /** * Runtime context for evaluating an expression. * @@ -118,6 +133,19 @@ export interface EvalContext { * real answer (this subject holds nothing) and evaluates to `false`. */ permissions?: EvalPermissions; + /** + * The acting subject's held system capabilities — see + * {@link EvalSystemPermissions} for the shape and where it comes from. + * + * Read by exactly one binding, `current_user.hasCapability(name)`, and bound + * only when {@link user} is also present, on the terms {@link permissions} + * is: ⛔ NOT mounted as a CEL variable, so the function stays the only door. + * + * **Absent ≠ empty.** With no list at all `hasCapability()` THROWS + * (`ok: false, kind: 'runtime'`) rather than answering; an EMPTY list is a + * real answer (this subject holds no capability) and evaluates to `false`. + */ + systemPermissions?: EvalSystemPermissions; /** Free-form bag for niche call sites; merged onto the variable scope. */ extra?: Record; } diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index 3f73d5a3928..71c111c3df3 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -1277,6 +1277,25 @@ describe('visibility-predicate-unknown-function (#13594)', () => { .toContain(VISIBILITY_BARE_IDENTIFIER); }); + it('admits the capability predicate `current_user.hasCapability(name)` — and still refuses an invented sibling', () => { + // cloud#3036 ruling A: a page predicate asks the question the action gate + // asks. `@objectstack/formula` registers `hasCapability` receiver-only, so + // the existence arm — keyed on the environment, never a list of ours — + // admits it with no edit here. + // Measured on a PAGE component node, the surface the ruling is about. + const page = (predicate: string) => ({ + pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibleWhen: predicate }] }] }], + }); + expect(validateVisibilityPredicates(page("current_user.hasCapability('manage_users')"))).toEqual([]); + // The control that makes the empty list a reading: one letter more is an + // unknown function, refused as an error on the same surface. + const invented = validateVisibilityPredicates(page("current_user.hasCapabilities('manage_users')")) + .filter((f) => f.rule === VISIBILITY_PREDICATE_UNKNOWN_FUNCTION); + expect(invented).toHaveLength(1); + expect(invented[0].severity).toBe('error'); + expect(invented[0].message).toContain('`hasCapabilities`'); + }); + it('⛔ offers no "did you mean" suggestion, however close the typo (refinement 2)', () => { // 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. diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 209357d037d..bf978a24342 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -766,7 +766,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * this surface does not have. See that key's own describe in * `component.zod.ts` rather than assuming either environment carries over. */ - visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. \"page.selectedProjectId != ''\""), + visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. \"page.selectedProjectId != ''\""), // `visibility` REMOVED at 18 (#23543 F4, ADR-0089 D2): through 17 it was the // page component's deprecated spelling of `visibleWhen`, folded onto it at // parse by a `.transform` that went with the key. The tombstone carries the