Repository navigation
Commit 5e113b3
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
File tree
- .changeset
- content/docs/references
- api
- data
- system
- docs
- adr
- audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- core/src/security
- lint/src
- metadata-core/src
- metadata-protocol/src
- platform-objects/src/apps/translations
- plugins/plugin-security/src
- spec
- api-surface
- authorable-surface
- export-origins
- liveness
- state-counts
- src
- data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
970 | 970 | | |
971 | 971 | | |
972 | 972 | | |
| 973 | + | |
973 | 974 | | |
974 | 975 | | |
975 | 976 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
173 | 173 | | |
174 | 174 | | |
175 | 175 | | |
| 176 | + | |
176 | 177 | | |
177 | 178 | | |
178 | 179 | | |
| |||
436 | 437 | | |
437 | 438 | | |
438 | 439 | | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
439 | 446 | | |
440 | 447 | | |
441 | 448 | | |
| |||
0 commit comments