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
34 changes: 34 additions & 0 deletions .changeset/4421-current-user-can-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/core': minor
'@object-ui/permissions': minor
'@object-ui/app-shell': minor
---

feat: an action's `visible` / `disabled` predicate can ask `current_user.can(object, verb)` — the caller's object permissions, from the payload the built-in Edit / Delete buttons are already gated by

A custom action that replaces a built-in CRUD button — a logical delete that archives
instead of deleting, say — can now carry the same gate the built-in button had:

```yaml
visible: current_user.can('account', 'delete')
```

It answers from the signed-in user's `/auth/me/permissions` payload once that payload
has loaded, on the record header and the row menu alike. Until then it gives no answer:
the predicate faults, and a surface that evaluates `visible` fail-closed does not render
the action. The `user` / `ctx.user` / `os.user` aliases are the same call.

This is client-side UI gating only. It decides whether the button is shown; the server
still enforces object permissions on the request the action sends and answers 403 when
they are not held.

- `@object-ui/permissions`: the permission context carries `effectiveObjects`, the
response's `objects` map verbatim (`undefined` when the provider holds no such
response — the role-based `PermissionProvider`, or no provider at all).
- `@object-ui/core`: `evalFieldPredicate` hands the acting subject's permissions to the
CEL engine as `EvalContext.permissions`; `bindSubjectPermissions` /
`subjectPermissionsOf` are the carrier. `@objectstack/formula` is now required at
`^17.5.0`, the first release that answers `can`.
- `@object-ui/app-shell`: `ExpressionProvider` binds the map into the predicate scope it
publishes only while `usePermissions().isLoaded` is true, and the record-form field
evaluators take the same input, so one predicate answers the same everywhere.
38 changes: 38 additions & 0 deletions content/docs/layout/page-header.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ The identity side of the comparison is `os.user` — the spec's canonical CEL id
scope, and the same one the server binds — with `current_user`, `user` and `ctx.user`
available as aliases of it.

## Gating an action on the caller's object permissions

`current_user.can(object, verb)` answers "may the signed-in user do *verb* on *object*"
from the caller's effective object permissions — the `/auth/me/permissions` payload the
console already holds, the one its built-in Edit and Delete buttons are gated by. It is
the gate to write when a custom action replaces a built-in one, for example a logical
delete that archives the record instead of removing it:

```yaml
actions:
- name: account_void
label: Void
locations: [record_header, list_item]
visible: current_user.can('account', 'delete')
```

The row menu (`list_item`) evaluates the same predicate against the same scope, so one
declaration gates both places.

- `object` is the object's API name. `verb` must come from the closed verb table
`OBJECT_PERMISSION_VERBS` in `@objectstack/spec/security`; a verb outside it is a fault,
not a `false`.
- Call it on the signed-in user. `user.can(…)`, `ctx.user.can(…)` and `os.user.can(…)`
are the same call; a bare `can(…)`, or `record.can(…)`, is a fault.
- Until the permissions payload has loaded there is no answer: the call faults, and a
header action whose `visible` faults is not rendered (see above). Gate on `visible`,
not `disabled` — a `disabled` predicate that faults leaves the action enabled.
- `requiredPermissions` is a different gate: it names system capabilities, which the
platform action route enforces as well as the UI. `current_user.can` asks about
object permissions and does nothing on the server.

**Client-side UI gating only.** This first phase is client-side: `current_user.can` decides
whether the button is shown, nothing more. It is not an authorization boundary — the
server still enforces the caller's object permissions on the request the action sends,
and answers 403 when they are not held. Server-side evaluation of the same call (formula
fields, validation rules, row-level security) is tracked separately in objectstack#18783;
until it lands, do not rely on `can` in an expression the server evaluates.

## Layout

