Skip to content

fix: an API key inherits its identity's credential policy, not the tenant default - #363

Merged
rsharath merged 1 commit into
mainfrom
fix/api-key-inherits-identity-policy
Sep 17, 2026
Merged

rsharath merged 1 commit into
mainfrom
fix/api-key-inherits-identity-policy

Conversation

@rsharath

Copy link
Copy Markdown
Contributor

Fixes #362.

What was wrong

POST /api-keys with an identity_id and no credential_policy_id assigned the key the tenant default policy, then checked that policy against the identity's. An identity whose policy is stricter than default failed the subset check and could never be issued another key. Its first key survives only because registration passes the identity policy directly.

RotateKey calls CreateKey the same way, so rotation was broken for those identities too. The issue flagged that as untested — it is real, and it is reachable from the product at POST /v1/admin/agents/:id/rotate-key. That matters more than creation: rotation is the path you are on when a key is already compromised.

Why it happened

The comment above the subset check already described the intended behaviour — that a caller who does not set CredentialPolicyID inherits the identity policy. The code could not do it: policy resolution ran before the identity was loaded, so the tenant default was the only option available at that point. The bug was structural, not a typo, which is why a reasonable-looking if policyID == "" branch had nothing better to choose.

Loading the identity first makes the documented behaviour expressible. Resolution is now explicit: the caller's policy, else the identity's, else the tenant default when the identity has none.

Two incidental improvements: the old if req.IdentityID != "" guard was already dead (ErrIdentityLinkRequired returns early and EnsureServiceIdentity fills the field), so it is gone; and EnsureDefaultPolicy is now called only on the last path — the tenant default is still seeded by identity registration (identity.go:1398) and token issuance (credential.go:762), so no tenant is left without one.

A test asserted the bug

TestAPIKeyCreate_InheritsIdentityPolicyPasses had a "Path 1" expecting the 400, with a comment reasoning its way there, under a test name claiming inheritance. It now expects the key to be created, and reads it back through GET /api-keys/{id} to confirm the key is bound to the identity's policy rather than merely allowed through the subset check.

Worth a reviewer's eye: if that assertion was deliberate design rather than written to match observed behaviour, this is the line to push back on.

Verified

  • Full integration suite: 797 passed under -race.
  • Proven load-bearing: reverting only apikey.go while keeping the inverted test reproduces the reported failure exactly — expected: 201 … must inherit the identity's policy, not the broader tenant default.
  • Unit suite, build and vet clean.

Downstream

highflame-admin#1367 is the same incident from the other side — Admin turned this 400 into an opaque 500 and dropped credential_policy_id. Fixed in highflame-admin#1369, which also works around this bug by forwarding the identity's own policy id (that takes the subset-check skip path), so Admin does not need to wait on this release.

🤖 Generated with Claude Code

…nant default (#362)

POST /api-keys with an identity_id and no credential_policy_id gave the key the
tenant default policy, then checked that policy against the identity's. An
identity whose policy is stricter than default therefore failed the subset check
and could never be issued another key — its first one survives only because
registration passes the identity policy directly. RotateKey calls CreateKey the
same way, so rotation was broken for those identities too, which matters more:
that is the path you are on when a key is already compromised.

The comment above the subset check already described the intended behaviour —
that a caller who does not set CredentialPolicyID inherits the identity policy.
The code could not do it: policy resolution ran before the identity was loaded,
so the tenant default was the only option it had. Loading the identity first
makes the documented behaviour expressible, and resolution becomes explicit:
the caller's policy, else the identity's, else the tenant default when the
identity has none.

EnsureDefaultPolicy is now called only on that last path. The tenant default is
still seeded by identity registration and token issuance, so no tenant is left
without one.

TestAPIKeyCreate_InheritsIdentityPolicyPasses asserted the old behaviour: its
"Path 1" expected the 400, under a name claiming inheritance. It now expects the
key to be created, and reads it back through GET /api-keys/{id} to confirm the
key is bound to the identity's policy rather than merely allowed through the
subset check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rsharath
rsharath merged commit b7ed84c into main Sep 17, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API key for an identity uses the tenant default policy, not the identity's policy

2 participants