Repository navigation
Commit de8cd58
Fixes #20932
Clause-②: no (narrowing)
## What this changes
The security layer's field predicate guard refuses a query that filters,
sorts, groups or aggregates by a field the caller's field-level
permissions hide (`403 PERMISSION_DENIED`), because which rows answer
would disclose the value the field mask withholds. It collected the
fields a condition names from the condition's **keys** only, so a hidden
field named only as a **cross-field comparand** was never judged by that
rule.
`collectConditionFields` now also collects the field every cross-field
comparand inside a field constraint names, into the same set, and the
existing field rule judges it. One collection, one rule, no second
guard. A hidden field as a comparand now answers the same refusal (code,
status and body) as the same hidden field written as a key.
Dispatched by the `domain:services` seat (seat post #6021), session
`session_01XY5uCwTjZj7884yYtyur4H`; claim comment 5919655256, triage
direction 5919556782.
## Per position (disclosure-safe: classes, no request shapes)
| Comparand position the filter grammar admits | Door | Before (class) |
After |
|---|---|---|---|
| The whole comparand of a scalar comparison (each of `$eq` `$ne` `$gt`
`$gte` `$lt` `$lte`) | data read | served: the comparison was answered |
`403 PERMISSION_DENIED`, body equal to the key form's |
| Under a logical group (`$and`, `$or`, `$not`, and nested groups) |
data read | served | same refusal as the key form |
| The whole-day offset wrapper: its base reference | data read | served
| same refusal as the key form |
| The whole-day offset wrapper: its offset column | data read | served |
same refusal as the key form |
| The query condition of a grouped query (plain, and under `$or`) |
aggregate path | served | same refusal as the key form |
| A per-aggregation filter | aggregate path | served | same refusal as
the key form |
| `having` | guard (unit pin) | not collected | collected, same refusal
as the key form at the guard; not pinned through a route (see Acceptance
notes) |
| A member of a list operator, an endpoint of a range operator | none |
not a comparand position: the grammar refuses a reference there |
unchanged, not pinned |
A comparand naming a field the caller may read is served as before (the
control, in every position above).
## Mechanism readings
- **D1, the collector and its callers.** `collectConditionFields` is
called by itself (logical groups) and by `collectQueryFields` (`where`,
`having`, each aggregation's `filter`). `collectQueryFields` is called
only by `assertReadableQueryFields`, which has one call site: the
security plugin's engine middleware, for every verb that carries a
predicate (the read, `findOne`, `count` and `aggregate` on the caller's
query; bulk `update` / `delete` on the caller's own condition). The
package index re-exports all three; no in-repo consumer outside tests.
So the aggregate path is not a separate caller: it gets the widened
collection through the same call.
- **D2, the comparand reader.** `FieldReferenceSchema` from
`@objectstack/spec/data`, the grammar's exported declaration of a
cross-field reference (including the whole-day offset's own nested
reference). No function that yields the referenced field is exported:
the filter module's and the driver readers' reference predicates are
module-private. So each node of a field constraint is asked of the
schema itself (`safeParse`), the same reading `objectql`'s having filter
already takes. The walk carries no operator list and no position list:
it visits every node of the constraint, so a position the grammar admits
is covered without being named. `packages/spec/src`,
`packages/objectql/src` and `service-analytics` are untouched.
- **D3, positions.** The grammar admits a reference as the whole
comparand of the six scalar comparisons, with or without the whole-day
offset (whose offset may itself be a reference), under any logical
nesting, in each clause that carries a condition. It refuses one as a
list member or a range endpoint. Every admitted position is pinned.
- **D4, the refusal.** The key form is the reference: the route pins
read it first, per hidden field and per path, and require the comparand
form's status and whole body to equal it; the unit pins require equal
`code`, `status`, message and details.
- **D6, `Clause-②`.** Widening measured absent: no export added or
removed (the package index and manifest are untouched, the new helper is
module-private), and the collected set only grows, so nothing refused
before is served now. Narrowing measured present: on the pre-fix source,
all 15 route refusal pins answered 200 where the fix answers 403. Line 2
read by `scripts/pm/clause2-line.mjs` (`readClause2Line`): declared,
value `no`, arm `narrowing`. The changeset is `minor` with the BREAKING
banner and its ADR-0087 marker.
## Pins, red/green and ablation
Commits: the pins (`9e602fe84`, red on that commit by design), the fix
(`3b34899fc`), the changeset wording (`215bc699b`, HEAD). Pushed
together only after the fix was committed.
- `packages/plugins/plugin-security/src/predicate-guard.test.ts`: the
collection and the verdict, per position, with the key form as the
reference and a readable-comparand control.
- `packages/rest/src/data-field-comparand-permission.test.ts`: the real
`SecurityPlugin` on a real `ObjectQL` over a real `SqlDriver`, through
the data query route, on the data read and the aggregate path, each
refusal compared with the key form's, each position with its readable
control.
All readings below were taken on HEAD `215bc699b`.
- **Red/green on the committed tree** (`predicate-guard.ts` restored
from the pins commit, pins at HEAD, package rebuilt and the built output
proven to carry no comparand collection): unit 23 failed / 12 passed
(35), route 15 failed / 15 passed (30). Every refusal pin red, every
control green. Green leg (restored from HEAD by absolute path, blob
equal to the HEAD blob, `git diff HEAD` 0 bytes, rebuilt, fix proven
present in the built output): unit 35/35, route 30/30.
- **Ablation of the widened collection** (the collector's call replaced
by a no-op reference so the package still compiles; the mutation proven
on disk by anchor count 1 to 0 and blob change; rebuilt; built output
proven without the call): predicted unit 23 failed / 12 passed and route
15 failed / 15 passed; observed exactly that. Restored: blob equal to
the HEAD blob, `git diff HEAD` empty, rebuilt, the call present in the
built output again. A first attempt that deleted the call outright did
not build (the helper became unused), so the suite would have read a
stale build; it was discarded and is not counted.
## Tests and gates
- `pnpm --filter @objectstack/plugin-security test`: 149 files passed;
3252 passed, 23 skipped. Exit 0.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0
(source, scripts, and the test layer under `tsconfig.test.json`, 0 debt
entries).
- `pnpm --filter @objectstack/rest test`: 244 files passed; 4895 passed,
114 skipped. Exit 0.
- `pnpm --filter @objectstack/rest typecheck`: exit 0; the new route pin
is in the program.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` at `215bc699b`
derives 64 commands; those plus the 4 roster families named at dispatch
(`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`) were run one at
a time, each exit code captured before any pipe: 68 of 68 exit 0. A full
build (72 tasks) preceded the gates that read built output.
Reconciliation: `✓ dispatch-gates --ran: 64 derived famil(ies) accounted
for — 64 run, 0 NOT-MEASURED`.
- Lint, narrowed and proven: the population is the three changed
TypeScript files (eslint's own config applies to each; it ignores the
changeset); `eslint --no-inline-config --format json` reports 3 files, 0
errors, 0 warnings; this repo's config enables no type-aware linting, so
the diff cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's.
- The client envelope-caller census needs no row: the pins call no
censused client method.
## Acceptance notes
- **`having`**: a comparand there is collected and pinned at the guard
(unit); it is NOT MEASURED through a route.
- **`findOne`, `count`, bulk `update` / `delete`**: covered by
construction (the guard's single call site serves every verb that
carries a predicate) and by the collector pins; not pinned per verb in
the committed suite.
- **Analytics**: inherits the fix through the engine path; not touched
and not pinned here. Its own gate is #20917's (PR #20931).
- **A comparand inside a nested-relation condition** is collected by the
same walk and judged against the queried object's own field permissions
(the refusing direction). NOT MEASURED.
- **Drivers**: the route pin runs on SQLite only; the guard runs before
any driver.
- **`main` moved**: `origin/main` is at `013f97df9`, 8 commits past this
branch's base `1571aedce` (read just before this PR opened). None
touches this claim's surface, so the branch is not merged (per the
dispatch order); this PR's CI on the merge ref reads the merged tree.
`dispatch-gates` flagged three of its derivation inputs as changed on
`main` (two ADR-anchor files for other packages and the doc-authoring
prose-id baseline); the derived family list is unchanged.
- **CI**: not awaited; the CI-only lanes `dispatch-gates` names (the
test shards, temporal conformance, dogfood, build core, the workspace
type-check lanes, the artifact-roster and wide-population families) are
CI's.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6b3a01 commit de8cd58
4 files changed
Lines changed: 379 additions & 0 deletions
File tree
- .changeset
- packages
- plugins/plugin-security/src
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
Lines changed: 96 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| 26 | + | |
26 | 27 | | |
27 | 28 | | |
28 | 29 | | |
| |||
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
39 | 46 | | |
40 | 47 | | |
41 | 48 | | |
| |||
46 | 53 | | |
47 | 54 | | |
48 | 55 | | |
| 56 | + | |
49 | 57 | | |
50 | 58 | | |
51 | 59 | | |
52 | 60 | | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
53 | 90 | | |
54 | 91 | | |
55 | 92 | | |
| |||
0 commit comments