Skip to content

fix(plugin-security): say "not published" when an unpublished object is denied, instead of naming an internal security step - #10423

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10401-unpublished-object-deny-message
Aug 20, 2026
Merged

fix(plugin-security): say "not published" when an unpublished object is denied, instead of naming an internal security step#10423
os-zhuang merged 1 commit into
mainfrom
claude/issue-10401-unpublished-object-deny-message

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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_DENIED code, same HTTP 403, same [Security] Access denied prefix
(a matcher the transports read as "this is a 403", per the errors.ts header, not
house 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 —

[Security] Access denied: the security posture of object 'shyx_customer' could not be resolved for operation 'find'

— 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_metadata row with state='draft' and no published one:

[Security] Access denied: object 'shyx_customer' is not published — a draft declaration
exists but no published one, so there is no security posture to authorize 'find' against.
Publish the object to make it queryable. This is NOT a permissions problem — no sharing
rule, visibility setting or permission-set change grants access to an unpublished object.

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' keeps
matching; what follows it is new:

[Security] Access denied: the security posture of object 'shyx_customer' could not be
resolved for operation 'find' — neither the live schema nor the metadata service returned
a declaration for it, so access fails closed. Check that the object is declared and
published on this runtime. This is NOT a permissions problem — no sharing rule, visibility
setting or permission-set change grants access to an object whose declaration cannot be read.

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_crud layer detail and
the operator log line are all derived from one module,
packages/plugins/plugin-security/src/unresolved-posture.ts, with both surfaces sharing
the 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_metadata keys a pending edit as state: 'draft' and the published value as
state: 'active', so a draft-only object resolves from neither the live ObjectQL schema
nor the metadata service — which is exactly how it lands on the fail-closed branch
wearing the same sentence as a missing declaration. probeUnpublishedDraft asks that
one question. Four deliberate properties:

  • It runs only on the path already refusing.getObjectSecurityMeta returns before
    reaching it on every posture that resolves — pinned by a test asserting an allowed
    request issues zerosys_metadata reads. On the refusal path it is one indexed
    lookup beside the metadata.get that path already performs.
  • It reads under a system context, so it short-circuits this very middleware at its
    isSystem guard (security-plugin.ts:1168) rather than re-entering the posture
    resolution it is called from. Pinned by a test reading the probe's actual context.
  • It fails safe in one direction only. No sys_metadata in 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 both
    conditions. 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.
  • It is not org-scoped, and the wording is written so it need not be. A draft row
    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.unresolvedCause carries it, documented as explanation-only: both
causes deny, identically and fail-closed.

Reverse-verification sweep of surfaces pinning the old text

  • This repo: one file pinned it — metadata-unresolvable-posture.test.ts, whose
    toContain('could not be resolved') is on the unresolvable branch and still passes
    unchanged (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.
  • Sibling repos: a read-only grep of the local ../cloud and ../objectui
    checkouts at their current HEAD found no pins of either phrase (*.ts, *.tsx,
    *.mjs, *.js, *.md, excluding node_modules/changelogs). Worth a PM re-sweep at
    merge time, since those checkouts are not necessarily at their origin/main.
  • No new error code, so ADR-0112's catalog and ledger are untouched. details is
    deliberately not used as the discriminator carrier, for the reason errors.ts already
    records: 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.ts is real
and points that way — but it also records why the [Security] Access denied prefix is
load-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 built
    first (EXIT=0).
  • pnpm --filter @objectstack/plugin-security test67 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 typechecktsc --noEmit, EXIT=0.
  • Gate set derived from the real diff with node scripts/pm/dispatch-gates.mjs (no
    hand-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 CLI
    it 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: false and with a
throwing 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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 13 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx(via sys_metadata (literal))
  • content/docs/api/index.mdx(via sys_metadata (literal))
  • content/docs/concepts/metadata-lifecycle.mdx(via sys_metadata (literal))
  • content/docs/data-modeling/drivers.mdx(via sys_metadata (literal))
  • content/docs/data-modeling/objects.mdx(via sys_metadata (literal))
  • content/docs/deployment/cli.mdx(via sys_metadata (literal))
  • content/docs/deployment/validating-metadata.mdx(via sys_metadata (literal))
  • content/docs/kernel/services-checklist.mdx(via sys_metadata (literal))
  • content/docs/permissions/authorization.mdx(via sys_metadata (literal))
  • content/docs/permissions/field-level-security.mdx(via SecurityPlugin (symbol))
  • content/docs/permissions/index.mdx(via SecurityPlugin (symbol))
  • content/docs/permissions/permission-sets.mdx(via sys_metadata (literal))
  • content/docs/plugins/packages.mdx(via SecurityPlugin (symbol), sys_metadata (literal))
  • content/docs/ui/forms.mdx(via SecurityPlugin (symbol))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via SecurityPlugin (symbol), sys_metadata (literal))
  • content/docs/releases/v16.mdx(via sys_metadata (literal))
  • content/docs/releases/v17.mdx(via sys_metadata (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e502a6a8ebafaee434a14481a45494a1dd4958c9packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9cba205e1cfa1976cc7225ce2492bad040f90ce8 — the merge of head ea29e7d58bc13d02d7608fdead32a6d0768a1151 into base e502a6a8ebafaee434a14481a45494a1dd4958c9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e502a6a8ebafaee434a14481a45494a1dd4958c9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 20, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 20, 2026 18:52
@os-zhuang
os-zhuang added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit 24ba050Aug 20, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10401-unpublished-object-deny-message branch August 20, 2026 19:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

1 participant

@os-zhuang