You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 06c762e
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-security): retire seven dead { records } find-result limbs, and stop the permission-set loader inventing an empty page (#15838)
* fix(plugin-security): retire seven dead `{ records }` find-result limbs, and stop the permission-set loader inventing an empty page
Six of the seven `Array.isArray(x) ? x : x.records` blocks in this plugin carried
an unreachable envelope limb over an engine `find()` result. The limb is removed
on a MEASUREMENT rather than on the declared type: `IDataEngine.find` says
`Promise<any[]>`, but a declared type is not proof here — this repo also carries
a `find()` that resolves a `QueryResult` envelope and never an array. A real
`ObjectQL` over a real `SqlDriver` was booted and each seam driven through the
shipped function that owns it; every one answered a bare array with no own
`records` key, on a populated page and an empty one alike.
Each of the six keeps its existing disposition for a non-array. Removing a dead
limb must not quietly convert a seam that gaps into one that invents an empty.
The seventh block is the opposite defect and is repaired in the opposite
direction. `SecurityPlugin`'s `sys_permission_set` loader swallowed a thrown read
into `[]` and mapped an unreadable result to `[]` too, so three distinct facts
left by one door. This is the enforcement plane: "no permission sets" silently
withdraws grants that exist while every request still looks normal, and the
swallow made `PermissionEvaluator.resolvePermissionSets`' own "db lookup failed"
warn unreachable — the diagnostic this repo had already built for exactly this
loss. The read fault now propagates and an unreadable page is refused with
`DATABASE_ERROR`; a page carrying a non-row refuses too, where the trailing
filter used to drop it in silence. Enforcement is unchanged in both directions —
an unanswered read still grants nothing — but it is now sayable.
Seven pins, one per block, each driving its own seam against the real engine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
* test(plugin-security): route the pin's engine seam through the producer predicates, and re-anchor the rotted system-context rows
Two gate repairs on the #15598 pins, neither a behaviour change.
`check:engine-double-contract`: the pin file's `observed()` recorder forwards
every verb to a real `ObjectQL`, but a seam that merely forwards is exactly the
shape that reads as "not a double" and then admits a call the real engine would
refuse. Its `update`/`findOne`/`delete` now open with the producer's own
predicates (`assertEngineUpdateDispatch` / `assertEngineFindOnePredicate` /
`assertEngineDeleteDispatch` from `@objectstack/metadata-core`), and the ledger
learns about the newly pinned double — the ratchet grows, it is not weakened.
`check:system-context-census`: line rot, not a finding. The loader repair added
a net 66 lines to `security-plugin.ts` (77 added / 11 deleted), and every rotted
anchor the census reported was off by exactly 66. Repaired with the gate's own
`--fix`; only line numbers in `content/docs/permissions/system-context.mdx`
change, no prose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
* docs(changeset): qualify the enforcement claim — unchanged on every reachable input, fail-closed on the two that are not
Clause-② review measured `PermissionEvaluator.resolvePermissionSets` through
this PR's own `dbLoaderFor(ORG)` over a real ObjectQL/SqlDriver, at HEAD and at
the merge-base blob, on five engine conditions. A healthy page, a thrown read
and `undefined` resolve identically before and after — every result the shipped
engine actually produces. An envelope and a page carrying a non-object element
do NOT: they granted at base and refuse at HEAD.
Both are fail-closed and both are unreachable on the measured engine, which is
what the eleven pins establish, so this is a declared narrowing rather than a
discovered move — but "unchanged in both directions" is an unqualified claim
about behaviour and it sits in the changeset, which feeds release notes. The
reviewer's wording replaces it verbatim.
⛔ No source change: the refusal itself was reviewed and passed exactly as it
stands. Changeset and PR body carry identical wording.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Remove seven dead `{ records }` union-normalizer limbs on engine `find()` results, and repair the one that was silently dropping instead of gapping.
6
+
7
+
Six seams in this plugin normalized an engine read as `Array.isArray(x) ? x : x.records`. The envelope limb was unreachable: `ObjectQL.find` resolves a bare array of row objects, measured by booting a real engine over a real `SqlDriver` and driving each seam through the shipped function that owns it, rather than inferred from `IDataEngine.find`'s declared `Promise<any[]>` (a declared type is not proof — this repo also has a `find()` that resolves an envelope). Each seam keeps its existing disposition for a non-array; only the dead limb is gone.
8
+
9
+
The seventh is repaired in the opposite direction. `SecurityPlugin`'s `sys_permission_set` loader mapped three different facts onto one value: a read that succeeded on an empty catalog, a read that threw, and a read that resolved something it could not read all left as `[]`. On the enforcement plane that silently withdraws grants that exist while every request still looks normal, and it made `PermissionEvaluator`'s existing "db lookup failed" warning unreachable — so a transient database error and an empty catalog produced identical, undiagnosable 403s. The loader now lets the read fault propagate and refuses an unreadable result with `DATABASE_ERROR`. Enforcement is unchanged for every result the shipped engine produces; an envelope or a non-row element now refuses (fail-closed) where the old code read through it. An unanswered read still grants nothing; what changes is that it is now reported instead of silent.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+8-8Lines changed: 8 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -87,14 +87,14 @@ that silently does not happen.
87
87
88
88
| # | Behaviour when `isSystem`| Package | What you get / what you lose | Anchor |
89
89
|:--|:---|:---|:---|:---|
90
-
| 1 |**The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page |`security-plugin.ts:1620`|
91
-
| 2 |**`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2546` (the step 3.5 block), skipped by `:1620`|
| 1 |**The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page |`security-plugin.ts:1686`|
91
+
| 2 |**`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2612` (the step 3.5 block), skipped by `:1686`|
| 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted |`object-schema-fls.ts:228`|
97
-
| 8 |`explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check |`security-plugin.ts:3887`|
97
+
| 8 |`explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check |`security-plugin.ts:3953`|
98
98
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId`|`anonymous-deny.ts:154`|
@@ -120,7 +120,7 @@ that silently does not happen.
120
120
| 26 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index |`objectql/src/engine.ts:6881`|
121
121
| 27 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld |`objectql/src/engine.ts:12477`|
122
122
| 28 | Reference-cleanup log attributes the write to `'system'`| objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor`|`objectql/src/engine.ts:12406`|
123
-
| 29 |**Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) |`security-plugin.ts:1620`|
123
+
| 29 |**Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) |`security-plugin.ts:1686`|
0 commit comments