The renderer picks one of two layouts when it renders:
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"@object-ui/providers": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/formula": "^17.0.0",
"@objectstack/formula": "^17.5.0",
"@objectstack/lint": "^17.0.0",
"@objectstack/spec": "^17.5.0",
"@sentry/react": "^10.70.0",
Expand Down
8 changes: 7 additions & 1 deletion packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
ExpressionProvider,
createExpressionEvaluator,
isObjectFieldVisible,
useExpressionPermissions,
} from '../providers/ExpressionProvider.js';
import { buildExpressionUser } from '../providers/expressionUser.js';
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
Expand Down Expand Up @@ -692,6 +693,10 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// object) and `features`. Its `user` was hand-rolled too, without
// `positions` — so `'sales' in current_user.positions`, the gate the server
// enforces on write, faulted here rather than hiding the field.
// objectui#4421 — the same `permissions` input the provider below reads, so
// `current_user.can(...)` in a field's `visibleWhen` answers here exactly as
// it does under the provider (one bag, objectui#6493).
const expressionPermissions = useExpressionPermissions();
const expressionEvaluator = useMemo(
// ⛔ No `app`: objectui#8155 removed it from the predicate scope, because
// neither ADR-0068 nor the engine's `SCOPE_ROOTS` declares such a root.
Expand All @@ -709,8 +714,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
() => createExpressionEvaluator({
user: buildExpressionUser(user),
features,
permissions: expressionPermissions,
}),
[user, features],
[user, features, expressionPermissions],
);

// objectui#5619 — `isWorkspaceAdminResolved` belongs in this readiness gate
Expand Down
101 changes: 95 additions & 6 deletions packages/app-shell/src/providers/ExpressionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
*/

import React, { createContext, useContext, useMemo } from 'react';
import { ExpressionEvaluator } from '@object-ui/core';
import { ExpressionEvaluator, bindSubjectPermissions } from '@object-ui/core';
import { PredicateScopeProvider, reportUnresolvableVisibilityPredicate } from '@object-ui/react';
import { usePermissions } from '@object-ui/permissions';
import { toEvalPermissions, type EvalPermissions } from '@objectstack/formula';

