Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/24646-explain-on-behalf-of-write-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): `security.explain` answers an on-behalf-of caller's by-id update and delete the way the write itself does

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):

- 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;
- the delegator's own row-level write filter is ANDed beside the principal's, as the write's pre-image check ANDs it.

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.
16 changes: 16 additions & 0 deletions .changeset/24646-parent-gates-one-question.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/service-storage': minor
'@objectstack/plugin-audit': minor
'@objectstack/spec': minor
---

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

Clause-②: yes (widening)

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.

- **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.
- **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.
- **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.
- **`@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.
36 changes: 18 additions & 18 deletions content/docs/permissions/attachments-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,30 +43,30 @@ independent of the record-level checks below.
Creating a `sys_attachment` requires that the caller may **edit** the parent
record — Salesforce edit-on-parent parity — and "may edit" has one meaning
here: **a by-id update of that parent record, by the same caller, would be
admitted.** The gate asks three questions in order and admits only when all
three do:

1. record sharing (`ISharingService.checkEdit`);
2. the master-detail check, for a `controlled_by_parent` parent
(`ISecurityService.checkControlledByParentWrite`);
3. the security service's judgement of that update — the record-grained verdict
`security.explain` reports for `update` on the parent record (`allowed` and
`record.visible` both).
admitted.** The gate asks exactly that question, of the security service: the
record-grained verdict `security.explain` reports for `update` on the parent
record (`allowed` and `record.visible` both). That verdict is the write path's
own composition — the parent object's grant, its row-level security, its record
sharing at the caller's write depth, the master of a `controlled_by_parent`
parent, and both legs of an on-behalf-of (agent) request — so a caller who can
update the parent record by id can also attach files to it, and a caller who
cannot, still cannot. A caller whose write depth on the parent object is
`unit`, `unit_and_below` or `org` attaches wherever that depth lets them update.

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

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

| Code | Status | When |
| --- | --- | --- |
Expand Down
Loading
Loading