Skip to content

fix(plugin-security): PermissionDeniedError carries status beside statusCode, so a share-link permission refusal answers 403 at both doors - #21429

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21405-permission-denied-status
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21405-permission-denied-status

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21405
Clause-②: no

What was wrong

PermissionDeniedError (plugin-security/src/errors.ts) declared statusCode = 403 and no status. It was the only error class in that module that did. A door that reads status alone derived no status from it. plugin-sharing's share-link route door reads err?.status ?? 500, and that door serves /api/v1/share-links on the standalone server.

Measured on a showcase boot (@objectstack/verify, with the app's own default profile), as a plain member, at main 6d67ad5:

request plugin route door runtime dispatcher /share-links domain
POST /share-links on a showcase_client_brief record the member cannot read 500 PERMISSION_DENIED 403 PERMISSION_DENIED
GET /share-links 500 PERMISSION_DENIED 403 PERMISSION_DENIED

The dispatcher door was driven in-process, the way @objectstack/verify's own handle drives it: an HttpDispatcher over the same booted kernel, with the same bearer token.

The change (the triage ruling on the card)

  • PermissionDeniedError carries readonly status = 403 beside statusCode = 403. The code, the message, details, developerMessage and statusCode are unchanged. No door is edited, and no status helper is added anywhere.
  • The module's "Why each carries BOTH status and statusCode" note now names the readers as they stand today. The share-link route door and the sandbox boundary's passthrough read status alone. errorFromThrown and mapDataError read both spellings. The note used to say mapDataError reads status alone, which stopped being true when it learned both spellings.

Pins

  • Enumeration (plugin-security/src/errors.test.ts). Every Error subclass that errors.ts exports is constructed, and each must carry a numeric status and statusCode with equal values. The population is read from the module's exports, so a class added later is checked without being listed. A floor names the eight classes exported today, so the enumeration cannot pass over nothing.
  • One refusal, both doors (runtime/src/domains/share-links-enforcement-context.test.ts, the new [#21405] block). The harness has one engine double with the whole SecurityPlugin middleware booted on it, one ShareLinkService and one envelope. It drives both production entries: registerShareLinkRoutes mounted on a route recorder, and handleShareLinksRequest over the dispatcher's own errorFromThrown. A create by a caller with no allowRead on the object answers 403 PERMISSION_DENIED through both doors, under the single and the group posture, and writes no link.
  • Create only. PR fix(plugin-sharing): a plain member's own share-link list is self-scoped (ADR-0111) #21403 (for share-links: GET /api/v1/share-links answers 403 to every plain member on every object — listLinks reads sys_share_link under the caller's context, which member_default does not grant #21328, merged into this branch) made a member's own list a self-scoped read. Measured on the showcase boot after that merge: GET /share-links answers 200 through both doors, with no filter, with the Share dialog's object and record filter, and with includeRevoked. No list request reaches the refusal any more, so no list case is pinned. Before the merge, a list case was written, and it went red under the ablation below.
  • The existing [#6649] shared-catch case drove the production class to reach the dispatcher catch's statusCode channel. The class now carries status as well, so the case adds a statusCode-only throw beside it, and that channel stays pinned.

Tests (head 01bb1de unless noted)

  • Runtime: share-links-enforcement-context, data-permission-denied-envelope, permission-denied-error-parity and share-links-internal-hash-probe give 4 files, 39 passed.
  • pnpm --filter @objectstack/runtime typecheck is OK. Its test layer holds 27 files / 190 errors / 68 signatures in its ledger, unchanged.
  • pnpm --filter @objectstack/plugin-security typecheck is OK. errors.test.ts is in the tsconfig.test.json program (--listFiles: 1 hit).
  • pnpm --filter @objectstack/plugin-security test: 161 files, 3502 passed, 33 skipped. pnpm --filter @objectstack/plugin-sharing test: 38 files, 928 passed. Both ran at 46b09ea. Since then, the only change in either package is a comment in errors.test.ts.
  • Importers whose answer could move: the 13 rest test files that import @objectstack/plugin-security give 209 passed. The dogfood share-link files (share-links-self-list, showcase-client-liaison-fixtures, audit-log-internal-fields) pass. Both ran at 46b09ea.
  • Gates: dispatch-gates --commands at 01bb1de derives 67 commands, and all 67 exit 0. The --ran reconciliation reports 67 derived, 67 run and 0 NOT-MEASURED, derived from the recorded exit codes. At an earlier head, check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 unbuilt packages). Those were built, and every later run exits 0.
  • Lint (narrowed): eslint --no-inline-config --format json over the 3 changed TS files reports 3 files, 0 errors and 0 warnings, and the config resolves for each file. eslint.config.mjs enables no type-aware linting (its own note: no parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The full pnpm lint is CI's.

Ablations

Each leg ran from a committed head through scripts/ablation-replace.mjs: the anchor hit, the blob changed, and the restore was proven by an empty git diff HEAD. plugin-security resolves from dist/ in the runtime and dogfood suites, so each of those legs rebuilt it, and scripts/ablation-dist-preflight.mjs proved the marker in dist/ (4 files) and then absent (all 6 files, tree clean).

mutation in errors.ts suite red green
status renamed off PermissionDeniedError (status_ablated_21405) runtime share-links-enforcement-context (head d9f9aab) 2: both [#21405] postures, plugin door expected 500 to be 403 18, every [#6649] dispatcher case included (that door reads statusCode)
same errors.test.ts 2: the enumeration (PermissionDeniedError: status=undefined statusCode=403) and the 403 case 1 (the floor)
same, at b7fdf64 the showcase dogfood pin then on this branch (create and list, both doors) 2: create 500 vs 403, list 500 vs 403 1 (persona)
a scratch export class AblatedStatusCodeOnlyError extends Error { readonly statusCode = 418; } errors.test.ts 1: AblatedStatusCodeOnlyError: status=undefined statusCode=418 — declare both 2
status = 404 on PermissionDeniedError errors.test.ts 2: status 404 !== statusCode 403 and the 403 case 1

The restore legs pass: runtime 20/20 and errors.test.ts 3/3. The first attempt at the planted-class leg did not run. ablation-replace refused it before any test, because its replacement contained the anchor, so the anchor count did not fall. It was redone with an anchor the replacement does not contain.

Docs

content/docs/** (outside releases/) and skills/** hold no sentence this change makes false. The status table in protocol/kernel/error-handling.mdx gives 403 for insufficient permissions, and that is now true at the share-link door. permissions/field-level-security.mdx shows a partial dump of the thrown error without status. The dump states nothing false, so it is left alone (an edit was made on this branch and reverted).

Acceptance notes

  • Pin location. The claim put the route pin in packages/qa/dogfood/test/ or beside the plugin. A dogfood version came first: a showcase boot, both doors, create and list. It passed, and it went red under ablation. It reached the dispatcher door by importing runtime/src/http-dispatcher.ts, and check:test-source-alias refused that (exit 1): four new unaliased artifact imports into dogfood (metadata-protocol, observability, rest, service-datasource). Fixing that means aliasing them in dogfood's vitest config, which changes every dogfood boot. The plugin packages cannot import runtime, because of the dependency direction. The runtime package already imports both doors, so the pin moved there, beside the dispatcher's own [#6649] block.
  • Doors this fixes, named and not edited. Each reads status alone:
    • plugin-sharing/src/share-link-routes.ts, five catches (create, list, revoke, resolve, messages). Create and list are measured above. Resolve and messages read under the system context, so they never meet this refusal. Revoke is not measured.
    • The sandbox boundary. SANDBOX_ERROR_PASSTHROUGH in runtime/src/sandbox/quickjs-runner.ts carries code, fields, status and userMessage, but not statusCode. A PermissionDeniedError from a host call inside a sandboxed body now crosses with its 403. Not measured.
    • metadata-protocol/src/protocol.ts, the deleteMetaItem catch (e.status = err?.status ?? 500). Not measured.
    • service-analytics/src/analytics-service.ts, hasDeclaredErrorEnvelope (a numeric status plus a code). A PermissionDeniedError now counts as declared and is re-thrown before the missing-source heuristic. Not measured.
    • runtime/src/domains/actions.ts, the setActionActive catch (status, else 503). Not measured, and this refusal is unlikely there.
  • Readers whose wire answer does not move.
    • rest's resolveErrorResponse passthrough reads status alone. A PermissionDeniedError used to fall through to mapDataError, which answered 403 PERMISSION_DENIED with the message and object. It now takes the passthrough arm, with the same status, code and object. The message passes the 500-character client bound and the declared-code-prefix strip, and neither changes a message inside those limits. This is read from the code; the 13 rest test files above pass.
    • rest-server's analytics envelope reader ① now answers this refusal where ①b answered it, with the same status and code. Read from the code, not measured.
    • plugin-auth's createOAuthClient catch reads status alone, but it only meets better-auth errors.
  • A stale comment in a door file, not edited. runtime/src/domains/share-links.ts (the catch's docblock) says the enforcement refusals "carry statusCode, not status". That is no longer true of PermissionDeniedError. Carrier: whoever next touches that file; no carrier is named.
  • plugin-security/src/packaged-permission-set-lock.ts holds two more 403 classes outside errors.ts. Both already carry both spellings. As ruled, the enumeration covers errors.ts exports only.
  • [Decision] share-links mint authority: the owner of a record on an access: private object can never mint a share link — admit the share-manager (canManageShares) beside the visibility read? (amends ADR-0111 D8 rule 1) #21329 (createLink refusals) is not addressed here. Triage orders it after this card, and its pins can now read either door.

Generated by Claude Code

claude added 6 commits October 2, 2026 13:34
…atusCode

The class declared statusCode alone, the only error class in errors.ts that
did, so plugin-sharing's share-link route door (which reads status alone)
answered a plain member's permission refusal as 500 while the runtime
dispatcher answered the same throw with 403. It now carries both spellings
with equal values.

Pins: an enumeration over every error class errors.ts exports (both
spellings, equal values), and a showcase-boot dogfood pin driving one
refusal through both share-link doors for create and list.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…rmission-denied-status

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…r's list is self-scoped

The self-scoped list from origin/main answers a plain member's list 200
through both doors, so no list request reaches the refusal any more; the
pin keeps the create refusal, and the class comment and changeset name it.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…he dispatcher domain

The dogfood pin reached the dispatcher door only by importing runtime
source, which check:test-source-alias refuses (four new unaliased artifact
imports into dogfood). The runtime package imports both doors already, so
the two-door create pin moves beside the dispatcher's own #6649 block:
one engine with the real SecurityPlugin middleware, one ShareLinkService,
one envelope, and both production door entries. The shared-catch case keeps
a statusCode-only throw now that the production class carries status too.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… statusCode

PermissionDeniedError now carries both spellings with equal values, so the
example dump of the thrown error lists both.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
The example is a partial dump of the thrown error (it lists no
developerMessage either), so it states nothing false without status. The
docs sweep finds no sentence this change makes false.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 2, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/field-level-security.mdx (via PermissionDeniedError (symbol, a top-level class))
  • content/docs/permissions/permissions-matrix.mdx (via PermissionDeniedError (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/share-links (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via PermissionDeniedError (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via PermissionDeniedError (symbol, a top-level class))

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

  • content/docs/releases/v14.mdx (via /api/v1/share-links (route, a path literal in a comment on a changed line))

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

Coarse fallback — 16 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 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5e4907ad9ec2ff2305ea15c042e52a4c5d14656f — the merge of head 01bb1de91b7b468df9c63ee2d0017fcf07b65cca into base 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5, 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 5e4907ad9ec2ff2305ea15c042e52a4c5d14656f && git checkout 5e4907ad9ec2ff2305ea15c042e52a4c5d14656f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 01bb1de91b7b468df9c63ee2d0017fcf07b65cca && git checkout -B drift-repro 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 && git merge --no-ff 01bb1de91b7b468df9c63ee2d0017fcf07b65cca

node scripts/docs-audit/affected-docs.mjs --json 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5

⚠️ 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 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants