Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions internal/constants/audit_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,22 @@ const (
// wrong secret) — mirrors AuditLoginFailedEvent for the human login path.
AuditTokenClientCredentialsFailedEvent = "token.client_credentials_failed"
// AuditTokenExchangeEvent is logged when a token exchange occurs (RFC 8693).
//
// Reserved, deliberately unused: a successful exchange is recorded as
// AuditTokenIssuedEvent with grant_type=token-exchange in its Metadata, and
// ROADMAP_V2.md documents "token.issued" as the issuance event. Switching the
// action would silently break any query already filtering on it. Failures use
// AuditTokenExchangeFailedEvent below, mirroring the client_credentials pair.
AuditTokenExchangeEvent = "token.exchange"
// AuditTokenExchangeFailedEvent is logged when an RFC 8693 token exchange is
// REJECTED.
//
// Every rejection on that endpoint used to be silent — 14 refusal paths, none
// audited — while the client_credentials path already audited its failures.
// An agent probing the delegation endpoint therefore left no trail at all,
// which is the opposite of what a delegation surface needs: the whole point of
// the act chain is that an agent's activity is attributable.
AuditTokenExchangeFailedEvent = "token.exchange_failed"
// AuditWorkloadAuthEvent is logged when a workload authenticates via client_assertion
// (K8s SA token, SPIFFE JWT-SVID, or generic OIDC workload token).
AuditWorkloadAuthEvent = "token.workload_auth"
Expand Down
10 changes: 10 additions & 0 deletions internal/grpcsrv/interceptors/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,23 @@ func enforceDelegatedScope(tokenData *token.SessionOrAccessTokenData, fullMethod
if tokenData == nil || strings.TrimSpace(tokenData.ActorID) == "" {
return nil
}
// Both refusals below are metered, matching the GraphQL choke point. This was
// silent on every transport, so an operator could not see agents hitting
// their ceiling — the number needed before deciding whether to widen one. The
// labels distinguish the two, because they call for opposite actions:
// "not_delegatable" means the method is not on the delegated allow-list at
// all (a client bug, or probing), "scope_missing" means it is reachable but
// this token was not granted the scope. Method names are NOT included — they
// would be a high-cardinality label on an internet-facing path.
required, ok := delegatedscope.RequiredForGRPC(fullMethod)
if !ok {
// Fail closed: an operation nobody has cleared for delegated callers is
// out of reach for an agent, whatever scope it holds.
metrics.RecordSecurityEvent("delegated_insufficient_scope", "grpc_not_delegatable")
return status.Error(codes.PermissionDenied, "insufficient_scope")
}
if !delegatedscope.Satisfied(tokenData.Scope, required) {
metrics.RecordSecurityEvent("delegated_insufficient_scope", "grpc_scope_missing")
return status.Error(codes.PermissionDenied, "insufficient_scope")
}
return nil
Expand Down
10 changes: 10 additions & 0 deletions internal/http_handlers/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,23 @@ func (h *httpProvider) gqlDelegatedScopeMiddleware() gql.FieldMiddleware {
return next(ctx) // first-party token; see the package comment.
}

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