Skip to content

Commit 59ff12f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/sharing-position-recompute
Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
2 parents 6d46619 + 4e0f5f5 commit 59ff12f

22 files changed

Lines changed: 1454 additions & 463 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): `security.explain` answers an on-behalf-of caller's by-id update and delete the way the write itself does
6+
7+
For a principal acting on behalf of a user (an OAuth agent), explain's record-grained `update` / `delete` verdict now composes what the by-id PATCH / DELETE composes (ADR-0090 D10):
8+
9+
- record sharing's per-record gate is asked twice, as the write asks it: as the delegated principal at its agent-leg write depth, then as the delegator at the delegator's own write depth;
10+
- the delegator's own row-level write filter is ANDed beside the principal's, as the write's pre-image check ANDs it.
11+
12+
Before, explain asked the gate once at owner-only depth and left the delegator's write filter out, so it could refuse a row the write admits and admit a row the write refuses. Ordinary (non-delegated) callers are unaffected, and so is the delegated read verdict.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/plugin-audit': minor
4+
'@objectstack/spec': minor
5+
---
6+
7+
feat(service-storage, plugin-audit): the attachment and comment gates judge "may edit the parent record" by the parent's own by-id update alone
8+
9+
Clause-②: yes (widening)
10+
11+
A caller who can update the parent record by id can also attach files to it and moderate its comments; a caller who cannot, still cannot. The gates only stop refusing what the CRUD path already admits.
12+
13+
- **What widens.** The `sys_attachment` parent gate (`@objectstack/service-storage`: attaching, detaching or editing another user's attachment, re-pointing one, and `assertMayAttach`) and the `sys_comment` moderation gate (`@objectstack/plugin-audit`: editing or removing another person's comment) now ask one question about the parent record: the security service's record-grained judgement of a by-id `update` of it (`security.explain` for `update` on the parent, admitting only `allowed === true` and `record.visible === true`). They no longer ask record sharing at owner-only depth first, nor the master-detail check: both are part of that judgement already. So a caller whose write depth on the parent object is `unit`, `unit_and_below` or `org` may now attach and moderate wherever that depth lets them update the parent, and so may a caller an app-authored row-level update policy admits.
14+
- **What does not move.** A caller the parent's by-id update refuses is refused, with the same codes as before (`ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`, all 403), and a caller who cannot read the parent still gets the not-visible refusal. A `controlled_by_parent` parent is still judged through its master. Uploaders keep their own attachments and authors their own comments. Posting a comment stays a read rule on the parent. A store fault keeps its own status.
15+
- **Kernels that judge no by-id update.** Where the security service serves no `explain` (or there is none), both gates keep exactly the composition they had: record sharing's tri-state edit verdict at owner-only depth, then the master-detail check. With no sharing service, a caller-scoped read of the parent is still asked first.
16+
- **`@objectstack/spec`.** The `ISharingService` contract prose ("Write DEPTH is an input the CALLER supplies") now records that the parent-record gates ask the security service's by-id update judgement instead of this service, and why they never resolve the parent's depth themselves. No type, member or export changes.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,30 @@ independent of the record-level checks below.
4343
Creating a `sys_attachment` requires that the caller may **edit** the parent
4444
record — Salesforce edit-on-parent parity — and "may edit" has one meaning
4545
here: **a by-id update of that parent record, by the same caller, would be
46-
admitted.** The gate asks three questions in order and admits only when all
47-
three do:
48-
49-
1. record sharing (`ISharingService.checkEdit`);
50-
2. the master-detail check, for a `controlled_by_parent` parent
51-
(`ISecurityService.checkControlledByParentWrite`);
52-
3. the security service's judgement of that update — the record-grained verdict
53-
`security.explain` reports for `update` on the parent record (`allowed` and
54-
`record.visible` both).
46+
admitted.** The gate asks exactly that question, of the security service: the
47+
record-grained verdict `security.explain` reports for `update` on the parent
48+
record (`allowed` and `record.visible` both). That verdict is the write path's
49+
own composition — the parent object's grant, its row-level security, its record
50+
sharing at the caller's write depth, the master of a `controlled_by_parent`
51+
parent, and both legs of an on-behalf-of (agent) request — so a caller who can
52+
update the parent record by id can also attach files to it, and a caller who
53+
cannot, still cannot. A caller whose write depth on the parent object is
54+
`unit`, `unit_and_below` or `org` attaches wherever that depth lets them update.
5555

