You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
Commit 9cebfca
Browse filesBrowse the repository at this point in the historyBrowse files
authored
feat(app-shell,core,permissions): an action predicate can ask current_user.can(object, verb) once the permissions payload has loaded (objectui#4421) (#11208)
Part of #4421 — the objectui half: the binding, its pins and the Rider 2
wording. What stays open on the card is Rider 1 on the fail-soft legs
measured below (a decision for the seat, see "Rider 1 — measured per
leg"). Server parity is objectstack#18783's.
Clause-②: yes
## What this does
An action's `visible` / `disabled` predicate can now ask
`current_user.can(object, verb)` and get the caller's object-permission
verdict from the loaded `/auth/me/permissions` payload — the card's
case: an app turns the built-in Delete off, ships a logical delete in
its place, and gates it with `visible: current_user.can('account',
'delete')`.
The engine half landed as `@objectstack/formula@17.5.0` (`dyn.can(dyn,
dyn)`, answered from `EvalContext.permissions`, receiver compared by
identity with the bound `current_user`, loud refusal when the context
carries no permission data). This PR is the wiring, through ONE seam:
- **`@object-ui/permissions`** — the permission context carries
`effectiveObjects`: the response's `objects` map verbatim, or
`undefined` when the provider holds no such response (the role-based
`PermissionProvider`, or no provider). `undefined` and `{}` stay
distinct.
- **`@object-ui/core`** — `evalFieldPredicate`, the one place every
action surface reaches the CEL engine, hands the acting subject's
permissions to `ExpressionEngine.evaluate` as `permissions`. The map
rides on the subject object under a symbol (`bindSubjectPermissions` /
`subjectPermissionsOf`, new file `evaluator/subjectPermissions.ts`):
every key of the scope bag is a CEL root and the engine keeps this map
out of the variable namespace, while the subject is the one object every
bag already carries by reference under all four aliases, through every
spread and `ExpressionContext`. No surface got its own copy of the
hand-off.
- **`@object-ui/app-shell`** — `ExpressionProvider` reads
`usePermissions()` itself and binds the map only while `isLoaded` is
true (`useExpressionPermissions`, which adapts the payload through the
formula package's `toEvalPermissions` once per payload object, outside
React). One subject object under `current_user` / `user` / `ctx.user` /
`os.user`, the one carrying the map. The two imperative field-visibility
evaluators (`AppContent`'s record-form modal and `RecordFormPage`) take
the same input, so one predicate answers the same under the provider and
beside it (objectui#6493's one-bag rule).
- `@objectstack/formula` floor raised to `^17.5.0` in `@object-ui/core`
and `@object-ui/app-shell` — app-shell now calls `toEvalPermissions`,
which does not exist before 17.5.0. The lockfile moves only the two
`specifier` lines; the resolved version was already 17.5.0.
- **Docs (Rider 2)** — `content/docs/layout/page-header.mdx` gains
"Gating an action on the caller's object permissions", ending with the
scope sentence: client-side UI gating only, not an authorization
boundary, the server still answers 403; server-side evaluation is
objectstack#18783.
- Changeset `.changeset/4421-current-user-can-binding.md` (minor on the
three packages).
## Zone 2 — the PM's mechanism assumptions, measured
1. **Installed engine** — read in `node_modules/@objectstack/formula`
(17.5.0 `dist`): `registerPermissionPredicate` registers `dyn.can(dyn,
dyn): bool`; subject = `buildScope(ctx).current_user` (which `extra` can
supply); verbs via the spec's `resolveObjectPermissionVerb`; no
`permissions` in the context ⇒ throws (`ok: false`, `kind: 'runtime'`,
message names the missing input). A probe through `extra` with a
symbol-bearing subject: granted `true`, denied `false`, missing map ⇒
fault, all four aliases identical, a COPY of the subject as receiver ⇒
refused. **Holds.**
2. **Client already holds the payload** — `MePermissionsProvider` holds
it but did NOT expose the `objects` map (only verdict functions).
**Falsified in part**: the producer needed `effectiveObjects`; the
`isLoaded` gate is applied as assumed.
3. **Rider 1 at the binding via each surface's fault policy** —
**falsified for some legs**, table below.
4. **Global `usePermissions` default** — the binding never calls
`usePermissions().can`; it reads `effectiveObjects`, which the
no-provider answer does not carry. Measured in the render pin: with no
provider, `usePermissions().can('account','delete')` answers `true`
while the bound action is hidden. Changing the global default would move
no binding verdict, so Rider 1 does not need it. **Left untouched**
(`usePermissions.ts` is not in this diff); the ruling-text conflict is
recorded in the report for the seat.
5. **Rider 2 home** — the page that documents header-action `visible`
predicates and the identity scope (`page-header.mdx`); the section also
covers the row menu.
## Rider 1 — measured per leg
Three states on one verb (`delete`): not loaded (no permission provider
— the state a mount outside `MePermissionsProvider` is in permanently),
loaded-granted, loaded-denied. Real `MePermissionsProvider` + real
`ExpressionProvider` + the real surface.
| surface / leg | not loaded | granted | denied | pinned here |
|---|---|---|---|---|
| row menu `visible` (`evalRowPredicate`, `fallback: false`) | hidden |
shown | hidden | yes |
| record header `visible` (`page:header`) | hidden | shown | hidden |
yes |
| `action:button` `visible` (`useCondition`, `throwOnError`) | hidden |
shown | hidden | yes |
| `action:button` `disabled: !can(…)` (fail-soft, fallback `true`) |
disabled | enabled | disabled | no (measured) |
| `record:quick_actions` / `ActionEngine` context | hidden | **hidden**
| hidden | no (measured) |
| `action:group` inline `visible` (fail-soft) | **shown** | shown |
hidden | no (measured) |
| `action:icon` `visible` (fail-soft) | **shown** | shown | hidden | no
(measured) |
| related-list toolbar `visible` (`RelatedToolbarButton`, fail-soft) |
**shown** | shown | hidden | no (measured) |
| record header `disabled: !can(…)` (`fallback: false`) | **enabled** |
enabled | disabled | no (measured) |
- The bold "not loaded" cells are the legs where Rider 1 does not hold:
they apply their existing fail-soft fault policy to the engine's
refusal. `action:group` / `action:icon` live in
`packages/components/src/renderers/action/**`, serial behind
objectui#11185; the others would need a fault-policy change for every
faulting predicate on the leg, wider than "fail closed on the
no-permissions error". Neither was done here.
- Reachability: on the console's app routes the not-loaded state does
not render — `MePermissionsProvider` shows its loading screen until the
first answer, and after it `isLoaded` stays true (its refetch effect
keys on values that do not change). It is reachable where a predicate
scope is published outside that provider: the `/forms/:name` route
(`InternalFormRoute` mounts its own `ExpressionProvider`), and
standalone embeds.
- The `ActionEngine` row: its context is the `ActionProvider` context,
which binds `user` / `ctx.user` but never `current_user`, so `can` is
refused in every state — it never leaks, and it never answers. Out of
this card's claimed surface; reported.
- The measurement for the rows marked "measured" was a throwaway probe,
not committed: pinning a fail-open leg as expected would fossilize it.
## Tests
Head of the runs: `d4bf561239`. Commands from the worktree root, each
through the shared verify lock; verdicts are the tools' own lines.
- `pnpm exec vitest run
packages/app-shell/src/providers/__tests__/currentUserCan-4421.render.test.tsx
packages/core/src/evaluator/__tests__/subjectPermissions-4421.test.ts` —
`Tests 25 passed (25)`.
- `pnpm exec vitest run packages/permissions/ packages/core/` — `Test
Files 201 passed (201)`, `Tests 3882 passed | 27 skipped (3909)`.
- `pnpm exec vitest run packages/app-shell/` in shards — see the report
for the per-shard verdicts and which shards ran.
- `pnpm --filter @object-ui/core type-check` / `@object-ui/permissions`
/ `@object-ui/app-shell` — exit 0 each (after `turbo run build
--filter="@object-ui/app-shell^..."`, 28 tasks successful).
- `node scripts/check-changeset-presence.mjs` — exit 0, "12 source
file(s) of 3 released package(s) changed, and this change declares 1
changeset(s)".
- `pnpm check:control-bytes` — OK. `pnpm check:doc-types` — "Every
documented component type is registered." `pnpm check:doc-snippets` —
"679 of 679 block(s) judged, 0 failed" (after its scoped build). `pnpm
check:new-line-citations` — "0 new citation(s)".
- No user-facing i18n string was added (the one new `console.error` is a
developer diagnostic), so the i18n gates were not run.
**Reverse verification (ablation), direction predicted in the pin's
docblock before running.** Committed first; the mutation went through
`ablation-replace.mjs` (anchor `...(permissions !== undefined ? {
permissions } : {}),` deleted from `fieldRules.ts`; "ok mutation landed:
anchor 1 to 0, blob 4028931 to da7c89d41dab"). Result: `Tests 14
failed | 11 passed (25)` — every GRANTED arm and every DENIED arm red
(the denied arms through their "nothing reported" assertion; hidden
either way), every NOT-LOADED arm green, as predicted. Restore: "ok
restored: blob == HEAD (4028931) and `git diff HEAD` is empty"; the
pins re-ran green after (`Tests 25 passed (25)`).
The first ablation run disagreed with the prediction on the DENIED arms,
and the reason is worth keeping: every fault report on these surfaces is
warn-once per (locator, predicate), so with one action name across arms
the not-loaded arm's report silenced the same report in the denied arm,
and its "nothing reported" assertion passed for a reason unrelated to
the verdict. The fix was one action name per arm; the second run is the
one quoted above.
## Surface notes
- Beyond the claim's listed files: `RecordFormPage.tsx` (the same
builder's second imperative caller, one-bag rule),
`packages/permissions/src/{PermissionContext,MePermissionsProvider,PermissionProvider}`
(the producer of the map — the claim said "if the real producer is
elsewhere, fix it there"), and the two `package.json` floors plus two
lockfile `specifier` lines.
- In-flight overlap: objectui#11185 (`components/renderers/action/**`)
and objectui#11186 (`app-shell/src/hooks/**`, `layout/**`) — this diff
touches neither set.
## Acceptance notes
- Not filed, recorded: the throwing `useCondition` leg reports only "its
predicate threw — CEL predicate failed to evaluate: SOURCE", dropping
the engine's reason (here "carries no permission data"); and its line
prints `Predicate: [object Object]` for an envelope. The
`evalRowPredicate` family forwards the engine reason. Diagnostics only;
no verdict moves.
- Not filed, recorded: the engine answers from the server's enforcement
fold (`objectPermissionGrants`: `=== true`, absent object ⇒ `false`,
super-user bits folded, `export` needs `allowExport`), while the
client's `MePermissionsProvider.check` reads `!== false`, falls back to
a `'*'` entry, and maps `export` to `allowRead`. On a payload that
materialises every registered object with explicit booleans the two
agree; on a hand-built payload they can differ. Which one the built-in
buttons should follow is outside this card.
- `isLoaded` is the gate, as the dispatch assumed: during a refetch
`MePermissionsProvider` still holds its previous map with `isLoaded`
false, so a `can`-gated action hides for that window while the built-in
buttons keep answering from the old map. In the console that refetch
does not occur today.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Warren Buffett <warren@objectstack.ai>
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
8
+
9
+
A custom action that replaces a built-in CRUD button — a logical delete that archives
10
+
instead of deleting, say — can now carry the same gate the built-in button had:
11
+
12
+
```yaml
13
+
visible: current_user.can('account', 'delete')
14
+
```
15
+
16
+
It answers from the signed-in user's `/auth/me/permissions` payload once that payload
17
+
has loaded, on the record header and the row menu alike. Until then it gives no answer:
18
+
the predicate faults, and a surface that evaluates `visible` fail-closed does not render
19
+
the action. The `user` / `ctx.user` / `os.user` aliases are the same call.
20
+
21
+
This is client-side UI gating only. It decides whether the button is shown; the server
22
+
still enforces object permissions on the request the action sends and answers 403 when
23
+
they are not held.
24
+
25
+
- `@object-ui/permissions`: the permission context carries `effectiveObjects`, the
26
+
response's `objects` map verbatim (`undefined` when the provider holds no such
27
+
response — the role-based `PermissionProvider`, or no provider at all).
28
+
- `@object-ui/core`: `evalFieldPredicate`hands the acting subject's permissions to the
29
+
CEL engine as `EvalContext.permissions`; `bindSubjectPermissions` /
30
+
`subjectPermissionsOf`are the carrier. `@objectstack/formula` is now required at
31
+
`^17.5.0`, the first release that answers `can`.
32
+
- `@object-ui/app-shell`: `ExpressionProvider`binds the map into the predicate scope it
33
+
publishes only while `usePermissions().isLoaded` is true, and the record-form field
34
+
evaluators take the same input, so one predicate answers the same everywhere.
0 commit comments