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
27 changes: 27 additions & 0 deletions .changeset/21771-write-door-unreadable-is-not-found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/plugin-security': minor
"@objectstack/spec": patch
---

fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers

Clause-②: no (narrowing)

<!-- adr-0087: registered by-id-write-unreadable-row-not-found -->

**BREAKING**: a by-id update or delete of a row the caller cannot read now answers `404 RECORD_NOT_FOUND`, with exactly the body an id that names no row gets, for every principal class. On the write doors, "hidden" and "gone" are now one answer to a caller who cannot read the row. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed, and no error code is new.

**What changed.** The answer used to depend on which gate saw the row first. Where a write-class row filter binds the caller, the by-id write pre-image check answered `403 PERMISSION_DENIED`. Where none binds it, a later gate answered with its own 403: `FORBIDDEN` from record sharing, or a parent-derived gate's code on attachments and comments. Meanwhile a nonexistent id answered `404`. So the write door could tell a hidden row apart from a missing one. The pre-image check now asks the read door's own question first, for the by-id write the caller addressed: a by-id read in the caller's context, every data middleware's visibility included. A row that read does not return gets the read door's not-found producer. A store fault propagates as raised, and a read-time policy refusal is not treated as absence.

**What is refused now that was not.** A principal that no write-class row filter binds could have its by-id write admitted on a row the read door hides from it. One example is the uploader of an attachment, or the author of a comment, whose parent record they can no longer read. That write is now refused with the not-found answer, as it already was for every principal a row filter binds.

**FROM → TO.** A by-id update or delete of a row hidden from the caller: FROM a `403` (`PERMISSION_DENIED`, `FORBIDDEN`, or a parent-derived gate's code) → TO `404 RECORD_NOT_FOUND`, the body a nonexistent id gets.

**If you are affected.** A client that read a by-id write's `403` as "the row exists, but you may not change it" should read `404 RECORD_NOT_FOUND` the way the read door means it: no row you can see has this id.

**Unchanged.**
- A caller who can read the row but may not write it keeps its 403. They already see the row.
- By-id writes the platform issues under the caller's context keep their previous answer, because the caller never named their target: the engine's cascade delete of a dependent row, a hook's write, and the referential clear of a lookup.
- Writes that are not routed by id are unchanged.

`security/explain` follows enforcement. Its record verdict for an update or delete of a record the principal cannot read is now the missing-record shape: `visible: false`, with no decider.
7 changes: 4 additions & 3 deletions content/docs/permissions/attachments-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ editable by design). A multi-delete requires *every* matched row to pass.
| Code | Status | When |
| --- | --- | --- |
| `ATTACHMENT_DELETE_DENIED` | 403 | The caller can read the attachment, but is neither the uploader nor able to edit the parent record |
| `PERMISSION_DENIED` | 403 | The caller cannot read the parent record, so the attachment is not visible to them. This is the platform's not-visible refusal whichever layer gives it — the row-level write check that runs before the gate, or the gate itself for a caller that check does not cover — and it names neither the parent nor the attachment's link to it |
| `RECORD_NOT_FOUND` | 404 | A delete or update **by id** of an attachment the caller cannot read — its parent record is not readable to them. On the write doors a row the caller cannot read is a row that does not exist: the answer is exactly what an id that names no attachment gets, for every caller, so a hidden attachment and a missing one cannot be told apart. It applies even to the uploader once the parent is out of their sight, because the read door no longer returns the attachment to them either |
| `PERMISSION_DENIED` | 403 | The gate's own not-visible refusal, where the gate answers before that check. It names neither the parent nor the attachment's link to it |

An update of another user's attachment follows the same rule — the uploader or
a parent editor — and a caller who cannot read the parent gets the same
not-visible refusal.
a parent editor — and a by-id update of an attachment the caller cannot read
gets the same not-found answer.

