Repository navigation
fix(metadata-protocol): global search skips objects and fields the caller cannot read - #21879
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…ects Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
… may query Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…ledger Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…rch sweep canReadObject in plugin-security answers false on a permission-resolution failure, so an outage skips objects rather than failing the search. Also note why an undefined getQueryableFields answer narrows nothing here. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Resolve the engine-double ledger by regenerating it with check-engine-double-contract --write (both pins kept). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c35865977ecc56168356516804b3a2cdf0bb6a85 && git checkout c35865977ecc56168356516804b3a2cdf0bb6a85
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e864db56dffc2dec3290e5f0700f9d1606a1c830 d3ee9012187d7d54b24cf4b76b47859f67d3b377 && git checkout -B drift-repro e864db56dffc2dec3290e5f0700f9d1606a1c830 && git merge --no-ff d3ee9012187d7d54b24cf4b76b47859f67d3b377
node scripts/docs-audit/affected-docs.mjs --json e864db56dffc2dec3290e5f0700f9d1606a1c830
|
Fixes #21836
Clause-②: no
What changed
searchAll(packages/metadata-protocol/src/protocol.ts) no longer answers a member's whole global search with403 PERMISSION_DENIEDbecause one object in scope is unreadable.securityservice'scanReadObjectwith 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.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 assearchFields. 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, nosearchFieldsis sent, so the request is the same as before.totalObjects. The decision is made before any row is read, so no hit, count or timing depends on what it holds. NoobjectsSkippedfield was added, because that count would itself describe objects the caller cannot see. An explicitobjects=naming an unreadable object gets the same answer as a name that matches no object.canReadObjectorgetQueryableFieldsthrows, the search fails instead of shrinking. Without a security service, or with one that lacks these methods, there is no pre-filter, andfindstill enforces. Calls that carry no context are not pre-filtered.catchsaid 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:
PermissionDeniedErrorfor 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 shippedplugin-security, a permission outage is not distinguished by either route:canReadObjectanswersfalseon 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.canReadObjectis 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:objects=with an unreadable object answers the same as a nonexistent name;searchFields;getQueryableFields, sends nosearchFields;Dogfood:
packages/qa/dogfood/test/search-skip-unreadable.dogfood.test.tsboots a real kernel with the realSecurityPluginover HTTP. Its two app objects hold rows matching one term, and the member can read one of them. It covers:/datadoor;objects=open,walledreturns the readable hit only;objects=walledgives the same answer as a nonexistent name;Ablation (dogfood, one-off, nothing left in the tree)
node scripts/ablation-replace.mjsreplaced thecanReadObjectskip line with a constant-false guard carrying the markerABLATED_21836(anchor hits 1 to 0, blob 66cc5f6879b0 to 5d37739011c9).OS_SKIP_DTS=1for the metadata-protocol build, thenablation-dist-preflightconfirmed the marker is present indist/index.jsanddist/index.cjs.403 PERMISSION_DENIED("You do not have permission to perform this action.").git diff HEAD. The rebuilt closure then passedablation-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 runover the 7 search test files: 68 passed.typecheckfor metadata-protocol and dogfood: exit 0. Both programs include the new test files (--listFilesOnly).dispatch-gates.mjs --commandsare green, pluscheck:type-check-debt, which ran under the verify lock. The exception ischeck:dual-build-cjs-loads, which printed PREREQUISITE NOT MET because unrelated packages in this worktree have nodist/. That gate was NOT MEASURED and is declared to CI.check:engine-double-contractasked for the new double to be pinned, and that pin is committed.eslint --no-inline-config --format json: 3 files, 0 errors, 0 warnings. Type-aware linting is not enabled ineslint.config.mjs(noparserOptions.project), so this diff cannot change the verdict on any untouched file.Acceptance notes
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 gets200fromGET /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 insearchAllbecomes redundant and can be deleted. It is reported for filing by the seat.canReadObjectinplugin-securityreturnsfalse, logged aterror, 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