Skip to content

feat(hub): POST /api/v1/agent/secrets — the fetch endpoint (#127, P2c) - #1340

Open
ptone wants to merge 7 commits into
sn-metaauth-inv/delete-refresh-agent-tokenfrom
sn-metaauth-inv/p2c-agent-secret-fetch
Open

feat(hub): POST /api/v1/agent/secrets — the fetch endpoint (#127, P2c)#1340
ptone wants to merge 7 commits into
sn-metaauth-inv/delete-refresh-agent-tokenfrom
sn-metaauth-inv/p2c-agent-secret-fetch

Conversation

@ptone

@ptone ptone commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

The agent secret-fetch endpoint — the channel that makes P3 (removing credentials from argv) possible.

This does not close #127. P3 is the phase that stops the leak.

  • POST /api/v1/agent/secrets — authenticated agents fetch secret values by key name
  • Two gates, both required: stored entitlement list (gate 1, enumeration block) AND live resolution (gate 2, revocation)
  • Five distinct outcomes per key — none collapsed, each tested
  • New scope ScopeAgentSecretFetch granted to baseline and full roles
  • Smart scope guard distinguishes pre-existing tokens from genuine denial

Design decisions

Both gates, not either (§3.6.2): Gate 1 is the stored entitled-key list from the credential row, looked up by JTI hash. Gate 2 is live resolution with scope filter + progeny authz. The answer is the intersection. This keeps the stored list load-bearing without making it authoritative.

Five outcomes: (1) value returned, (2) entitled but unavailable (decryption failure), (3) access withdrawn since mint, (4) not in stored list (indistinguishable from "does not exist"), (5) no credential row (legacy token).

Three column states: populated list (normal), empty list (entitled to nothing), NULL (minting-path bug — fails closed loudly). All three tested explicitly.

Smart scope guard: Tokens whose role WOULD receive the scope but don't carry it (pre-existing) get "issued before secret-fetch capability existed; restart or refresh." Tokens whose role would NOT receive it get generic "insufficient scope." Both branches tested. Wrong advice (telling a readonly agent to refresh) would be worse than no advice.

Double fetch accepted: Auth middleware fetches the credential row and keeps only cred.ID. This handler re-fetches by JTI hash to get EntitledSecretKeys. This is load-bearing — the middleware fails open on ErrNotFound and store errors; this handler fails closed.

Credential-minting path enumeration

Five paths at base 8840155, searched git grep -n 'GenerateAgentToken\b' 8840155 -- 'pkg/hub/*.go':

  1. httpdispatcher.go:452 — DispatchAgentCreate
  2. httpdispatcher.go:1983 — DispatchAgentStart
  3. httpdispatcher.go:2242 — DispatchAgentRestart
  4. httpdispatcher.go:2358 — DispatchAgentResetAuth
  5. handlers_agents_core.go:2789 — handleAgentTokenRefresh

All five record entitled keys. The sixth (RefreshAgentToken) was deleted in PR #1338.

Test plan

14 tests, all passing:

  • TestSecretFetch_Row1_HappyPath — entitled, resolves, returns value
  • TestSecretFetch_Row2_EntitledButUnavailable — encrypted value, no key
  • TestSecretFetch_Row3_AccessWithdrawn — revocation-rejection test
  • TestSecretFetch_Row4_NotInStoredList — indistinguishable from "not found"
  • TestSecretFetch_Row5_NoCredentialRow — legacy pre-table token
  • TestSecretFetch_ColumnNULL — minting-path bug detection
  • TestSecretFetch_ColumnEmptyList — entitled to nothing
  • TestSecretFetch_ColumnPopulated — normal mixed operation
  • TestSecretFetch_ScopeGuard_PreExistingToken — "refresh" advice
  • TestSecretFetch_ScopeGuard_GenuineDenial — generic denial, no refresh advice
  • TestSecretFetch_ScopePresentButEntitlementEmpty — scope ≠ control
  • TestSecretFetch_Gate1Mutation — gate 1 is load-bearing
  • TestSecretFetch_NoBody — 400 on missing keys
  • TestSecretFetch_MethodNotAllowed — 405 on GET
  • go vet ./pkg/hub/ — clean
  • go build ./... — clean
  • TestScopesForRole_Baseline / _Full — updated for new scope count

Scion Agent (sn-metaauth-inv) added 7 commits August 29, 2026 01:03
The agent secret-fetch endpoint. Two gates, both required:

Gate 1 (stored list): credential row's EntitledSecretKeys by JTI hash.
  Cheap, blocks enumeration, session-stable.
Gate 2 (live resolution): scope filter + progeny authz at request time.
  Makes revocation take effect immediately.

Five outcomes per key:
1. In stored list, resolves now → the value
2. In stored list, exists but unreadable → "entitled but unavailable"
3. In stored list, no longer authorized → "access withdrawn, refresh"
4. Not in stored list → indistinguishable from "does not exist"
5. No credential row → "token predates entitlement recording"

Three column states (NULL, empty, populated) each handled distinctly.
NULL fails closed loudly — indicates a minting-path bug.

New scope ScopeAgentSecretFetch ("agent:secret:fetch") granted to
baseline and full roles. It is a ROLE FILTER, not a capability grant.
Smart scope guard distinguishes pre-existing tokens (role would receive
scope but token doesn't carry it) from genuinely unprivileged tokens.

The middleware fails open on credential-store errors (auth.go:179-183).
This handler does its own lookup and fails closed. The double fetch is
accepted and load-bearing.
…P2c)

14 tests covering the full P2c requirement set:

Five outcomes (each with a named test):
- Row 1: happy path — entitled, resolves, returns value
- Row 2: entitled but unavailable — encrypted value, no key
- Row 3: access withdrawn — secret de-scoped after credential mint
- Row 4: not in stored list — indistinguishable from "does not exist"
- Row 5: no credential row — legacy pre-table token

Three column states:
- NULL: minting-path bug, fails closed loudly
- Empty list: entitled to nothing, all keys are row 4
- Populated: normal operation, mixed match/no-match

Smart scope guard (two branches):
- Pre-existing token: role WOULD receive scope, says "refresh"
- Genuine denial: role would NOT receive scope, generic message

Gate-1 mutation test: entitled key returned, non-entitled key blocked,
proving gate 1 is load-bearing.

Scope-present-but-empty-entitlement: scope IS present, entitled list
empty, fetch denied. Proves the scope is not the control.

Also updates ScopesForRole tests for new ScopeAgentSecretFetch (baseline
6 scopes, full 9 scopes).
The requireAgentSecretFetchScope guard uses ScopeAgentStatusUpdate as a
proxy for "would this token's role receive ScopeAgentSecretFetch" because
AgentTokenClaims carries scopes but not the role string — the role is
consumed at mint-time and discarded.

Add a full comment explaining WHY the proxy is forced (no Role field in
JWT claims) and WHAT BREAKS if ScopeAgentStatusUpdate's role assignment
changes independently.

Add TestScopeGuardProxy_DriftDetection: iterates all four roles and
asserts the biconditional — a role receives ScopeAgentSecretFetch iff
it receives ScopeAgentStatusUpdate. If a future agentrole.go change
breaks this coupling, the test fails in the file being edited, not
silently in an error message during token migration.
No canonical AllRoles() exists — ValidAgentRole is a switch. The drift
test now asserts the known role count (4) and validates every entry
against ValidAgentRole. A fifth role added to ValidAgentRole without
updating the test triggers a failure with an actionable message pointing
to the biconditional and requireAgentSecretFetchScope.

Also probes a broader set of plausible role names as a second line of
defense against roles that bypass the count check.
Add AllAgentRoles() as the single source of truth for stock roles.
Rewrite ValidAgentRole to iterate it, so the two cannot drift apart.
A fifth role must be added to AllAgentRoles() to be valid at all,
which means the drift-detection test covers it automatically.

Replace the three-layer tripwire (circular count check, validation
loop, candidate probe) with a single iteration of AllAgentRoles().
The test is now just the biconditional assertion it was always meant
to be.

Behaviour is identical: ValidAgentRole accepts exactly the same four
values and rejects everything else. All existing role tests pass
untouched.
R1: Hoist computeEntitledSecretKeys out of the per-key loop. One
listing per request, not one per key. The state that triggers the
entitled-but-not-resolved branch is correlated — a backend wobble
or key rotation puts many keys into it at once — so the multi-key
case is the common case, not the rare one.

O1 (elevated to Required): When listErr is non-nil, default to the
transient reading (row 2 / unavailable), not the permanent one
(row 3 / access withdrawn). The old code fell through to row 3 on
listing errors, telling the operator "access has been withdrawn;
refresh the token" when nothing was revoked and refreshing cannot
fix a backend outage. An operator who believes entitlement was
withdrawn may re-grant permissions that were never lost — a
security-relevant change made on a false report.

R2: PR description updated to state "This does not close #127.
P3 is the phase that stops the leak."
Add TestSecretFetch_ListingError_ReportsUnavailableNotWithdrawn: when
the entitlement listing fails (backend outage), the handler must report
"unavailable" not "access withdrawn". Asserts status == unavailable,
error mentions "listing", and error does NOT contain "withdrawn" or
"refresh".

Mutation verified: deleting the `if listErr != nil` branch causes the
test to go red with four failures — status flips to access_withdrawn,
error loses "listing", gains "withdrawn" and "refresh". Restored,
green.

Seam: failingListSecretsStore wraps the real store and overrides
ListSecrets to return an error. The secret backend's internal store
remains real, so Resolve() succeeds. The handler's
computeEntitledSecretKeys uses s.store (the wrapper), which fails.
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.

1 participant