Skip to content

Commit 3e41e73

Browse files
authored
security(exchange): stop a logged-out subject seeding new delegations (#776)
* security(exchange): stop a logged-out subject seeding new delegations Token exchange verified a subject_token's signature, issuer and token_type and nothing else. delegationSessionIsLive then refused the RESULTING token — but only at Authorizer's own surfaces. A delegated token is bound to a third-party `resource` and validated by that server, which has no view of this session store. So after a logout the user's agent kept minting fresh, externally valid credentials on their behalf until the subject_token expired, and nothing downstream could tell. The probe produced a fully-formed delegated token for https://api.example.com/v1 from an already-logged-out subject. Checked only when there is something to check. A sid is either well-formed or absent — DelegationSessionID returns "" rather than a partial value — and its absence is a documented state meaning "this subject had no session" (CreateDelegatedAccessToken omits the claim entirely then, so its presence always means checkable). Rejecting that case too would delete a supported branch rather than close the gap. Service-account subjects stay exempt: a client_credentials token has no browser session, and its liveness comes from the IsActive check above. Enforcing there would break every agent-to-agent hop. The rejection reuses the opaque invalid_grant of the invalid-subject path, so the endpoint cannot be used to probe who is currently signed in. This changes what a third-party resource server observes, so it needs a delegation-design decision, not just a code review — see specs/AGENTIC_DELEGATION_DESIGN.md. TestTokenExchangeRejectsInvalidResourceIndicator minted its subject via CreateAuthToken without registering a session, making it revoked-shaped. Every production path that issues an access token registers it, so the test was the anomaly; it now registers one. * feat(observability): audit and meter agent delegation activity An agent's activity has to be attributable — that is what the RFC 8693 act chain is for. Success was audited and counted; every one of the fourteen refusal paths on /oauth/token's exchange grant was silent. No audit row, no metric, only a Debug log. The sibling client_credentials grant already audits its failures, so machine-identity auth failures were attributable while delegation failures — carrying a user's authority, so the more sensitive of the two — were not. An agent probing the delegation endpoint left no trail. Every refusal now routes through one helper, which is also what keeps it true: a rejection path added later cannot be silent without deliberately bypassing it. - audit: token.exchange_failed, actor = the calling agent, metadata = a fixed reason constant naming the rule that refused. Never the subject id or the token — a refusal record must not be where an unverified subject's identity gets written. - metric: authorizer_auth_events_total{event=token_exchange} on both success and failure. EventTokenIssued is unchanged and still counts all issuance, but carries no grant label, so delegated issuance was indistinguishable from any other grant and had no failure count to form a rate against. - metric: security event token_exchange_rejected, reason=<constant>. The session-liveness refusal keeps returning the same opaque invalid_grant as a malformed token, so the endpoint is not an oracle for who is signed in — but its audit reason differs, because that row is written server-side and never reaches the caller. Also meters the agent scope-ceiling enforcement point, silent on every transport until now: delegated_insufficient_scope with separate labels for "not on the delegated allow-list" (a client bug, or probing) and "reachable but this token lacks the scope" (widen the ceiling, or don't). Operators had no way to see agents hitting their ceiling, which is the number needed before deciding whether to widen one. Method names are deliberately not labels — high cardinality on an internet-facing path. And meters delegated-token validation refusals, so revocation actually firing (logout, password reset, admin revoke taking an agent's access down with the user's session) is externally visible rather than a Debug line. AuditTokenExchangeEvent stays reserved and unused: success remains token.issued, which ROADMAP_V2.md documents, and renaming the action would silently break any query already filtering on it.
1 parent 25a75fb commit 3e41e73

9 files changed

Lines changed: 553 additions & 61 deletions

File tree

