Skip to content

fix(iam): name the missing grant in a caller's own denial - #3699

Merged
freeqaz merged 12 commits into
mainfrom
api-denial-grant
Oct 26, 2026
Merged

freeqaz merged 12 commits into
mainfrom
api-denial-grant

Conversation

@freeqaz

@freeqaz freeqaz commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The API answered every 403 for the caller's own permissions with "The exact platform operation was not authorized.", although AuthorizationDeniedError already carries the checked action and resource. Callers could not tell which grant was missing or who could grant it. The console fell back to "Access denied. You do not have permission for this operation." from about 15 call sites, and the CLI kept a per-command hint table to work around it. docs/reference/agents/deployment.md said "Denials of your own permissions stay generic" without a reason; the owner approved reversing it for clearer UX.

Change

API (apps/controller/src/http/caller-denial.ts, used by http/errors.ts, requireInstallationAdmin and the native admin proxy):

  • A caller's own denial names the grant the request checked and who grants it: Not authorized: this needs Agent deploy on Agent <id>. Ask an Installation administrator to grant it.
  • Variants: a create names its Namespace and says only an Installation administrator can create it (no Role can hold create, so it asks for no grant): Not authorized: this needs Secret create in Namespace <id>. Only an Installation administrator can create Secrets: ask one to create it.; installation:administer asks an Installation administrator to do it or grant it; read_logs says Agent read_logs (or Agent administer when no Restriction applies).
  • The native admin proxy's JSON denial says the request needs Agent administer on "this Agent" and names no Agent. A signed-in person with no platform identity had no grant checked and keeps the old sentence; that check comes before the host.
  • The six caller-state reasons ("The acting account is disabled.", "Only the initiating actor can retry provisioning.", "Only the initiating actor can read provisioning status.", "The human administrator session is no longer current.", "The assigning session is no longer current.", "The acting identity is unavailable.") now use CallerStateDeniedError and pass through as written. A provisioning retry by another actor now says "retry provisioning" (it said "read provisioning status").
  • Denials with no checked grant (unprovisioned identity, Kubernetes API 403, native admin host checks) keep the old sentence. Status codes and code values are unchanged. Who may do what is unchanged.

