Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-security): say "not published" when an unpublished object is denied, instead of naming an internal security step - #10423
Conversation
…is denied, instead of naming an internal security step (#10401) The #3545 fail-closed deny is correct and unchanged — same PermissionDeniedError, same PERMISSION_DENIED, same 403, same `[Security] Access denied` matcher prefix. What was wrong was the explanation. "The security posture of object 'X' could not be resolved for operation 'find'" covered two conditions with two different remedies, and named a *security* step while doing it — so every reader, human and model, took it for a permissions problem and went hunting for a sharing rule. Measured downstream (cloud#1481): an AI turn burned seven tool calls and a free plan's daily allowance before telling the user the object was "missing its sharing/visibility setting". The two conditions now read differently: an object with a sys_metadata draft and no published row is told to publish it; a genuinely unreadable declaration keeps the pre-existing clause verbatim (so any surface pinning it still matches). Both sentences end by saying, in words, that permissions are not the lever. Both surfaces that state this condition — the middleware throw and the explain engine's object_crud detail — now derive from one wording module, because the two drifting apart is the defect shape this closes. The discriminator is a best-effort sys_metadata probe: it runs only on the path already refusing, reads under a system context so it cannot re-enter the middleware, and degrades to the both-conditions wording on any failure. A posture that resolves never probes at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 14 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 — 13 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 9cba205e1cfa1976cc7225ce2492bad040f90ce8 && git checkout 9cba205e1cfa1976cc7225ce2492bad040f90ce8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e502a6a8ebafaee434a14481a45494a1dd4958c9 ea29e7d58bc13d02d7608fdead32a6d0768a1151 && git checkout -B drift-repro e502a6a8ebafaee434a14481a45494a1dd4958c9 && git merge --no-ff ea29e7d58bc13d02d7608fdead32a6d0768a1151
node scripts/docs-audit/affected-docs.mjs --json e502a6a8ebafaee434a14481a45494a1dd4958c9
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10401
The deny is unchanged; the sentence is the whole change
The #3545 fail-closed refusal stays exactly as it is — same
PermissionDeniedError,same
PERMISSION_DENIEDcode, same HTTP 403, same[Security] Access deniedprefix(a matcher the transports read as "this is a 403", per the
errors.tsheader, nothouse style). Nothing here widens access, and no access decision branches on any of
the new information.
What changed is what the refusal says. One sentence —
— covered two conditions with two different remedies, and named neither. Because it
described an internal security step, every reader took it for a permissions problem
and went looking for a sharing rule to change. Measured downstream
(objectstack-ai/cloud#1481): an end-user AI turn asked "how many customers do I have?"
against a draft-only object, spent seven tool calls oscillating between a metadata
plane that said the object existed and this refusal, then told the user the object was
"missing its sharing/visibility setting" — confident, professional, and wrong. On a
free plan that one turn also exhausted the daily allowance.
What it says now
Unpublished draft — a
sys_metadatarow withstate='draft'and no published one:Genuinely unresolvable — never declared, or a metadata-store outage. The
pre-#10401 opening clause is kept verbatim, so any surface matching
the security posture of object 'X' could not be resolved for operation 'Y'keepsmatching; what follows it is new:
Both branches end by saying, in words, that permissions are not the lever. A refusal
that names the wrong remedy is worse than a terse one: it is load-bearing for the next
reader's diagnosis, and the incident above is what that costs.
One wording, not two literals
The incident shape this card closes is two files stating one refusal and drifting
apart. So the middleware throw, the explain engine's
object_crudlayer detail andthe operator log line are all derived from one module,
packages/plugins/plugin-security/src/unresolved-posture.ts, with both surfaces sharingthe same remedy sentence. A test asserts the middleware's message is that module's
output verbatim, and that the remedy string appearing in the explain prose is the same
string appearing in the throw.
How the discriminator is obtained, and why it cannot hurt
sys_metadatakeys a pending edit asstate: 'draft'and the published value asstate: 'active', so a draft-only object resolves from neither the live ObjectQL schemanor the metadata service — which is exactly how it lands on the fail-closed branch
wearing the same sentence as a missing declaration.
probeUnpublishedDraftasks thatone question. Four deliberate properties:
getObjectSecurityMetareturns beforereaching it on every posture that resolves — pinned by a test asserting an allowed
request issues zero
sys_metadatareads. On the refusal path it is one indexedlookup beside the
metadata.getthat path already performs.isSystemguard (security-plugin.ts:1168) rather than re-entering the postureresolution it is called from. Pinned by a test reading the probe's actual
context.sys_metadatain the deployment(file-backed metadata, LiteKernel test kernels), an unprovisioned store, a driver
error — all answer
'unknown'and the caller gets the wording that covers bothconditions. It can never turn a resolved posture into a denial, and never a refusal
into a grant: it is read after the deny decision is already made.
owned by another organization still makes "a draft declaration exists but no published
one" a true statement about this runtime, which is all the message claims.
ObjectSecurityMeta.unresolvedCausecarries it, documented as explanation-only: bothcauses deny, identically and fail-closed.
Reverse-verification sweep of surfaces pinning the old text
metadata-unresolvable-posture.test.ts, whosetoContain('could not be resolved')is on the unresolvable branch and still passesunchanged (that clause is kept verbatim). No other source, doc, fixture or gate in
6125 tracked text files matches
security posture of/could not be resolved for operation.../cloudand../objectuicheckouts at their current HEAD found no pins of either phrase (
*.ts,*.tsx,*.mjs,*.js,*.md, excludingnode_modules/changelogs). Worth a PM re-sweep atmerge time, since those checkouts are not necessarily at their origin/main.
detailsisdeliberately not used as the discriminator carrier, for the reason
errors.tsalreadyrecords: it is not a reliable carrier across both transports, so each sentence has to
stand on its own.
Considered and not taken: a separate error class
The issue suggests "deny with its own code". The #7474 precedent in
errors.tsis realand points that way — but it also records why the
[Security] Access deniedprefix isload-bearing, and a code/status move is the half of this that could break a consumer
branching on 403. The dispatched ruling was to keep the rejection contract untouched and
make only the explanation honest, which is what landed. If a machine-readable
discriminator is later wanted, it is an additive follow-up on a contract this PR has not
moved.
Verification
All on the pushed head
ea29e7d58, clean tree, exit codes captured before any pipe.pnpm --filter '@objectstack/plugin-security^...' build— dependency closure builtfirst (EXIT=0).
pnpm --filter @objectstack/plugin-security test— 67 files / 1318 tests passed,including the 14 new ones and the 11 pre-existing 元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545 pins, unmodified.
pnpm --filter @objectstack/plugin-security typecheck—tsc --noEmit, EXIT=0.node scripts/pm/dispatch-gates.mjs(nohand-supplied paths), all green:
check:authz-resolver·check:changeset-gate-self-tests·check:cross-package-test-inputs·check:objectui-changeset·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:query-options-erasure·check:engine-double-contract·check:where-matcher·check:type-check-coverage·check:nul-bytes·check:i18n(after building the CLIit requires — "OK, 9 packages, all bundles in sync") ·
check:type-check-debt --re-measure(after building@objectstack/service-knowledge,the one unbuilt closure member it named — "OK, 33 ledger entries re-measured, none
above its recorded number, surplus: none") ·
scripts/check-adr-0087-registration.mjs·scripts/check-changeset-no-major.mjs·scripts/check-empty-changeset.mjs·scripts/check-cross-package-test-inputs.mjs·scripts/docs-audit/check-affected-docs.mjs.No ablation was run: the discriminator's two branches are exercised as live negative
controls inside the suite itself — the same harness with
draftRow: falseand with athrowing probe both produce the unresolvable wording and assert the unpublished wording
is absent — so a mutation leg would re-prove what the negative controls already prove.
Generated by Claude Code