Skip to content

fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414) - #7449

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-7414-permission-denied-user-copy
Aug 10, 2026
Merged

fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414)#7449
os-zhuang merged 3 commits into
mainfrom
claude/issue-7414-permission-denied-user-copy

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #7414

The 403 the object-CRUD gate throws is correct and its transport is fine. What reached the end user was the problem: Error.message is the body's human-readable string on every transport (mapDataError's body.error, the dispatcher's error.message) and Console renders it verbatim in a toast, so an operator in a fully localized app read

[Security] Access denied: operation 'delete' on object 'app_child_object'
is not permitted for positions [org_member, everyone]

English-only, naming a table they have never seen, ending in internal authorization vocabulary that reads as a contradiction to someone who does hold rights on the record they clicked. And it is not confined to obviously-unauthorized actions: cascadeDeleteRelations re-authorises every cascade child independently, so an ordinary parent delete the app deliberately granted can surface a 403 naming a child object the operator never addressed.

The mechanism is #7307's, not a second one

This card is the second consumer of the shared operation-message catalog PR #7423 built, not a new remedy for the same defect class:

Piece Reused as-is
packages/spec/src/system/operation-message.ts renderOperationMessage(), BUILTIN_OPERATION_MESSAGES, the errors. override address, the four-rung resolution ladder, the "a misbehaving i18n service must not turn the refusal into a 500" guard
OPERATION_MESSAGE_FALLBACK_LOCALE unchanged en last resort
the two-audience split message = user's half in ExecutionContext.locale; developerMessage = the previous sentence byte for byte

Only the catalog content is new: one key, permission_denied, in all four shipped locales. No new exportspackages/spec's api-surface / export-origins / authorable-surface baselines are untouched, which is why this PR carries no regenerated artifacts (verified by running those gates, below).

One deliberate divergence in the sentence itself

delete_restricted names the objects because the user must know which related records block them — that is the action they can take. An object-permission refusal gives the user nothing to act on by naming the object, and on a cascade the object the gate refuses is a child they never addressed. So permission_denied takes no placeholders at all, and the label ladder (bundle → declared label → API name) is deliberately not used here: its last rung is the API name, which is exactly what must not reach the toast. Reported rather than papered over — the dispatch for this card presumed a label ladder, and it does not apply.

Where the structured fields actually land — measured, and the answer changed the design

PR #7423 justified shipping developerMessage over the wire with: "It discloses nothing the envelope did not already carry: dependentObject and object are API names on the same body." The issue's own "Expected" section asserts the same for this error — that operation / object / positions "are the right channel … and they are already there". That is only half true, and the half that is false is the one this decision rests on.

Transport Code Does error.details reach the client?
@objectstack/rest (mapDataError, rest-server.ts:726-737) body is { error, code, object? }; error.details is never read, and that object is the object the route named No
@objectstack/runtime dispatcher (http-dispatcher.ts:1945-1951) this.error(e.message, 403, { code: 'PERMISSION_DENIED', ...(e.details ?? {}) })buildApiErrorerror.details Yes

So on the REST transport the positions, the operation and (on a cascade) the child object's API name reach a client through nothing but this message. Adding a developerMessage there would have been a new disclosure of internal authorization vocabulary, introduced by a card whose purpose was to reduce disclosure. One error class cannot honestly carry a per-transport disclosure policy, so:

The second row of that table deserves a card of its own: positions and permissionSets already reach the browser today on the dispatcher transport, which this copy fix cannot and must not change (enforcement and the structured payload are explicitly out of scope). Filed separately as #7450.

Anchors re-derived at the branch point

Branch point 2ef18070e (= PR #7423's squash). The triage anchor security-plugin.ts:1160 still held exactly:

1160:            `[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' ` +
1161:              `is not permitted for positions [${positions.join(', ')}]`,

PR #7346 (comments) and PR #7400 (checkAuthoredRowWrite probe scope) are both landed and untouched by this change.

Verification

  • Catalogpackages/spec/src/system/operation-message.test.ts: locale rendering, jaja-JP base-language match, de-DEen fallback, override under errors.permission_denied, a throwing i18n service, and an absence guard over positions / permissionSets / [Security] / Access denied / operation in every locale — each asserted on top of a positive identity so an empty string cannot satisfy it.
  • Call site — new packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts, driving the real SecurityPlugin middleware, and resolving overrides through the real II18nService (FileI18nAdapter from @objectstack/service-i18n, added as a devDependency) rather than a hand-written t. A stub is free to agree with a producer that disagrees with the shipped implementation, and this repo has two brace conventions in flight (fix(plugin-audit): resolve reference titles in activityMilestones summary tokens (#7290) #7333).
  • Enforcement pinscode / statusCode / the whole details payload asserted unchanged. Flagged in the test file as non-regression guards rather than revert-detectors: they are green on main too, by construction, which is the point of a copy-only card.
  • The harness bit back once, and that is recorded in the file. The first draft's engine double ignored the where on find, so the bootstrap permission sets SecurityPlugin.start() seeds were all handed back to the dbLoader for any unresolved name — including admin_full_access — and the gate admitted the delete under 12 assertions written to see it refused. The double now honours the predicate, and the comment above makeEngine says why.

Not done, deliberately

…copy to end users (#7414)

The 403 refusal is correct and its transport is fine; the message was not.
`Error.message` is the body's human-readable string on every transport and
Console renders it verbatim in a toast, so a business user in a localized app
got an English sentence naming an object API name they had never seen and
ending in `positions [org_member, everyone]`.

The error now carries two messages because it has two audiences:

- `message` — the user's half, rendered in `ExecutionContext.locale` through
  the SHARED operation-message catalog #7307 built (`errors.permission_denied`).
  It names no object, no operation and no position, in any shipped locale.
- `developerMessage` — the developer's half, the previous sentence byte for
  byte. LOGGED at the throw site rather than shipped: unlike the 409, this
  body carries no structured API names, so shipping it would have added a
  disclosure instead of removing one. It is a sibling of `details`, never a
  member of it, because `details` is what the dispatcher serialises.

`code` / `statusCode` / `details` unchanged; one PERMISSION_DENIED (ADR-0112),
two sentences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 1:26pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec.

109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security, @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

claude added 2 commits August 10, 2026 13:08
…'s dispatch predicates (#7414)

`check:engine-double-contract` caught the new fixture's `delete()`/`update()`
being looser than `ObjectQL`'s — a fake that accepts call shapes the engine
refuses collects greens the producer would not. Both verbs now open with
`assertEngineDeleteDispatch` / `assertEngineUpdateDispatch` from
`@objectstack/metadata-core`.

Also corrects the flag on the catalog's placeholder case: reverse verification
measured it going RED on a reverted catalog (it reads the ENTRY, not the
rendering), so the comment claiming it cannot bite was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
…sage (#7414)

`share-links-enforcement-context.test.ts` drives the REAL security gate and
asserted `toContain('Access denied')` on the message. That string is now the
developer half (logged, not shipped); the message is the catalog's user-facing
sentence. Re-spelled against `BUILTIN_OPERATION_MESSAGES.en.permission_denied`
so the case keeps its stated purpose — proving an authorization answer reached
the client rather than being swallowed into the generic internal-error string —
without needing another re-spell on the next copy edit.

Found by CI, not by the local sweep: the rule's consumption radius is wider than
the edited package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system size/l tests tooling

Projects

None yet

2 participants