diff --git a/.changeset/18159-record-block-field-security-pair.md b/.changeset/18159-record-block-field-security-pair.md index 9ef401a8f5..d502a9a04c 100644 --- a/.changeset/18159-record-block-field-security-pair.md +++ b/.changeset/18159-record-block-field-security-pair.md @@ -12,6 +12,6 @@ All three blocks are `strictObject`s that declared neither key, while `@object-u - **`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. - **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. - **⚠️ 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. -- **⚠️ 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. +- **The third key the same three renderers read — `requiredPermissions` — is declared in the same release, by its own entry.** It is the block-level ADR-0066 capability gate, not a member of this pair. ⚠️ **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. diff --git a/.changeset/18159-record-block-required-permissions.md b/.changeset/18159-record-block-required-permissions.md new file mode 100644 index 0000000000..80546d8511 --- /dev/null +++ b/.changeset/18159-record-block-required-permissions.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +`record:details`, `record:highlights` and `record:related_list` accept `requiredPermissions`, the block-level capability gate objectui's detail renderers read, with the same shape and the same describe as `record:quick_actions` — whose published describe changes in this release (#18159). + +Clause-②: yes (widening) + +- **Additive.** All three blocks are `strictObject`s that refused the key by name. It is now declared optional, `z.array(z.string())`, with no schema default, so an absent key stays absent and nothing that parsed before stops parsing. +- **One key, one meaning, one text, on all four record blocks.** The names are ADR-0066 capabilities (what permission sets grant through `systemPermissions`), not object actions. The user must hold all of them; otherwise the block renders an insufficient-permissions notice in place of its content. It is presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities renders the block as if they were held (fails open). To keep data from a user, gate the object, the field or the action. +- **⚠️ Published text changes: the describe of `record:quick_actions.requiredPermissions`.** It read "Hide the whole bar unless the current user holds every named permission on this object." Against the renderer the pinned console ships, "on this object" is false — the gate reads the user's capability set and is not object-scoped — and the sentence named no fail-open case. The shape is unchanged; only the text moves. If a page writes object actions there (`read`, `update`), the console reads them as capability names. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 9219cdc48b..48cb88d60e 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1194,6 +1194,7 @@ View filter rule | **showHeader** | `boolean` | optional | Render the detail body's own heading (renderer default: off). | | **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. | | **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. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordDetailsProps.sections[number]` @@ -1264,6 +1265,7 @@ Type: `string` | **layout** | `Enum<'horizontal' \| 'vertical'>` | optional (default: `"horizontal"`) | Layout orientation for highlight fields | | **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. | | **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. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordHighlightsProps.fields[number]` @@ -1335,7 +1337,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **actionNames** | `string[]` | optional | Names of actions declared on this object (`actions[]`), in display order. The engine still location-filters named actions. Measured: when omitted (and the host supplies nothing) the bar resolves NO actions and renders its empty placeholder — it does not fall back to "every action at this location", whatever the registration's input list claims. | -| **requiredPermissions** | `string[]` | optional | Hide the whole bar unless the current user holds every named permission on this object. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **location** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>` | optional | Which declared action location this bar renders (renderer default: `record_header`). | | **align** | `Enum<'start' \| 'center' \| 'end'>` | optional | Horizontal alignment of the button row (renderer default: `end`). | | **inline** | `boolean` | optional | Render in the flow instead of pulling up into the record-header band. The page header sets this itself when it hosts the bar in its own action slot. | @@ -1386,6 +1388,7 @@ Type: `string` | **add** | `{ picker: object; linkField?: string; label?: string \| Record }` | optional | Add-existing-via-picker config (generic m2m/junction assignment). | | **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. | | **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. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordRelatedListProps.columns[number]` diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index e1e58ea732..ed849ec252 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1092,6 +1092,7 @@ "ui/RecordDetailsProps:inlineEdit", "ui/RecordDetailsProps:layout [RETIRED]", "ui/RecordDetailsProps:redactFields", + "ui/RecordDetailsProps:requiredPermissions", "ui/RecordDetailsProps:sections", "ui/RecordDetailsProps:showHeader", "ui/RecordHighlightsProps:aria", @@ -1099,6 +1100,7 @@ "ui/RecordHighlightsProps:fields", "ui/RecordHighlightsProps:layout", "ui/RecordHighlightsProps:redactFields", + "ui/RecordHighlightsProps:requiredPermissions", "ui/RecordHistoryProps:emptyText", "ui/RecordHistoryProps:limit", "ui/RecordHistoryProps:unknownUserText", @@ -1125,6 +1127,7 @@ "ui/RecordRelatedListProps:redactFields", "ui/RecordRelatedListProps:relationshipField", "ui/RecordRelatedListProps:relationshipValueField", + "ui/RecordRelatedListProps:requiredPermissions", "ui/RecordRelatedListProps:showViewAll", "ui/RecordRelatedListProps:sort", "ui/RecordRelatedListProps:title", diff --git a/packages/spec/src/ui/component-record-block-field-security.test.ts b/packages/spec/src/ui/component-record-block-field-security.test.ts index 11f6904942..ccea01b37f 100644 --- a/packages/spec/src/ui/component-record-block-field-security.test.ts +++ b/packages/spec/src/ui/component-record-block-field-security.test.ts @@ -1,7 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #18159 — the record-block field-security pair, and the key that is -// deliberately NOT beside it. +// #18159 — the three keys objectui reads on the record blocks, all declared. // // `record:details`, `record:highlights` and `record:related_list` are // `strictObject`s, and objectui's `@object-ui/plugin-detail` reads three keys @@ -10,33 +9,23 @@ // refused at parse while the renderer honoured the same document on the raw-node // path — a contract that could not be satisfied by writing it down. // -// This card declares TWO of the three and forks the third, so the pins below -// come in two kinds, and the difference matters: +// The card declared the field-security PAIR first and held the third key until +// its semantics were ruled (#19186 ruling B: an ADR-0066 capability set) and the +// renderers read it that way at the pin. Every pin below is an ACCEPT pin: a +// full `safeParse` success is the assertion (not merely "no +// `unrecognized_keys`"), because the value arm is part of what is being +// declared, and a key-only assertion would stay green over a declaration that +// refused every value. // -// ACCEPT pins — `enforceFieldSecurity` / `redactFields` now parse GREEN on -// all three blocks. A full `safeParse` success is the assertion -// (not merely "no `unrecognized_keys`"): the value arm is part -// of what is being declared, and a key-only assertion would -// stay green over a declaration that refused every value. +// `requiredPermissions` is pinned through the card's two instruments, each with +// its lit controls `aria` / `fields`: // -// ABSENCE pin — `requiredPermissions` is still refused BY NAME on all three. -// That is a deliberate outcome, not an oversight. The renderer -// evaluates it as `perms.can(objectName, name)`, whose second -// parameter is this repo's closed `PermissionActionSchema` enum -// and NOT the ADR-0066 capability set every other -// `requiredPermissions` in this spec names. Measured on the two -// shipped providers: an unmapped name falls to the object's -// `allowRead` bit under the backend-backed one (so a capability -// nobody holds passes for every reader), and is denied for -// everyone under the role-based one whenever the object carries -// a permission config. Declaring it would mint the ADR-0049 -// fail-open access gate this repo retired on -// `app.areas[].requiredPermissions` in 17.0.0. +// A — a parse probe on each block's own legal base document; +// B — an enumeration of the block's public zod `.shape`. // -// ⚠️ This pin is tree-scoped: it records what the contract -// accepts TODAY, not that the key may never be declared. The -// ruling that settles the fork updates this file in the same -// PR — it does not route around it. +// Plus the ruling's own condition: the four record blocks carrying the key +// (the three above and `record:quick_actions`) declare it IDENTICALLY — same +// shape, same describe, word for word. import { describe, expect, it } from 'vitest'; import { @@ -46,7 +35,7 @@ import { RecordQuickActionsProps, RecordRelatedListProps, } from './component.zod'; -import { PermissionActionSchema } from '../kernel/plugin-security-advanced.zod'; +import { z } from 'zod'; /** A document that is legal on its own, per block — the baseline every case adds to. */ const BASE: Record> = { @@ -168,36 +157,98 @@ describe('#18159 — `redactFields` is declared on all three blocks', () => { }); }); -describe('#18159 — `requiredPermissions` is REFUSED on the three blocks (the forked key)', () => { - it('is refused by name on each of the three, with the base document legal on its own', () => { +/** Instrument B: the keys a schema declares, read off zod's public `.shape`. */ +const shapeKeys = (type: string): string[] => + Object.keys((ComponentPropsMap[type as keyof typeof ComponentPropsMap] as { shape?: object }).shape ?? {}); + +/** Instrument B, map-wide: every `ComponentPropsMap` row declaring `key`. */ +const declaring = (key: string) => + Object.entries(ComponentPropsMap) + .filter(([, schema]) => Object.keys((schema as { shape?: object }).shape ?? {}).includes(key)) + .map(([type]) => type) + .sort(); + +/** One lit `aria` value — accepted on all three blocks, so the probe can say yes. */ +const ARIA = { ariaLabel: 'Record block' }; + +describe('#18159 — `requiredPermissions` is declared on the three blocks (instruments A and B)', () => { + it.each(BLOCKS)('A · %s parses GREEN with the key set — the whole document, beside the lit control `aria`', (type) => { + // Lit control first, on the same block and the same base document: a probe + // that cannot say yes to a key known to be declared proves nothing below. + expect(parse(type, { aria: ARIA }).success).toBe(true); + + const r = parse(type, { requiredPermissions: ['crm.manage'] }); + expect(r.success).toBe(true); + expect((r.data as Record).requiredPermissions).toEqual(['crm.manage']); + + // And both at once — the key does not displace its neighbours. + expect(parse(type, { aria: ARIA, requiredPermissions: ['crm.manage'] }).success).toBe(true); + }); + + it.each(BLOCKS)('B · %s lists the key in its `.shape`, beside the lit control `aria`', (type) => { + const keys = shapeKeys(type); + expect(keys).toContain('aria'); + expect(keys).toContain('requiredPermissions'); + }); + + it('B · the census: exactly the three blocks and `record:quick_actions` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { + expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:quick_actions'].sort()); + // Controls: the census can find a key at all, and finds the blocks under + // test when it should. `fields` is declared on two of the three (the related + // list's column key is `columns`), which is itself a discriminating reading. + const aria = declaring('aria'); + for (const type of BLOCKS) expect(aria).toContain(type); + expect(aria.length).toBeGreaterThan(BLOCKS.length); + const fields = declaring('fields'); + expect(fields).toContain('record:details'); + expect(fields).toContain('record:highlights'); + expect(fields).not.toContain('record:related_list'); + }); + + it('names are capabilities, not object actions: any string is a legal element, `read` as much as `crm.manage`', () => { for (const type of BLOCKS) { - const issue = unknownKeyIssue(type, { requiredPermissions: ['crm.manage'] }); - expect(issue).toBeDefined(); - expect(issue!.message).toContain('requiredPermissions'); + const r = parse(type, { requiredPermissions: ['crm.manage', 'read'] }); + expect(r.success).toBe(true); + expect((r.data as Record).requiredPermissions).toEqual(['crm.manage', 'read']); } }); - it('is refused for the CRUD spelling too — the refusal is the key, not the value', () => { + it('accepts the empty list, and carries NO schema default — an absent key stays absent', () => { for (const type of BLOCKS) { - expect(unknownKeyIssue(type, { requiredPermissions: ['read'] })).toBeDefined(); + const empty = parse(type, { requiredPermissions: [] }); + expect(empty.success).toBe(true); + expect((empty.data as Record).requiredPermissions).toEqual([]); + const absent = parse(type, {}); + expect(absent.success).toBe(true); + expect(absent.data as Record).not.toHaveProperty('requiredPermissions'); } }); - it('the sibling `record:quick_actions` DOES declare it — the distinction a whole-file screen gets backwards', () => { - const r = RecordQuickActionsProps.safeParse({ requiredPermissions: ['crm.manage'] }); - expect(r.success).toBe(true); - expect(ComponentPropsMap['record:quick_actions']).toBe(RecordQuickActionsProps); + it('refuses a bare string and a non-string member as TYPE errors, never as unknown keys', () => { + for (const type of BLOCKS) { + for (const bad of ['crm.manage', [1]] as unknown[]) { + const r = parse(type, { requiredPermissions: bad }); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(false); + } + } }); - it('names a vocabulary the renderer\'s evaluator cannot express: `perms.can()` takes the closed PermissionAction enum', () => { - // Why the key forks rather than being declared. `requiredPermissions` means - // ADR-0066 CAPABILITIES everywhere else in this spec (`action`, `app`, - // `field`, `bulkAction`); the renderer routes it into the object-ACTION - // evaluator, whose vocabulary is this enum and nothing else. - expect(PermissionActionSchema.safeParse('read').success).toBe(true); - expect(PermissionActionSchema.safeParse('update').success).toBe(true); - expect(PermissionActionSchema.safeParse('crm.manage').success).toBe(false); - expect(PermissionActionSchema.safeParse('showcase.restricted_ops').success).toBe(false); + it('one key, one text: the four record blocks declare it IDENTICALLY — shape and describe, word for word', () => { + const declaration = (schema: { shape: Record }) => + JSON.stringify(z.toJSONSchema(schema.shape.requiredPermissions!, { io: 'input' })); + const reference = declaration(RecordQuickActionsProps as unknown as { shape: Record }); + for (const schema of [RecordDetailsProps, RecordHighlightsProps, RecordRelatedListProps]) { + expect(declaration(schema as unknown as { shape: Record })).toBe(reference); + } + // The comparison is not vacuous: the shared declaration is an optional + // string array that carries a describe. + const qa = (RecordQuickActionsProps as unknown as { shape: Record }).shape.requiredPermissions!; + expect(qa.safeParse(undefined).success).toBe(true); + expect(qa.safeParse(['a']).success).toBe(true); + expect(qa.safeParse('a').success).toBe(false); + expect(typeof qa.description).toBe('string'); + expect((qa.description ?? '').length).toBeGreaterThan(0); }); }); @@ -213,14 +264,7 @@ describe('#18159 — the pair was declared on THESE blocks only', () => { }); it('exactly three rows in `ComponentPropsMap` declare each key', () => { - const declaring = (key: string) => - Object.entries(ComponentPropsMap) - .filter(([, schema]) => Object.keys((schema as { shape?: object }).shape ?? {}).includes(key)) - .map(([type]) => type) - .sort(); expect(declaring('enforceFieldSecurity')).toEqual([...BLOCKS].sort()); expect(declaring('redactFields')).toEqual([...BLOCKS].sort()); - // The control that the census above can find a key at all. - expect(declaring('requiredPermissions')).toEqual(['record:quick_actions']); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 3b788891c7..e3c96047ba 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -853,6 +853,59 @@ export const PageCardProps = strictObject({ * ---------------------------------------------------------------------- */ +/** + * The ONE describe of the block-level `requiredPermissions` gate, carried word + * for word by `record:details`, `record:highlights`, `record:related_list` and + * `record:quick_actions` (#18159, seat ruling A on #19186's ruling-B semantics: + * one word, one meaning, one text). The shape is `z.array(z.string()).optional()` + * on all four; `component-record-block-field-security.test.ts` holds the four + * declarations identical. + * + * Each clause is read off the objectui pin this repo builds against + * (`.objectui-sha` = `f8a9d0fb0596`), under `packages/plugin-detail/src/renderers/` + * and `packages/permissions/src/`: + * + * 1. CAPABILITIES, NOT OBJECT ACTIONS. Every block gates through + * `perms.hasCapabilities(required)` — `record-details.tsx:235`, + * `record-highlights.tsx:93`, `record-related-list.tsx:276`, + * `record-quick-actions.tsx:263` — and never `perms.can(objectName, …)`; + * each renderer's docblock states the capability "is not object-scoped" + * (`record-details.tsx:224`, `record-highlights.tsx:77`, + * `record-related-list.tsx:263`, `record-quick-actions.tsx:252`). `read` + * is looked up in the capability set like any other name. + * 2. ALL OF THEM. `MePermissionsProvider.tsx:416` is + * `required.every((p) => held.has(p))`. + * 3. THE OUTCOME. Each block returns a `role="status"` "Insufficient + * permissions to view …" notice instead of its content — + * `record-details.tsx:235-243`, `record-highlights.tsx:151-164`, + * `record-related-list.tsx:276-284`, `record-quick-actions.tsx:263-271`. + * Checks that already withhold the content run first on two of them (no + * record bound, `record-details.tsx:187`; no object, + * `record-related-list.tsx:218`; the related object's read gate, + * `record-related-list.tsx:236`), which is why the text says "wherever it + * would otherwise render" rather than promising the notice unconditionally. + * 4. PRESENTATION ONLY. The gate is renderer code: nothing in this repo's + * server packages reads a page component's `requiredPermissions`. The data + * stays in reach — a gated `record:highlights` registers no field names + * (`record-highlights.tsx:146-149`), so `record:details` no longer + * de-duplicates those fields out of its body. To keep a value from a + * user, gate the object, the field or the action. + * 5. UNRESOLVED CAPABILITIES FAIL OPEN. `hasCapabilities` answers `true` when + * `systemPermissions` was never reported (`MePermissionsProvider.tsx:414`), + * under the role-based provider (`PermissionProvider.tsx:77`) and with no + * provider mounted (`usePermissions.ts:45`); a REPORTED empty array reaches + * the `every` at `:416` and gates. + * + * Unlike the field's own `requiredPermissions` (ADR-0066 D3, enforced by the + * server before the payload leaves it), this key authorises + * nothing. + */ +const RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION = + '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. ' + + 'When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. ' + + 'Presentation only: it authorises nothing, and the data API still serves the same data to the same user. ' + + 'A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open.'; + export const RecordDetailsProps = strictObject({ surface: 'this `record:details`', history: PROPS_HISTORY, @@ -1120,18 +1173,14 @@ export const RecordDetailsProps = strictObject({ * field list they remove the same rows. Converging them is a contract * question this card did not open. * - * ⚠️ The THIRD key objectui reads on these three blocks — - * `requiredPermissions` — is deliberately NOT declared here. Its read is - * `perms.can(objectName, name)`, whose second parameter is the closed - * `PermissionActionSchema` enum (`create`/`read`/…/`admin`), not the - * ADR-0066 capability set every other `requiredPermissions` in this spec - * names. Measured: under the backend-backed provider an unmapped name falls - * to the object's `allowRead` bit, so a capability nobody holds passes for - * every reader; under the role-based provider 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 this repo retired on - * `app.areas[].requiredPermissions` in 17.0.0. The exit is the spec seat's - * to rule. + * ⚠️ The THIRD key objectui reads on these three blocks, + * `requiredPermissions`, is declared below them — a different mechanism + * from this pair and from the field's own `requiredPermissions` above. It + * is the block-level ADR-0066 capability gate, read through the capability + * set, with the one describe it shares word for word with + * `record:quick_actions` ({@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION} + * carries the renderer read points). Like this pair it is presentation + * only: it authorises nothing. */ enforceFieldSecurity: z.boolean().optional().describe( '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.', @@ -1139,6 +1188,12 @@ export const RecordDetailsProps = strictObject({ redactFields: z.array(z.string()).optional().describe( '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.', ), + /** + * Block-level ADR-0066 capability gate (#18159) — same shape and same + * describe as `record:quick_actions.requiredPermissions`; the read points + * behind every clause are on {@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}. + */ + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); @@ -1255,8 +1310,8 @@ export const RecordRelatedListProps = strictObject({ }).optional().describe('Add-existing-via-picker config (generic m2m/junction assignment).'), /** * The record-block field-security pair — see the family header on - * `RecordDetailsProps` for what the two keys are, what they are not, and why - * the third key objectui reads on this block is not declared. + * `RecordDetailsProps` for what the two keys are, what they are not, and how + * they differ from the block-level `requiredPermissions` gate declared below. * * On THIS block the pair folds `columns` rather than a field list, and * `redactFields` is additionally handed down to `RelatedList` itself: the @@ -1273,6 +1328,12 @@ export const RecordRelatedListProps = strictObject({ redactFields: z.array(z.string()).optional().describe( '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.', ), + /** + * Block-level ADR-0066 capability gate (#18159) — same shape and same + * describe as `record:quick_actions.requiredPermissions`; the read points + * behind every clause are on {@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}. + */ + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); @@ -1356,8 +1417,8 @@ export const RecordHighlightsProps = strictObject({ layout: z.enum(['horizontal', 'vertical']).default('horizontal').describe('Layout orientation for highlight fields'), /** * The record-block field-security pair — see the family header on - * `RecordDetailsProps` for what the two keys are, what they are not, and why - * the third key objectui reads on this block is not declared. + * `RecordDetailsProps` for what the two keys are, what they are not, and how + * they differ from the block-level `requiredPermissions` gate declared below. * * On THIS block the pair folds the normalized `fields` chips. The renderer * expresses the fail-closed arm by dropping unnameable entries BEFORE the @@ -1372,6 +1433,12 @@ export const RecordHighlightsProps = strictObject({ redactFields: z.array(z.string()).optional().describe( '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.', ), + /** + * Block-level ADR-0066 capability gate (#18159) — same shape and same + * describe as `record:quick_actions.requiredPermissions`; the read points + * behind every clause are on {@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}. + */ + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); @@ -1785,7 +1852,7 @@ export const RecordQuickActionsProps = strictObject({ }, }, { actionNames: z.array(z.string()).optional().describe('Names of actions declared on this object (`actions[]`), in display order. The engine still location-filters named actions. Measured: when omitted (and the host supplies nothing) the bar resolves NO actions and renders its empty placeholder — it does not fall back to "every action at this location", whatever the registration\'s input list claims.'), - requiredPermissions: z.array(z.string()).optional().describe('Hide the whole bar unless the current user holds every named permission on this object.'), + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), location: ActionLocationSchema.optional().describe('Which declared action location this bar renders (renderer default: `record_header`).'), align: z.enum(['start', 'center', 'end']).optional().describe('Horizontal alignment of the button row (renderer default: `end`).'), inline: z.boolean().optional().describe('Render in the flow instead of pulling up into the record-header band. The page header sets this itself when it hosts the bar in its own action slot.'),