export interface ExpressionContextValue {
/** Current authenticated user */
Expand Down Expand Up @@ -59,6 +61,16 @@ export interface ExpressionScopeInput {
* and still publishes it on the React context value.
*/
features?: Record<string, any>;
/**
* The caller's effective object permissions — the data
* `current_user.can(object, verb)` is answered from (objectui#4421) — or
* `undefined` while there is no LOADED payload to answer from. Take it from
* {@link useExpressionPermissions}, which is what decides "loaded"; ⛔ never
* pass `{}` for "not loaded yet": an empty map is a real answer ("holds
* nothing"), and the engine refuses loudly precisely so that a missing
* payload cannot pass for one.
*/
permissions?: EvalPermissions;
}

/**
Expand Down Expand Up @@ -169,8 +181,81 @@ export interface ExpressionScopeInput {
export function buildExpressionScope({
user = {},
features = {},
permissions,
}: ExpressionScopeInput = {}): Record<string, any> {
return { current_user: user, user, ctx: { user }, os: { user }, features };
// ONE subject object under all four spellings, and it is the one carrying the
// permissions: the engine answers `can` only for a receiver IDENTICAL to the
// bound `current_user`, so a second copy under any alias would be refused.
const subject = bindSubjectPermissions(user, permissions);
return { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject }, features };
}

/**
* One adapted map per payload object, kept outside React (AGENTS.md #10): the
* provider publishes the SAME `objects` object for the same response, so this
* is keyed on the payload, never on a memoised identity. `null` records a
* payload `toEvalPermissions` refused, so the refusal is reported once.
*/
const ADAPTED_PERMISSIONS = new WeakMap<object, EvalPermissions | null>();

function adaptEffectiveObjects(objects: object): EvalPermissions | undefined {
let adapted = ADAPTED_PERMISSIONS.get(objects);
if (adapted === undefined) {
try {
adapted = toEvalPermissions(objects);
} catch (err) {
// Refused, not repaired: the formula package's adapter has no lenient
// arm, and neither does this one. The binding stays UNBOUND, so every
// `current_user.can(...)` faults (and each surface applies its own fault
// policy) instead of answering from a map that is not the contract.
console.error(
'[object-ui] current_user.can(object, verb) is not bound: the permissions '
+ `payload was refused — ${err instanceof Error ? err.message : String(err)}`,
);
adapted = null;
}
ADAPTED_PERMISSIONS.set(objects, adapted);
}
return adapted ?? undefined;
}

/**
* The effective object permissions the predicate scope binds for
* `current_user.can(object, verb)` (objectui#4421), or `undefined` when there
* is no loaded payload to answer from.
*
* ## Rider 1: absent while not loaded, never "empty"
*
* The maintainer's ruling makes a permission-shaped binding fail-CLOSED while
* the permissions payload has not loaded. The map is therefore handed on only
* when `usePermissions().isLoaded` is true AND the provider holds an
* `/auth/me/permissions` answer; otherwise nothing is bound and the engine
* REFUSES `can` (`ok: false`, a fault naming the missing input). What happens
* next is the evaluating surface's own FAULT policy, unchanged by this binding:
* a leg that evaluates fail-closed (`throwOnError: true` on `useCondition`,
* `fallback: false` on `evalRowPredicate`) hides the action, a fail-soft leg
* does not. Which legs are which is deliberately not listed here (AGENTS.md
* #9); `providers/__tests__/currentUserCan-4421.render.test.tsx` re-measures
* the three states on the surfaces it pins. ⛔ No `{}` stand-in: it would
* answer "holds nothing" — a quiet `false` indistinguishable from a real
* denial, which the engine's contract forbids a caller to fabricate.
*
* `isLoaded` is the gate rather than "some map is present" because a
* refetching `MePermissionsProvider` still holds its previous map while
* `isLoaded` is false; the ruling's "has not loaded" is read as the provider's
* own flag.
*
* ## Why the global no-provider default is not involved
*
* `usePermissions()` with no provider answers `can: () => true` (fail-open, the
* built-in affordances' standalone-embed contract). This binding never calls
* it: it reads `effectiveObjects`, which the no-provider answer does not carry,
* so under no provider `current_user.can(...)` faults — it does not inherit
* that `true`.
*/
export function useExpressionPermissions(): EvalPermissions | undefined {
const { isLoaded, effectiveObjects } = usePermissions();
return isLoaded && effectiveObjects ? adaptEffectiveObjects(effectiveObjects) : undefined;
}

/**
Expand All @@ -194,22 +279,26 @@ interface ExpressionProviderProps {
}

export function ExpressionProvider({ children, user = {}, app = {}, data = {}, features = {} }: ExpressionProviderProps) {
// objectui#4421 — read HERE, once, so every mount of this provider (and every
// surface under it) binds `current_user.can(...)` from the same payload with
// no prop to thread and no per-surface copy of the hand-off.
const permissions = useExpressionPermissions();
const value = useMemo(() => {
const evaluator = createExpressionEvaluator({ user, features });
const evaluator = createExpressionEvaluator({ user, features, permissions });
// `app` and `data` are still published on the context value — `DashboardView`
// reads `app` as a plain value. Neither is handed to the evaluator:
// objectui#8155 (`app`), objectui#8166 (`data`).
return { user, app, data, features, evaluator };
}, [user, app, data, features]);
}, [user, app, data, features, permissions]);

// Also feed the predicate scope used by useCondition/useExpression in
// @object-ui/react so action visibility predicates (e.g. on toolbar
// buttons) can see deployment-level flags like features.multiOrgEnabled.
// The SAME bag the evaluator above got — one builder, so the imperative and
// the hook-driven halves of this provider cannot drift apart either.
const scope = useMemo(
() => buildExpressionScope({ user, features }),
[user, features],
() => buildExpressionScope({ user, features, permissions }),
[user, features, permissions],
);

return (
Expand Down
Loading
Loading