diff --git a/.changeset/25760-controlled-by-parent-owner-may-edit.md b/.changeset/25760-controlled-by-parent-owner-may-edit.md new file mode 100644 index 00000000000..a1ce87fc539 --- /dev/null +++ b/.changeset/25760-controlled-by-parent-owner-may-edit.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': minor +'@objectstack/lint': minor +--- + +feat(spec,plugin-security,lint): a `controlled_by_parent` detail may opt its rows' owners into updating them with read access to the master — `sharing.controlledByParent.ownerMayEdit: true` + +Clause-②: yes (widening) — a detail row's owner now passes the master-detail write gate with READ on the master, on an object that declares the opt-in; everyone else, and every other shape of write, still needs master EDIT. The default is unchanged. +Metadata-protocol: added (`ObjectSchema.sharing.controlledByParent.ownerMayEdit`, a per-object opt-in read only under `sharingModel: 'controlled_by_parent'`) +Ruling-ref: 6398566164 (letter 1) + +ADR-0055 §3 had one rule for every write of a `controlled_by_parent` detail: EDIT access to the master record. Salesforce's Activities under OWD = Controlled by Parent let the owner of an activity edit it while merely reading its account; the platform could not express that, and an authored `owner_id == current_user.id` update policy on the detail could not either — the detail's own policies compose AND with the master gate, so it only narrowed the master's editors. + +- **Spec.** `ObjectSchema` gains `sharing: { controlledByParent: { ownerMayEdit: boolean } }`, both blocks closed. `ownerMayEdit: true` is refused at parse on any object that is not a `sharingModel: 'controlled_by_parent'` detail, at `sharing.controlledByParent.ownerMayEdit`. The reader `controlledByParentOwnerMayEdit(schema)` (`@objectstack/spec/data`) fails closed on every shape but the parsed `true`. +- **Gate.** On an opted-in detail, a by-id `update` by the caller whose `userId` is the stored row's `owner_id` (the registry's injected per-record owner) passes step 2.8 when the caller can READ the master row, asked of the read door under the caller's own context. Insert, delete, a predicate (`multi: true`) update and a delegated (on-behalf-of) write keep the master-edit rule; a body that re-points the master reference still asks EDIT on the master it names; a row with no owner admits nobody. The detail's own object grant and row-level policies run before the leg, unchanged. An opt-in on an object whose schema carries no `owner_id` column (`ownership` other than `'user'`) is inert and reported once per object at `warn` by the relation resolver. +- **Lint.** `os lint` reports `security-controlled-by-parent-owner-policy-without-opt-in` (warning) for an `update` or `all` row-level policy whose `using` or `check` keys a field on the caller (`owner_id == current_user.id`, either operand order, the SQL `=` spelling included) on a `controlled_by_parent` detail that has not declared the opt-in. `os explain security-controlled-by-parent-owner-policy-without-opt-in` prints why such a policy can only narrow, and what the opt-in changes. +- **ADR-0055** §3 is amended (2026-10-26, #25760) with the opt-in, its boundaries, and what stays out of scope. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 425b799a95d..9168815b6aa 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -970,6 +970,7 @@ Metadata query with filtering, sorting, and pagination | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index c98d0986658..e26e6057748 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -173,6 +173,7 @@ const result = ApiMethod.parse(data); | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -436,6 +437,12 @@ const result = ApiMethod.parse(data); | **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning | | **approvalsVisibleToReaders** | `boolean` | optional (default: `false`) | Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it | +### Nested Shape: `Object.sharing` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **controlledByParent** | `{ ownerMayEdit?: boolean }` | optional | Opt-ins read only under sharingModel: 'controlled_by_parent' (ADR-0055 §3): ownerMayEdit. | + ### Nested Shape: `Object.defaultRowScope` | Property | Type | Required | Description | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index a4d4316610a..110c79bf40e 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -355,6 +355,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -650,6 +651,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/docs/adr/0055-master-detail-controlled-by-parent.md b/docs/adr/0055-master-detail-controlled-by-parent.md index 3222b80b523..6b752b1683c 100644 --- a/docs/adr/0055-master-detail-controlled-by-parent.md +++ b/docs/adr/0055-master-detail-controlled-by-parent.md @@ -3,6 +3,7 @@ **Status**: Accepted (2026-06-19) — implemented in this PR (P0–P2) · **Amended** (2026-09-07, #11082 / PR #11183 — the "Single-level only in v1" limit under Consequences and the "Transitive nested master-detail chains" **Non-goal** are both **reversed**: since `@objectstack/plugin-security` 17.3.0 (merged 2026-08-23) the read derivation and the write gate compose `controlled_by_parent` across a chain, bounded by a cycle guard and a cost ceiling that both fail **closed**. Nothing else in this record changes — §1–§4, the mechanism choice, the set-size limit and the share-table non-goal all stand. See **"Amendment (2026-09-07, #11082): transitive chains compose — the single-level non-goal is reversed"** at the end.) · **Revisited in part** (2026-10-10, [ADR-0139](./0139-engine-internal-semi-join-leaf-readable-parent-ids.md), Proposed) — §2's alternatives row (a) is reversed for ONE engine-internal, non-authorable leaf the engine composes from the compiler's output; the RLS compiler still has no subquery form, and §2's chosen mechanism (b), its `rlsMembership` IN-form and the set-size limit all stand. · **Amended** (2026-10-22, #24932, ruling B `6303721609` and Q1 C / Q2 A `6329027469`) — **§2's chosen mechanism (b) is retired for `controlled_by_parent`, and the set-size limit with it**: the read derivation (`packages/plugins/plugin-security/src/security-plugin.ts#computeControlledByParentFilter`) answers ADR-0139's `ReadableIds` leaf over the master, the engine composes it from this plugin's own `canReadObject` / `getReadFilter` of the master, and the driver compiles one statement with the master inside a subquery — the seventh consumer of that leaf, in the end-state form. No master id set is pre-resolved any more. The 2026-10-10 line above reads as of its date. What still stands: §1 (the one required `master_detail`), §3 (the write gate), the cycle guard and the depth bound (now the engine's own cut, judged first over the registry), and the RLS compiler's `rlsMembership` IN-form, which serves `current_user.` memberships and never served this derivation. The limit that replaces the set-size ceiling is ADR-0139 D5's two master shapes, which the boot refuses (#25144). +· **Amended** (2026-10-26, #25760, ruling `6398566164`) — **§3 gains one per-object opt-in**: `sharing.controlledByParent.ownerMayEdit: true` lets the OWNER of a detail row (its `owner_id`) pass the write gate on a by-id `update` of that row with **read** access to the master; every other caller, and every insert, delete, predicate update and on-behalf-of write, keeps the master-**edit** rule. The default is unchanged. See **"Amendment (2026-10-26, #25760): the owner of a detail row may update it with read on the master, by opt-in"** at the end. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs) **Surfaced by**: an audit of master-detail permission semantics — `OWDModel.controlled_by_parent` is **declared but unenforced** (zero runtime consumers; not reachable through the object's `sharingModel` enum; the RLS compiler is relationship-blind). This is *false compliance* (ADR-0049) and *unproven liveness* (ADR-0054). @@ -59,6 +60,7 @@ In the pre-image block (`packages/plugins/plugin-security/src/security-plugin.ts - resolve the target's master id (`row[masterFieldKey]`; for `create`, the master id in the incoming body), and - re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny. - **Rule:** editing/deleting/creating a detail requires **edit** access to its master (Salesforce master-detail semantics). Reading a detail requires **read** access to its master (§2). + ⚠️ **Amended 2026-10-26 (#25760) — one opt-in.** A detail that declares `sharing.controlledByParent.ownerMayEdit: true` lets the **owner** of a detail row (`owner_id`) pass this gate on a **by-id `update`** of that row with **read** access to the master. The rule above stands for every other caller and every other write; see the Amendment at the end before deriving an invariant from this line. This reuses the existing re-read-and-deny pattern; no new enforcement layer. @@ -155,3 +157,34 @@ The value is not derivable from this tree; the constant's header records how it ### Why this was worth amending rather than left to the code An ADR recording a **narrower** enforcement than the runtime performs errs in the safe direction for users and the **unsafe direction for reviewers**. Someone reviewing a later change to this surface derives the invariant from this record, and would have read the chain walk as a deviation from a documented single-level boundary — the concrete hazard being that they "restore" single-level behaviour believing they are fixing a drift, re-opening the org-wide read and write above. That is why the two statements are marked **in place** rather than left to be rediscovered — and why the read derivation's own header points back at this record, naming the scope line it closes and noting that the ADR is amended separately. This is that amendment. + +--- + +## Amendment (2026-10-26, #25760): the owner of a detail row may update it with read on the master, by opt-in + +**Ruling.** objectstack-ai/objectstack#25760, maintainer ruling of record `6398566164` (batch #14 item 2, letter 1, 「同意」 2026-10-25): option 1 — a per-object opt-in on a `controlled_by_parent` detail, `sharing.controlledByParent.ownerMayEdit: true`. With it, the detail row's own owner passes the write gate with **read** on the master; everyone else still needs master **edit**. The default is unchanged. Option 2 (`writeRequires: 'read'`, the detail's own object permission and RLS deciding the write for everyone) was **not** chosen: it opens the write to everyone the detail's own grants admit, a wider door than the need. + +### The gap this closes + +§3 asks **edit** on the master for every write of a detail, and a detail's own row-level policies compose with that answer as an **AND** (§2: "composes with the detail's own tenant/owner RLS (all AND-ed)"). So an `update` policy written on a detail to let the row's owner write it — `owner_id == current_user.id` — could only ever **narrow** the master's editors to the owner; it never admitted an owner who merely reads the master. That is the common shape, not an app quirk: the person a task, an activity or an obligation is assigned to reads the account or the contract and does not edit it (the originating measurement: a `controlled_by_parent` obligation under a signed contract, whose owner's PATCH answered `403 … requires edit access to its master record (… row-level security)` while `security/explain` answered `allowed: true`). Salesforce, the model this record closes gaps against, keeps **"the owner of an activity always has full access to it"** under Activities OWD = Controlled by Parent; this platform had no equivalent. + +### What the opt-in does, exactly + +- **Spec.** `ObjectSchema` gains a strict `sharing` block with one nested block, `controlledByParent`, with one key, `ownerMayEdit: boolean` (`packages/spec/src/data/object.zod.ts`). The one reader is `controlledByParentOwnerMayEdit`, exported beside the declaration, so the gate, the parse and the lint ask one question one way. The parse **refuses** `ownerMayEdit: true` on an object whose `sharingModel` is not `controlled_by_parent`: nothing reads it there, and a declared capability the runtime never delivers is the ADR-0049 shape §1 already refuses for the master relation. +- **Gate.** `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite` gains one leg, before the §3 chain walk, for a **by-id `update`** only. It admits the write when **all** hold: the detail's resolved relation carries the opt-in **and** the registered schema carries the `owner_id` column (`CbpRelation.ownerMayEdit`, resolved beside the relation in `security-plugin-cbp-relation.ts`); the write is **not** on behalf of another user; the stored row's `owner_id` is the caller's own `userId`; and the caller can **read** the master row, asked of the read door itself under the caller's context — the master's object grant, its row-level and sharing scope and, for a master that is itself `controlled_by_parent`, the chain the engine composes (ADR-0139 D6). A hidden row and a refused read are one answer, "not readable" (#7505). When any condition fails, the write takes the §3 walk and carries the walk's own refusal; the leg adds no envelope of its own. +- **What the leg does not change.** Gate ① (the detail's own object-level `update` grant) and step 2.7 (the detail's own row-level policies, where `owner_id == current_user.id` now decides as authored) run before it, unchanged. A body that re-points the master reference still asks **edit** on the master it names: the owner may change their row, not move it under a master they cannot edit. The on-behalf-of path keeps both floors, as ADR-0090 D10 keeps them for every other relief. +- **Lint.** `@objectstack/lint` gains `security-controlled-by-parent-owner-policy-without-opt-in` (`warning`): an `update` / `all` policy that keys a field on `current_user.id`, on a `controlled_by_parent` detail of the stack that has **not** opted in, is flagged with the opt-in as the fix. Advisory, because a narrowing can be meant and no runtime door refuses the shape. + +### What is out of scope, and why + +The ruling names **update**. So the leg does not reach: an **insert** (no row to own; the master named in the body is asked for edit as before), a **delete** (deleting a child is still answered by the master, as §3 and `masterGateCoversOperation` have it), a **predicate** (`multi: true`) update (it judges every master of the rows it selects and has no single row whose owner could be asked), and a **delegated** write. Each is a widening of its own, to be ruled on its own evidence; none is implied by this one. Option 2's door — the detail's own grants deciding the write for everyone — stays closed. + +### Fail-closed boundaries + +- An opt-in on an object whose schema carries no `owner_id` column (`ownership: 'business_unit' | 'org' | 'none'`, or a platform-managed object) names nobody: the relation resolver resolves `ownerMayEdit: false`, reports it once per object (`reportOwnerMayEditWithoutOwnerColumn`), and every write keeps the §3 rule. The parse cannot refuse this half (the injected-column plan imports `object.zod.ts`; the same cycle its attached-block refusal stops at), so it is said at resolution. +- A row with no owner, or another's, admits nobody; a master the caller cannot read admits nobody; a store fault during the master read propagates as raised, never as a verdict. +- The opt-in is a **declaration on the detail**, read through one spec reader: the gate never pattern-matches a policy's text, and no consumer tolerates an alias. + +### What is unchanged + +§1 (one required `master_detail`, no author-written derivation), §2 (read derived from the master), the chain walk, the cycle guard and the depth bound all stand. Every detail that does not declare the opt-in behaves byte-for-byte as before this amendment. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index afd86d8067f..3178141232e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 159 | 82 | 2 | 0 | 75 | +| `data/` | 161 | 84 | 2 | 0 | 75 | ## `data/` — sites @@ -50,13 +50,13 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 7 | | `mapping.zod.ts` | 5 | -| `object.zod.ts` | 21 | +| `object.zod.ts` | 23 | | `picklist.zod.ts` | 3 | | `query.zod.ts` | 5 | | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **159** | +| **total** | **161** | ## `data/` — open @@ -64,7 +64,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**75 strip of 159**, in 10 file(s). +**75 strip of 161**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -78,7 +78,7 @@ over it is here. | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **75** | **159** | +| **total** | **75** | **161** | | Bucket | Sites | |---|---| diff --git a/packages/core/src/security/auth-gate.test.ts b/packages/core/src/security/auth-gate.test.ts index 020a2cc6c13..da374011349 100644 --- a/packages/core/src/security/auth-gate.test.ts +++ b/packages/core/src/security/auth-gate.test.ts @@ -568,6 +568,11 @@ describe('auth-gate (ADR-0069 session gate)', () => { // (path, method) answers that moved must be EXACTLY the hook route — POST, // in either case — unscoped or under one `/environments/:id` scope, every // move a newly exempt answer, none newly gated. + // 30 s, not vitest's 5 s default: this boundary walks about 133k paths × 6 + // methods through the predicate. It takes about 1.3 s on an idle box and + // 5.4 s on a Test Core runner that carries core beside spec's two test tasks + // (PR #26005, shard 5, three runs in a row), where the default refused a + // healthy enumeration. The timeout is a ceiling on load, not a budget. it('BOUNDARY — newly exempt is exactly POST on the four-segment hook route, unscoped or scoped, nothing else', () => { const PRE_MOUNT_BASES: readonly (readonly string[])[] = [['api', 'v1'], ['api'], []]; const PRE_SCOPE_SEGMENTS: readonly string[] = ['environments', 'projects']; @@ -652,7 +657,7 @@ describe('auth-gate (ADR-0069 session gate)', () => { expect(inCorpus.has(p), p).toBe(true); expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(false); } - }); + }, 30_000); }); describe('evaluateAuthGate', () => { diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 6ca352cc6df..55489ae72d6 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -344,6 +344,7 @@ export { SECURITY_DELEGATION_MISSING_REASON, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION, + SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, } from './validate-security-posture.js'; export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js'; diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 6a2fa5fba76..61a7653c826 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -696,6 +696,34 @@ const SECURITY_CBP_NO_RELATION_EXPLANATION: RuleExplanation = { ], }; +const SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION: RuleExplanation = { + rule: 'security-controlled-by-parent-owner-policy-without-opt-in', + covers: 'why a detail policy keyed on the caller cannot admit', + paragraphs: [ + 'A `controlled_by_parent` detail derives its record access from its master (ADR-0055). On a write, ' + + 'the master gate asks for EDIT access to the master row, and the detail\'s own row-level policies ' + + 'compose with that answer as an AND: they can take rows away from a caller the master gate admits, ' + + 'and they can never add one. So an `update` policy that keys a field on the caller — ' + + '`owner_id == current_user.id` — narrows the callers who may edit the master to the row\'s owner, ' + + 'and admits nobody who merely reads the master. For a row whose owner is not a master editor ' + + '(the common case: the person a task or an obligation is assigned to reads the account or the ' + + 'contract) the policy is dead, and the write is refused by the master gate as if it were absent.', + 'The opt-in `sharing: { controlledByParent: { ownerMayEdit: true } }` on the detail is what changes ' + + 'the gate\'s answer (ADR-0055 §3 as amended): the owner of a detail row — its `owner_id`, the ' + + 'platform\'s injected per-record owner — updates that row by id with READ access to the master, ' + + 'and the detail\'s own policies then decide as authored. Everyone else, and every insert, delete, ' + + 'multi-row update and on-behalf-of write, still needs edit access to the master. The opt-in ' + + 'reads `owner_id` and nothing else: a policy keyed on another field stays a narrowing even ' + + 'after it, so key the policy on `owner_id` or declare what the other field means in prose.', + 'This is a warning, not an error: a narrowing can be meant (only the owner among the master\'s ' + + 'editors may update), and no runtime door refuses the shape. To fix it, declare the opt-in on ' + + 'the detail when the owner is meant to write with read on the master, or drop the policy when ' + + 'the master gate alone is the intended rule. The opt-in is refused by the parse on an object ' + + 'whose `sharingModel` is not `controlled_by_parent`, so it cannot be declared where nothing ' + + 'reads it.', + ], +}; + const SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = { rule: 'security-fls-unknown-field', covers: 'how the runtime resolves a field-permission key', @@ -4987,6 +5015,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [DATASET_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: DATASET_FILTER_FIELD_UNKNOWN_EXPLANATION, [SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION.rule]: SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION, [SECURITY_CBP_NO_RELATION_EXPLANATION.rule]: SECURITY_CBP_NO_RELATION_EXPLANATION, + [SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION.rule]: SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION, [SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION.rule]: SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION, [SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION.rule]: SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION, [SECURITY_WILDCARD_VAMA_EXPLANATION.rule]: SECURITY_WILDCARD_VAMA_EXPLANATION, diff --git a/packages/lint/src/validate-security-posture-owner-may-edit.test.ts b/packages/lint/src/validate-security-posture-owner-may-edit.test.ts new file mode 100644 index 00000000000..157430fbb6f --- /dev/null +++ b/packages/lint/src/validate-security-posture-owner-may-edit.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0055 §3 amendment · #25760 · ruling 6398566164] + * `security-controlled-by-parent-owner-policy-without-opt-in`: an `update` + * (or `all`) row-level policy keyed on the caller — `owner_id == + * current_user.id` — on a `controlled_by_parent` detail that has NOT declared + * `sharing: { controlledByParent: { ownerMayEdit: true } }` can only narrow + * the master's editors; it never admits the row's owner, which is what such a + * policy is written to do. Advisory (`warning`): a narrowing can be meant, and + * no runtime door refuses the shape. + * + * The positive control comes first; every silence below is measured against + * the same fixture with one thing changed. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackSchema } from '@objectstack/spec'; +import { ObjectSchema } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, validateSecurityPosture } from './validate-security-posture.js'; +import { explainRule } from './rule-explanations.js'; + +const MASTER = { name: 'omd_contract', label: 'Contract', sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }; +const DETAIL_FIELDS = { + name: { name: 'name', type: 'text', label: 'Name' }, + contract: { name: 'contract', type: 'master_detail', label: 'Contract', reference: 'omd_contract', required: true }, +}; +/** A `controlled_by_parent` detail with one resolving master, plus `extra` (the opt-in, or nothing). */ +const detail = (extra: Record = {}) => ({ + name: 'omd_obligation', label: 'Obligation', sharingModel: 'controlled_by_parent', fields: DETAIL_FIELDS, ...extra, +}); +const OPT_IN = { sharing: { controlledByParent: { ownerMayEdit: true } } }; + +type Policy = Record; +const policy = (overrides: Policy = {}): Policy => ({ + name: 'omd_obligation_owner_updates', object: 'omd_obligation', operation: 'update', using: 'owner_id == current_user.id', ...overrides, +}); +const stack = (objects: unknown[], ...policies: Policy[]) => ({ + objects, + permissions: [{ name: 'omd_requester', label: 'Requester', objects: {}, rowLevelSecurity: policies }], +}); + +const ownerArm = (s: Record) => + validateSecurityPosture(s).filter((f) => f.rule === SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN); + +describe('security-controlled-by-parent-owner-policy-without-opt-in (#25760)', () => { + // ── POSITIVE CONTROL — the arm must be able to SEE before any silence below is worth believing. + it('flags an owner-keyed update policy on a controlled_by_parent detail that has not opted in — one warning, at the predicate', () => { + const findings = ownerArm(stack([MASTER, detail()], policy())); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, + where: 'permission set "omd_requester"', + path: 'permissions[0].rowLevelSecurity[0].using', + }); + expect(findings[0].message).toContain('update policy "omd_obligation_owner_updates" keys on the caller (owner_id == current_user.id)'); + expect(findings[0].message).toContain('controlled_by_parent detail "omd_obligation", which has not opted in'); + expect(findings[0].message).toContain('never admits the owner'); + expect(findings[0].hint).toContain('sharing: { controlledByParent: { ownerMayEdit: true } }'); + expect(findings[0].hint).toContain('"omd_obligation"'); + }); + + it('silent once the detail declares the opt-in — the policy then decides as authored', () => { + expect(ownerArm(stack([MASTER, detail(OPT_IN)], policy()))).toEqual([]); + }); + + it('`os explain` carries the rule', () => { + const explanation = explainRule(SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN); + expect(explanation?.rule).toBe(SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN); + expect(explanation?.paragraphs.join('\n')).toContain('ownerMayEdit'); + }); + + describe('the predicate shapes that key on the caller', () => { + it.each([ + ['the SQL spelling the compiler bridges', 'owner_id = current_user.id'], + ['the operands reversed', 'current_user.id == owner_id'], + ['the record-qualified field', 'record.owner_id == current_user.id'], + ['a conjunction carrying the identity term', "owner_id == current_user.id && stage == 'open'"], + ['a disjunction carrying the identity term', "stage == 'open' || owner_id == current_user.id"], + ])('%s', (_label, using) => { + const findings = ownerArm(stack([MASTER, detail()], policy({ using }))); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('(owner_id == current_user.id)'); + }); + + it('names every caller-keyed field once, in predicate order', () => { + const findings = ownerArm(stack([MASTER, detail()], policy({ using: 'assignee == current_user.id || owner_id == current_user.id || owner_id == current_user.id' }))); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('(assignee == current_user.id, owner_id == current_user.id)'); + }); + + it('a `check` predicate is judged too, at its own path; `using` and `check` each report once', () => { + const findings = ownerArm(stack([MASTER, detail()], policy({ using: "stage == 'open'", check: 'owner_id == current_user.id' }))); + expect(findings.map((f) => f.path)).toEqual(['permissions[0].rowLevelSecurity[0].check']); + const both = ownerArm(stack([MASTER, detail()], policy({ check: 'owner_id == current_user.id' }))); + expect(both.map((f) => f.path)).toEqual([ + 'permissions[0].rowLevelSecurity[0].using', + 'permissions[0].rowLevelSecurity[0].check', + ]); + }); + + it.each([ + ['a predicate that does not key on the caller', "stage == 'open'"], + ['a comparison other than equality', 'owner_id != current_user.id'], + ['the caller compared to a literal, not a field', "current_user.id == 'usr_1'"], + ['a traversal, which the compiler refuses and is not a field', 'contract.owner_id == current_user.id'], + ['a predicate that does not parse (the parse rule reports it)', 'owner_id == ('], + ['an empty predicate', ''], + ])('silent on %s', (_label, using) => { + expect(ownerArm(stack([MASTER, detail()], policy({ using })))).toEqual([]); + }); + }); + + describe('which policies are the arm\'s subject', () => { + it('`all` covers update and is flagged; select, insert and delete are not', () => { + expect(ownerArm(stack([MASTER, detail()], policy({ operation: 'all' })))).toHaveLength(1); + for (const operation of ['select', 'insert', 'delete']) { + expect(ownerArm(stack([MASTER, detail()], policy({ operation }))), operation).toEqual([]); + } + }); + + it('a disabled policy is not judged', () => { + expect(ownerArm(stack([MASTER, detail()], policy({ enabled: false })))).toEqual([]); + expect(ownerArm(stack([MASTER, detail()], policy({ enabled: true })))).toHaveLength(1); + }); + + it("a wildcard policy ('*') names no detail", () => { + expect(ownerArm(stack([MASTER, detail()], policy({ object: '*' })))).toEqual([]); + }); + + it('each policy reports at its own index, across sets', () => { + const findings = validateSecurityPosture({ + objects: [MASTER, detail()], + permissions: [ + { name: 'first', objects: {}, rowLevelSecurity: [policy({ using: "stage == 'open'" }), policy({ name: 'second_of_first' })] }, + { name: 'second', objects: {}, rowLevelSecurity: [policy({ name: 'first_of_second', operation: 'all' })] }, + ], + }).filter((f) => f.rule === SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN); + expect(findings.map((f) => [f.where, f.path])).toEqual([ + ['permission set "first"', 'permissions[0].rowLevelSecurity[1].using'], + ['permission set "second"', 'permissions[1].rowLevelSecurity[0].using'], + ]); + expect(findings[0].message).toContain('"second_of_first"'); + expect(findings[1].message).toContain('"first_of_second"'); + }); + }); + + describe('which objects are the arm\'s subject', () => { + it('silent on a private object: its own update policy admits as authored', () => { + expect(ownerArm(stack([MASTER, detail({ sharingModel: 'private' })], policy()))).toEqual([]); + }); + + it('silent on a controlled_by_parent object whose master does not resolve — the no-relation rule owns that object', () => { + const findings = validateSecurityPosture(stack([MASTER, detail({ fields: { name: DETAIL_FIELDS.name } })], policy())); + expect(findings.map((f) => f.rule)).toContain('security-controlled-by-parent-no-relation'); + expect(findings.map((f) => f.rule)).not.toContain(SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN); + }); + + it('silent on a policy naming an object the stack does not declare', () => { + expect(ownerArm(stack([MASTER, detail()], policy({ object: 'omd_elsewhere' })))).toEqual([]); + }); + + it('`ownerMayEdit: false` is not an opt-in', () => { + expect(ownerArm(stack([MASTER, detail({ sharing: { controlledByParent: { ownerMayEdit: false } } })], policy()))).toHaveLength(1); + }); + }); + + describe('the parsed path reads what the raw path reads', () => { + it('the PARSED detail and set: flagged without the opt-in, silent with it', () => { + const parsedSet = PermissionSetSchema.parse({ name: 'omd_requester', objects: {}, rowLevelSecurity: [policy()] }); + const without = { objects: [ObjectSchema.parse(MASTER), ObjectSchema.parse(detail())], permissions: [parsedSet] }; + expect(ownerArm(without)).toHaveLength(1); + const withOptIn = { objects: [ObjectSchema.parse(MASTER), ObjectSchema.parse(detail(OPT_IN))], permissions: [parsedSet] }; + expect(ownerArm(withOptIn)).toEqual([]); + }); + + it('the fixture is key-legal: the stack schema refuses no key it carries', () => { + const result = ObjectStackSchema.safeParse({ + manifest: { id: 'com.example.omd', name: 'omd', version: '1.0.0', type: 'app' }, + ...stack([MASTER, detail(OPT_IN)], policy()), + }); + expect(result.success ? [] : result.error.issues.filter((i) => i.code === 'unrecognized_keys')).toEqual([]); + }); + }); +}); diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index 7e910ba7837..2720bdcf38d 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -30,6 +30,7 @@ import { SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION, SECURITY_MODIFY_ALL_DELETE_WITHHELD, + SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, } from './validate-security-posture.js'; import { lintDataModel } from './data-model-rules.js'; import { explainRule } from './rule-explanations.js'; @@ -1568,6 +1569,16 @@ const NOT_SCHEMA_RECEIVERS: Record = { visible: "a `VisibleExtension` — this file's own fold of one extension entry, not an authored surface.", left: 'a `VisibleExtension`, the left operand of the fold-order comparator.', right: 'a `VisibleExtension`, the right operand of the fold-order comparator.', + // [#25760] `callerIdentityKeyedFields` walks the formula package's own CEL + // AST (`CelAstNode`, `{ op, args }`), the parse of a predicate string — not + // an authored surface; the authored read behind it is `policy[predicate]`. + n: "a `CelAstNode` — the formula package's parse of a predicate, not an authored surface.", + r: 'a `CelAstNode` — the receiver operand of a member access in that parse.', + // [#25760] Not a receiver at all: the predicate grammar's caller-identity + // spelling (`current_user.id`), quoted inside the owner arm's verdict and + // hint text. The scan reads code with its string literals intact, so the + // spelling presents as a receiver `current_user` reading a key `id`. + current_user: "the RLS predicate grammar's `current_user.id`, a string literal in the owner arm's message text — not a receiver.", }; const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [ @@ -1599,10 +1610,23 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s { receiver: 'o', expected: ['name'], declaredBy: 'ObjectSchema', keys: () => Object.keys(ObjectSchema.shape) }, { receiver: 'ps', - expected: ['fields', 'isDefault', 'label', 'name', 'objects'], + // [#25760] `rowLevelSecurity` joined when the controlled-by-parent owner + // arm started reading a set's update policies. + expected: ['fields', 'isDefault', 'label', 'name', 'objects', 'rowLevelSecurity'], declaredBy: 'PermissionSetSchema', keys: () => Object.keys(PermissionSetSchema.shape), }, + // [#25760] One row-level policy, read by the controlled-by-parent owner arm + // for the keys that decide whether it is that arm's subject. `using` and + // `check` are read through a computed key (`policy[predicate]`), which the + // scan cannot see; the corpus fixture below carries both spellings, so a + // retired `check` would still fail the key-legality guard. + { + receiver: 'policy', + expected: ['enabled', 'name', 'object', 'operation'], + declaredBy: 'PermissionSetSchema.rowLevelSecurity[]', + keys: () => shapeKeysOf(PermissionSetSchema.shape.rowLevelSecurity), + }, // `reference_to` is absent — the other half of the #5017 fix. { receiver: 'def', expected: ['reference'], declaredBy: 'FieldSchema', keys: () => Object.keys(FieldSchema.shape) }, // `required` joined this list with #7503 and left it with #23272: what @@ -1756,6 +1780,10 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te // #23210: the merged-shape pass's own collections — JS Map / Set / Array methods. 'bodies', 'entries', 'targets', 'owners', 'extensions', 'byCarrier', 'writers', 'tiedWriters', 'merged', 'baseFields', 'blamed', + // #25760: the controlled-by-parent owner arm's own collections and the predicate string. + 'cbpDetailsWithoutOptIn', // the detail names without the opt-in — JS Set methods. + 'policies', // one set's `rowLevelSecurity` records — `.length`, a JS property. + 'predicate', // a policy's `using` / `check` text, a string — `.trim`. ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); @@ -1870,6 +1898,7 @@ const RULE_IDS: Record = { SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION, SECURITY_MODIFY_ALL_DELETE_WITHHELD, + SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, }; const TEXT_FIELD = { a: { type: 'text', label: 'A' } } as const; @@ -1961,6 +1990,26 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: Record ], }, }, + { + // [#25760] A resolving controlled_by_parent detail with no opt-in, and an + // `update` policy keyed on the caller — every key the arm reads + // (`rowLevelSecurity[].{name,object,operation,using,check,enabled}` and + // the detail's `sharing`) is one an author can legally write. + label: 'cbp-owner-policy-without-opt-in', + stack: { + objects: [ + objectFixture({ name: 'invoice', sharingModel: 'private' }), + objectFixture({ + name: 'line_item', sharingModel: 'controlled_by_parent', + fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true } }, + }), + ], + permissions: [{ + name: 'ps', label: 'PS', objects: {}, + rowLevelSecurity: [{ name: 'own_lines', object: 'line_item', operation: 'update', using: 'owner_id == current_user.id', enabled: true }], + }], + }, + }, { label: 'grant-expired-at-authoring', stack: { data: [{ object: 'sys_user_position', records: [{ user_id: 'u1', position: 'p', valid_until: '2020-01-01T00:00:00Z' }] }] }, @@ -1983,7 +2032,8 @@ describe('validateSecurityPosture — every branch is reachable without an undec it('maps every `findings.push` site in the source', () => { // 16 since #23268: the external dial's alias and wider-than-internal sites left with the key. // 17 since #25575: allowDelete: false beside modifyAllRecords: true. - expect(pushedRuleIds()).toHaveLength(17); + // 18 since #25760: an owner-keyed update policy on a controlled_by_parent detail that has not opted in. + expect(pushedRuleIds()).toHaveLength(18); }); it('reaches every `findings.push` site from that corpus', () => { diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 5ba60477883..6f7ce759dac 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -21,6 +21,7 @@ * | security-cbp-no-relation (error) | #7503 (runtime refusal #7474) | * | security-cbp-ambiguous-relation(error) | #14747 order-dependent master; runtime refusal since #23100 | * | security-modify-all-delete-withheld(error) | #25575 Modify All includes delete — UNPARSED intakes only, see the arm | + * | security-controlled-by-parent-owner-policy-without-opt-in (warning) | ADR-0055 §3 amendment (ruling 6398566164): an owner-keyed update policy on a detail that has not opted in cannot admit the owner | * * Per ADR-0049 discipline these are NOT advisory security: every `error` rule * mirrors a runtime enforcement point (D1 fail-closed OWD default, D4 @@ -119,7 +120,8 @@ * credit belongs to the schema's closed enum. */ -import { isControlledByParentMasterField, referenceCarrierOf } from '@objectstack/spec/data'; +import { parseCelToAst, sqlPredicateToCel, type CelAstNode } from '@objectstack/formula'; +import { controlledByParentOwnerMayEdit, isControlledByParentMasterField, referenceCarrierOf } from '@objectstack/spec/data'; import { describeAnchorForbiddenBits, type AnchorBindingContext } from '@objectstack/spec/security'; import { indexObjectGraph, nearestName, packagesOf, recordsOf, type ObjectGraph } from './object-graph.js'; @@ -138,6 +140,7 @@ export const SECURITY_DELEGATION_MISSING_REASON = 'security-delegation-missing-r export const SECURITY_CBP_NO_RELATION = 'security-controlled-by-parent-no-relation'; export const SECURITY_CBP_AMBIGUOUS_RELATION = 'security-controlled-by-parent-ambiguous-relation'; export const SECURITY_MODIFY_ALL_DELETE_WITHHELD = 'security-modify-all-delete-withheld'; +export const SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN = 'security-controlled-by-parent-owner-policy-without-opt-in'; export type SecuritySeverity = 'error' | 'warning' | 'info'; @@ -402,6 +405,100 @@ function grantsObjectAccess(p: AnyRec): boolean { ); } +/** + * [ADR-0055 §3 amendment · ruling 6398566164] The RLS write classes a policy + * must be in to be the `update` policy the owner arm judges: `update`, or + * `all`, which covers it. A `select`, `insert` or `delete` policy keyed on + * the caller is read scoping or another verb's rule, never this arm's subject. + */ +const UPDATE_CLASS_RLS_OPERATIONS: ReadonlySet = new Set(['update', 'all']); + +/** + * The record fields a predicate compares to the CALLER'S identity — + * `FIELD == current_user.id` in either operand order, under `&&` / `||` / + * parentheses — in walk order, each once. + * + * Read off the canonical CEL parse (`parseCelToAst`, the one front end every + * consumer in this repo shares, after the SQL bridge `sqlPredicateToCel` that + * the compiler itself applies to a stored `owner_id = current_user.id`), and + * judged by SHAPE on that AST rather than compiled: this arm asks only which + * fields a policy keys on the caller, never whether the policy lowers — that + * is `rls-predicate-*`'s question, answered on the compiler's own output. A + * predicate that does not parse keys on nothing here; the parse rule reports + * it. The field spellings are the compiler's (`classify` in + * `cel-to-filter.ts`): a bare identifier, or `record.FIELD`; a longer path is + * a traversal the compiler refuses and is not a field. + */ +function callerIdentityKeyedFields(predicate: unknown): string[] { + if (typeof predicate !== 'string' || predicate.trim() === '') return []; + const ast = parseCelToAst(sqlPredicateToCel(predicate)); + if (!ast) return []; + const fields: string[] = []; + const node = (v: unknown): CelAstNode | null => + v !== null && typeof v === 'object' && 'op' in (v as object) ? (v as CelAstNode) : null; + const isCallerId = (v: unknown): boolean => { + const n = node(v); + if (!n || n.op !== '.' || !Array.isArray(n.args)) return false; + const [recv, member] = n.args as [unknown, unknown]; + const r = node(recv); + return r !== null && r.op === 'id' && r.args === 'current_user' && member === 'id'; + }; + const fieldOf = (v: unknown): string | null => { + const n = node(v); + if (!n) return null; + if (n.op === 'id') return typeof n.args === 'string' ? n.args : null; + if (n.op !== '.' || !Array.isArray(n.args)) return null; + const [recv, member] = n.args as [unknown, unknown]; + const r = node(recv); + return r !== null && r.op === 'id' && r.args === 'record' && typeof member === 'string' ? member : null; + }; + const walk = (v: unknown): void => { + const n = node(v); + if (!n) return; + if (n.op === '==' && Array.isArray(n.args) && n.args.length === 2) { + const [left, right] = n.args as [unknown, unknown]; + const field = isCallerId(left) ? fieldOf(right) : isCallerId(right) ? fieldOf(left) : null; + if (field !== null && !fields.includes(field)) fields.push(field); + return; + } + if (Array.isArray(n.args)) for (const item of n.args) walk(item); + else walk(n.args); + }; + walk(ast); + return fields; +} + +/** + * `security-controlled-by-parent-owner-policy-without-opt-in` for one policy, + * at `path` (the predicate that keys on the caller). + */ +function pushCbpOwnerPolicyWithoutOptIn( + findings: SecurityFinding[], + psName: string, + path: string, + policyName: string, + objName: string, + fields: readonly string[], +): void { + const keyed = fields.map((f) => `${f} == current_user.id`).join(', '); + findings.push({ + severity: 'warning', + rule: SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, + where: `permission set "${psName}"`, + path, + // One verdict sentence; why a detail's own policy can only narrow, and + // what the opt-in changes, is this id's `os explain` entry. + message: + `update policy "${policyName}" keys on the caller (${keyed}) on controlled_by_parent detail ` + + `"${objName}", which has not opted in, so it never admits the owner: every write of a detail needs ` + + `edit access to its master, and this policy can only narrow master editors`, + // One remedy line. + hint: + `Declare sharing: { controlledByParent: { ownerMayEdit: true } } on "${objName}" so its owner_id updates ` + + `with read access to the master, or drop the policy`, + }); +} + /** * `security-controlled-by-parent-no-relation` for one object, at `path`. One * site for the declared object and the merged one @@ -729,6 +826,60 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } // master_detail fields, or none. See {@link pushExtensionMergedCbpFindings}. pushExtensionMergedCbpFindings(findings, stack); + // ── [ADR-0055 §3 amendment · ruling 6398566164] an owner-keyed update ── + // ── policy on a detail that has not opted in ─────────────────────────── + // A `controlled_by_parent` detail's by-id write passes the master gate + // (step 2.8) only with EDIT access on the master, and the detail's own + // row-level policies compose AND with it (step 2.7). So an `update` policy + // keyed on the caller — `owner_id == current_user.id` — can only NARROW the + // callers who may edit the master to the row's owner; it never admits an + // owner who merely reads the master, which is what such a policy is written + // to do. The opt-in `sharing.controlledByParent.ownerMayEdit: true` is what + // changes the gate's answer (the owner of a row updates it by id with read + // on the master); this arm flags the policy on a detail that has not + // declared it. Advisory `warning`, not `error`: a narrowing can be meant + // (only the owner among the master's editors), and no runtime door refuses + // the shape — the write is refused per row, by the master gate, as it was. + // + // Judged on the detail the policy names, as declared in THIS stack, and + // only where its master resolves (an object that derives no master is + // refused whole by the two rules above). A wildcard policy names no detail. + // The opt-in is read through the spec's one reader, raw or parsed, so the + // unparsed doors (`os lint` on a raw config) read it as the parse does. + const cbpDetailsWithoutOptIn = new Set(); + for (const obj of objects) { + if (!obj || typeof obj !== 'object' || typeof obj.name !== 'string') continue; + if (owdOf(obj) !== 'controlled_by_parent' || !resolveCbpRelation(obj)) continue; + if (!controlledByParentOwnerMayEdit(obj)) cbpDetailsWithoutOptIn.add(obj.name); + } + if (cbpDetailsWithoutOptIn.size > 0) { + for (let i = 0; i < permissionSets.length; i++) { + const ps = permissionSets[i]; + if (!ps || typeof ps !== 'object') continue; + const psName = typeof ps.name === 'string' ? ps.name : `(permission set ${i})`; + const policies = recordsOf(ps.rowLevelSecurity); + for (let j = 0; j < policies.length; j++) { + const policy = policies[j]; + if (!policy || typeof policy !== 'object' || policy.enabled === false) continue; + if (typeof policy.object !== 'string' || !cbpDetailsWithoutOptIn.has(policy.object)) continue; + if (!UPDATE_CLASS_RLS_OPERATIONS.has(String(policy.operation ?? ''))) continue; + const policyName = typeof policy.name === 'string' ? policy.name : `(policy ${j})`; + for (const predicate of ['using', 'check'] as const) { + const fields = callerIdentityKeyedFields(policy[predicate]); + if (fields.length === 0) continue; + pushCbpOwnerPolicyWithoutOptIn( + findings, + psName, + `permissions[${i}].rowLevelSecurity[${j}].${predicate}`, + policyName, + policy.object, + fields, + ); + } + } + } + } + // [#16108] The object graph, for the FLS field-existence rule below. Built // ONCE here rather than per permission set: `indexObjectGraph` walks every // object's whole field map, and a stack with N sets would otherwise pay for diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 499b92f5515..fbb83f3e1ff 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -733,6 +733,9 @@ export const OBJECT_REFERENCE_POSITIONS: Readonly> = { managedBy: keep, ownership: keep, systemFields: keep, datasource: keep, access: keep, requiredPermissions: keep, fileAccessDelegate: keep, editMode: keep, enable: keep, sharingModel: keep, externalSharingModel: keep, protection: keep, + // [#25760] The per-object sharing opt-ins (`sharing.controlledByParent.ownerMayEdit`, + // ADR-0055 §3 amendment): a block of booleans that names no field. + sharing: keep, // [#22386] Read attachments: a block name and its leaf keys name what a // service attaches to a served row, never a field — the spec refuses a // block that repeats a declared field name — so nothing here can be denied. diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index d1c3b130754..bc317b9eb43 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -312,7 +312,8 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * blocks a service attaches per caller on read), 45 → 46 when it gained * `defaultRowScope` (#24338, the object's default row scope), and 46 → 47 * when it gained `membershipKeys` (#25234, the roster object's declarative - * membership keys). `field` moved 75 → 77 the + * membership keys), and 47 → 48 when it gained `sharing` (#25760, the + * per-object sharing opt-ins of the ADR-0055 §3 amendment). `field` moved 75 → 77 the * same way when it gained `dueLike` and `settledWhen` (#22227, a date's * deadline and when it is settled), 77 → 78 when it gained * `conditionalFormatting` (#22228, a field's cell formatting rules), and @@ -329,7 +330,7 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no */ const CARD_PROPERTY_COUNTS: Record = { agent: 26, app: 30, dashboard: 22, dataset: 16, field: 80, flow: 24, - hook: 22, object: 47, page: 26, position: 13, report: 21, skill: 17, tool: 14, + hook: 22, object: 48, page: 26, position: 13, report: 21, skill: 17, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 8a8bd3d8db4..ae59c273659 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -353,6 +353,18 @@ export const enMetadataForms: NonNullable = { label: "Sharing Model", helpText: "Org-Wide Default record visibility for internal users. A custom object that omits it resolves to private at runtime (ADR-0090 D1)." }, + sharing: { + label: "Sharing", + helpText: "Per-object sharing opt-ins that refine the org-wide default above; they never replace it. Unset: the default of each opt-in applies." + }, + "sharing.controlledByParent": { + label: "Controlled by parent", + helpText: "Read only while sharingModel is 'controlled by parent'; refused on any other sharing model." + }, + "sharing.controlledByParent.ownerMayEdit": { + label: "Owner may edit", + helpText: "On: the owner of a detail row (its owner_id) may update that row by id with read access to the master; everyone else, and every insert, delete, multi-row update and on-behalf-of write, still needs edit access to the master. Off (the default): every write of a detail needs edit access to its master." + }, access: { label: "Access", helpText: "Wildcard-grant posture (ADR-0066 D2). Absent resolves to public. It decides whether a permission set's '*' object grant covers this object; record visibility between users is sharingModel." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 409018c8da6..9efad055ce2 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -353,6 +353,18 @@ export const esESMetadataForms: NonNullable = label: "Modelo de uso compartido", helpText: "Visibilidad predeterminada de la organización (OWD) para usuarios internos. Un objeto personalizado que lo omita se resuelve como private en tiempo de ejecución (ADR-0090 D1)." }, + sharing: { + label: "Compartición", + helpText: "Opciones de compartición por objeto que afinan el valor predeterminado de toda la organización indicado arriba; nunca lo sustituyen. Sin definir: se aplica el valor predeterminado de cada opción." + }, + "sharing.controlledByParent": { + label: "Controlado por el registro principal", + helpText: "Solo se lee cuando sharingModel es 'controlled by parent'; con cualquier otro modelo de compartición se rechaza." + }, + "sharing.controlledByParent.ownerMayEdit": { + label: "El propietario puede editar", + helpText: "Activado: el propietario de una fila de detalle (su owner_id) puede actualizar esa fila por id con acceso de lectura al registro principal; todos los demás, y cada inserción, eliminación, actualización de varias filas y escritura en nombre de otro, siguen necesitando acceso de edición al registro principal. Desactivado (el predeterminado): cada escritura de un detalle necesita acceso de edición a su registro principal." + }, access: { label: "Acceso", helpText: "Postura ante las concesiones comodín (ADR-0066 D2). Si se omite, se resuelve como public. Decide si la concesión de objeto '*' de un conjunto de permisos cubre este objeto; la visibilidad de registros entre usuarios la decide sharingModel." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 1e23c195d45..6f89cf2a49e 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -353,6 +353,18 @@ export const jaJPMetadataForms: NonNullable = label: "共有モデル", helpText: "社内ユーザー向けの組織既定のレコード可視性(OWD)。カスタムオブジェクトで省略した場合、実行時は private として解決されます(ADR-0090 D1)。" }, + sharing: { + label: "共有", + helpText: "上の組織全体のデフォルトを細かく調整する、オブジェクト単位の共有オプトインです。デフォルトを置き換えることはありません。未設定: 各オプトインのデフォルトが適用されます。" + }, + "sharing.controlledByParent": { + label: "親レコードに従う", + helpText: "sharingModel が 'controlled by parent' のときだけ読み取られます。それ以外の共有モデルでは拒否されます。" + }, + "sharing.controlledByParent.ownerMayEdit": { + label: "所有者は編集可", + helpText: "オン: 子レコードの所有者(その owner_id)は、親レコードの読み取り権限があればその行を id 指定で更新できます。それ以外の人、およびすべての挿入・削除・複数行更新・代理書き込みには、引き続き親レコードの編集権限が必要です。オフ(デフォルト): 子レコードのすべての書き込みに親レコードの編集権限が必要です。" + }, access: { label: "アクセス設定", helpText: "ワイルドカード付与の方針(ADR-0066 D2)。省略時は public として解決されます。権限セットの '*' オブジェクト付与がこのオブジェクトに及ぶかどうかを決めます。ユーザー間のレコード可視性は sharingModel が決めます。" diff --git a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts index 7eab218f78a..10ce99bd2d0 100644 --- a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts @@ -942,10 +942,16 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // `enable.approvalsVisibleToReaders` toggle — a label and a help text, two // leaves, all six translated leaves authored by the same card, decided in // the two rows above. `capabilities` reads 9 → 11; `advanced` is untouched. - expect(PANEL_LEAVES.length).toBe(107); + // 113 since #25760 (ADR-0055 §3 amendment, ruling 6398566164): that card + // gave `advanced` the `sharing` composite beside `sharingModel` — the block, + // its `controlledByParent` sub-block and the `ownerMayEdit` toggle, a label + // and a help text each, three rows and six leaves, all eighteen translated + // leaves authored by the same card. `advanced` reads 96 → 102; + // `capabilities` is untouched. + expect(PANEL_LEAVES.length).toBe(113); expect(PANEL_LEAVES.every((l) => l.prop === 'label' || l.prop === 'helpText')).toBe(true); expect(PANEL_LEAVES.filter((l) => l.section === 'capabilities').length).toBe(11); - expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(96); + expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(102); }); it('⭐ DARK, OUTWARD — the open sections are excluded, and `fields.placeholder` is the one that proves it', () => { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 04d0e0c837c..f2e3b4580b2 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1220,7 +1220,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 680 since the dataset form's measures repeater offers `duration` (the // time between two datetime fields) — one row label, authored in all // three locales. - expect(translated.length, `${locale} positive control`).toBe(680); + // 683 since #25760 (ADR-0055 §3 amendment): the object form's `sharing` + // composite — the block, its `controlledByParent` sub-block and the + // `ownerMayEdit` toggle — three row labels, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(683); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index bddb993a46a..e799e40b0dc 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -353,6 +353,18 @@ export const zhCNMetadataForms: NonNullable = label: "共享模型", helpText: "面向内部用户的组织级默认记录可见性(OWD)。自定义对象若不声明,运行时按 private 处理(ADR-0090 D1)。" }, + sharing: { + label: "共享", + helpText: "按对象声明的共享可选项,用于细化上方的组织级默认值,但绝不取代它。未设置:各可选项按各自的默认值生效。" + }, + "sharing.controlledByParent": { + label: "随父记录控制", + helpText: "仅在 sharingModel 为 'controlled by parent' 时读取;在其他共享模型下会被拒绝。" + }, + "sharing.controlledByParent.ownerMayEdit": { + label: "所有者可编辑", + helpText: "开启:子记录的所有者(其 owner_id)只要对父记录有读权限,就可以按 id 更新该行;其他人,以及所有新增、删除、多行更新和代办写入,仍需要对父记录的编辑权限。关闭(默认):子记录的每一次写入都需要对其父记录的编辑权限。" + }, access: { label: "访问策略", helpText: "通配授权策略(ADR-0066 D2)。不声明时按 public 处理。它决定权限集的 '*' 对象授权是否覆盖本对象;用户之间的记录可见性由 sharingModel 决定。" diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts new file mode 100644 index 00000000000..76c5ad67cfc --- /dev/null +++ b/packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts @@ -0,0 +1,404 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0055 §3 amendment, #25760 — ruling 6398566164] A `controlled_by_parent` +// detail may opt its rows' OWNERS into updating them with READ access to the +// master: `sharing: { controlledByParent: { ownerMayEdit: true } }`. +// +// ## The gap this pins +// +// Salesforce's Activities under OWD = Controlled by Parent let the owner of an +// activity edit it while reading its account. ADR-0055 §3 had one rule for +// every write of a detail — EDIT access to the master — so a detail row's +// owner who could only read the master was refused their own row, and an +// authored `owner_id == current_user.id` update policy on the detail could +// not change that (step 2.7's policies compose AND with the master gate). +// +// The ruling (letter 1): an opt-in, per object, default unchanged; with it the +// detail row's own owner passes the write gate with READ on the master; +// everyone else still needs master EDIT. The ruling names `update`; this suite +// pins insert, delete, a predicate update and a delegated write OUT of the +// opt-in, each refused by the master gate as before. +// +// ## The rig +// +// The real `SecurityPlugin` over a real `ObjectQL` and SQL driver +// (better-sqlite3, in memory), the real `SharingService` as the kernel's +// `sharing` service with its middleware on the engine after this plugin's, and +// the platform's own `member_default` set carrying the ownership floor — the +// `controlled-by-parent-chain-middle-master.test.ts` rig. The owner column the +// gate compares against is the registry's INJECTED `owner_id` +// (`applySystemFields`, `ownership: 'user'` by default): `omd_obligation` +// declares none, so the suite proves the column the opt-in names is the one +// every stored row already carries. +// +// omd_obligation --contract--> omd_contract (opted in, ownership: user) +// omd_task --contract--> omd_contract (NOT opted in, ownership: user) +// omd_note --contract--> omd_contract (opted in, ownership: none) + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import type { ControlledByParentWriteOutcome } from '@objectstack/spec/contracts'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { wireEngineSecuritySource } from './engine-security-source.testkit.js'; + +const SYS = { context: { isSystem: true } } as never; +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; + +const CONTRACT = 'omd_contract'; +const OBLIGATION = 'omd_obligation'; +const TASK = 'omd_task'; +const NOTE = 'omd_note'; + +/** Creates every row (the floor's `created_by`); never a caller, never an owner. */ +const REP = 'usr_rep'; +/** The persona of the ruling: owns the detail row, reads the master, cannot edit it. */ +const OWNER = 'usr_owner'; +/** Reads the master, cannot edit it, owns nothing. */ +const STRANGER = 'usr_stranger'; +/** Edits the master through Modify All Data; owns nothing. */ +const EDITOR = 'usr_editor'; +/** Owns the detail row and cannot even READ the master. */ +const BLIND = 'usr_blind'; +/** A delegator holding the owner's grants and bound by no floor, for the on-behalf-of pin. */ +const DELEGATOR = 'usr_delegator'; +const ORG = 'org_omd'; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; +/** READ on every contract, EDIT on none: the master is readable and NOT editable. */ +const MASTER_READ = { allowRead: true, viewAllRecords: true }; +/** Modify All Data on the contract, which the sharing verdict answers `allow` for. */ +const MASTER_EDIT = { allowRead: true, allowCreate: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true }; +const DETAILS = { [OBLIGATION]: CRUD, [TASK]: CRUD, [NOTE]: CRUD }; + +const REQUESTER_SET = PermissionSetSchema.parse({ name: 'omd_requester', objects: { [CONTRACT]: MASTER_READ, ...DETAILS } }); +const EDITOR_SET = PermissionSetSchema.parse({ name: 'omd_editor', objects: { [CONTRACT]: MASTER_EDIT, ...DETAILS } }); +/** CRUD on the details and NO grant on the contract: the master is not readable. */ +const BLIND_SET = PermissionSetSchema.parse({ name: 'omd_blind', objects: { ...DETAILS } }); +const SETS = [MEMBER_DEFAULT, REQUESTER_SET, EDITOR_SET, BLIND_SET]; + +/** The execution context `resolveAuthzContext` hands the middleware for an organization member holding the sets. */ +const memberOf = (userId: string, ...sets: string[]) => ({ + userId, + tenantId: ORG, + positions: ['org_member'], + permissions: [...sets, 'member_default'], + posture: 'MEMBER', +}); +const OWNER_CALLER = memberOf(OWNER, REQUESTER_SET.name); +const STRANGER_CALLER = memberOf(STRANGER, REQUESTER_SET.name); +const EDITOR_CALLER = memberOf(EDITOR, EDITOR_SET.name); +const BLIND_CALLER = memberOf(BLIND, BLIND_SET.name); +const onBehalfOf = (caller: object, delegator: string) => ({ ...caller, onBehalfOf: { userId: delegator } }); + +type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number; message: string }; + +const landed = (p: Promise): Promise => + p.then( + () => ({ kind: 'admitted' as const }), + (e: any) => ({ + kind: 'refused' as const, + code: e?.code == null ? undefined : String(e.code), + status: e?.statusCode ?? e?.status, + message: `${String(e?.message ?? '')} ${String(e?.developerMessage ?? '')}`, + }), + ); + +let engine: ObjectQL; +let security: { checkControlledByParentWrite: (object: string, id: string, context: unknown) => Promise }; +/** The plugin's `logger.warn`, for the one report this suite pins. */ +const warnings = vi.fn<(msg: string, meta?: unknown) => void>(); +let seq = 0; + +const detail = (name: string, label: string, extra: Record = {}) => ({ + name, + label, + sharingModel: 'controlled_by_parent', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + contract: { name: 'contract', type: 'master_detail', reference: CONTRACT, required: true }, + created_by: { name: 'created_by', type: 'text' }, + }, + ...extra, +}); + +const OPT_IN = { sharing: { controlledByParent: { ownerMayEdit: true } } }; + +beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.controlled-by-parent-owner-may-edit', + name: 'A controlled_by_parent detail whose owner may edit with read on the master', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysRecordShare, + SysUser, + SysOrganization, + SysMember, + SysUserPosition, + SysUserPermissionSet, + { + name: CONTRACT, + label: 'Contract', + sharingModel: 'private', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + owner_id: { name: 'owner_id', type: 'text' }, + created_by: { name: 'created_by', type: 'text' }, + }, + }, + // The opt-in, on a detail that declares NO owner column of its own: the + // registry's injected `owner_id` is the one the gate reads. + detail(OBLIGATION, 'Obligation', OPT_IN), + // The default: no opt-in, so ADR-0055 §3 unamended. + detail(TASK, 'Task'), + // The opt-in on a detail whose rows carry NO owner (`ownership: 'none'` + // injects no `owner_id`): the opt-in names nobody and is reported. + detail(NOTE, 'Note', { ...OPT_IN, ownership: 'none' }), + ], + } as never); + await engine.syncSchemas(); + + await engine.insert('sys_user', [REP, OWNER, STRANGER, EDITOR, BLIND, DELEGATOR].map((id) => ({ + id, name: id, email: `${id}@omd.test`, + })), SYS); + await engine.insert('sys_organization', [{ id: ORG, name: 'OMD', slug: 'omd' }], SYS); + // The delegator holds the requester's set through a stored position row + // owned by ORG, so the on-behalf-of link resolves to the same grants the + // owner holds — and NO `sys_member` row, so no ownership floor binds the + // delegator's pass: the only thing left to refuse a delegated update is the + // master gate, which is the pin (see the chain-middle-master suite for why + // the row lands straight through the driver). + await (engine as any).getDriver('sys_user_position').create('sys_user_position', { + id: 'up_delegator', user_id: DELEGATOR, position: REQUESTER_SET.name, organization_id: ORG, + }); + + let securityService: unknown; + let sharing: SharingService | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? SETS.find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null, + list: async () => SETS, + }, + get sharing() { return sharing; }, + }; + const ctx = { + logger: { info: vi.fn(), warn: warnings, error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { if (name === 'security') securityService = svc; }, + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + wireEngineSecuritySource(engine, () => plugin); + sharing = new SharingService({ engine: engine as never, securityService: () => securityService as never }); + engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' }); + security = securityService as typeof security; +}); + +afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } +}); + +interface Pair { contract: string; row: string } + +/** + * A fresh contract and one detail row under it per case. Every row is CREATED + * by `REP` unless the case names its creator — the floor's `created_by` never + * names the caller — and the detail row is OWNED by `owner` (`owner_id`), the + * column the opt-in reads: the two are different people, so what admits the + * owner below is the opt-in and not the creator floor. + */ +async function seed(object: string, opts: { owner?: string | null; createdBy?: string } = {}): Promise { + const n = ++seq; + const pair = { contract: `contract_${n}`, row: `${object}_${n}` }; + await engine.insert(CONTRACT, [{ id: pair.contract, name: `contract ${n}`, owner_id: REP, created_by: REP }], SYS); + const row: Record = { id: pair.row, name: 'as seeded', contract: pair.contract, created_by: opts.createdBy ?? REP }; + if (opts.owner !== undefined) row.owner_id = opts.owner; + await engine.insert(object, [row], SYS); + return pair; +} + +const stored = (object: string, id: string) => + engine.findOne(object, { where: { id }, context: { isSystem: true } } as never) as Promise | null>; +const rowsUnder = async (object: string, contract: string) => + ((await engine.find(object, { where: { contract }, context: { isSystem: true } } as never)) as Array>).length; + +const updateById = (object: string, pair: Pair, caller: object, data: Record = { name: 'edited by id' }) => + landed(engine.update(object, data, { where: { id: pair.row }, context: { ...caller } } as never)); +const updateByPredicate = (object: string, pair: Pair, caller: object) => + landed(engine.update(object, { name: 'edited by predicate' }, { where: { id: { $in: [pair.row] } }, multi: true, context: { ...caller } } as never)); +const insertUnder = (object: string, pair: Pair, caller: object) => + landed(engine.insert(object, { name: 'inserted', contract: pair.contract }, { context: { ...caller } } as never)); +const deleteById = (object: string, pair: Pair, caller: object) => + landed(engine.delete(object, { where: { id: pair.row }, context: { ...caller } } as never)); + +/** The ADR-0112 envelope of a master-gate refusal: the CALLER's object and verb (#7474), and the master that refused. */ +function expectMasterGateRefusal(answer: Answer, verb: string, object: string): void { + expect(answer).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + const message = (answer as { message: string }).message; + expect(message).toContain(`[Security] Access denied: ${verb} on '${object}' requires edit access to its master record`); + expect(message).toContain(`master '${CONTRACT}'`); +} + +const reportsNaming = (object: string) => + warnings.mock.calls.filter(([msg]) => String(msg).startsWith(`[security/controlled-by-parent] object "${object}"`)); + +describe('[#25760] the opt-in: the owner of a detail row updates it by id with READ on the master', () => { + it('the column the opt-in names is the registry\'s injected owner_id — the detail declares none of its own', () => { + const fields = (engine.getSchema(OBLIGATION) as { fields?: Record } | undefined)?.fields ?? {}; + expect(Object.keys(fields)).toContain('owner_id'); + expect(reportsNaming(OBLIGATION), 'nothing to report: the opt-in names a column the schema carries').toEqual([]); + }); + + it('the owner, who reads the contract and cannot edit it: the by-id update is ADMITTED and the row changed', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + expect(await updateById(OBLIGATION, pair, OWNER_CALLER)).toEqual({ kind: 'admitted' }); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('edited by id'); + }); + + it('CONTROL — the same grants, not the owner: refused by the master gate, and the row is as seeded', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + expectMasterGateRefusal(await updateById(OBLIGATION, pair, STRANGER_CALLER), 'update', OBLIGATION); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('as seeded'); + }); + + it('a row that names no owner admits nobody through the opt-in', async () => { + const pair = await seed(OBLIGATION, { owner: null }); + expectMasterGateRefusal(await updateById(OBLIGATION, pair, OWNER_CALLER), 'update', OBLIGATION); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('as seeded'); + }); + + it('the owner who cannot READ the contract is refused, and the row is as seeded', async () => { + const pair = await seed(OBLIGATION, { owner: BLIND }); + expect(await updateById(OBLIGATION, pair, BLIND_CALLER)).toMatchObject({ kind: 'refused' }); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('as seeded'); + }); + + it('CONTROL — a master editor who owns nothing is admitted as before: the §3 rule is unchanged for everyone else', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + expect(await updateById(OBLIGATION, pair, EDITOR_CALLER)).toEqual({ kind: 'admitted' }); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('edited by id'); + }); +}); + +describe('[#25760] what the opt-in does NOT widen — each shape keeps the master-edit rule', () => { + it('a DELETE by the owner is refused by the master gate, and the row stays', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + expectMasterGateRefusal(await deleteById(OBLIGATION, pair, OWNER_CALLER), 'delete', OBLIGATION); + expect(await stored(OBLIGATION, pair.row)).not.toBeNull(); + }); + + it('an INSERT under a contract the owner only reads is refused by the master gate, and no row lands', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + const before = await rowsUnder(OBLIGATION, pair.contract); + expectMasterGateRefusal(await insertUnder(OBLIGATION, pair, OWNER_CALLER), 'insert', OBLIGATION); + expect(await rowsUnder(OBLIGATION, pair.contract)).toBe(before); + }); + + it('a PREDICATE update (multi: true) selecting the owner\'s own row is refused, and the row is as seeded', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + const answer = await updateByPredicate(OBLIGATION, pair, OWNER_CALLER); + expect(answer).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect((answer as { message: string }).message).toContain('requires edit access to its master record'); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('as seeded'); + }); + + it('a DELEGATED update by the owner (on behalf of a delegator with the same grants) is refused by the master gate', async () => { + // A delegated by-id write keeps BOTH floors at step 2.7 (ADR-0090 D10: + // the detail's floor is not handed to the master gate on that path), so + // the owner CREATED this row — its own floor passes — and the delegator + // is bound by none; the only thing left to refuse is the master gate, + // which is what the owner leg must leave standing on a delegated write. + const pair = await seed(OBLIGATION, { owner: OWNER, createdBy: OWNER }); + expectMasterGateRefusal(await updateById(OBLIGATION, pair, onBehalfOf(OWNER_CALLER, DELEGATOR)), 'update', OBLIGATION); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('as seeded'); + // Control — delegation is the only variable: the same owner, acting for itself, on the same row. + expect(await updateById(OBLIGATION, pair, OWNER_CALLER)).toEqual({ kind: 'admitted' }); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('edited by id'); + }); + + it('a body that RE-POINTS the row under another contract the owner only reads is refused: the owner may change the row, not move it', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + const other = await seed(OBLIGATION, { owner: OWNER }); + expectMasterGateRefusal( + await updateById(OBLIGATION, pair, OWNER_CALLER, { name: 'moved', contract: other.contract }), + 'update', + OBLIGATION, + ); + expect(await stored(OBLIGATION, pair.row)).toMatchObject({ name: 'as seeded', contract: pair.contract }); + }); + + it('…while a body naming the row\'s OWN contract names no new master and is admitted', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + expect(await updateById(OBLIGATION, pair, OWNER_CALLER, { name: 'kept', contract: pair.contract })).toEqual({ kind: 'admitted' }); + expect((await stored(OBLIGATION, pair.row))?.name).toBe('kept'); + }); +}); + +describe('[#25760] the default is unchanged: a detail that has NOT opted in refuses its owner as before', () => { + it('the owner of a task row, reading the contract, is refused by the master gate', async () => { + const pair = await seed(TASK, { owner: OWNER }); + expectMasterGateRefusal(await updateById(TASK, pair, OWNER_CALLER), 'update', TASK); + expect((await stored(TASK, pair.row))?.name).toBe('as seeded'); + }); + + it('CONTROL — the master editor is admitted on the task', async () => { + const pair = await seed(TASK, { owner: OWNER }); + expect(await updateById(TASK, pair, EDITOR_CALLER)).toEqual({ kind: 'admitted' }); + }); +}); + +describe('[#25760] the opt-in on a detail whose rows carry no owner (ownership: none) names nobody', () => { + it('the caller is refused by the master gate, and the object is reported once', async () => { + const pair = await seed(NOTE); + expectMasterGateRefusal(await updateById(NOTE, pair, OWNER_CALLER), 'update', NOTE); + expect((await stored(NOTE, pair.row))?.name).toBe('as seeded'); + + const reports = reportsNaming(NOTE); + expect(reports).toHaveLength(1); + expect(reports[0][0]).toContain('declares sharing.controlledByParent.ownerMayEdit: true but carries no owner_id column'); + expect(reports[0][1]).toEqual({ object: NOTE }); + + // Resolved once per cache generation: a second write re-reads the cached relation, nothing is said again. + await updateById(NOTE, pair, OWNER_CALLER); + expect(reportsNaming(NOTE)).toHaveLength(1); + }); +}); + +describe('[#25760] ISecurityService.checkControlledByParentWrite answers as the write path does', () => { + it('allow for the owner who reads the master; deny for the same grants without ownership', async () => { + const pair = await seed(OBLIGATION, { owner: OWNER }); + expect(await security.checkControlledByParentWrite(OBLIGATION, pair.row, { ...OWNER_CALLER })).toEqual({ outcome: 'allow' }); + expect(await security.checkControlledByParentWrite(OBLIGATION, pair.row, { ...STRANGER_CALLER })).toMatchObject({ outcome: 'deny' }); + }); + + it('deny for the owner of a detail that has not opted in', async () => { + const pair = await seed(TASK, { owner: OWNER }); + expect(await security.checkControlledByParentWrite(TASK, pair.row, { ...OWNER_CALLER })).toMatchObject({ outcome: 'deny' }); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts b/packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts index 1fdf157c5ba..4dcf40296e5 100644 --- a/packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts +++ b/packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts @@ -43,7 +43,7 @@ import type { SecurityPlugin } from './security-plugin.js'; // [ADR-0055 §1] The parse's own one-master judgement, asked of an object that // reached the registry without the parse (see `resolveCbpRelation`). -import { controlledByParentMasterRefusal } from '@objectstack/spec/data'; +import { controlledByParentMasterRefusal, controlledByParentOwnerMayEdit } from '@objectstack/spec/data'; import { masterGovernsRowWrites, OWD_DELEGATING_ROW_WRITES } from './platform-ownership-policies.js'; /** @@ -79,12 +79,24 @@ export interface CbpRelationHost { readonly logger: SecurityPlugin['logger']; readonly ql: SecurityPlugin['ql']; reportCbpMasterUnresolved: SecurityPlugin['reportCbpMasterUnresolved']; + reportOwnerMayEditWithoutOwnerColumn: SecurityPlugin['reportOwnerMayEditWithoutOwnerColumn']; reportRejectedReferenceAlias: SecurityPlugin['reportRejectedReferenceAlias']; } +/** + * [ADR-0055 §3 amendment] The column {@link CbpRelation.ownerMayEdit} compares + * the caller against: the registry's injected per-record owner + * (`resolveInjectedSystemColumns`' `owner` anchor, which `applySystemFields` + * writes into the registered schema's field map under `ownership: 'user'`, the + * default), or an author's own field of that name, which the registry lets + * win. One spelling, the sharing service's `OWNER_FIELD` and the platform's + * "My records" scope read the same column. + */ +const OWNER_COLUMN = 'owner_id'; + /** The body of `SecurityPlugin#resolveCbpRelation`; the method's docblock there is its contract. */ export function resolveCbpRelation( - this: Pick, + this: Pick, object: string, ): CbpRelation | null { if (this.cbpRelCache.has(object)) return this.cbpRelCache.get(object) ?? null; @@ -151,6 +163,14 @@ export function resolveCbpRelation( // own type for the key and the lint's reading of it. const candidates = entries.filter(([, f]) => f?.type === 'master_detail' && f?.required === true && ref(f)); const found = candidates.length === 1 ? candidates[0] : undefined; + // [ADR-0055 §3 amendment] Resolved HERE, on the same schema the relation + // came from, so the opt-in and the relation can never describe two objects. + // Judged only for an object whose relation resolves: an opt-in on an object + // that derives no master is moot — every write of it is refused anyway. + const optedIn = found !== undefined && controlledByParentOwnerMayEdit(schema); + const hasOwnerColumn = entries.some(([key]) => key === OWNER_COLUMN); + if (optedIn && !hasOwnerColumn) this.reportOwnerMayEditWithoutOwnerColumn(object); + const ownerMayEdit = optedIn && hasOwnerColumn; if (!found && masterGovernsRowWrites(schema)) { this.reportCbpMasterUnresolved(object, entries, candidates.map(([k]) => String(k))); } @@ -183,6 +203,10 @@ export function resolveCbpRelation( def?.type === 'master_detail' && !!def?.required && !def?.readonly && !def?.system, // [#25095] From the same def, asked of the parse's own predicate. masterNotWritable: masterNotWritableRefusal(object, String(found[0]), def), + // [ADR-0055 §3 amendment · ruling 6398566164] The opt-in, read through + // the spec's one reader, and only where the schema carries the column + // it names — see {@link CbpRelation.ownerMayEdit}. + ownerMayEdit, }; } this.cbpRelCache.set(object, rel); @@ -228,6 +252,21 @@ export function reportCbpMasterUnresolved( ); } +/** The body of `SecurityPlugin#reportOwnerMayEditWithoutOwnerColumn`; the method's docblock there is its contract. */ +export function reportOwnerMayEditWithoutOwnerColumn( + this: Pick, + object: string, +): void { + this.logger.warn?.( + `[security/controlled-by-parent] object "${object}" declares sharing.controlledByParent.ownerMayEdit: true ` + + `but carries no ${OWNER_COLUMN} column (ownership is 'business_unit', 'org' or 'none', or the object is ` + + 'platform-managed), so the opt-in names nobody: every write of its rows keeps requiring edit access to ' + + "the master (ADR-0055 §3). Declare ownership: 'user' (the default) so the rows carry an owner, or delete " + + 'the key.', + { object }, + ); +} + /** The body of `SecurityPlugin#reportRejectedReferenceAlias`; the method's docblock there is its contract. */ export function reportRejectedReferenceAlias( this: Pick, diff --git a/packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts b/packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts index 42288a9bd6b..abb007e2160 100644 --- a/packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts +++ b/packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts @@ -71,6 +71,7 @@ import { copyOfCallerPredicate, PREDICATE_WRITE_READABLE_ID_CAP, predicateWriteReadableCapError, + readUnlessRefused, requiredCapsForOperation, } from './security-plugin.js'; import { withoutOperationPrivateKeys } from '@objectstack/core'; @@ -103,6 +104,15 @@ import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js */ const MASTER_EDIT_DENIAL_LEG = new WeakMap(); +/** + * [ADR-0055 §3 amendment] The stored column the owner leg of + * {@link assertControlledByParentWrite} compares the caller against: the + * platform's injected per-record owner, the same `owner_id` the relation + * resolver confirmed the schema carries before it set + * {@link CbpRelation.ownerMayEdit} (`security-plugin-cbp-relation.ts`). + */ +const OWNER_COLUMN = 'owner_id'; + /** * [ADR-0055] The master-detail write check's own outcome, read off what * {@link SecurityPlugin.assertControlledByParentWrite} threw. `null` when the @@ -311,6 +321,8 @@ export async function assertControlledByParentWrite( // target row (read as system — we only need its FK value). let masterId: unknown; let detailRecordId: unknown; + /** The stored detail row a by-id write addresses, for the owner leg below; `null` on every other shape. */ + let detailRow: Record | null = null; if (operation === 'insert') { const data = opCtx.data; if (Array.isArray(data)) { @@ -366,6 +378,7 @@ export async function assertControlledByParentWrite( const row = await this.readRowById(object, targetId, { isSystem: true }); if (!row) throw new DetailRecordNotFoundError(object, operation, targetId); masterId = row[rel.fk]; + detailRow = row; } if (masterId == null) { // [#8688] CONDITIONAL stand-down — maintainer ruling of 2026-08-15. @@ -463,6 +476,68 @@ export async function assertControlledByParentWrite( throw new MasterReferenceMissingError(object, operation, rel.fk, detailRecordId); } + // [ADR-0055 §3 amendment · ruling 6398566164] The OWNER of a detail row + // updates it with READ access to the master, when the detail opts in + // (`sharing.controlledByParent.ownerMayEdit: true`, carried on the + // relation as {@link CbpRelation.ownerMayEdit}). Salesforce's "the owner of + // an activity always has full access to it" under Activities OWD = + // Controlled by Parent, narrowed to the ruling's verb: every other caller, + // and every other shape of write, keeps the §3 rule below. + // + // Four conditions, each a boundary of the ruling and each fail-closed: + // + // • a by-id UPDATE — the stored row was read above (`detailRow`). An + // insert has no row to own; a delete is not the ruled verb (deleting a + // child is still answered by the master); a predicate (`multi: true`) + // update judges every master of the rows it selects and has no single + // row whose owner could be asked, so it returned above, unchanged; + // • NOT delegated — read off `opCtx.context`, the one envelope both passes + // of a delegated write share (ADR-0090 D10, as the master-row leg reads + // it): an on-behalf-of write keeps the master-edit rule on the agent's + // pass AND the delegator's, exactly as it keeps both floors; + // • the CALLER is the row's owner — the stored row's `owner_id` (the + // platform's injected per-record owner, the column the relation resolver + // confirmed the schema carries) is the caller's own `userId`. A row with + // no owner, or another's, admits nobody here; + // • the caller can READ the master row — asked of the read door itself + // under the caller's own context, the same probe the by-id pre-image + // gate uses, so the answer is the one a direct read of the master gives: + // the master's object grant, its row-level and sharing scope, and, for a + // master that is itself `controlled_by_parent`, the chain the engine + // composes for its read (ADR-0139 D6). A hidden row and a refused read + // (a declared 4xx) are one answer, "not readable" (#7505, + // {@link readUnlessRefused}); a store fault propagates as raised, never + // as a verdict. + // + // What the leg does NOT change: the detail's own object-level `update` + // grant (gate ①, judged before this middleware step) and its own row-level + // policies (step 2.7, judged before this step, where an authored + // `owner_id == current_user.id` update policy decides as authored) still + // run; the sharing service's verdict on the detail is unchanged; and a body + // that re-points the master reference still asks EDIT on the master it + // names (`askBodyNamedMaster`): the owner may change their row, not move it + // under a master they cannot edit. A body naming the stored master names no + // new master and is not asked again. + // + // When any condition fails the write takes the §3 walk below and, refused, + // carries the walk's own sentence — the owner leg adds no envelope of its + // own, so nothing new reaches a response body. + if ( + operation === 'update' && + detailRow !== null && + rel.ownerMayEdit && + !opCtx?.context?.onBehalfOf?.userId && + context?.userId != null && + detailRow[OWNER_COLUMN] != null && + String(detailRow[OWNER_COLUMN]) === String(context.userId) + ) { + const masterRead = await readUnlessRefused(() => this.readRowById(rel.master, masterId, context)); + if (!masterRead.refused && masterRead.value != null) { + await askBodyNamedMaster([masterId]); + return; + } + } + await this.assertControlledByParentChainEditable( permissionSets, object, rel, masterId, context, opCtx, masterEditDenied, denyMasterEdit, ); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index d10a363f1df..7c0cbb1899d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -76,6 +76,7 @@ import { // it reads (`CbpRelationHost`). import { reportCbpMasterUnresolved, + reportOwnerMayEditWithoutOwnerColumn, reportRejectedReferenceAlias, resolveCbpRelation, type CbpRelationHost, @@ -727,6 +728,23 @@ interface CbpRelation { * derives from. One rule for both flags, with one prescription. */ masterNotWritable: string | null; + /** + * [ADR-0055 §3 amendment · ruling 6398566164] The object opted its detail + * rows' owners into writing with READ on the master — + * `sharing.controlledByParent.ownerMayEdit: true`, read through the spec's + * one reader (`controlledByParentOwnerMayEdit`, `@objectstack/spec/data`) — + * AND the registry's schema carries the `owner_id` column the gate compares + * the caller against. Both, or `false`: an opt-in on an object with no owner + * column names nobody, so it is inert here (reported once per object by + * {@link SecurityPlugin.reportOwnerMayEditWithoutOwnerColumn}) rather than + * a widening the stored rows cannot honour. + * + * Read by the by-id `update` leg of + * {@link SecurityPlugin.assertControlledByParentWrite} and by nothing else: + * an insert, a delete, a predicate write and a delegated write keep the + * master-edit rule whatever this says. + */ + ownerMayEdit: boolean; } /** @@ -4455,15 +4473,43 @@ export class SecurityPlugin implements Plugin { // [#25223 cut 2] The body moved to `security-plugin-cbp-relation.ts` (see `CbpRelationHost`). const readCbpRelCache = () => this.cbpRelCache; const readQl = () => this.ql; - const host: Pick = { + const host: Pick = { get cbpRelCache() { return readCbpRelCache(); }, get ql() { return readQl(); }, reportCbpMasterUnresolved: (...a) => this.reportCbpMasterUnresolved(...a), + reportOwnerMayEditWithoutOwnerColumn: (...a) => this.reportOwnerMayEditWithoutOwnerColumn(...a), reportRejectedReferenceAlias: (...a) => this.reportRejectedReferenceAlias(...a), }; return resolveCbpRelation.call(host, object); } + /** + * [ADR-0055 §3 amendment · ruling 6398566164] Report that a + * `controlled_by_parent` object declares + * `sharing.controlledByParent.ownerMayEdit: true` while its registered + * schema carries no `owner_id` column — `ownership` is `business_unit`, + * `org` or `none`, or the object is platform-managed — so the opt-in names + * nobody and the gate keeps the master-edit rule for every caller. + * + * The parse cannot refuse this half (the injected-column plan lives in a + * module that imports `object.zod.ts`, the cycle that file's attached-block + * refusal stops at too), so it is said here, once per object per cache + * generation, on the same channel and at the same granularity as + * {@link SecurityPlugin.reportCbpMasterUnresolved}: the only call site is + * `resolveCbpRelation`'s resolution body, which runs on a `cbpRelCache` miss + * alone and re-arms when metadata changes. `warn`, not `error`, by the + * degradation-log-level question: nothing claimed persisted is lost, and the + * answer is the narrow one — the default rule, not a fall-open. + */ + private reportOwnerMayEditWithoutOwnerColumn(object: string): void { + // The body lives in `security-plugin-cbp-relation.ts` (see `CbpRelationHost`). + const readLogger = () => this.logger; + const host: Pick = { + get logger() { return readLogger(); }, + }; + reportOwnerMayEditWithoutOwnerColumn.call(host, object); + } + /** * [ADR-0055 §1 · #23100] Report that a `controlled_by_parent` object resolved NO * master relation, so every read of it is denied and every write refused — and diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 9449990146a..50d6a678ce0 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -844,6 +844,7 @@ "classifyFilterToken (function)", "containsUnresolvedPlaceholder (function)", "controlledByParentMasterRefusal (function)", + "controlledByParentOwnerMayEdit (function)", "countAuthorableFields (function)", "credentialFreeMongoOptions (function)", "credentialFreeUrl (function)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 5fc0f149aed..dfecf95f28c 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -690,6 +690,7 @@ "data/Object:publicSharing", "data/Object:requiredPermissions", "data/Object:searchableFields", + "data/Object:sharing", "data/Object:sharingModel", "data/Object:stageField", "data/Object:systemFields", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 457d665180b..587103d9cef 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -830,6 +830,7 @@ "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)", "containsUnresolvedPlaceholder": "src/data/driver/common.zod.ts#containsUnresolvedPlaceholder (function)", "controlledByParentMasterRefusal": "src/data/controlled-by-parent-master.ts#controlledByParentMasterRefusal (function)", + "controlledByParentOwnerMayEdit": "src/data/object.zod.ts#controlledByParentOwnerMayEdit (function)", "countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)", "credentialFreeMongoOptions": "src/data/driver/common.zod.ts#credentialFreeMongoOptions (function)", "credentialFreeUrl": "src/data/driver/common.zod.ts#credentialFreeUrl (function)", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 7ae22bae634..623ec024dd0 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -158,6 +158,28 @@ "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts#effectiveSharingModel (`schema?.sharingModel ?? schema?.security?.sharingModel`, collapsed onto the three behaviours the service enforces; `controlled_by_parent` folds to the master-derived branch and an unrecognised stored value fails CLOSED to `private`)", "note": "ADR-0055 high-risk class (sharing): the `controlled_by_parent` value derives a detail object's access from its master — the security layer injects `masterFK IN (accessible master ids)` on reads and requires master edit-access on by-id writes. The proof asserts a member who cannot read the master can neither read nor by-id-write the detail, and is not over-blocked on a master they own. 2026-08-28: RE-ANCHORED (commit 93ea19bca) and REPOINTED — `sharing-service.ts:54` had rotted onto a blank line between an interface and a `makeShareId` docblock. Re-closed by hand against c459da6bc." }, + "sharing": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/spec/src/data/object.zod.ts#controlledByParentOwnerMayEdit (the ONE reader of the `sharing` block: `sharing?.controlledByParent?.ownerMayEdit === true`, exported beside the declaration); packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts#resolveCbpRelation (reads it onto the resolved relation's `ownerMayEdit`, only where the registered schema carries `owner_id`)", + "note": "ADR-0055 §3 amendment (#25760, ruling 6398566164). The block carries per-object sharing opt-ins that refine `sharingModel`; one nested block today, drilled below. The parse refuses `ownerMayEdit: true` off a `controlled_by_parent` detail (object.zod.ts#refuseControlledByParentOwnerMayEditOffDetail), so the key cannot be declared where nothing reads it.", + "children": { + "controlledByParent": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/spec/src/data/object.zod.ts#controlledByParentOwnerMayEdit (reads `controlledByParent.ownerMayEdit` under `sharing`); packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts#resolveCbpRelation (the runtime read, onto `CbpRelation.ownerMayEdit`)", + "note": "The opt-ins read only under `sharingModel: 'controlled_by_parent'` (ADR-0055 §3 amendment, #25760).", + "children": { + "ownerMayEdit": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite (the owner leg: on a by-id `update` of a detail whose relation carries `ownerMayEdit`, a non-delegated caller who is the stored row's `owner_id` and can READ the master row passes the master-write gate; every other write takes the ADR-0055 §3 chain walk); packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts#resolveCbpRelation (resolves `ownerMayEdit` beside the relation, `false` and reported once when the schema carries no `owner_id`); packages/lint/src/validate-security-posture.ts#callerIdentityKeyedFields (the lint arm `security-controlled-by-parent-owner-policy-without-opt-in` flags an owner-keyed update policy on a detail that has not declared `ownerMayEdit`)", + "note": "ADR-0055 §3 amendment (#25760, ruling 6398566164): the owner of a detail row updates it by id with read on the master. Pinned through the real SecurityPlugin + ObjectQL over SqlDriver in packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts: an owner who reads the master is admitted and the row changes; a non-owner, a delete, an insert, a predicate update, a delegated write and an owner who cannot read the master are refused by the master gate; a detail without the opt-in refuses the owner as before." + } + } + } + } + }, "defaultRowScope": { "status": "live", "verifiedAt": "2026-10-19", diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 5ab04973d56..182b35b9a4a 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 66 | 0 | 0 | 1 | 0 | 67 | +| `object` | 67 | 0 | 0 | 1 | 0 | 68 | diff --git a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts index bbcf6ca9155..7f7431850c3 100644 --- a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts +++ b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts @@ -85,6 +85,8 @@ const CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER = [ 'access', 'lifecycle', 'enable', + // [#25760] `sharing` — the controlled_by_parent opt-ins block (ADR-0055 §3 amendment). + 'sharing', 'defaultRowScope', 'publicSharing', 'protection', diff --git a/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts b/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts index c0b35033e75..330cb878589 100644 --- a/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts +++ b/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts @@ -84,6 +84,8 @@ const CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER = [ 'access', 'lifecycle', 'enable', + // [#25760] `sharing` — the controlled_by_parent opt-ins block (ADR-0055 §3 amendment). + 'sharing', 'defaultRowScope', 'publicSharing', 'protection', diff --git a/packages/spec/src/data/controlled-by-parent-owner-may-edit.test.ts b/packages/spec/src/data/controlled-by-parent-owner-may-edit.test.ts new file mode 100644 index 00000000000..449c6eb1b2c --- /dev/null +++ b/packages/spec/src/data/controlled-by-parent-owner-may-edit.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { ZodError } from 'zod'; + +// Read through the `@objectstack/spec/data` entry, not the module: the pin is +// on the PUBLISHED reader the gate and the lint import. +import { controlledByParentOwnerMayEdit } from './index'; +import { ObjectSchema } from './object.zod'; + +/** + * [ADR-0055 §3 amendment · ruling 6398566164] `sharing.controlledByParent.ownerMayEdit` + * is an authorable key of a `controlled_by_parent` detail — reachable (no + * `unrecognized_keys`, the parse keeps the value) — and the parse refuses it + * on an object whose `sharingModel` is not `controlled_by_parent`, at the key's + * own path, with the prescription. The exported reader answers `true` for the + * boolean `true` alone, and `false` for every unreadable shape. + */ + +const md = (reference: string) => ({ type: 'master_detail', label: reference, reference, required: true }); + +const detail = (extra: Record = {}) => ({ + name: 'omd_obligation', + label: 'Obligation', + sharingModel: 'controlled_by_parent', + fields: { contract: md('omd_contract') }, + ...extra, +}); + +const OPT_IN = { sharing: { controlledByParent: { ownerMayEdit: true } } }; +const OWNER_MAY_EDIT_PATH = 'sharing.controlledByParent.ownerMayEdit'; + +function issuesOf(obj: Record): Array<{ code: string; path: string; message: string }> { + const parsed = ObjectSchema.safeParse(obj); + if (parsed.success) return []; + return parsed.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +} + +describe('sharing.controlledByParent.ownerMayEdit (ADR-0055 §3 amendment)', () => { + it('is an authorable key of a controlled_by_parent detail: the parse keeps it and refuses nothing', () => { + const parsed = ObjectSchema.safeParse(detail(OPT_IN)); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect(parsed.data.sharing?.controlledByParent?.ownerMayEdit).toBe(true); + expect(controlledByParentOwnerMayEdit(parsed.data)).toBe(true); + // The whole parse is green, so no key of the block landed as `unrecognized_keys`. + expect(issuesOf(detail(OPT_IN)).filter((i) => i.code === 'unrecognized_keys')).toEqual([]); + }); + + it('`ObjectSchema.create()` keeps it too', () => { + const created = ObjectSchema.create(detail(OPT_IN) as never); + expect(controlledByParentOwnerMayEdit(created)).toBe(true); + }); + + it.each([ + ['private', "under 'private'"], + ['public_read', "under 'public_read'"], + ['public_read_write', "under 'public_read_write'"], + ])('is refused on a %s object, at the key, with the fix', (sharingModel, under) => { + const issues = issuesOf({ name: 'omd_contract', label: 'Contract', sharingModel, fields: {}, ...OPT_IN }); + const own = issues.filter((i) => i.path === OWNER_MAY_EDIT_PATH); + expect(own).toHaveLength(1); + expect(own[0]!.code).toBe('custom'); + expect(own[0]!.message.startsWith( + `Object "omd_contract" declares sharing.controlledByParent.ownerMayEdit: true ${under}.`, + )).toBe(true); + expect(own[0]!.message).toContain("sharingModel: 'controlled_by_parent' plus one required master_detail field"); + // Nothing else is refused: the object is otherwise sound. + expect(issues).toHaveLength(1); + }); + + it('is refused on an object that declares no sharingModel at all, saying so', () => { + const issues = issuesOf({ name: 'omd_contract', label: 'Contract', fields: {}, ...OPT_IN }); + const own = issues.filter((i) => i.path === OWNER_MAY_EDIT_PATH); + expect(own).toHaveLength(1); + expect(own[0]!.message).toContain('under no sharingModel.'); + }); + + it('`ObjectSchema.create()` throws the same refusal', () => { + expect(() => + ObjectSchema.create({ name: 'omd_contract', label: 'Contract', sharingModel: 'private', fields: {}, ...OPT_IN } as never), + ).toThrow(ZodError); + }); + + it.each([ + ['`ownerMayEdit: false`', { sharing: { controlledByParent: { ownerMayEdit: false } } }], + ['an empty `controlledByParent` block', { sharing: { controlledByParent: {} } }], + ['an empty `sharing` block', { sharing: {} }], + ])('%s on a private object is not an opt-in and is not refused', (_label, sharing) => { + const obj = { name: 'omd_contract', label: 'Contract', sharingModel: 'private', fields: {}, ...sharing }; + expect(issuesOf(obj)).toEqual([]); + expect(controlledByParentOwnerMayEdit(obj)).toBe(false); + }); + + it('the block is closed: `ownerMayEdit` one level up is refused as an unknown key, naming its level', () => { + const issues = issuesOf(detail({ sharing: { ownerMayEdit: true } })); + const unknown = issues.filter((i) => i.code === 'unrecognized_keys'); + expect(unknown).toHaveLength(1); + expect(unknown[0]!.path).toBe('sharing'); + expect(unknown[0]!.message).toContain('sharing.controlledByParent'); + }); + + it('the nested block is closed: a misspelt key under `controlledByParent` is refused', () => { + const issues = issuesOf(detail({ sharing: { controlledByParent: { ownerCanEdit: true } } })); + const unknown = issues.filter((i) => i.code === 'unrecognized_keys'); + expect(unknown).toHaveLength(1); + expect(unknown[0]!.path).toBe('sharing.controlledByParent'); + }); + + it('only the boolean opts in: a string is refused by the parse, and the reader reads it as off', () => { + const raw = detail({ sharing: { controlledByParent: { ownerMayEdit: 'true' } } }); + const issues = issuesOf(raw); + expect(issues.some((i) => i.path === OWNER_MAY_EDIT_PATH && i.code === 'invalid_type')).toBe(true); + expect(controlledByParentOwnerMayEdit(raw)).toBe(false); + }); + + it('the reader fails closed on every unreadable shape', () => { + for (const shape of [null, undefined, 42, 'x', {}, { sharing: null }, { sharing: { controlledByParent: null } }, { sharing: { controlledByParent: { ownerMayEdit: 1 } } }]) { + expect(controlledByParentOwnerMayEdit(shape)).toBe(false); + } + }); +}); diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index cfcbc82604c..311080b3b34 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -582,6 +582,29 @@ export const objectForm = defineForm({ { label: 'Public read/write — everyone reads and writes', value: 'public_read_write' }, { label: 'Controlled by parent — derived from the master record', value: 'controlled_by_parent' }, ] }, + // [ADR-0055 §3 amendment · ruling 6398566164] The per-object sharing + // opt-ins, beside the OWD they refine. A composite over the one nested + // block, each sub-row declared so its label and help text reach the + // translation catalogs; the one leaf is a switch, the `enable` toggles' + // face. The help text states what the runtime does with the value and + // what its absence resolves to, the half an author cannot see from the + // schema. + { + field: 'sharing', + type: 'composite', + helpText: 'Per-object sharing opt-ins that refine the org-wide default above; they never replace it. Unset: the default of each opt-in applies.', + fields: [ + { + field: 'controlledByParent', + label: 'Controlled by parent', + type: 'composite', + helpText: "Read only while sharingModel is 'controlled by parent'; refused on any other sharing model.", + fields: [ + { field: 'ownerMayEdit', label: 'Owner may edit', type: 'boolean', helpText: "On: the owner of a detail row (its owner_id) may update that row by id with read access to the master; everyone else, and every insert, delete, multi-row update and on-behalf-of write, still needs edit access to the master. Off (the default): every write of a detail needs edit access to its master." }, + ], + }, + ], + }, // #20349 — the two ADR-0066 access keys, beside `sharingModel`, the third // leg of the same story (grant coverage, capability gate, record // visibility). diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 829e7108518..18360b61afb 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2244,6 +2244,47 @@ function refuseControlledByParentWithoutOneMaster( ctx.addIssue({ code: 'custom', path: [refusal.path], message: refusal.message }); } +/** + * [ADR-0055 §3 amendment · ruling 6398566164] `sharing.controlledByParent.ownerMayEdit: true` + * is read by exactly one door — the master-write gate of a + * `sharingModel: 'controlled_by_parent'` detail — so on any other object it is + * a declared capability the runtime never delivers (ADR-0049). Refused here, + * at the object's own door, with the path of the key and the fix: a + * `private` / `public_read` / `public_read_write` object already lets its + * owner write under its own model, so the key is deleted; an object meant to + * be a detail sets `sharingModel: 'controlled_by_parent'` and declares its + * one required `master_detail` (judged beside this, by + * {@link refuseControlledByParentWithoutOneMaster}). + * + * Only the sharing-model half is judged at parse. Whether the object carries + * the `owner_id` column the opt-in reads is the injected-column plan's + * question (`resolveInjectedSystemColumns`, which imports this module — the + * same cycle the attached-block refusal above stops at); the gate reads the + * stored row's `owner_id` and admits nobody when it is absent, and the + * relation resolver reports the inert opt-in once per object. + */ +function refuseControlledByParentOwnerMayEditOffDetail( + objectName: unknown, + sharingModel: unknown, + sharing: unknown, + ctx: z.RefinementCtx, +): void { + if (!controlledByParentOwnerMayEdit({ sharing })) return; + if (sharingModel === 'controlled_by_parent') return; + const name = typeof objectName === 'string' && objectName.length > 0 ? objectName : ''; + const model = typeof sharingModel === 'string' ? `'${sharingModel}'` : 'no sharingModel'; + ctx.addIssue({ + code: 'custom', + path: ['sharing', 'controlledByParent', 'ownerMayEdit'], + message: + `Object "${name}" declares sharing.controlledByParent.ownerMayEdit: true under ${model}. ` + + "The opt-in is read only on a sharingModel: 'controlled_by_parent' detail (ADR-0055 §3): it lets the " + + "row's owner update the row with read access to the master. Under any other sharing model the owner " + + 'already writes under that model, and nothing reads this key. Delete the key, or make this object a ' + + "detail: sharingModel: 'controlled_by_parent' plus one required master_detail field.", + }); +} + /** * [ADR-0015 §4.2 · #25168] An `external` binding names, in the object's own * `datasource`, a datasource that is not the platform's managed primary. @@ -3023,6 +3064,69 @@ const ObjectSchemaBase = strictObject( */ sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent']).optional().describe('Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1).'), + /** + * Per-object sharing opt-ins that REFINE the org-wide default + * {@link sharingModel} declares — never replace it. One block today: + * + * - `controlledByParent` — read only under + * `sharingModel: 'controlled_by_parent'` (ADR-0055 §3, as amended for + * ruling 6398566164). `ownerMayEdit: true` lets the owner of a DETAIL row + * — the principal its injected `owner_id` names — pass the master-write + * gate on a by-id `update` of that row with READ access to the master, + * where every other caller still needs EDIT access to the master. The + * default (the key absent or `false`) is unchanged: editing a detail + * requires edit on its master. The opt-in widens nothing else: an + * `insert`, a `delete`, a predicate (`multi: true`) update and an + * on-behalf-of write keep the master-edit rule, and the detail's own + * row-level policies still compose (AND) on the admitted write, so an + * owner-keyed `update` policy on the detail decides as authored. + * + * The parse refuses the opt-in on an object whose `sharingModel` is not + * `controlled_by_parent` ({@link refuseControlledByParentOwnerMayEditOffDetail}): + * a declaration no door reads is a capability the runtime never delivers. + * The runtime reads the block through {@link controlledByParentOwnerMayEdit}, + * exported beside this declaration, so the gate and the lint + * (`security-controlled-by-parent-owner-policy-without-opt-in`) ask one + * question one way. + */ + sharing: strictObject({ + surface: "this object's `sharing` block", + history: + 'Closed from its first release: an opt-in that widens who may write a detail row must be ' + + 'spelled exactly, so a misspelt key is refused rather than dropped into a silent default.', + guidance: { + sharingModel: + '`sharingModel` is the org-wide default record visibility and is a TOP-LEVEL object key, not a ' + + '`sharing` key. `sharing` carries the per-object opt-ins that refine it.', + publicSharing: + '`publicSharing` (the share-link policy) is a TOP-LEVEL object key, not a `sharing` key.', + ownerMayEdit: + '`ownerMayEdit` lives one level down, under `sharing.controlledByParent`, because it is read ' + + "only on a `sharingModel: 'controlled_by_parent'` detail.", + }, + }, { + controlledByParent: strictObject({ + surface: "this object's `sharing.controlledByParent` block", + history: + 'Closed from its first release: a key here changes who may write a detail row, so an unknown ' + + 'spelling is refused with its prescription rather than dropped into the default.', + }, { + ownerMayEdit: z.boolean().optional().describe( + "Opt-in on a sharingModel: 'controlled_by_parent' detail (ADR-0055 §3 amendment): true lets the " + + 'owner of a detail row (its owner_id) update that row by id with READ access to the master; ' + + 'everyone else, and every insert, delete, predicate update and on-behalf-of write, still needs EDIT ' + + 'access to the master. Absent or false: the default, master edit for every write. Refused on an ' + + 'object whose sharingModel is not controlled_by_parent.', + ), + }).optional().describe( + "Opt-ins read only under sharingModel: 'controlled_by_parent' (ADR-0055 §3): ownerMayEdit.", + ), + }).optional().describe( + 'Per-object sharing opt-ins that refine the org-wide default sharingModel declares. ' + + "controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with " + + 'read access to the master (ADR-0055 §3 amendment).', + ), + /** * The object's default row scope (#24164 ruling E′, item 1): one row-level * security policy, the permission-set `rowLevelSecurity` entry shape without @@ -3306,6 +3410,9 @@ const ObjectSchemaBase = strictObject( // under `controlled_by_parent` — the one master relation the runtime derives // access from. See the helper's docblock. refuseControlledByParentWithoutOneMaster(object.name, object.sharingModel, object.fields, ctx); + // [ADR-0055 §3 amendment · ruling 6398566164] `sharing.controlledByParent.ownerMayEdit` + // is read only on a `controlled_by_parent` detail. See the helper's docblock. + refuseControlledByParentOwnerMayEditOffDetail(object.name, object.sharingModel, object.sharing, ctx); // [ADR-0015 §4.2 · #25168] An `external` binding is not on the platform's // managed primary datasource. See the helper's docblock. refuseExternalBindingOnPrimaryDatasource(object.name, object.external, object.datasource, ctx); @@ -3349,6 +3456,30 @@ export function isPublicSharingEnabled(schema: unknown): boolean { ?.publicSharing?.enabled === true; } +/** + * [ADR-0055 §3 amendment · ruling 6398566164] Has this object opted its detail + * rows' owners into writing with READ on the master — + * `sharing.controlledByParent.ownerMayEdit: true`? + * + * The ONE reading of the opt-in declared in the `sharing` block above, + * exported beside it so every surface that asks the question asks it one way: + * the master-write gate (`@objectstack/plugin-security`, through the + * relation it resolves per object), the parse refusal above + * (`refuseControlledByParentOwnerMayEditOffDetail`) and the security-posture + * lint (`@objectstack/lint`). The same shape as {@link isPublicSharingEnabled}. + * + * Fail-CLOSED: an absent block, an absent schema and an unreadable schema are + * one answer, `false`. Only the boolean `true` opts in — a truthy `'true'` or + * `1` that never went through this schema widens nothing. It does NOT read + * `sharingModel`: whether the object is a detail at all is the caller's + * question (the parse refuses the opt-in elsewhere; the gate asks it only of an + * object whose relation resolved). + */ +export function controlledByParentOwnerMayEdit(schema: unknown): boolean { + return (schema as { sharing?: { controlledByParent?: { ownerMayEdit?: unknown } } } | null | undefined) + ?.sharing?.controlledByParent?.ownerMayEdit === true; +} + /** * Converts a snake_case name to a human-readable Title Case label. * @example snakeCaseToLabel('project_task') → 'Project Task'