diff --git a/.changeset/b3-positions-predicate-scope.md b/.changeset/b3-positions-predicate-scope.md new file mode 100644 index 000000000..69a6dbc75 --- /dev/null +++ b/.changeset/b3-positions-predicate-scope.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +Forward the authenticated user's `positions` into the client predicate scope (`current_user.positions`) in the console shell and the record form page. Position-gated select options (`'admin' in current_user.positions`, ADR-0058 / objectui#2284) now hide client-side like they do everywhere else, instead of failing open as visible and only being rejected by the server on submit — `positions` is the actor shape the framework rule-validator actually binds and enforces. Docs, the schema-catalog role-gated example, the skills guide, and inline examples switch the role-gating spelling from `current_user.roles` (never bound server-side, so never enforced) to `current_user.positions`. diff --git a/content/docs/fields/select.mdx b/content/docs/fields/select.mdx index c4911cfa3..94906c2a0 100644 --- a/content/docs/fields/select.mdx +++ b/content/docs/fields/select.mdx @@ -70,7 +70,7 @@ cascade-clear propagate down the chain. { "label": "Private (only me)", "value": "private" }, { "label": "My team", "value": "team" }, { "label": "Whole organization", "value": "org" }, - { "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.roles" } + { "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.positions" } ]} ``` diff --git a/docs/adr/0058-cascading-select-options.md b/docs/adr/0058-cascading-select-options.md index cd01e75d2..0ece3c35a 100644 --- a/docs/adr/0058-cascading-select-options.md +++ b/docs/adr/0058-cascading-select-options.md @@ -37,9 +37,9 @@ Rationale: its cascade; `visibleWhen` is already the field-level CEL predicate (ADR-0036). Reusing both introduces no new category and no bespoke matrix format. 2. **`visibleWhen` is strictly more expressive than a value cascade.** It sees - `current_user` (roles/tenant) and `now()`, so the *same* knob covers cascades + `current_user` (positions/tenant) and `now()`, so the *same* knob covers cascades (`record.country == 'cn'`) **and** role/context gating - (`'admin' in current_user.roles`). `validFor` can only express "varies with + (`'admin' in current_user.positions`). `validFor` can only express "varies with another field's value" — it cannot gate by role. `current_user` is already bound on every predicate surface (server formula, RLS, client UI gates), so a role-referencing option predicate needs **zero new binding**. @@ -113,7 +113,7 @@ expression parses and the field exists. `@object-ui/core`'s `lintOptionPredicates(fields)` (`evaluator/optionLint.ts`) closes this at authoring/CI time with three conservative checks: `syntax` (via `@objectstack/formula`'s `validateExpression`, no schema hint so a legitimate -`current_user.roles` reference is never flagged), `unknown-field` (a `record.` +`current_user.positions` reference is never flagged), `unknown-field` (a `record.` naming no declared sibling), and `option-literal-not-in-domain` (a literal compared against an *enum* sibling that is outside its declared value set). It only flags what it can statically prove — non-`record.` roots, open-domain diff --git a/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json b/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json index a5a31bf9c..26fa6f8e7 100644 --- a/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json +++ b/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json @@ -21,7 +21,7 @@ { "label": "Public — external", "value": "public", - "visibleWhen": "'admin' in current_user.roles" + "visibleWhen": "'admin' in current_user.positions" } ] } diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index b9f5b42a0..ffed4b060 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -619,8 +619,14 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // name/email/role were forwarded → isPlatformAdmin-gated actions were // hidden even for platform admins. isPlatformAdmin: (user as any).isPlatformAdmin ?? false, + // Positions are what the SERVER binds as `current_user` for per-option + // `visibleWhen` authorization gating (ADR-0058; framework EvalUser — + // objectui#2284). Forwarding them lets a position-gated option + // (`'admin' in current_user.positions`) hide client-side too, instead + // of failing open as visible and only being rejected on submit. + positions: (user as any).positions ?? [], } - : { name: 'Anonymous', email: '', role: 'guest', isPlatformAdmin: false }; + : { name: 'Anonymous', email: '', role: 'guest', isPlatformAdmin: false, positions: [] }; return ( diff --git a/packages/app-shell/src/views/RecordFormPage.tsx b/packages/app-shell/src/views/RecordFormPage.tsx index ded678b5a..8d4db1dd5 100644 --- a/packages/app-shell/src/views/RecordFormPage.tsx +++ b/packages/app-shell/src/views/RecordFormPage.tsx @@ -159,8 +159,15 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { const expressionUser = useMemo( () => user - ? { name: user.name, email: user.email, role: user.role ?? 'user' } - : { name: 'Anonymous', email: '', role: 'guest' }, + ? { + name: user.name, + email: user.email, + role: user.role ?? 'user', + // Server-parity actor shape for per-option `visibleWhen` gating + // (ADR-0058): the rule-validator binds `current_user.positions`. + positions: (user as any).positions ?? [], + } + : { name: 'Anonymous', email: '', role: 'guest', positions: [] }, [user], ); diff --git a/packages/core/src/evaluator/__tests__/optionLint.test.ts b/packages/core/src/evaluator/__tests__/optionLint.test.ts index 2360899cb..32285b60c 100644 --- a/packages/core/src/evaluator/__tests__/optionLint.test.ts +++ b/packages/core/src/evaluator/__tests__/optionLint.test.ts @@ -58,7 +58,7 @@ describe('lintOptionPredicates — clean predicates', () => { type: 'select', options: [ { label: 'Standard', value: 'standard' }, - { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" }, + { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" }, ], }, ]); diff --git a/packages/core/src/evaluator/__tests__/optionRules.test.ts b/packages/core/src/evaluator/__tests__/optionRules.test.ts index 34968edfa..6824d2933 100644 --- a/packages/core/src/evaluator/__tests__/optionRules.test.ts +++ b/packages/core/src/evaluator/__tests__/optionRules.test.ts @@ -89,18 +89,18 @@ describe('resolveVisibleOptions — cascade', () => { describe('resolveVisibleOptions — role / context gating via scope', () => { const options: OptionLike[] = [ { label: 'Standard', value: 'standard' }, - { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" }, + { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" }, ]; it('hides the admin option for a non-admin', () => { - const vals = resolveVisibleOptions(options, {}, { current_user: { roles: ['sales'] } }).map( + const vals = resolveVisibleOptions(options, {}, { current_user: { positions: ['sales'] } }).map( (o) => o.value, ); expect(vals).toEqual(['standard']); }); it('offers the admin option to an admin', () => { - const vals = resolveVisibleOptions(options, {}, { current_user: { roles: ['admin'] } }).map( + const vals = resolveVisibleOptions(options, {}, { current_user: { positions: ['admin'] } }).map( (o) => o.value, ); expect(vals).toEqual(['standard', 'admin_only']); diff --git a/packages/core/src/evaluator/optionLint.ts b/packages/core/src/evaluator/optionLint.ts index 58e3babbf..97bb5a197 100644 --- a/packages/core/src/evaluator/optionLint.ts +++ b/packages/core/src/evaluator/optionLint.ts @@ -27,7 +27,7 @@ * 1. `syntax` — the predicate is valid CEL. Delegated to the canonical * `@objectstack/formula` `validateExpression` (no schema * hint → pure parse validation, so a legitimate - * `current_user.roles` reference is never flagged). + * `current_user.positions` reference is never flagged). * 2. `unknown-field` — a `record.` reference names a field the form * doesn't declare (a sibling typo, `record.contry`). * 3. `option-literal-not-in-domain` — a `record. == ''` (or diff --git a/packages/core/src/evaluator/optionRules.ts b/packages/core/src/evaluator/optionRules.ts index 6107d5c81..9b653b199 100644 --- a/packages/core/src/evaluator/optionRules.ts +++ b/packages/core/src/evaluator/optionRules.ts @@ -16,7 +16,7 @@ * {@link evalFieldPredicate}). This expresses both: * - cascades — `record.country == 'cn'` (the dependent list narrows as the * controlling field changes), and - * - role/context gating — `'admin' in current_user.roles`. + * - role/context gating — `'admin' in current_user.positions`. * * A field declares which sibling fields its option list reacts to via * `dependsOn` (aligns with `@objectstack/spec` Field.dependsOn — the same knob diff --git a/packages/fields/src/widgets/SelectField.cascade.test.tsx b/packages/fields/src/widgets/SelectField.cascade.test.tsx index 2da26677c..6c7656355 100644 --- a/packages/fields/src/widgets/SelectField.cascade.test.tsx +++ b/packages/fields/src/widgets/SelectField.cascade.test.tsx @@ -102,14 +102,14 @@ describe('SelectField — role / context gating (#2284)', () => { type: 'select', options: [ { label: 'Standard', value: 'standard' }, - { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" }, + { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" }, ], } as any; it('clears an admin-only value for a non-admin (offered set excludes it)', () => { const onChange = vi.fn(); render( - + { it('keeps an admin-only value for an admin', () => { const onChange = vi.fn(); render( - +