‎internal/constants/audit_event.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,22 @@ const (
299299
// wrong secret) — mirrors AuditLoginFailedEvent for the human login path.
300300
AuditTokenClientCredentialsFailedEvent = "token.client_credentials_failed"
301301
// AuditTokenExchangeEvent is logged when a token exchange occurs (RFC 8693).
302+
//
303+
// Reserved, deliberately unused: a successful exchange is recorded as
304+
// AuditTokenIssuedEvent with grant_type=token-exchange in its Metadata, and
305+
// ROADMAP_V2.md documents "token.issued" as the issuance event. Switching the
306+
// action would silently break any query already filtering on it. Failures use
307+
// AuditTokenExchangeFailedEvent below, mirroring the client_credentials pair.
302308
AuditTokenExchangeEvent = "token.exchange"
309+
// AuditTokenExchangeFailedEvent is logged when an RFC 8693 token exchange is
310+
// REJECTED.
311+
//
312+
// Every rejection on that endpoint used to be silent — 14 refusal paths, none
313+
// audited — while the client_credentials path already audited its failures.
314+
// An agent probing the delegation endpoint therefore left no trail at all,
315+
// which is the opposite of what a delegation surface needs: the whole point of
316+
// the act chain is that an agent's activity is attributable.
317+
AuditTokenExchangeFailedEvent = "token.exchange_failed"
303318
// AuditWorkloadAuthEvent is logged when a workload authenticates via client_assertion
304319
// (K8s SA token, SPIFFE JWT-SVID, or generic OIDC workload token).
305320
AuditWorkloadAuthEvent = "token.workload_auth"

‎internal/grpcsrv/interceptors/auth.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,23 @@ func enforceDelegatedScope(tokenData *token.SessionOrAccessTokenData, fullMethod
242242
if tokenData == nil || strings.TrimSpace(tokenData.ActorID) == "" {
243243
return nil
244244
}
245+
// Both refusals below are metered, matching the GraphQL choke point. This was
246+
// silent on every transport, so an operator could not see agents hitting
247+
// their ceiling — the number needed before deciding whether to widen one. The
248+
// labels distinguish the two, because they call for opposite actions:
249+
// "not_delegatable" means the method is not on the delegated allow-list at
250+
// all (a client bug, or probing), "scope_missing" means it is reachable but
251+
// this token was not granted the scope. Method names are NOT included — they
252+
// would be a high-cardinality label on an internet-facing path.
245253
required, ok := delegatedscope.RequiredForGRPC(fullMethod)
246254
if !ok {
247255
// Fail closed: an operation nobody has cleared for delegated callers is
248256
// out of reach for an agent, whatever scope it holds.
257+
metrics.RecordSecurityEvent("delegated_insufficient_scope", "grpc_not_delegatable")
249258
return status.Error(codes.PermissionDenied, "insufficient_scope")
250259
}
251260
if !delegatedscope.Satisfied(tokenData.Scope, required) {
261+
metrics.RecordSecurityEvent("delegated_insufficient_scope", "grpc_scope_missing")
252262
return status.Error(codes.PermissionDenied, "insufficient_scope")
253263
}
254264
return nil

‎internal/http_handlers/graphql.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,13 +268,23 @@ func (h *httpProvider) gqlDelegatedScopeMiddleware() gql.FieldMiddleware {
268268
return next(ctx) // first-party token; see the package comment.
269269
}
270270

271+
// Both refusals below are metered. This is the agent scope-ceiling
272+
// enforcement point, and it was previously silent on every transport: an
273+
// operator had no way to see that agents were hitting their ceiling, which
274+
// is exactly the number needed before deciding whether to widen one. The
275+
// two outcomes are separate labels because they call for opposite actions
276+
// — "not_delegatable" means the operation is not on the delegated
277+
// allow-list at all (a client bug, or probing), while "scope_missing"
278+
// means it is reachable but this token was not granted it.
271279
required, ok := delegatedscope.RequiredForGraphQL(fc.Field.Name)
272280
if !ok {
273281
// Fail closed: an operation nobody has cleared for delegated
274282
// callers is out of reach for an agent, whatever scope it holds.
283+
metrics.RecordSecurityEvent("delegated_insufficient_scope", "graphql_not_delegatable")
275284
return nil, gqlerror.Errorf("insufficient_scope")
276285
}
277286
if !delegatedscope.Satisfied(token.ClaimScopes(claims), required) {
287+
metrics.RecordSecurityEvent("delegated_insufficient_scope", "graphql_scope_missing")
278288
return nil, gqlerror.Errorf("insufficient_scope")
279289
}
280290
return next(ctx)

0 commit comments

Comments
 (0)