Skip to content

Commit 5e113b3

Browse files
objectstack-fleet[bot]zhuangjianguoclaude
authored
feat(spec,plugin-security,lint): sharing.controlledByParent.ownerMayEdit — a detail row's owner updates it with read on the master (#26005)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb5W448EqsD4LxfjvHNdpyC4)_ Fixes #25760 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) ## What changes 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, because the detail's own policies compose AND with the master gate (step 2.7 then 2.8), so such a policy only narrowed the master's editors. - **Spec** (`packages/spec/src/data/object.zod.ts`, `object.form.ts`, liveness ledger, generated authorable surface). `ObjectSchema` gains `sharing: { controlledByParent: { ownerMayEdit: boolean } }`, both blocks closed (`strictObject`, with guidance for the near-miss spellings). `ownerMayEdit: true` on an object that is not a `sharingModel: 'controlled_by_parent'` detail is refused at parse, one `custom` issue at `sharing.controlledByParent.ownerMayEdit`, with the prescription. The reader `controlledByParentOwnerMayEdit(schema)` (`@objectstack/spec/data`) answers `true` only for the parsed literal and fails closed on every other shape. - **Gate** (`packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts`, `security-plugin-cbp-relation.ts`, `security-plugin.ts`). `CbpRelation` carries `ownerMayEdit`, resolved on the same registered schema the relation came from, and only when that schema carries the `owner_id` column (the registry's injected per-record owner under `ownership: 'user'`, the default). In `assertControlledByParentWrite`, before the §3 chain walk, a by-id `update` on an opted-in detail by the caller whose `userId` equals the stored row's `owner_id` is admitted when the caller can READ the master row, asked of the read door under the caller's own context (`readRowById` through `readUnlessRefused`, the same probe the by-id pre-image gate uses; a hidden row and a declared refusal are one answer, a store fault propagates). A body that re-points the master reference still asks EDIT on the master it names. `ISecurityService.checkControlledByParentWrite` answers the same way, since it runs the same check. - **Lint** (`packages/lint/src/validate-security-posture.ts`, `rule-explanations.ts`, `index.ts`). New advisory arm `security-controlled-by-parent-owner-policy-without-opt-in` (warning): an `update` or `all` row-level policy whose `using` or `check` keys a field on the caller (`FIELD == current_user.id`, either operand order, the SQL `=` spelling the compiler bridges, `record.FIELD` too) on a `controlled_by_parent` detail that has not declared the opt-in. Judged by shape on the canonical CEL parse; a predicate that does not parse is the parse rule's. `os explain` carries the entry. `warning`, not `error`, because `validateSecurityPosture` also runs at the runtime publish doors, where an `error` refuses the write, and a narrowing can be meant. - **ADR-0055** amended in place (status line, §3 rule line, a final Amendment section: ruling, gap, what the opt-in does, out of scope, fail-closed boundaries, unchanged). Governed surface, Tier H. ## Scope: `update` only — stated as the ruling asks The ruling names `update`. This PR admits the owner on a **by-id update** and nothing else: - **insert**: no stored row to own; the master is still asked for EDIT. - **delete**: not the ruled verb; still asked for EDIT on the master. - **predicate update** (`multi: true`): judges every master of the rows it selects and has no single row whose owner could be asked; unchanged. - **delegated write** (on-behalf-of): keeps the master-edit rule on both passes, as ADR-0090 D10 keeps both floors for every other relief. - A row with no owner admits nobody; an opt-in on a detail whose schema has no `owner_id` column (`ownership` other than `'user'`) is inert and reported once per object at `warn` by the relation resolver (the parse cannot refuse that half: the injected-column plan lives in a module that imports `object.zod.ts`). Insert and delete can be added later under the same key if the maintainer rules so; nothing here forecloses it. ## 维护者速读(草稿) ### 改了什么 给 `controlled_by_parent`(访问随主记录)的从对象加了一个按对象声明的开关 `sharing.controlledByParent.ownerMayEdit: true`。打开后,从记录的**所有者**(`owner_id` 是自己)只要能**读**主记录,就能按 id 修改自己这条从记录;其他人、以及新增/删除/按条件批量改/代办写入,仍然要求对主记录有**编辑**权。不开这个开关,一切照旧。`os lint` 新增一条提示:在没开开关的从对象上写了 `owner_id == current_user.id` 的更新策略,会提醒作者这条策略永远放不进所有者。ADR-0055 §3 同步修订。 ### 为什么改 裁决 6398566164(第 1 条)。对应 Salesforce 的"活动随父记录控制,但活动所有者始终能编辑自己的活动"这一常见业务形态:销售能看客户、不能改客户,但要能改自己名下挂在客户上的任务/义务。原来平台表达不了,作者自己写行级策略也无效(策略只会收窄,不会放宽主记录门)。 ### 风险与代价(含回滚) - 放宽只在三件事同时成立时发生:对象显式声明了开关、调用者就是该行的 `owner_id`、调用者能读主记录。默认值不变,存量对象不受影响。 - 守住的边界都有测试钉住:非所有者、无所有者的行、读不到主记录的所有者、新增、删除、批量改、代办写、把行挪到另一个主记录下,全部仍被主记录门拒绝。 - 回滚:删除对象上的这一个键即可恢复原行为;代码层面整条逻辑是写门里一个独立的、先于原链路的分支,去掉分支即回到 §3 原规则。 - 成本:一次 `sys_`-级读探针(读主记录)只在上述三件事成立时才发生。 ### 席位意见 (留空) ### 你要做的 1. 审阅 `docs/adr/0055-master-detail-controlled-by-parent.md` 的修订段落(受管面,Tier H),确认"只放宽按 id 更新、不含新增/删除"的范围与裁决一致。 2. 批准后由席位落地;若希望新增/删除也纳入开关,请在卡上追加裁决,本 PR 不预设。 ## Acceptance notes - Owner = the platform's injected per-record owner `owner_id` (the sharing service's `OWNER_FIELD`, the "My records" scope), not `created_by`: the ruling says "the detail row's own owner", and the platform has one owner concept. - The master READ probe runs under the caller's own context through the engine's read door, so it is the answer a direct read of the master gives (object grant, row-level and sharing scope, and the chain for a master that is itself a detail). For a detail whose master the caller cannot read, the by-id pre-image already answers not-found before the gate; the suite pins that refusal too. - The detail's own object-level `update` grant and its own row-level policies (step 2.7) run before the leg, unchanged; an authored `owner_id == current_user.id` policy on an opted-in detail now decides as authored. - The lint test's structural meta-guard (#5017) gained the new reads (`ps.rowLevelSecurity`, a `policy` receiver against `PermissionSetSchema.rowLevelSecurity[]`, the CEL AST receivers excused by name) and the push-site count moved 17 → 18 with a corpus fixture. - Out of scope, noted not filed: `content/docs/permissions/*.mdx` do not yet mention the opt-in (the generated spec docs do, through the key's describe); the owner-column half of the opt-in is reported at runtime rather than refused at parse (documented in the ADR amendment and the gate). ## Verification Head `dece7114`. Local verification, all under the shared verify lock where it is a build or a suite (the three affected packages, their dependency closure built first with `pnpm --filter '@objectstack/plugin-security^...' --filter '@objectstack/lint^...' build`, exit 0): - `pnpm --filter @objectstack/spec test` — Test Files 722 passed | 1 skipped (723), Tests 21899 passed | 1 skipped | 1 todo. `typecheck` exit 0. - `pnpm --filter @objectstack/plugin-security test` — Test Files 224 passed (224), Tests 4700 passed | 45 skipped. `typecheck` exit 0. - `pnpm --filter @objectstack/lint test` — Test Files 164 passed (164), Tests 7363 passed. `typecheck` exit 0. - The new suites on their own: spec parse pins 14/14, plugin-security rig 17/17, lint arm + the posture suite + rule explanations 206/206. - Ablation of the gate leg (`rel.ownerMayEdit` short-circuited to false, then the file restored from git): 4 of 17 rig pins red — the owner's admitted update, the delegated case's control, the body naming the row's own contract, and `checkControlledByParentWrite` answering `allow`; the 13 refusal pins stayed green, as they must (the leg adds no refusal of its own). - `pnpm --filter @objectstack/spec check:generated` exit 0 after regenerating the five artefacts it proved stale (api-surface, export-origins, docs, strictness-ledger counts, liveness counts); `check:liveness`, `check:api-surface`, `check:authorable-surface`, `check:docs` exit 0. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 124 families for this change set): 122 run, every one exit 0, each exit code captured before any pipe; `--ran` reconciliation exit 0 with 0 UNRUN. 2 NOT MEASURED locally, both a recorded exit 3 (prerequisite: the whole farm's `dist/`, which this seat did not build): `pnpm check:dual-build-cjs-loads`, `pnpm check:i18n` — CI's check-runs are the verdict for those two. Four pins outside the new suites moved with this diff, each a one-line or typed change, none a behaviour change: the two compose-stacks pins list `sharing` as the config object it is; the lint barrel-export pin wants the rule id on one line; the #5017 meta-guard table gained the arm's reads. --- _Generated by [Claude Code](https://claude.ai/code/session_01XT1qCAHDq5w7zUftxENNeE)_ --------- Co-authored-by: zhuangjianguo <19182527+zhuangjianguo@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbb7599 commit 5e113b3

34 files changed

Lines changed: 1432 additions & 18 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/lint': minor
5+
---
6+
7+
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`
8+
9+
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.
10+
Metadata-protocol: added (`ObjectSchema.sharing.controlledByParent.ownerMayEdit`, a per-object opt-in read only under `sharingModel: 'controlled_by_parent'`)
11+
Ruling-ref: 6398566164 (letter 1)
12+
13+
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.
14+
15+
- **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`.
16+
- **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.
17+
- **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.
18+
- **ADR-0055** §3 is amended (2026-10-26, #25760) with the opt-in, its boundaries, and what stays out of scope.

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,7 @@ Metadata query with filtering, sorting, and pagination
970970
| **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. |
971971
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
972972
| **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). |
973+
| **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). |
973974
| **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. |
974975
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | 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. |
975976
| **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. |

‎content/docs/references/data/object.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ const result = ApiMethod.parse(data);
173173
| **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. |
174174
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
175175
| **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). |
176+
| **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). |
176177
| **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. |
177178
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | 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. |
178179
| **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);
436437
| **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning |
437438
| **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 |
438439

440+
### Nested Shape: `Object.sharing`
441+
442+
| Property | Type | Required | Description |
443+
| :--- | :--- | :--- | :--- |
444+
| **controlledByParent** | `{ ownerMayEdit?: boolean }` | optional | Opt-ins read only under sharingModel: 'controlled_by_parent' (ADR-0055 §3): ownerMayEdit. |
445+
439446
### Nested Shape: `Object.defaultRowScope`
440447

441448
| Property | Type | Required | Description |

0 commit comments

Comments
 (0)