Skip to content

Commit 1fd4fe3

Browse files
committed
Merge origin/main into claude/issue-20018-native-read-scope-faces
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
2 parents 0b03a53 + b373596 commit 1fd4fe3

13 files changed

Lines changed: 1593 additions & 25 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): `security/explain` fails closed when a dependency it shares with enforcement throws, so a request that fails is no longer reported as allowed (#20002)
6+
7+
Clause-②: no
8+
9+
`POST /api/v1/security/explain` calls the same functions as the enforcement middleware. Enforcement does not catch a failure in them, so the request fails. The explain engine caught the same failure and turned it into a value that it then read as an answer. So when the sharing service's share store was unavailable, `{ object, operation: 'read', recordId }` answered `decision.record.visible: true` (`decidedBy: 'sharing'`, sharing layer `admitted`), and the caller's `find` for the same row threw. Four call sites had this problem:
10+
11+
- **The sharing read filter** (the reported case). A failure became `null`, which the record matcher reads as "no filter". So an unshared row, a shared row, and the caller's own row were all reported visible.
12+
- **The sharing service's per-record `update` / `delete` gate.** A failure became "no gate wired", so ownership, a `read` share or the OWD answered a write that the by-id `PATCH` / `DELETE` then failed on.
13+
- **The layered row-level security composition.** A failure became "no tenant wall and no business RLS". The row was reported visible, while `allowed` was `false` because of the same failure.
14+
- **An on-behalf-of delegator whose grants could not be read.** A failure became "no delegation", so the agent's own grants decided alone and `allowed` was `true`. The same `find` answered `503 SERVICE_UNAVAILABLE`.
15+
16+
Each failure is now reported as it happened. The affected layer's `record.outcome` is `not_evaluated`, with no `rowFilter` and no `matchesRecord`, and its `detail` says the layer could not be evaluated. `record.visible` is `false`, and `decidedBy` names the layer that failed: `sharing`, or `rls` for the composition. For the delegator case, the `principal` and `object_crud` layers deny and `allowed` is `false`. The response has no new keys, and `not_evaluated` is an existing outcome value.
17+
18+
Unchanged:
19+
20+
- Enforcement admits and refuses exactly what it did before.
21+
- A dependency that answers is reported exactly as before. That includes a read filter that answers "no restriction", such as an `org`-depth reader's `null`.
22+
- Failures that already failed closed are unchanged: record fetch, share listing, and permission-set resolution for the principal or the delegator.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a delete refused because its reference cleanup trips a traversing validation rule now says so, naming the delete, the cleared reference and the repair (#20006)
6+
7+
Clause-②: no
8+
9+
Deleting a record clears each `set_null` reference to it (the default for an optional `lookup`) with an UPDATE of every record that references it. That cleanup resolves no related record for a validation rule, so a `script` / `cross_field` rule on the referencing object that reads through a reference (`record.account.status`) cannot be evaluated there. It refuses the cleanup, and the delete with it. That refusal is unchanged: same `VALIDATION_FAILED` error, same `rule_violation` field error, same `constraint` (`reason: 'unevaluable'`, the fault, the missing key), and the same set of deletes refused.
10+
11+
What changes is the message. It used to be the generic one about the rule's own object: `The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field.` Whoever deleted the record did not write that rule, and following the advice adds a bogus column to the wrong object. The message now names the blocked delete, the reference being cleared, the rule and its object, and the repairs:
12+
13+
```text
14+
Cannot delete crm_account (acc_1): the delete clears `account` on the crm_deal records that reference it,
15+
and validation rule 'closed_account_frozen' on crm_deal could not be evaluated on that write — it reads
16+
'status' through `account`, and a rule is given no related record while a delete clears references.
17+
Guard the rule on `account` being set: make it the `then` of a `conditional` rule whose `when` is
18+
`record.account != null`. Or change `deleteBehavior` on crm_deal.account: 'cascade' deletes those records
19+
with the crm_account, 'restrict' refuses the delete while they exist.
20+
```
21+
22+
- **The guard** is offered only to a rule that reads through the reference the cleanup empties. Such a rule already refuses every write that leaves that reference empty (`no single related record`), so the guard only lets those writes through. The guarded rule is still judged on every write where the reference is set.
23+
- **A rule that reads only through another reference** is offered only `deleteBehavior`. A guard on the cleared reference would stop judging that rule on every record whose cleared reference is empty, on every insert and update.
24+
- **On a multi-value reference** the cleanup removes the deleted record and keeps the other members, so the guard would still run the rule. There, only `deleteBehavior` is offered.
25+
- **Unchanged:** a rule that reads a key where it is not held keeps today's text byte for byte, whichever key the fault reports. Those reads are `record.KEY`, `record.FIELD.KEY` through a field that is not a reference, `previous.KEY`, and `previous.FIELD.KEY` through any field, when the record, the previous row or the field's value lacks `KEY`. A declared column always reads, as `null` when empty, so it never counts. A rule that reads through no reference keeps today's text too, and so does every write that is not a delete's reference cleanup.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: no shipped permission set below platform admin reads a row of `sys_verification` or `sys_jwks` — the credential rows those objects declare private (#20027)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level).
12+
13+
`sys_verification` (one-time verification and password-reset tokens) and
14+
`sys_jwks` (JWT signing keys) declare `access: { default: 'private' }`, and the
15+
shipped permission sets documented them as denied to every principal below
16+
platform admin. The sets did not hold that: the read they grant on every
17+
better-auth-managed object is an explicit per-object entry, which the `private`
18+
posture does not govern, and neither object had a row policy behind it.
19+
20+
**What a principal can no longer read.** Every shipped set that grants that read
21+
and carries row-level security — `member_default`, `viewer_readonly`,
22+
`organization_admin` and its wall-less variant `organization_admin_no_bypass` —
23+
now declares a row policy that admits no row on each of the two objects
24+
(`sys_verification_none`, `sys_jwks_none`). That covers the rows the objects
25+
declare private, including a caller's own verification row. An MCP agent acting
26+
for a user is bounded by that user's sets and reads the same. `admin_full_access`
27+
is unchanged and still reads every row.
28+
29+
**Remedy.** None is expected to be needed: no shipped product surface reads these
30+
tables under a user context. A deployment that genuinely needs a principal below
31+
platform admin to inspect them grants that in a permission set of its own, with
32+
a row-level-security policy that names the rows it may see.
33+
34+
Unchanged: better-auth's own verification, password-reset and token-signing flows
35+
(they read and write through its adapter under system context, which no row
36+
policy reaches), the owner-scoped reads of the other `private` identity objects
37+
(`sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token`), and
38+
every other managed object.
39+
40+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched, no object definition or column changes, and the shipped permission sets are code evaluated from the in-memory declaration, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. The change is a narrowing of what the platform's own sets admit at runtime. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers it (not `registered` / `already-registered`); and it is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

0 commit comments

Comments
 (0)