Skip to content

Commit 62b6a2f

Browse files
os-zhuangclaude
andauthored
docs(spec,plugin-audit): record the sys_comment parent gate's own depth as ruled, with the fail-open that blocks widening it (#7144) (#7305)
The maintainer's Option 1 ruling of 2026-08-10: the `sys_comment` parent gates stay at `own`, deliberately tighter than the parent's edit authority. Recorded on `ISharingService` — the contract both parent-record gate kits reach this service through — including WHY the alternative is off the table, so the next reader can tell "deliberately tighter" from "nobody got round to it": `getEffectiveScope` returns 'org' when no permission set mentions the object, indistinguishable from a genuine modifyAllRecords holder, so feeding `resolveWriteScope` into the owner-match is a real widening today. Comments only — every changed line is a doc-block line; no gate changes what it returns for any input. Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 123b6c6 commit 62b6a2f

3 files changed

Lines changed: 87 additions & 2 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/plugin-audit": patch
4+
---
5+
6+
docs(spec,plugin-audit): record that the parent-record write gates match ownership at `own` BY DESIGN (#7144)
7+
8+
Documentation only — no gate changes what it returns for any input.
9+
10+
`ISharingService`'s write gates widen ownership "by write DEPTH", but that depth
11+
is an INPUT the caller supplies: the CRUD middleware resolves it for the object
12+
of the operation in flight and stamps it on the operation context. The
13+
`sys_comment` gates (`@objectstack/plugin-audit`) and the `sys_attachment` kit
14+
(`@objectstack/service-storage`) ask this service about the PARENT record's
15+
object, so the stamped depth belongs to a different object and is dropped — and
16+
the owner-match runs at its narrowest, `own`. A caller whose write depth on the
17+
parent is `unit` / `unit_and_below` / `org` can therefore edit that parent
18+
directly and is refused when editing a comment or attachment on it.
19+
20+
That divergence is deliberate and runs in the restrictive direction (refusals,
21+
never a leak). The contract now says so, and — the part that matters for anyone
22+
tempted to "fix" it — says WHY the alternative is not merely unimplemented:
23+
`ISecurityService.resolveWriteScope`, the only tool a package outside
24+
`plugin-security` has for the parent's depth, fails OPEN, because
25+
`getEffectiveScope` returns `'org'` when no permission set mentions the object
26+
at all — indistinguishable from a genuine `modifyAllRecords` holder. Handed to a
27+
write gate as the depth it becomes authoritative on its own and the owner-match
28+
short-exits `true` for every owned row of that object. Inheriting the parent's
29+
edit authority starts with a depth primitive that can tell "org depth" from
30+
"nothing matched", not with these gates.

‎packages/plugins/plugin-audit/src/comment-access-hooks.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,16 @@ function withoutOperationPrivateKeys(exec: Record<string, unknown>): ExecutionCo
258258
* Note what deliberately did NOT change: no access DEPTH is synthesised for the
259259
* parent object. Absent depth leaves the sharing owner-match at its narrowest
260260
* (`own`) — the safe direction, and byte-for-byte the behaviour the projection
261-
* produced. Resolving the parent's own depth (the other candidate shape) would
262-
* WIDEN this gate and is a separate decision; see #7141's PR discussion. */
261+
* produced.
262+
*
263+
* [#7144] That is now the RULED shape, not a pending question: the maintainer's
264+
* ruling of 2026-08-10 keeps these gates at `own`, deliberately tighter than
265+
* the parent's real edit authority. The reasoning — including the fail-open in
266+
* `ISecurityService.resolveWriteScope` that makes the widening unsafe to wire
267+
* today — is recorded once on the contract that owns this gate's meaning
268+
* (`@objectstack/spec` — `ISharingService`, "Write DEPTH is an input the CALLER
269+
* supplies"), because the `sys_attachment` kit reaches the same gate from
270+
* another package; it is not restated here. */
263271
function callerContext(ctx: any): ExecutionContext {
264272
const exec = ctx?.input?.options?.context;
265273
if (exec && typeof exec === 'object') {

‎packages/spec/src/contracts/sharing-service.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,46 @@ export type SharingWriteVerdict = 'allow' | 'abstain' | 'deny';
257257
* `org_user_ids`, `systemPermissions`, `posture` (ADR-0095 D2) and
258258
* `tabPermissions` included. Which of those a deployment makes load-bearing
259259
* depends on its tenancy posture, which the caller cannot know.
260+
*
261+
* ## Write DEPTH is an input the CALLER supplies — absent means `own` (#7144)
262+
*
263+
* The write gates below widen ownership "by write DEPTH" (ADR-0057 D1: `own` /
264+
* `own_and_reports` / `unit` / `unit_and_below` / `org`). That depth is NOT a
265+
* field of {@link ExecutionContext}, and this service never derives it: the
266+
* CRUD middleware resolves it for the object of the operation IN FLIGHT and
267+
* stamps it on the operation context as an operation-private (`__`-prefixed)
268+
* key, which the default implementation's owner-match reads. A caller that is
269+
* not that middleware hands over no depth, and the owner-match then runs at its
270+
* NARROWEST — owner-only, i.e. `own`.
271+
*
272+
* **For the PARENT-record gates that is deliberate, not an oversight.** The
273+
* `sys_comment` gates (`@objectstack/plugin-audit`) and the `sys_attachment`
274+
* kit (`@objectstack/service-storage`) ask this service about the PARENT
275+
* record's object while the operation in flight is on the CHILD, so the stamped
276+
* depth was resolved for a different object and is dropped rather than carried
277+
* across. Stated as behaviour: a caller whose write depth on the parent object
278+
* is `unit` / `unit_and_below` / `org` can edit that parent directly through
279+
* the CRUD path, and is REFUSED when editing or deleting a comment or
280+
* attachment on it. The divergence runs in the RESTRICTIVE direction —
281+
* refusals, never a leak — and the maintainer ruling of 2026-08-10 (#7144,
282+
* split from #7141 / PR #7143) is that these gates STAY at `own`.
283+
*
284+
* **Why the obvious "fix" is off the table** — recorded here so the next reader
285+
* can tell "deliberately tighter" from "nobody got round to it". Making these
286+
* gates inherit the parent's real edit authority means resolving the parent's
287+
* depth through the only tool a package outside `plugin-security` has,
288+
* `ISecurityService.resolveWriteScope` — and that fails OPEN on one input:
289+
* `getEffectiveScope` returns `'org'` when NO permission set mentions the
290+
* object at all (`plugin-security/src/permission-evaluator.ts` —
291+
* `if (!matched) return 'org'`), which is byte-identical to what a genuine
292+
* `modifyAllRecords` holder gets. That is why the method's own doc block calls
293+
* `'org'` authoritative ONLY when paired with an explicit
294+
* `ISecurityService.hasWriteBypass` check. Handed to a write gate as the depth
295+
* it becomes fully authoritative on its own, and the owner-match short-exits
296+
* `true` for EVERY owned row of an unmatched object — a real widening, not a
297+
* theoretical one. So inheriting the parent's depth starts with a depth
298+
* primitive that can tell "org depth" from "nothing matched", never with these
299+
* gates.
260300
*/
261301
export interface ISharingService {
262302
/**
@@ -308,6 +348,13 @@ export interface ISharingService {
308348
* `modifyAllRecords` super-user bypass. Always true for system context,
309349
* `public` objects, and objects with no owner field.
310350
*
351+
* [#7144] The write DEPTH in that first sentence is an INPUT this service is
352+
* handed, never one it resolves — so a caller outside the CRUD middleware
353+
* (the `sys_comment` and `sys_attachment` PARENT-record gates, which both
354+
* reach this service through this method) matches ownership at `own`. That is
355+
* the ruled shape, and the fail-open that blocks widening it is written up
356+
* once in "Write DEPTH is an input the CALLER supplies" on this interface.
357+
*
311358
* [#6428] The two-state PROJECTION of {@link checkEdit}: `true` for every
312359
* verdict that is not `deny`, i.e. `allow` and `abstain` alike. That
313360
* collapse is the historical semantics, kept byte-for-byte so existing

0 commit comments

Comments
 (0)