Skip to content

fix(plugin-security,spec): the end-user-facing [Security] Access denied gates stop showing developer copy (#7451) - #7471

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-7451-security-denial-user-copy-end-user-gates
Aug 10, 2026
Merged

fix(plugin-security,spec): the end-user-facing [Security] Access denied gates stop showing developer copy (#7451)#7471
os-zhuang merged 2 commits into
mainfrom
claude/issue-7451-security-denial-user-copy-end-user-gates

Conversation

@os-zhuang

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

Copy link
Copy Markdown
Contributor

Fixes #7451

The card asked for a classification before a patch, so that is what this PR leads with. Its first deliverable is which of the ~40 [Security] Access denied templates a non-admin principal can reach on an ordinary business operation, measured rather than inferred from file names. Only that half is converted here; the admin-surface half is reported below as a follow-up recommendation, per the claim comment's scoping.

Census, re-measured at the branch point

git grep -c "[Security] Access denied" at 45cd354ec (the branch point), producers only — the matcher/doc occurrences in plugin-security/src/errors.ts (1), rest/src/rest-server.ts (3) and runtime/src/security/resolve-execution-context.ts (1) are startsWith fallbacks and comments, not thrown copy, and the card correctly excludes them:

File Card (2ef18070e) Measured (45cd354ec) Delta
plugin-security/src/security-plugin.ts 19 total / 18 remaining 20 raw / 19 producers / 18 unconverted the 20th raw hit is #7449's developerMessage, exactly as the card predicted
plugin-security/src/delegated-admin-gate.ts 13 13
plugin-security/src/suggested-audience-bindings.ts 3 3
plugin-auth/src/invitation-role-cap.ts 3 3
plugin-security/src/permission-set-projection.ts 1 1
plugin-security/src/system-write-guard.ts 1 1
plugin-security/src/predicate-guard.ts 1 1

42 producer templates, 41 unconverted at the branch point. The census is otherwise unchanged from the card's: the three landings since (#7346 comments-only, #7400 probe scope, #7449 one template) moved only security-plugin.ts, and only by #7449's conversion. Two test files also matched the grep (permission-denied-user-copy.test.ts, row-write-widener-composition.test.ts); they are consumers, counted separately below.

The classification

The question asked at each site was which principal reaches this gate on an ordinary business operation — not which file it lives in.

Converted: end-user-facing, with measured pull

Site Reached by Evidence
security-plugin.ts:1434 row-level pre-image write denial any member writing a row an authored using policy excludes showcase authors using: 'assignee == current_user.email' on its own business objects (examples/app-showcase/src/security/permission-sets.ts:64); packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts already drives it end to end over REST as a non-admin and asserts the English sentence off the response body
security-plugin.ts:1786 row-level CHECK post-image denial any member whose write would move a row out of a check policy showcase authors check: 'owner == current_user.email' (permission-sets.ts:95) with the comment "a contributor cannot reassign an invoice they own to someone else" — an ordinary CRM action
security-plugin.ts:1180 capability AND-gate any member acting on an object an author gated with requiredPermissions requiredPermissions is an authorable top-level object key (ADR-0066 D3, packages/spec/src/data/object.zod.ts:587); the card's own example is a business object

The capability gate is the one whose audience is genuinely mixed, and it is worth stating rather than smoothing over: measured on today's shipped metadata, the only objects declaring object-level requiredPermissions are sys_scim_provider and sys_sso_provider, both gated on manage_platform_settings, i.e. admin surface. It is still converted, because the mixed audience dissolves for this template specifically: the user-facing sentence it renders (permission_denied) is correct for both audiences, and the only facts that would distinguish them are capability IDs, which are internal vocabulary by construction. Nothing is lost either way — no ADR citation, no capability name — because the developer half is preserved verbatim.

Not converted: admin surface (the card's own reading, confirmed)

delegated-admin-gate.ts (13), suggested-audience-bindings.ts (3), permission-set-projection.ts (1), system-write-guard.ts (1), and security-plugin.ts's package-managed / permission-set admin-door family (:2457, :3314, :3339, :3376, :3389, :3456, :3482, :3500). Reached from an admin console by someone administering the system; most cite ADR clauses (ADR-0090 D5/D9, D6/D12, ADR-0091 D3, ADR-0103), and localizing them would destroy the citation without helping anyone. Recommended for a follow-up card as retain as developer copy, not as a translation backlog.

Not converted: reachable by an end user, but NOT cleanly one situation

Reported rather than guessed, which is what the audience split exists for:

  • security-plugin.ts:3981 (assertControlledByParentWrite) is end-user-facing with real pull — controlled_by_parent is declared by examples/app-crm/src/objects/opportunity-line-item.object.ts and showcase's invoice / expense-report, so an ordinary user editing an invoice line meets it. But its single deny() helper multiplexes six conditions under one sentence, and three of them are not access verdicts at all: 'controlled_by_parent declared but no master_detail relation' (a metadata defect), 'target record not found', and 'detail record has no master reference'. Giving that one sentence user-facing copy would tell a user "you do not have access to the related record" when the truth is that their app is misconfigured — and would hide the metadata defect from the author. Splitting it is a real decision, not a copy edit.
  • security-plugin.ts:1657 (owner_id is system-managed, transfer grant) is a plausible end-user situation — record reassignment is a core CRM action — but has zero measured pull: allowTransfer appears nowhere in either example app.
  • security-plugin.ts:1069 / :1140 (fail-closed: permission subsystem unavailable, unresolvable posture) are end-user visible during degradation, but the situation is "the check could not run", not "you lack permission". Copy that says the latter would be false, and the right treatment likely touches more than copy.
  • security-plugin.ts:953 (public-form grant), :1891 (tenant wall on a supplied organization_id), :1094 / :1265 (on-behalf-of delegation), predicate-guard.ts:122 (query names a non-readable field), plugin-auth/invitation-role-cap.ts (3): reached through a misbuilt or forged client, a configured agent integration, or a team-administration action, not through ordinary business work.

The three questions

Q2 — how many keys. Settled at claim time and applied as given: one key per situation, not per code and not per gate. Two new keys, not three, because the capability gate and the CRUD grant gate put the user in the same situation with the same remedy. The catalog module now carries that table in its header so the next consumer inherits the rule rather than the count.

Q1 — audience. Above.

Q3 — may the sentence name anything? Judged per site, and the answer was no three times for three different reasons:

Two inheritances that did not happen

Enforcement is untouched

Same statuses, same codes, same decision logic. Every details payload is asserted with toEqual at all three gates — including requiredPermissions, missingPermissions and recordId.

Verification

  • Catalogpackages/spec/src/system/operation-message.test.ts: locale rendering, base-language match (ja to ja-JP), de-DE fallback to en, overrides under the shared errors. address, a throwing i18n service, and a developer-vocabulary absence sweep in every locale — each on top of a positive identity, with the loop guarded by expect(locales.length).toBeGreaterThanOrEqual(4). Placeholder hygiene is asserted on the catalog entry, not the rendering, because rendering a removed key yields the bare key, which has no braces either.
  • Call sites — new packages/plugins/plugin-security/src/security-denial-user-copy.test.ts, driving the real SecurityPlugin middleware and resolving overrides through the real II18nService (FileI18nAdapter), never a stub: a stub is free to agree with whichever brace convention the test author had in mind, and this repo has two in flight (fix(plugin-audit): resolve reference titles in activityMilestones summary tokens (#7290) #7333).
  • The harness can say YES. Three admission controls (own-row update, satisfying post-image, own-row delete) exist because every denial case in that file would also pass against a harness that refuses everything. The engine double honours its where through matchesFilterCondition — the producer's own matcher — rather than a hand-written one: the pre-image gate re-reads with where: { $and: [{ id }, rlsFilter] }, and a matcher blind to $and would have made every denial case green while measuring the double's blindness. Its update() / delete() open with assertEngineUpdateDispatch / assertEngineDeleteDispatch.
  • Enforcement pins are flagged in the file as non-regression guards, not revert-detectors — they are green on main too, by construction, which is the point of a copy-only card.

Reverse verification

Directions were written to a file and the tree committed before the first mutation, so the presumptions could not be edited to fit the result. All five matched:

Mutation undone Predicted Measured
the three throw sites restored (catalog intact) RED RED — 18 failed / 10 passed, failures span all three gates, each showing the old English sentence in message and developerMessage undefined
record_access_denied deleted from all four locales, catalog layer RED via the entry assertion RED — 7 failed; receipt shows the bare key
same deletion, call-site layer (spec rebuilt) RED, via rung 4 of the ladder RED — 9 failed; the bare key reached message
enforcement pins (code / status / details) NO MOVEMENT — green on main too, by construction green in every configuration; they are not revert-detectors and the file says so
the test re-spells reverted, producer converted RED for the converted gates' pins, GREEN for controlled-by-parent-sharing.test.ts exactly that — 7 failed / 246 passed, and the deliberately-unconverted :3981 pin stayed green

The fourth row is the one worth reading: the template presumes "restore the limb, watch it go red", and for a copy-only card the enforcement pins cannot go red. Reported rather than dressed up as a passing revert test.

Consumption radius — and where grep was not enough

Grepped repo-wide before pushing. That found three pins. Two more were found only by running things, and both are worth recording because they name the limits of the grep:

  1. security-plugin.test.ts:1174 pinned a fragment (operation 'insert'), not a phrase — found by the package suite.
  2. service-automation/src/runas-system-stamping.integration.test.ts:308 pinned a regex over the message shape, toThrow(/denied|permission/i) — found only by CI's full-suite run. It was never asserting this refusal; it matched because the English sentence happened to open with "Access denied", and the localized sentence contains neither word. A regex like that goes red on a change that alters nothing it was written to protect.

All five re-spelled against the catalog constant rather than a literal, so the next copy edit needs no re-spell:

  • plugin-security/src/security-plugin.test.ts:1028 (capability gate) and :1174 (its per-operation narrowing);
  • plugin-security/src/row-write-widener-composition.test.ts (expectRowLevelDenial);
  • qa/dogfood/test/authored-row-write-scope.dogfood.test.ts (the end-to-end REST body);
  • service-automation/src/runas-system-stamping.integration.test.ts (now the ADR-0112 envelope: code + statusCode, never a bare throw).

None was weakened: each now asserts the user half against the constant and keeps the old sentence on developerMessage, so "which gate answered" is still a measured fact rather than an inference from a generic 403 — a stronger discrimination than before, on two axes instead of one.

…l and capability denials (#7451)

The three gates of the object CRUD middleware an ordinary non-admin principal
reaches on ordinary business work now render their user-facing half through the
shared operation-message catalog, in the caller's locale:

  - row-level pre-image write denial  -> errors.record_access_denied (new key)
  - row-level CHECK post-image denial -> errors.record_change_not_allowed (new key)
  - capability AND-gate (ADR-0066 D3) -> errors.permission_denied (reused)

Two new keys, not three: the unit is the SITUATION the user is in, not the gate
that answered. A caller missing a CRUD bit and a caller missing a capability
have one situation and one remedy, so they share a sentence; the row-level gates
do not, because their users can act differently (ask the record's owner / change
what they typed).

Each gate keeps its previous sentence byte for byte on `developerMessage`,
logged at the throw site and shipped on neither transport, per #7414's
measurement. Enforcement, statuses, codes and every `details` payload are
untouched.

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 3: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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests tooling labels Aug 10, 2026
…ADR-0112 envelope (#7451)

CI's full-suite run found a fifth consumption-radius pin that a phrase grep
could not: `rejects.toThrow(/denied|permission/i)`, which was matching the
SHAPE of the English copy rather than the refusal. The row-level gate's user
half is now a localized catalog sentence containing neither word, so the regex
went red on a change that altered nothing it was written to protect.

Pinned properly instead: `code` + `statusCode` (never a bare throw), the user
half against the catalog constant, and `developerMessage` for the one thing the
regex was reaching for — which gate refused.

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

documentation Improvements or additions to documentation protocol:system size/l tests tooling

Projects

None yet

2 participants