Skip to content

feat(secrets): add entitled_secret_keys to AgentCredential schema - #1332

Open
ptone wants to merge 4 commits into
mainfrom
sn-metaauth-inv/credential-entitled-keys
Open

feat(secrets): add entitled_secret_keys to AgentCredential schema#1332
ptone wants to merge 4 commits into
mainfrom
sn-metaauth-inv/credential-entitled-keys

Conversation

@ptone

@ptone ptone commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Summary

Adds entitled_secret_keys nullable JSON column to the AgentCredential Ent schema, recording which secret key names a session is entitled to fetch via the future POST /api/v1/agent/secrets endpoint (#127 P2, PR 1 of 3).

  • NULL (nil): no entitlement ever recorded - pre-migration credential or a start that failed between token generation and secret resolution. The endpoint must fail closed on NULL with a loud log (a hub bookkeeping error, not an auth failure).
  • [] (empty array): entitled to zero secrets - a valid, distinct state.

Migration: existing rows receive NULL, not []

Ent Migrate() uses ALTER TABLE ADD COLUMN entitled_secret_keys TEXT NULL - SQL standard behavior: existing rows receive NULL as the default, not an empty array. Measured:

  • The Ent schema uses Optional() which maps to Nullable: true in pkg/ent/migrate/schema.go
  • The check() function in the generated create builder does NOT require the field
  • CreateAgentCredential (the existing create path) does not set the field -> NULL
  • Only UpdateAgentCredentialEntitledKeys sets it -> called after secret resolution

Consequence: pre-migration credentials correctly read as NULL (unknown entitlement / bookkeeping failure), not [] (entitled to nothing). The loud-NULL error fires on these rows if they ever reach the endpoint - which they cannot, because pre-migration tokens lack ScopeAgentSecretFetch.

Design properties

  • Session-scoped: each lifecycle event (start/restart) mints a fresh token and credential row. The entitled set is recorded per-credential, not per-agent.
  • JTI binding: entitlement is bound to the exact token presented via token_jti_hash.
  • Agent-scoped write path: UpdateAgentCredentialEntitledKeys requires both jtiHash and agentID. The store scopes the update to the credential's owning agent, preventing a hash-computation bug from silently writing entitlement onto a different agent's credential.
  • Pre-migration rollout guard: pre-migration tokens lack ScopeAgentSecretFetch (PR 3), so NULL rows are unreachable from any token that can call the endpoint.
  • Refresh propagation: token refresh creates a new credential row (NULL entitled keys). PR 3 will copy entitled keys from old to new credential during refresh.

Test plan

  • set_keys_on_existing_credential
  • set_empty_keys (entitled to zero secrets) - verifies empty != nil
  • NULL_before_update (pre-migration state) - verifies nil default
  • not_found_returns_ErrNotFound_for_unknown_hash
  • not_found_returns_ErrNotFound_for_wrong_agent (cross-agent guard) - cross-agent write prevention
  • overwrite_existing_keys
  • EntitledSecretKeysRoundTrip - full create-update-read with field integrity
  • Full go build ./... clean
  • Full go test ./pkg/store/entadapter/ suite passes

Part of #127.

Scion Agent (sn-metaauth-inv) added 4 commits August 28, 2026 22:56
… P2 PR1)

Add a nullable JSON column `entitled_secret_keys` to the AgentCredential
Ent schema, recording which secret key names a session is entitled to
fetch via the future POST /api/v1/agent/secrets endpoint.

NULL vs empty semantics (fail-closed):
- NULL (nil): no entitlement ever recorded — pre-migration credential or
  a start that failed between token generation and secret resolution.
  The endpoint must fail closed with a loud log on NULL.
- [] (empty array): entitled to zero secrets — a valid state.

The entitled set is session-scoped: each lifecycle event (start/restart)
mints a fresh token and records a fresh entitled set on the new
credential. On token refresh, the entitled keys are copied from the old
credential to the new one (PR 3).

JTI binding: entitlement is bound to the exact token presented via the
credential's token_jti_hash. Pre-migration tokens lack
ScopeAgentSecretFetch, so NULL rows are unreachable from any token that
can call the endpoint.

Changes:
- pkg/ent/schema/agentcredential.go: new Optional JSON field
- pkg/store/models.go: EntitledSecretKeys field on AgentCredential struct
- pkg/store/store.go: UpdateAgentCredentialEntitledKeys on interface
- pkg/store/entadapter/credential_store.go: implementation + converter
- pkg/store/entadapter/credential_store_test.go: NULL/empty/set/overwrite/ErrNotFound
- pkg/ent/*: codegen output (334 insertions)
Add agentID parameter to UpdateAgentCredentialEntitledKeys so the store
scopes the update to the credential's owning agent. This prevents a
hash-computation bug from silently writing entitlement onto a different
agent's credential — the mismatch produces ErrNotFound rather than a
cross-agent entitlement grant.

The write path now matches the read path's discipline: scoping holds
by construction rather than by the caller being careful.

Adds cross-agent guard test: update with wrong agent ID must fail.
The three-state model (nil / empty / populated) requires that an empty
slice serializes as [] not as absent.  With omitempty, JSON marshaling
drops the field entirely for both nil and empty-slice, collapsing two
semantically distinct states into one on the wire.
nil and empty carry different semantics on this field: nil means
entitlement was never recorded (bookkeeping bug, fail closed),
empty means entitled to zero secrets (valid).  omitempty erases
that distinction because encoding/json omits both nil and empty
slices.

Nothing marshals AgentCredential to JSON today (measured 2026-08-28).
The comment addresses the future reader adding the first marshal path.
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