Skip to content

Commit d402e32

Browse files
os-billclaude
andauthored
spec(ui): record:details / record:highlights / record:related_list declare enforceFieldSecurity and redactFields — requiredPermissions forks on a measured fail-open gate (#19185)
Part of #18159 Clause-②: yes (widening) ⚠️ **`Part of`, not a closing keyword, and deliberately so.** The card routes three keys here and asks for a per-key exit. Two are declared below. The third, `requiredPermissions`, returns a **fork** that only a ruling can settle — merging this PR must not close the card, because the half it leaves open is the half with a measured fail-open gate behind it. --- ## What this declares `record:details`, `record:highlights` and `record:related_list` are `strictObject`s that declared neither `enforceFieldSecurity` nor `redactFields`, while `@object-ui/plugin-detail` reads and honours both on each of the three. An author who wrote either was refused at publish and the same document was honoured on the raw-node path. Both keys are now declared on all three blocks — optional, no schema default, so an absent key stays absent rather than becoming "the author asked for off". | key | what the render path does | |:---|:---| | `enforceFieldSecurity` (boolean) | Folds the block's field list — the detail body's `fields` and `sections`, the highlight chips, the related list's `columns` — through the caller's field-read permissions (`checkField`, the platform's own answer) before rendering. | | `redactFields` (string array) | Drops the names it lists outright. On `record:related_list` it is also handed down to `RelatedList`, because that component derives its own columns when none are authored — filtering the authored array alone let a redacted field return through the derivation (objectui#9053). | **The claim is held to what the render path does.** Both are presentation filters applied in the browser after the record is fetched: the values are in the page either way, so neither is a data-access control and neither is the object's `publicSharing.redactFields`, which removes them server-side. Each `describe()` says that in the text an author reads, rather than leaving the key names to imply it (Prime Directive #10). The gates that do keep a value from a caller are the field's own `requiredPermissions` / `maskingRule` (ADR-0066 D3) and the permission set. ## What this deliberately does NOT declare — `requiredPermissions` forks The card's Zone 3 instruction was to stop rather than declare a key whose honouring is itself the defect. That condition is met for `requiredPermissions`, and it is met on a measurement, not a reading of intent. The renderers evaluate it as `perms.can(objectName, name)` (`record-details.tsx`, `record-highlights.tsx`, `record-related-list.tsx`, each writing `name as any`). That second parameter is typed `PermissionAction` — **this package's own closed enum**, `packages/spec/src/kernel/plugin-security-advanced.zod.ts`: `create` · `read` · `update` · `delete` · `execute` · `manage` · `configure` · `share` · `export` · `import` · `admin`. It is not the ADR-0066 capability set, which is what `requiredPermissions` means on `action`, `app`, `field` and `bulkAction`, and which objectui evaluates through a different function entirely (`hasCapabilities`, over `systemPermissions`). Both shipped providers were run, each with self-tests and controls that fire in both directions: **`MePermissionsProvider`** (the backend-backed one, `/me/permissions`). Its `check` maps eight spellings to a permission bit and falls through: `const k = map[action as string] ?? 'allowRead'`. Driven with `allowRead: true`, every write bit `false`, and `systemPermissions: []` — reported, holding nothing: ``` SELFTEST ok granted verb 'read' -> allowed SELFTEST ok ungranted verb 'delete' -> denied can('contract', "read" ) -> true can('contract', "create" ) -> false can('contract', "delete" ) -> false can('contract', "manage" ) -> true -- in the enum, NOT in the map can('contract', "admin" ) -> true -- in the enum, NOT in the map can('contract', "crm.manage" ) -> true -- a capability nobody holds can('contract', "showcase.restricted_ops" ) -> true -- a capability nobody holds ``` **`PermissionProvider`** (the role-based one), through its real `evaluatePermission`: ``` SELFTEST ok granted verb 'read' with a config present -> allowed SELFTEST ok ungranted verb 'delete' with a config present -> denied SELFTEST ok no config for the object at all -> allowed (documented default) config PRESENT can('contract', "crm.manage" ) -> false -- denied for EVERYONE config ABSENT can('contract', "crm.manage" ) -> true -- allowed for EVERYONE ``` So one authored `requiredPermissions: ['crm.manage']` passes for every reader who can read the object under one provider, is denied to every reader under the other, and consults the caller's actual capability set under neither. Declaring it would mint the ADR-0049 **fail-open access gate** this repo retired from `app.areas[].requiredPermissions` in 17.0.0 — whose own conversion-registry entry says it in the same words: *"FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user"*. ⭐ objectui's own pin battery for this gate (`record-quick-actions.requiredPermissions-gate.test.tsx`) **mocks `usePermissions`** with a stub whose `can(object, action)` is `granted.has(action)`. It pins that the renderer calls `can()` with the right arguments; it cannot see what `can()` answers, which is where the defect lives. **The question for the seat** is in the report on the card. In short: (A) declare it as object ACTIONS (`PermissionActionSchema`), which is what `record-highlights.tsx`'s own comment says the author should write — *"record:\* may declare requiredPermissions like ['read','update']"* — and what the backend-backed provider's map can honour; (B) declare it as ADR-0066 capabilities and repair the renderer to evaluate it through `hasCapabilities`; (C) rule it host-composition surface and retire the reads, the ADR-0049 precedent. ⛔ Not decided here. ## Also measured, and left alone `record:quick_actions` **already declares** `requiredPermissions` (`z.array(z.string())`, described as *"unless the current user holds every named permission on this object"*) and its renderer performs the identical `perms.can(objectName, p as any)` read. That declaration is on a published surface and predates this branch; it is not touched here and is reported for filing rather than fixed on a card that does not name it. ## Evidence One instrument, self-tested before it was pointed at the tree, run on the same tree before and after the change. Two positive controls, both unmoved: ``` BEFORE AFTER ComponentPropsMap 48 entries 48 entries strict blocks 47 (loose: user:profile) 47 (loose: user:profile) requiredPermissions A=1 [record:quick_actions] A=1 [record:quick_actions] enforceFieldSecurity A=0 [] A=3 [details, related_list, highlights] redactFields A=0 [] A=3 [details, related_list, highlights] aria POSITIVE CONTROL A=21 A=21 fields POSITIVE CONTROL A=7 A=7 ``` Named refusals on `record:related_list`, base document legal on its own: ``` BEFORE AFTER CONTROL+ +columns success=true success=true CONTROL- +zzqx_no_such_key success=false unrecognized_keys [...] success=false unrecognized_keys [...] +requiredPermissions success=false unrecognized_keys [...] success=false unrecognized_keys [...] +enforceFieldSecurity success=false unrecognized_keys [...] success=true +redactFields success=false unrecognized_keys [...] success=true baseline success=true success=true ``` `packages/spec/authorable-surface/ui.json` moved by exactly six lines, all additions, and nothing else. ## Verification Run on `676d44d593`, the head this PR carries. - `pnpm --filter @objectstack/spec exec vitest run src/ui/component-record-block-field-security.test.ts` — 18 tests, exit 0. With the three sibling component suites: 4 files / 397 tests, exit 0. - `pnpm --filter @objectstack/spec typecheck` — exit 0 (test layer: 54 files / 259 errors / 144 pinned signatures held, unmoved). - `pnpm --filter @objectstack/spec check:generated` — exit 0. `gen:api-surface-declarations` and `gen:docs` were regenerated after the check proved them stale; `authorable-surface/ui.json` is the build's own output. `authorable-surface.base.json` is untouched. - `MANIFEST=$PWD/sdui.manifest.json pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict` — exit 0, as `lint.yml` spells it. ⚠️ Note this gate does not cover these three blocks at all: they are withdrawn from the react tier (`REACT_RECORD_BLOCK_ALTERNATIVES`), so no `spec-only` signal arises here — and `spec-only` is a reported, never-ratcheted signal in any case. - `pnpm lint` — the whole tree, `eslint . --no-inline-config`, exit 0. No narrowing was needed. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE` — **107 derived families accounted for: 106 run, 1 NOT MEASURED.** The one is `pnpm check:dual-build-cjs-loads`, which exits 3 (`PREREQUISITE NOT MET`) because ~34 packages have no `dist/` in this worktree; it needs a whole-workspace build, which CI's `Build Core` provides. Six other families first exited 3 or 1 on the same prerequisite class and were re-run green after building `@objectstack/lint`, `@objectstack/formula`, `@objectstack/client-react` and `@objectstack/driver-turso`. ## Acceptance notes Out of scope for this card, noted and not filed from here: - **`record:quick_actions.requiredPermissions` carries the same defect on a published surface** (`z.array(z.string())`, described as a capability gate, evaluated against the closed action enum). Reported for filing; deliberately not fixed on a card that does not name it. Successor: the same ruling that settles this card's fork. - **`redactFields` neighbours `hideFields` on `record:details`**, and on a well-formed field list the two remove the same rows. They are different channels — `hideFields` is the dedupe list the renderer also writes to (live `record:highlights` registrations, the page-title field), `redactFields` is the author's deliberate omission and the arm that participates in the fail-closed fold — and the TSDoc says so. Converging them is a contract question this card did not open. Successor: whoever next edits `RecordDetailsProps`. - **`packages/spec/src/ui/react-blocks.ts` was in the dispatched file surface and needed no edit.** All three blocks are withdrawn from the react tier because they take their record from context, so they carry `REACT_RECORD_BLOCK_ALTERNATIVES` rows rather than published props. Carrier: none — stated so the absence reads as measured rather than skipped. ## ⚠️ Not measured - The runtime behaviour of either declared key in a browser. No dogfood or integration run was made for this change, by anyone on this chain. - Whether any authored document anywhere writes these keys. "The schema refused it" is not "nobody writes it"; only the first is measured. - Whether the same gap exists on element faces beyond these three blocks. The census above covers all 48 `ComponentPropsMap` entries for the three key names; the renderer side was read only for `@object-ui/plugin-detail`. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 839d1b0 commit d402e32

6 files changed

Lines changed: 354 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`record:details`, `record:highlights` and `record:related_list` accept `enforceFieldSecurity` and `redactFields` — the two field-security keys objectui's detail renderers have been honouring on documents this contract refused by name (#18159).
6+
7+
Clause-②: yes (widening)
8+
9+
All three blocks are `strictObject`s that declared neither key, while `@object-ui/plugin-detail` reads both off each of the three. An author who wrote either was refused at publish, and the same document was honoured on the raw-node path — a contract that could not be satisfied by writing it down. Both keys are declared here, optional, with no schema default, so an absent key stays absent rather than becoming "the author asked for off".
10+
11+
- **`enforceFieldSecurity`** (boolean) folds the block's field list — the detail body's fields and sections, the highlight chips, the related list's `columns` — through the caller's field-read permissions before rendering, so a field the permission set denies leaves no empty row behind.
12+
- **`redactFields`** (string array) drops the names it lists outright. On `record:related_list` it also reaches the columns the list derives for itself when none are authored.
13+
- **The claim is held to what the render path does.** Both are presentation filters, applied in the browser after the record is fetched: the values are in the page either way, so neither is a data-access control and neither is the object's `publicSharing.redactFields`, which removes them server-side. Each `describe()` says that in the text an author reads, rather than leaving the key names to imply it (Prime Directive #10). The gates that do keep a value from a caller are the field's own `requiredPermissions` / `maskingRule` (ADR-0066 D3) and the permission set.
14+
- **⚠️ On `record:details`, `redactFields` neighbours the already-declared `hideFields`** and on a well-formed field list the two remove the same rows: `hideFields` is the dedupe channel the renderer also writes to (live `record:highlights` registrations, the page-title field), `redactFields` is the author's deliberate omission and the arm that participates in the renderer's fail-closed fold. Converging them is a contract question this change did not open.
15+
- **⚠️ The third key the same three renderers read — `requiredPermissions` — is deliberately NOT declared**, and stays refused by name on all three. Its read is `perms.can(objectName, name)`, whose second parameter is this package's own closed `PermissionActionSchema` enum, not the ADR-0066 capability set that name means on `action`, `app`, `field` and `bulkAction`. Measured on both shipped permission providers: under the backend-backed one an unmapped name falls through to the object's `allowRead` bit, so a capability the caller does not hold passes for every reader; under the role-based one the same name is denied for everyone whenever the object carries a permission config. Declaring it would mint the ADR-0049 fail-open access gate retired from `app.areas[].requiredPermissions` in 17.0.0. The exit is a ruling, not an omission.
16+
17+
⚠️ **Not measured here**: the runtime behaviour of either declared key in a browser, and whether any authored document anywhere writes them. "The schema refused it" is not "nobody writes it"; only the first is measured.

‎content/docs/references/ui/component.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,8 @@ View filter rule
10911091
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
10921092
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
10931093
| **showHeader** | `boolean` | optional | Render the detail body's own heading (renderer default: off). |
1094+
| **enforceFieldSecurity** | `boolean` | optional | Fold this block's field list through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value. |
1095+
| **redactFields** | `string[]` | optional | Field names this block never renders, whatever the permission answer (renderer default: render everything authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. Neighbours `hideFields`, which is the dedupe channel the renderer also writes to. |
10941096
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
10951097

10961098
### Nested Shape: `RecordDetailsProps.sections[number]`
@@ -1159,6 +1161,8 @@ Type: `string`
11591161
| :--- | :--- | :--- | :--- |
11601162
| **fields** | `(string \| { name: string; label?: string; type?: string; readonly?: boolean })[]` | ✅ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, type?, readonly?}` for inline overrides. |
11611163
| **layout** | `Enum<'horizontal' \| 'vertical'>` | optional (default: `"horizontal"`) | Layout orientation for highlight fields |
1164+
| **enforceFieldSecurity** | `boolean` | optional | Fold this block's highlight chips through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty chip behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the record is fetched either way and the server still decides every value. |
1165+
| **redactFields** | `string[]` | optional | Field names this block never renders as a chip, whatever the permission answer (renderer default: render every field authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. |
11621166
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
11631167

11641168
### Nested Shape: `RecordHighlightsProps.fields[number]`
@@ -1279,6 +1283,8 @@ Type: `string`
12791283
| **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records |
12801284
| **actions** | `string[]` | optional | Action IDs available for related records |
12811285
| **add** | `{ picker: object; linkField?: string; label?: string \| Record<string, string> }` | optional | Add-existing-via-picker config (generic m2m/junction assignment). |
1286+
| **enforceFieldSecurity** | `boolean` | optional | Fold this list's `columns` through the caller's FIELD-read permissions on the RELATED object before rendering (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the rows are fetched either way and the server still decides every value. |
1287+
| **redactFields** | `string[]` | optional | Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. |
12821288
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
12831289

12841290
### Nested Shape: `RecordRelatedListProps.filter[number]`

‎packages/spec/api-surface-declarations/ui.txt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3073,6 +3073,8 @@ declare const ComponentPropsMap: {
30733073
hideFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
30743074
inlineEdit: z.ZodOptional<z.ZodBoolean>;
30753075
showHeader: z.ZodOptional<z.ZodBoolean>;
3076+
enforceFieldSecurity: z.ZodOptional<z.ZodBoolean>;
3077+
redactFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
30763078
aria: z.ZodOptional<z.ZodObject<{
30773079
ariaLabel: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
30783080
key?: never;
@@ -3192,6 +3194,8 @@ declare const ComponentPropsMap: {
31923194
defaultValue?: never;
31933195
}>>]>>;
31943196
}, z.core.$strict>>;
3197+
enforceFieldSecurity: z.ZodOptional<z.ZodBoolean>;
3198+
redactFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
31953199
aria: z.ZodOptional<z.ZodObject<{
31963200
ariaLabel: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
31973201
key?: never;
@@ -3221,6 +3225,8 @@ declare const ComponentPropsMap: {
32213225
vertical: "vertical";
32223226
horizontal: "horizontal";
32233227
}>>;
3228+
enforceFieldSecurity: z.ZodOptional<z.ZodBoolean>;
3229+
redactFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
32243230
aria: z.ZodOptional<z.ZodObject<{
32253231
ariaLabel: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
32263232
key?: never;
@@ -18753,6 +18759,8 @@ declare const RecordDetailsProps: z.ZodObject<{
1875318759
hideFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
1875418760
inlineEdit: z.ZodOptional<z.ZodBoolean>;
1875518761
showHeader: z.ZodOptional<z.ZodBoolean>;
18762+
enforceFieldSecurity: z.ZodOptional<z.ZodBoolean>;
18763+
redactFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
1875618764
aria: z.ZodOptional<z.ZodObject<{
1875718765
ariaLabel: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
1875818766
key?: never;
@@ -18795,6 +18803,8 @@ declare const RecordHighlightsProps: z.ZodObject<{
1879518803
vertical: "vertical";
1879618804
horizontal: "horizontal";
1879718805
}>>;
18806+
enforceFieldSecurity: z.ZodOptional<z.ZodBoolean>;
18807+
redactFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
1879818808
aria: z.ZodOptional<z.ZodObject<{
1879918809
ariaLabel: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
1880018810
key?: never;
@@ -19023,6 +19033,8 @@ declare const RecordRelatedListProps: z.ZodObject<{
1902319033
defaultValue?: never;
1902419034
}>>]>>;
1902519035
}, z.core.$strict>>;
19036+
enforceFieldSecurity: z.ZodOptional<z.ZodBoolean>;
19037+
redactFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
1902619038
aria: z.ZodOptional<z.ZodObject<{
1902719039
ariaLabel: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
1902819040
key?: never;

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,15 +1039,19 @@
10391039
"ui/RecordChatterProps:width",
10401040
"ui/RecordDetailsProps:aria",
10411041
"ui/RecordDetailsProps:columns",
1042+
"ui/RecordDetailsProps:enforceFieldSecurity",
10421043
"ui/RecordDetailsProps:fields",
10431044
"ui/RecordDetailsProps:hideFields",
10441045
"ui/RecordDetailsProps:inlineEdit",
10451046
"ui/RecordDetailsProps:layout [RETIRED]",
1047+
"ui/RecordDetailsProps:redactFields",
10461048
"ui/RecordDetailsProps:sections",
10471049
"ui/RecordDetailsProps:showHeader",
10481050
"ui/RecordHighlightsProps:aria",
1051+
"ui/RecordHighlightsProps:enforceFieldSecurity",
10491052
"ui/RecordHighlightsProps:fields",
10501053
"ui/RecordHighlightsProps:layout",
1054+
"ui/RecordHighlightsProps:redactFields",
10511055
"ui/RecordHistoryProps:emptyText",
10521056
"ui/RecordHistoryProps:limit",
10531057
"ui/RecordHistoryProps:unknownUserText",
@@ -1067,9 +1071,11 @@
10671071
"ui/RecordRelatedListProps:add",
10681072
"ui/RecordRelatedListProps:aria",
10691073
"ui/RecordRelatedListProps:columns",
1074+
"ui/RecordRelatedListProps:enforceFieldSecurity",
10701075
"ui/RecordRelatedListProps:filter",
10711076
"ui/RecordRelatedListProps:limit",
10721077
"ui/RecordRelatedListProps:objectName",
1078+
"ui/RecordRelatedListProps:redactFields",
10731079
"ui/RecordRelatedListProps:relationshipField",
10741080
"ui/RecordRelatedListProps:relationshipValueField",
10751081
"ui/RecordRelatedListProps:showViewAll",

0 commit comments

Comments
 (0)