Disclosure rules (tested):

  • A resource ID is named only when the request carried it as a path parameter or in a reference field (id, ...Id, ...Ids) of the query or body; free text such as a search q never counts. An ID read from stored state reads as "a Secret this request uses". IDs outside a plain [A-Za-z0-9_.:-] shape are never echoed.
  • No binding, Role, Restriction or other principal is named. The IAM decision's reason is not used, so a Restriction and a missing grant get the same text.
  • The text depends only on the request and the checked grant, so an unknown ID and an unreadable one get the same answer apart from echoing the ID the caller sent. The 404/403 choice is unchanged.
  • AuthorizationDeniedError now records the principal whose grant was checked, and HTTP names the grant only when that is the caller. A decision for another principal (an Agent's own service principal checked without AgentPrincipalAuthorizationError, as in saved plugin discovery) keeps the generic sentence, even when the IAM Driver reports no identity in its evidence (Native IAM does so for an unprovisioned principal).
  • The native admin proxy resolves the Agent from the host, so its denial names no Agent and an unknown host gets the same text.

Clients:

  • Console: namedDenial() in api-client.mts. message() shows the API's named denial where the view names no grant or refusal of its own (a view's AccessNeed still wins; an Installation administrator's own denial keeps fix(console): show the API's typed refusal reasons through one shared describer #3690's plain text, since it asks an administrator); Delete shows it; the deploy refusal already showed any non-generic 403. After a named deploy denial the Agent page still reads the Secret operate answer again, so it names the bound Secrets the API's text leaves out. An older API's generic sentence keeps the console's own guidance.
  • CLI: a named denial prints as is with no hint; the old generic sentence still gets the per-command hint.

Docs: permissions cheat sheet "Denial messages" (with the six caller-state sentences), console reference, authorization reference, Agent deployment reference, CLI reference, native admin flow, and a breaking-change notice for clients that matched the old sentence. The Storybook "Permission denied" deletion scenario now carries the named denial.

Verification

  • New tests/integration/caller-denial-api.test.ts (production Fastify app, Native IAM Driver, live policy; mapped to checks-baseline-1): Agent read/update/delete/deploy/read_logs for an unreadable and an unknown Agent; Secret and Namespace create; Installation administer from the route and from the controller; a Restriction and no grant give the same answer; Configuration read names the ID when PATCH sends it and none when deploy reads it from storage. Fails on main, passes on this branch.
  • tests/conformance/http-errors.test.mjs: table over every resource kind and supported action (prefix, wire cap, Restriction vs no grant identical, ID only when sent, free text never counts, a create asks for no grant, another principal or no recorded principal stays generic, odd IDs not echoed), which fields count as sent IDs, a real Native IAM decision for an unprovisioned other principal, the six caller-state reasons, and the console message() precedence.
  • tests/integration/native-admin-access.test.mjs: the JSON denial for a denied caller, an unknown host and a Restriction is the same named text; a person with no platform identity gets the generic text. Fails on main.
  • tests/integration/postgres-agent-provisioning.test.mjs: another administrator's status read and retry each get their own sentence.
  • tests/integration/plugin-discovery-api.test.mjs: the caller's denials name the grant; the Agent principal's denial stays generic.
  • Updated: browser Preset read denial (named), an Installation administrator's Create Agent denial (plain text even when the API names the grant), deploy Secret operate denial (named by the API, Secrets named by the page), credential access retry's Agent read denial (named); presets, configuration body limits, Agent create validate, configuration controller and ServiceAccount force-delete oracle checks (now compared with each sent ID hidden), PostgreSQL platform state, browser draft guards (real API named deploy denial plus a routed older-API generic denial), Agent detail delete denial, Create Agent named denial.
  • CLI: go test ./internal/occcli (named denial gets no hint).
  • Pinned 20/20 at 2 CPUs on a remote test VM: the new and changed denial tests (caller-denial-api, http-errors, agent-create-validate, configuration body limits, presets, native-admin-access, plugin discovery) and the five changed browser tests (private network namespace). Whole touched browser files (710 tests) pass there after merging main, and the PostgreSQL platform lane passes.
  • tsc --build, ESLint and Prettier on touched files; docs word counts checked (authorization 2463, deployment 2492).

Risks

  • Clients that matched the old sentence exactly see new text (breaking-change notice added). Matching code FORBIDDEN keeps working.
  • Low: the denial now echoes the ID the caller sent; it never says whether that resource exists.
  • fix(console): disabled reasons name the grant and who gives it #3695 touches the same Delete fallback in deletion.mts and one console.md sentence; whichever lands second merges them (small, textual).
  • Follow-up idea, not in this PR: a structured error.authorization envelope field so clients stop parsing text (contract change).

The API answered every caller 403 with the same sentence, though the error
carries the checked action and resource. Name the grant and who grants it,
an ID only when the request carried it; pass the six caller-state reasons
through; console and CLI show the new text and keep the old one working.
…tests

Map the new caller-denial test to checks-baseline-1, compare a real and an
unknown ID's denial with each sent ID hidden, keep the console's own grant
text where a view names one, and document the denial text and the
breaking change for clients that matched the old sentence.
…delete denial in storybook

Plugin discovery checks the Agent's own service principal with a plain
denial; its grant must not read as the caller's own, so a decision whose
evidence names another principal keeps the generic sentence.
The authorization reference went over the 2,500-word limit; it now links
the cheat sheet's denial messages section and drops a repeated bullet.
@freeqaz
freeqaz requested a review from a team as a code owner October 26, 2026 00:45
@clawsweeper

clawsweeper Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review blocked

Automated review did not run, so no review verdict was produced.

Reason: The input-safety check rejected material in this revision. No detected value, path, or scanner output is reproduced here.

Automatic review is on hold, including after source changes. Request a fresh re-review after resolving the failure; maintainers can also release the hold through an intentional scanner-policy update.

Next step: If this is a genuine credential, remove and rotate it. If it is an intentional test fixture, a maintainer must review and qualify it.

View the workflow run.

@clawsweeper clawsweeper Bot added clawsweeper:intake:bug Preliminary submission classification; full source review pending. clawsweeper:intake:needs-repro Preliminary submission classification; full source review pending. labels Oct 26, 2026
A caller's named deploy denial names Secret operate but no Secret the
request did not send, so the Agent page still reads the Secret operate
answer again and names the bound Secrets. Browser tests now expect the
named Preset read denial, the Request ID after a named Create Agent
denial, and the named Secret operate deploy denial.
# Conflicts:
#	tests/browser/console-agents.test.ts
…ess retry

#3690's retry test sends the real API denial, which now names the Agent
read grant the retry's reread lacks.
- A create denial no longer asks for a grant: no Role can hold create, so
  it says only an Installation administrator can create it (finding 2581).
- An ID counts as sent only from a path parameter or a reference field
  (`id`, `...Id`, `...Ids`) in the query or body, never free text such as
  `q` (finding 2585).
- AuthorizationDeniedError records the principal whose grant was checked;
  HTTP names the grant only when that is the caller, so a denial for an
  unprovisioned Agent principal stays generic even though Native IAM
  reports no identity in its evidence (finding 2586).
- The permissions cheat sheet and the breaking-change notice list the six
  caller-state sentences (finding 2587).
- A provisioning retry by another actor says "retry provisioning"; the
  unreachable retry check is folded into the shared lookup (finding 2588).
- The native admin proxy keeps the generic text for a signed-in person
  with no platform identity, decided before the host (finding 2589).
@freeqaz
freeqaz merged commit bcddbea into main Oct 26, 2026
43 checks passed
@freeqaz
freeqaz deleted the api-denial-grant branch October 26, 2026 01:59
freeqaz added a commit that referenced this pull request Oct 26, 2026
…t list.mts

#3699 added a case that imported accessDenied from list.mts; this branch folded it into
access-reasons.mts (notIncluded), so the file failed to load in Checks and Conformance 1.
The case now compares with namespaceListDenied, the written-out expectation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

clawsweeper:intake:bug Preliminary submission classification; full source review pending. clawsweeper:intake:needs-repro Preliminary submission classification; full source review pending.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant