Skip to content

Commit 46b4360

Browse files
feat(objectql,plugin-security): the ReadableIds leaf composes for a delegated caller with the delegator intersection; the changeset, the ADR E2 row and the dangling-delegator report line
The missing-delegator refusal in computeDelegatedReadFilter carries the D10 invariant sentence the report-sink pin reads; the two bare-instance pins' docblocks say which refusal they now reach. The changeset for @objectstack/objectql and @objectstack/plugin-security (minor, as the earlier ADR-0139 composition cards). ADR-0139's E2 row notes D5.1's amendment beside its historical "three classes". Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91a18c8 commit 46b4360

5 files changed

Lines changed: 34 additions & 10 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
feat(objectql, plugin-security): the engine composes its `ReadableIds` leaf for a delegated (on-behalf-of) caller, scoped to the delegator intersection, instead of refusing it (ADR-0139 D5.1 as amended; O5 answered)
7+
8+
Clause-②: no
9+
10+
**Before.** A read carrying an on-behalf-of link whose final `where` held a `ReadableIds` leaf was refused `INTERNAL_ERROR` / 500 before any driver was asked (ADR-0139 D5.1 as accepted), and `getReadFilter` answered the deny sentinel for every delegated context, so the leaf's consumers routed such a caller to their bounded, fail-closed probe.
11+
12+
**Now.**
13+
14+
- `@objectstack/objectql`: the executor boundary composes the leaf for a delegated caller exactly as for any other. It asks `canReadObject` and `getReadFilter` with the delegated context as it stands (`onBehalfOf` intact) and composes what the security service answers; it derives no intersection of its own. The two remaining D5 classes, a parent an object-scoped read middleware narrows and a parent bound to another datasource, are still refused by name with the same envelope.
15+
- `@objectstack/plugin-security`: `getReadFilter` answers a delegated context the ADR-0090 D10 intersection, the six layers the engine middleware ANDs into a delegated `find` of the same object: the principal's row-level security and `controlled_by_parent` derivation, the delegator's two over the delegator's own rebuilt context, and the sharing visibility filter under each identity at its own depth. A delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves. It fails closed as the chain does: a delegator who does not exist, a delegator the object does not admit, a principal or a delegator that resolves no permission set, and any resolution failure answer the deny sentinel, and the leaf is `$in: []`.
16+
17+
**What a reader sees.** No shipped consumer writes a leaf for a delegated caller yet (each still routes it to its probe; that routing retires per consumer on its own card), so a delegated read of `sys_activity`, `sys_audit_log`, `sys_comment`, `sys_comment_reaction` or `sys_attachment` is served as before. A door that binds `getReadFilter` directly, such as the analytics raw-SQL path, scopes a delegated context to the intersection now; as accepted it denied that context outright.
18+
19+
**Unchanged.** `controlled_by_parent`'s own read scope (ADR-0055 §2 mechanism (b), the pre-resolved id set) and the nested master chain, which keep their own cards; the delegated `find`, `findOne`, `count` and `aggregate` through the engine middleware, which already applied the intersection.

‎docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ and none changes `packages/spec` before it.
323323
| Card | Decisions | What lands | Pins (red before, green after) | Order |
324324
|:--|:--|:--|:--|:--|
325325
| **E1** — the leaf | D1 | `ReadableIds` in `packages/spec/src/data/`; the comparand door admits the branded value and refuses the unbranded one | a JSON round-trip of a `where` holding the leaf is refused; the plain-object spelling is refused 400 | first |
326-
| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes, marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated, middleware-gated or federated parent ⇒ the loud refusal | after E1 |
326+
| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes as accepted (D5.1 amended 2026-10-21: the delegated caller composes), marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated, middleware-gated or federated parent ⇒ the loud refusal | after E1 |
327327
| **E3** — SQL family | D3 | `driver-sql` compiles the leaf inside `packages/drivers/driver-sql/src/sql-driver.ts#applyTenantScope`; wasm and local Turso inherit; the remote transport compiles it | a reader with more than 2,000 readable candidates gets every visible row; an unreadable row stays hidden; a deleted parent is excluded; `count` equals the rows served | after E2 |
328328
| **E4** — memory and mongodb | D3 | memory implements; mongodb refuses with the envelope | the conformance table records both | with E3 |
329329
| **E5** — the activity and ledger switch | D4, D5 | `packages/plugins/plugin-audit/src/parent-record-read-gate.ts#computeParentRecordFilter` emits leaves, no pre-scan; D5's classes keep the probe; letter C's refusal retired from these two | the measured scenario answers 1,808 of 1,808; the exemption capability unchanged; the outside class as a predicate | after E3 |

‎packages/plugins/plugin-security/src/default-report-sink.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
* Case 1 drives a REAL report site on a bare instance, so the pin is about what
2323
* an operator sees rather than about a field's contents. `getReadFilter` with an
2424
* on-behalf-of context is the one refusal reachable with no `ql`, no `metadata`
25-
* and no `start()`: it fails closed on the unimplemented D10 delegator
26-
* intersection (#2852) and reports through `this.logger.error?.()`.
25+
* and no `start()`: the ADR-0090 D10 intersection it composes (ADR-0139 D5.1 as
26+
* amended) needs the delegator's `sys_user` row, which a bare instance cannot
27+
* read, so the link names a delegator who does not exist and the read fails
28+
* closed on that dangling link, reporting through `this.logger.error?.()`.
2729
*
2830
* Case 2 pins the channel the LEDGER row is about. `error` stays optional by
2931
* #9754's own ruling (hosts inject reduced sinks); `warn` is the channel a

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6040,8 +6040,9 @@ export class SecurityPlugin implements Plugin {
60406040
if (del.kind !== 'resolved') {
60416041
this.logger.error?.(
60426042
`[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator ` +
6043-
`'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: ADR-0090 D10, a ` +
6044-
`delegated read with no delegator to intersect against is never scoped)`,
6043+
`'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: a delegated read is ` +
6044+
`never scoped wider than its delegator's own, and with no delegator to intersect against it is not ` +
6045+
`scoped at all; ADR-0090 D10)`,
60456046
);
60466047
return RLS_DENY_FILTER;
60476048
}

‎packages/plugins/plugin-security/src/start-logger-binding.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,13 @@ const sinkOf = (plugin: SecurityPlugin) => (plugin as any).logger;
7777
* Drive a REAL report through `this.logger` and return whether it landed.
7878
*
7979
* `getReadFilter` is a public instance method whose on-behalf-of refusal
80-
* (`this.logger.error?.(…)`, the ADR-0095/#2852 fail-closed branch) depends on
81-
* NONE of the handles captured below the bail-outs — no `this.ql`, no
82-
* `this.metadata`. That makes it the one report site that is genuinely
83-
* reachable on a bailed-out instance, which is what makes it the right probe
84-
* for "the sink RECEIVES something".
80+
* (`this.logger.error?.(…)`, the ADR-0090 D10 fail-closed branch for a
81+
* delegator who does not exist — on a bailed-out instance the delegator's
82+
* `sys_user` row cannot be read, so the link dangles) depends on NONE of the
83+
* handles captured below the bail-outs — no `this.ql`, no `this.metadata`.
84+
* That makes it the one report site that is genuinely reachable on a
85+
* bailed-out instance, which is what makes it the right probe for "the sink
86+
* RECEIVES something".
8587
*
8688
* Pre-fix this call is silent: `{}.error` is `undefined` and `?.()` no-ops.
8789
*/

0 commit comments

Comments
 (0)