The platform baseline also ships a parent-blind row-level delete floor
(`owner_only_deletes`: you may delete only the rows you created, for members
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ flowchart TD
| 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? |

<Callout type="tip">
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation. This keeps security checks efficient even on tables with millions of rows.
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). This keeps security checks efficient even on tables with millions of rows.
</Callout>

## See also
Expand Down
6 changes: 6 additions & 0 deletions content/docs/protocol/kernel/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,12 @@ Content-Type: application/json
}
```

⚠️ **This is the answer for a row the caller can read.** A by-id update or delete of a row
the caller **cannot read** answers exactly what an id that names no row answers —
`404 RECORD_NOT_FOUND` — whichever rule hides the row, so the write door never tells
"hidden" apart from "gone". A caller who can read the row but may not write it gets the 403
shown here.

⚠️ **`FORBIDDEN`, not `PERMISSION_DENIED`.** A by-id write the sharing rules refuse carries
`FORBIDDEN`; `PERMISSION_DENIED` is what the capability and identity guards carry. Both are
403s in the same envelope on this door, so branch on either — ⛔ but do not assume one code
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/plugin-audit/src/comment-access-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ function forbid(message: string, object?: string): never {
* The code of the platform's not-visible refusal: the one plugin-security's
* by-id write pre-image check throws when the caller's own read visibility
* does not reach the target row (`PermissionDeniedError`, 403).
*
* [#21771] No longer the pre-image check's answer for a row the caller cannot
* read: under the write doors' ruling A that check asks the caller's read
* visibility for every principal, and answers the by-id update or delete the
* caller addressed with what a nonexistent id answers, before this gate runs.
* This refusal stays the gate's own, for a write the gate answers first.
*/
const NOT_VISIBLE_CODE: StandardErrorCode = 'PERMISSION_DENIED';
const NOT_VISIBLE_STATUS = 403;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,9 @@ describe('sys_user self-service — the four pins, each attributed to a layer',
// with `refusedBy === 'object-gate'` — the object bit was false, so the row
// scope never ran and this pin proved nothing about it. Now the pre-image
// re-read HAPPENED, was scoped to the caller, and came back empty.
expect(r.preImageWheres).toEqual([{ $and: [{ id: PEER }, { id: ME }] }]);
// …and, the row being refused, the read question that keeps the 403: the
// member reads the peer's row, so it is not answered as a missing one.
expect(r.preImageWheres).toEqual([{ $and: [{ id: PEER }, { id: ME }] }, { id: PEER }]);
expect(r.error?.name).toBe('PermissionDeniedError');
expect(r.error?.code).toBe('PERMISSION_DENIED');
});
Expand Down
14 changes: 11 additions & 3 deletions packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,18 +205,26 @@ async function readFilter(cell: any, roleCtx: any): Promise<unknown> {
*/
async function writeFilter(cell: any, roleCtx: any): Promise<unknown> {
const plugin = new SecurityPlugin();
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true, findOneImpl: () => null });
// The write-class re-read finds nothing; [#21771] the addressed write's
// read question (a plain by-id read) finds the row, so the matrix keeps
// measuring the WRITE filter alone.
const h = makeHarness({
...cell,
orgScoping: cell.orgScoping ?? true,
findOneImpl: (q: any) => (q?.where?.$and ? null : { id: 'r1' }),
});
await plugin.init(h.ctx); await plugin.start(h.ctx);
const opCtx: any = {
object: cell.objectName, operation: 'update',
data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: roleCtx,
};
let threw: any = null;
try { await h.run(opCtx); } catch (e: any) { threw = e; }
if (h.findOne.mock.calls.length === 0) {
const reRead = h.findOne.mock.calls.find((call: any[]) => call[1]?.where?.$and);
if (!reRead) {
return threw ? `CRUD_DENY:${threw?.name ?? 'err'}` : 'BYPASS(no-write-filter)';
}
return h.findOne.mock.calls[0][1].where.$and.slice(1);
return reRead[1].where.$and.slice(1);
}

/**
Expand Down
Loading
Loading