5656
The same rule governs every check that asks about the parent: attaching,
5757
detaching or editing another user's attachment, re-pointing an attachment to
5858
another record (judged on the new parent), and the file-attachments service's
59-
caller-scoped `assertMayAttach`. It never admits more than the parent's update
60-
does, and it can refuse more: record sharing is asked at the narrowest edit
61-
depth for these gates (the ruling recorded on `ISharingService`, "Write DEPTH
62-
is an input the CALLER supplies").
59+
caller-scoped `assertMayAttach`.
6360

6461
When no sharing service is wired, a caller-scoped read of the parent (a
65-
`findOne`) stands in for step 1. A kernel with no security service asks no
66-
update judgement, as the parent's own update meets none there. A store fault in
67-
step 2 or 3 keeps its own status (a `503` stays a `503`); it is never reported
68-
as a refusal. `uploaded_by` is **server-stamped** from the session; a
69-
client-supplied value is ignored.
62+
`findOne`) is asked first. A security service that judges no by-id update (it
63+
serves no `explain`, or there is none) leaves the gate on the composition it
64+
had before: record sharing (`ISharingService.checkEdit`) at the narrowest edit
65+
depth, then, for a `controlled_by_parent` parent, the master-detail check
66+
(`ISecurityService.checkControlledByParentWrite`). A store fault keeps its own
67+
status (a `503` stays a `503`); it is never reported as a refusal.
68+
`uploaded_by` is **server-stamped** from the session; a client-supplied value
69+
is ignored.
7070

