Repository navigation
Commit 87712ab
fix(metadata-protocol): global search skips objects and fields the caller cannot read (#21879)
Fixes #21836
Clause-②: no
## What changed
`searchAll` (`packages/metadata-protocol/src/protocol.ts`) no longer
answers a member's whole global search with `403 PERMISSION_DENIED`
because one object in scope is unreadable.
- **Object level.** Before an object is queried, the sweep asks the
`security` service's `canReadObject` with the caller's context. That
method is the engine middleware's own read gate, arm for arm
(`ISecurityService.canReadObject`). An object it refuses is skipped.
- **Field level (found on the real boot).** With only the object-level
skip, a restricted member's UNSCOPED search was still 403. The cause:
`sys_user`'s server-resolved search fields include admin-only columns
(`role`, `ban_reason`, `last_login_ip`), and the engine's predicate
guard refuses a search that matches on a field the caller may not query.
Each object is now searched only on the fields the caller may query
(`getQueryableFields`), handed to the engine as `searchFields`. ADR-0061
says that key only ever narrows. An object left with no queryable search
field is skipped. When the caller may query every search field, no
`searchFields` is sent, so the request is the same as before.
- **No leak from a skipped object.** It is never queried, never named
and never counted in `totalObjects`. The decision is made before any row
is read, so no hit, count or timing depends on what it holds. No
`objectsSkipped` field was added, because that count would itself
describe objects the caller cannot see. An explicit `objects=` naming an
unreadable object gets the same answer as a name that matches no object.
- **Still fails closed.** A read error on a readable object propagates,
envelope intact. If `canReadObject` or `getQueryableFields` throws, the
search fails instead of shrinking. Without a security service, or with
one that lacks these methods, there is no pre-filter, and `find` still
enforces. Calls that carry no context are not pre-filtered.
- **The misleading comment is fixed.** The per-object `catch` said
object authorization is enforced at the REST door (`enforceAuth`) first.
That door checks authentication only. The comment now says where
admission is decided.
### Route chosen: the pre-filter, not catching the typed denial
The triage comment chose to catch the engine's typed denial. The
dispatch preferred the pre-filter. I took the pre-filter for two
reasons:
1. The middleware throws the same `PermissionDeniedError` for very
different causes: "permission subsystem unavailable", an unresolved
object posture, a missing delegator, a field-level filter-oracle
refusal. Catching the class would swallow every one of them, including a
field-level refusal on an object the caller may read (the 403 this
card's dogfood hit). With the shipped `plugin-security`, a permission
outage is not distinguished by either route: `canReadObject` answers
`false` on a resolution failure, so the pre-filter skips objects during
an outage too (see the Acceptance note below). The deciding reasons are
item 2 and the field-level fix, not outage handling.
2. The single-authority concern is met by contract: `canReadObject` is
specified to compute the middleware's verdict from the same resolution,
never a re-derivation.
## Tests
Unit tests are in
`packages/metadata-protocol/src/protocol.search-skip-unreadable.test.ts`,
12 cases:
- a control that reproduces the 403 when no security service is wired;
- a mixed scope: only readable hits, and the unreadable objects are not
queried, named or counted;
- the answer is the same whether or not a skipped object's rows would
match;
- an explicit `objects=` with an unreadable object answers the same as a
nonexistent name;
- an all-access caller gets the same answer as with no service;
- a read error on a readable object still fails the search;
- an admission check that throws fails the search;
- a call without a context is not pre-filtered;
- a partial queryable set is sent as `searchFields`;
- an object with no queryable search field is skipped;
- a full queryable set, or no answer from `getQueryableFields`, sends no
`searchFields`;
- a field check that throws fails the search.
Dogfood:
`packages/qa/dogfood/test/search-skip-unreadable.dogfood.test.ts` boots
a real kernel with the real `SecurityPlugin` over HTTP. Its two app
objects hold rows matching one term, and the member can read one of
them. It covers:
- control: the walled object is 403 at its own `/data` door;
- the member's unscoped search is 200 with the readable hit and never
names the walled object;
- `objects=open,walled` returns the readable hit only;
- `objects=walled` gives the same answer as a nonexistent name;
- the admin still gets both hits.
### Ablation (dogfood, one-off, nothing left in the tree)
- **Mutation.** `node scripts/ablation-replace.mjs` replaced the
`canReadObject` skip line with a constant-false guard carrying the
marker `ABLATED_21836` (anchor hits 1 to 0, blob 66cc5f6 to
5d37739011c9).
- **Build and check.** Ran `OS_SKIP_DTS=1` for the metadata-protocol
build, then `ablation-dist-preflight` confirmed the marker is present in
`dist/index.js` and `dist/index.cjs`.
- **Result.** Dogfood went **2 failed / 2 passed**. Both member cases
got `403 PERMISSION_DENIED` ("You do not have permission to perform this
action.").
- **Restore.** The tool restored the file, with blob equal to HEAD and
an empty `git diff HEAD`. The rebuilt closure then passed
`ablation-dist-preflight --absent`, with the marker absent from all 24
built files and the tree clean.
### Local verification (at the final head)
- `pnpm --filter @objectstack/metadata-protocol exec vitest run` over
the 7 search test files: 68 passed.
- Dogfood file: 4 passed.
- `typecheck` for metadata-protocol and dogfood: exit 0. Both programs
include the new test files (`--listFilesOnly`).
- The derived gate families from `dispatch-gates.mjs --commands` are
green, plus `check:type-check-debt`, which ran under the verify lock.
The exception is `check:dual-build-cjs-loads`, which printed
PREREQUISITE NOT MET because unrelated packages in this worktree have no
`dist/`. That gate was NOT MEASURED and is declared to CI.
- `check:engine-double-contract` asked for the new double to be pinned,
and that pin is committed.
- Lint, narrowed to the 3 changed TS files with `eslint
--no-inline-config --format json`: 3 files, 0 errors, 0 warnings.
Type-aware linting is not enabled in `eslint.config.mjs` (no
`parserOptions.project`), so this diff cannot change the verdict on any
untouched file.
## Acceptance notes
- **Out of scope here, and remains open as a separate finding: the same
field-level refusal at `GET /api/v1/data/sys_user?search=admin`.**
Measured on a fresh boot as a plain member: `403 PERMISSION_DENIED`
("query on 'sys_user' references field(s) not readable by the caller:
role, ban_reason, last_login_ip"). The same member gets `200` from `GET
/api/v1/data/sys_user`. The caller named no field. The server picked the
search fields and then refused the caller for them. The upstream fix
belongs in the engine's search expansion (`expandSearchOnAst`), which
could narrow to the caller's queryable fields. If that lands, the field
narrowing in `searchAll` becomes redundant and can be deleted. It is
reported for filing by the seat.
- `canReadObject` in `plugin-security` returns `false`, logged at
`error`, when permission resolution throws. It does not throw. During a
permission outage the search therefore skips objects instead of failing.
That is the method's documented fail-closed contract, and nothing leaks,
but it is not the propagate-on-outage behaviour of the rest of this
sweep. No defect is claimed; noted only.
---
_Generated by [Claude
Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 607463d commit 87712ab
5 files changed
Lines changed: 616 additions & 15 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- qa/dogfood/test
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
Lines changed: 325 additions & 0 deletions
| 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 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 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 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 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 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
0 commit comments