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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/18545-formula-can-permission-predicate.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions .changeset/18545-spec-object-permission-verbs.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 24 additions & 3 deletions packages/formula/src/cel-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions packages/formula/src/eval-permissions.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
for (const [object, value] of Object.entries(objects as Record<string, unknown>)) {
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('');
}
12 changes: 10 additions & 2 deletions packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Loading
Loading