7171
| Code | Status | When |
7272
| --- | --- | --- |

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3070,7 +3070,7 @@
30703070
"title": "Opt-in record-view auditing writes `read` rows for exactly the opted-in objects' detail reads, batched off the request path — stock boot audits nothing, excluded names refuse loudly, values never copied",
30713071
"since": "v17",
30723072
"status": "active",
3073-
"revision": 2,
3073+
"revision": 3,
30743074
"priority": "P2",
30753075
"surface": "api",
30763076
"personas": [
@@ -3109,7 +3109,7 @@
31093109
"evidence": "log line + the ledger rows before/after each read"
31103110
},
31113111
{
3112-
"clause": "the row records WHO/WHICH/WHEN and nothing else: action 'read', user_id = M, object_name/record_id correct, old_value AND new_value both null (the afterFind hook sees PRE-MASK plaintext, so copying values would mint a plaintext copy of exactly what FLS withholds — read-audit.ts,516-521), and created_at is the VIEW instant, not the flush instant",
3112+
"clause": "the row records WHO/WHICH/WHEN and nothing else: action 'read', user_id = M, object_name/record_id correct, old_value AND new_value both null (the afterFind hook sees PRE-MASK plaintext, so copying values would mint a plaintext copy of exactly what FLS withholds — packages/plugins/plugin-audit/src/read-audit.ts#buildRow writes both as null, for the reason the file header's section 'The row records NO field values, and that is load-bearing' gives), and created_at is the VIEW instant, not the flush instant",
31133113
"oracle": "api",
31143114
"verify": "field-by-field read of the landed row; created_at within the read's wall-clock window, not the flush's",
31153115
"evidence": "the row + the recorded clocks"
@@ -3173,6 +3173,12 @@
31733173
"date": "2026-10-17",
31743174
"change": "re-measured on main c91af8ed6 (checklist-accuracy finding from #23878): the stock leg's install-line grep was vacuous on a default boot, because the line logs at INFO and os dev defaults to warn, so steps[0], the fixture and clause 0's verify now boot at OS_LOG_LEVEL=info with an INFO-line positive control. The stock specimen GET /data/showcase_contact/ID is refused to a baseline member at the object gate (403), so the zero proved nothing either; the step now reads a showcase_account detail (200). knownGaps[0] gains the scripted scratch-copy recipe for the configured half, and steps 1 and 3 name X = showcase_account and the un-opted showcase_project. Re-run: stock boot at info (649 INFO lines), plain member detail reads of showcase_project, showcase_task and showcase_account all 200, action 'read' rows 0, no install line. Configured scratch copy: 'Plugin superseded: com.objectstack.audit' WARN, the sys_job exclusion WARN and 'AuditPlugin: record-view auditing installed on 1 object(s) — showcase_account'; member M's account detail read → 1 row (action read, user_id M, object_name showcase_account, record_id correct, old_value and new_value null, created_at 00:15:23.489Z inside the 23.385–23.497 read window, about 4 s before the flush read); the list read and the showcase_project detail read left the count at 1; the admin's detail read made it 2",
31753175
"ref": "#24036"
3176+
},
3177+
{
3178+
"revision": 3,
3179+
"date": "2026-10-20",
3180+
"change": "line citations re-pointed — the comma-range spelling that round 7 of wave anchor #24579 named (18.0 sweep; round 7's report 6281433885, out_of_scope_findings[1]). acceptance[2].clause cited lines 516-521 of read-audit.ts in the comma-range spelling, the second range of the two-range list 45397edf53 authored (55-75 and 516-521). At that commit lines 516-521 were the audit row's old_value / new_value nulls; at this base they hold a docblock, and the nulls sit in the buildRow closure of installReadAuditWriter. The clause now anchors read-audit.ts#buildRow and names the header section that gives the reason in prose. The spelling is what #13786's strip left when it removed only the first range of a ':A,B' list, and the line-citation limb reads only the colon spellings, so no gate caught it. Where the surrounding words still describe the range the strip removed, the new citation names that code too. No clause's assertion changed",
3181+
"ref": "#24752"
31763182
}
31773183
]
31783184
},
@@ -3181,7 +3187,7 @@
31813187
"title": "The /s/:token share-link landing page renders the resolved record minus redacted fields, gates on password, and refuses dead links with designed states — never a blank page or a raw error",
31823188
"since": "v16",
31833189
"status": "active",
3184-
"revision": 3,
3190+
"revision": 4,
31853191
"priority": "P2",
31863192
"surface": "browser",
31873193
"personas": [
@@ -3197,7 +3203,7 @@
31973203
],
31983204
"knownGaps": [
31993205
"automated.ref lives ENTIRELY in the objectui repo — from this checkout it is neither runnable nor pin-evidenced (the RUNNER standing fact): drive the item by hand in the browser, or run the pin in an objectui checkout and name the revision",
3200-
"the ai_conversations transcript branch (SharedRecordPage.tsx,190-234 — messages fetch + ChatbotEnhanced readOnly) is ai_conversations-only (Cloud/EE service-ai) — the same knownGap split the API item records for /:token/messages; not runnable on open-framework showcase",
3206+
"the ai_conversations transcript branch (objectui SharedRecordPage.tsx: the SharedRecordPage component's ai_conversations branch, with its messages-fetch effect and the ChatbotEnhanced readOnly transcript) is ai_conversations-only (Cloud/EE service-ai) — the same knownGap split the API item records for /:token/messages; not runnable on open-framework showcase",
32013207
"FIXED, regression row, expected PASS (objectui#11649): the page no longer folds every 401 into the password prompt. Measured at objectui 1fb1ced36243 (the pin this row was written at), again at daeb77fc84dd (#23878) and at f8267df93aba (this repo's .objectui-sha when revision 3 was written; re-read it at whatever pin the run boots): SharedRecordPage.tsx reads a 401 by the body's error.code through shared-record-shape.ts resolveGateOf — NEEDS_PASSWORD and WRONG_PASSWORD open the password prompt, SIGN_IN_REQUIRED opens the sign-in gate, and any other code shows the server's message, not a prompt — so a signed_in-audience link opened anonymously renders the dedicated 'Sign in required' state, never 'Password required'. acceptance[3] now asserts that state (QA run #23163 saw it live at objectstack 36b2889c). The objectui pins are apps/console/src/pages/SharedRecordPage-11649.test.tsx ('shows the sign-in path back to this link, and no password prompt') and shared-record-shape.test.ts ('reads each 401 code as its gate'), objectui-repo-only like knownGaps[0]"
32023208
]
32033209
},
@@ -3219,13 +3225,13 @@
32193225
{
32203226
"clause": "the password gate renders as designed and never leaks early: 401 → the 'Password required' prompt; wrong password → re-prompt with 'Wrong password.'; correct password → the record renders — and NO 401 response body carries the record",
32213227
"oracle": "network",
3222-
"verify": "the three round-trips (SharedRecordPage.tsx,143-176): two 401s with record-free bodies, then the 200 render; drive the input with native setter + events, not coordinate typing",
3228+
"verify": "the three round-trips (objectui SharedRecordPage.tsx: the component's password-gate branch, gate === 'password'): two 401s with record-free bodies, then the 200 render; drive the input with native setter + events, not coordinate typing",
32233229
"evidence": "the three network traces + screenshots of prompt and render"
32243230
},
32253231
{
32263232
"clause": "dead links refuse with designed, non-leaking states: revoked and expired render 'This link has expired or was revoked.' (410), a garbage token renders 'This link is invalid or no longer available.' (404) — never a blank page, a spinner that never settles, or a raw stack/JSON error, and no record data in any refusal body",
32273233
"oracle": "screenshot",
3228-
"verify": "each terminal state's screenshot shows the designed message (SharedRecordPage.tsx,178-186); the paired network bodies are record-free",
3234+
"verify": "each terminal state's screenshot shows the designed message (objectui SharedRecordPage.tsx: the component's error branch, which renders the message fetchResolve sets for a 404 or a 410); the paired network bodies are record-free",
32293235
"evidence": "screenshots + the 404/410 bodies"
32303236
},
32313237
{
@@ -3267,7 +3273,7 @@
32673273
},
32683274
"source": [
32693275
"objectui apps/console/src/App.tsx (the /s/:token route, mounted outside the authenticated shell)",
3270-
"objectui apps/console/src/pages/SharedRecordPage.tsx (status folds, password form, error states, generic record render + hidden-fields notice, ai_conversations branch,190-234)",
3276+
"objectui apps/console/src/pages/SharedRecordPage.tsx (status folds, password form, error states, generic record render + hidden-fields notice, ai_conversations branch)",
32713277
"objectui apps/console/src/pages/shared-record-shape.ts (normalizeResolvedShare — both envelopes, the #3983 rename)",
32723278
"cross-ref access-security.share-link-capability-tokens — the API half: mint/resolve/redaction/password/audience/revoke semantics and the #9308 showcase_client_brief fixture are asserted THERE; this item asserts only what the console renders of them",
32733279
"#9308 (the stock fixture), objectstack#3983 (the enveloped-branch rename regression the notice clause guards)"
@@ -3290,6 +3296,12 @@
32903296
"date": "2026-10-16",
32913297
"change": "knownGaps[2] stopped naming objectui 1fb1ced36243 as 'still this repo's .objectui-sha' (#23878 checklist-accuracy finding). The pin moved to daeb77fc84dd and then to f8267df93aba; at f8267df93aba SharedRecordPage.tsx still reads a 401 by its code through shared-record-shape.ts resolveGateOf, renders the 'Sign in required' state for SIGN_IN_REQUIRED, and both objectui pins (SharedRecordPage-11649.test.tsx, shared-record-shape.test.ts) are present. The revision 2 history row stays as written: it was true at its pin. Wording only, no re-run owed",
32923298
"ref": "#24036"
3299+
},
3300+
{
3301+
"revision": 4,
3302+
"date": "2026-10-20",
3303+
"change": "line citations re-pointed — the comma-range spelling that round 7 of wave anchor #24579 named (18.0 sweep; round 7's report 6281433885, out_of_scope_findings[1]). fixtures.knownGaps[1], acceptance[1].verify, acceptance[2].verify and source[1] cited objectui SharedRecordPage.tsx by the line ranges 190-234 (twice), 143-176 and 178-186 in the comma-range spelling (authored in 45397edf53). At the objectui pin that commit shipped, 9602dc820450, those lines were the ai_conversations branch, the password form and the error state; at the shipped pin 12dc7b47c340 all three have moved down, below a sign-in gate and a reworked fetchResolve, and the password branch now reads gate === 'password'. Each now names its branch of the SharedRecordPage component. objectui is read at the shipped pin 12dc7b47c340 and cited in prose, because no objectui anchor resolves in this gate. The spelling is what #13786's strip left when it removed only the first range of a ':A,B' list, and the line-citation limb reads only the colon spellings, so no gate caught it. Where the surrounding words still describe the range the strip removed, the new citation names that code too. No clause's assertion changed",
3304+
"ref": "#24752"
32933305
}
32943306
]
32953307
},

0 commit comments

Comments
 (0)