Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
16452b4
feat(controlplane): sign the scope type and scope id in API token claims
javirln Oct 5, 2026
c14d969
test(controlplane): use constants in the API token claim tests
javirln Oct 5, 2026
7c39a3a
feat(controlplane): verify an API token's row against its signed claims
javirln Oct 5, 2026
7632e6b
test(controlplane): cover a row that records no scope id
javirln Oct 5, 2026
e33e037
feat(controlplane): sign the token scope on API token creation and re…
javirln Oct 5, 2026
050cc7d
feat(controlplane): refuse an API token whose row disagrees with its …
javirln Oct 5, 2026
0493b93
fix(controlplane): guard API token revocation and log claim mismatches
javirln Oct 5, 2026
ded42fb
docs(controlplane): say the API token scope is confirmed by its signe…
javirln Oct 5, 2026
0192d45
refactor(controlplane): build the attestation robot account from the …
javirln Oct 5, 2026
2519b51
docs(controlplane): explain the API token claim checks in plainer words
javirln Oct 5, 2026
e911352
docs(controlplane): rewrite the API token claim comments in plain Eng…
javirln Oct 5, 2026
d6c3b17
fix(controlplane): compare the API token project and workflow with th…
javirln Oct 6, 2026
38ebab4
refactor(controlplane): name the claims scope getter GetScope and kee…
javirln Oct 6, 2026
279f3bd
test(controlplane): compare the whole error that a mismatched API tok…
javirln Oct 6, 2026
06e140f
refactor(controlplane): sign the API token scope kind in the scope claim
javirln Oct 6, 2026
db244c0
refactor(controlplane): read the API token scope from its claims firs…
javirln Oct 6, 2026
71a4d6b
refactor(controlplane): name the claims check validateScope and stop …
javirln Oct 6, 2026
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
122 changes: 51 additions & 71 deletions app/controlplane/internal/usercontext/apitoken_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,23 +71,28 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC

// We've received an API-token
if claimsHaveAudience(genericClaims, apitoken.Audience) {
var err error
tokenID, ok := genericClaims["jti"].(string)
if !ok || tokenID == "" {
claims, err := apitoken.ClaimsFromMap(genericClaims)
if err != nil {
// This control plane never signs a claim of the wrong type. The log line never
// includes the raw token or the claims map.
id, _ := genericClaims["jti"].(string)
logger.Errorw("msg", "[authN] API token claims do not decode", "id", id, "error", err)

return nil, errors.New("error mapping the API-token claims")
}

// Project ID is optional
projectID, _ := genericClaims["project_id"].(string)

workflowID, _ := genericClaims["workflow_id"].(string)
if claims.ID == "" {
return nil, errors.New("error mapping the API-token claims")
}

ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, workflowID)
ctx, _, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, claims, logger)
if err != nil {
return nil, fmt.Errorf("error setting current org and user: %w", err)
}

logger.Infow("msg", "[authN] processed credentials", "id", tokenID, "type", "API-token", "projectID", projectID)
// legacy_claims marks the tokens minted before the scope claims, to plan the end of
// their support
logger.Infow("msg", "[authN] processed credentials", "id", claims.ID, "type", "API-token", "projectID", claims.ProjectID, "legacy_claims", !claims.HasScopeClaims())
}

return handler(ctx, req)
Expand Down Expand Up @@ -126,81 +131,54 @@ func WithAttestationContextFromAPIToken(apiTokenUC *biz.APITokenUseCase, orgUC *
return nil, errors.New("error mapping the API-token claims")
}

ctx, err := setRobotAccountFromAPIToken(ctx, apiTokenUC, tokenID)
if err != nil {
return nil, fmt.Errorf("error extracting organization from APIToken: %w", err)
}

ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.WorkflowID)
ctx, token, err := setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, claims, logger)
if err != nil {
return nil, fmt.Errorf("error setting current org and user: %w", err)
}

logger.Infow("msg", "[authN] processed credentials", "id", tokenID, "type", "API-token")
// The robot account comes from the row that VerifyClaims checked.
ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: token.OrganizationID.String(), ProviderKey: attjwtmiddleware.APITokenProviderKey})

logger.Infow("msg", "[authN] processed credentials", "id", tokenID, "type", "API-token", "legacy_claims", !claims.HasScopeClaims())

return handler(ctx, req)
}
}
}

func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, tokenID string) (context.Context, error) {
if tokenID == "" {
return nil, errors.New("error retrieving the key ID from the API token")
}

// Check that the token exists and is not revoked
token, err := apiTokenUC.FindByID(ctx, tokenID)
if err != nil {
return nil, fmt.Errorf("error retrieving the API token: %w", err)
} else if token == nil {
return nil, errors.New("API token not found")
}

// Note: Expiration time does not need to be checked because that's done at the JWT
// verification layer, which happens before this middleware is called
if token.RevokedAt != nil {
return nil, errors.New("API token revoked")
}

ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: token.OrganizationID.String(), ProviderKey: attjwtmiddleware.APITokenProviderKey})

return ctx, nil
}

// Set the current organization and API-Token in the context. The project and workflow claims are
// cross-checked against the token row, never an authorization input: the row decides what the
// token reaches.
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, workflowIDInClaim string) (context.Context, error) {
if tokenID == "" {
return nil, errors.New("error retrieving the key ID from the API token")
// setCurrentOrgAndAPIToken loads the token's row and checks it against the signed claims. Then it
// puts the organization and the token in the context, and returns the row. The claims fix the
// token's scope, organization, project and workflow. The row must match them. The policies, the
// product project list and the revocation come only from the row.
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, claims *apitoken.CustomClaims, logger *log.Helper) (context.Context, *biz.APIToken, error) {
if claims == nil || claims.ID == "" {
return nil, nil, errors.New("error retrieving the key ID from the API token")
}

// Check that the token exists and is not revoked
token, err := apiTokenUC.FindByID(ctx, tokenID)
token, err := apiTokenUC.FindByID(ctx, claims.ID)
if err != nil {
return nil, fmt.Errorf("error retrieving the API token: %w", err)
return nil, nil, fmt.Errorf("error retrieving the API token: %w", err)
} else if token == nil {
return nil, errors.New("API token not found")
return nil, nil, errors.New("API token not found")
}

// Make sure that the projectID that comes in the token claim matches the one in the DB
if projectIDInClaim != "" {
if token.ProjectID == nil || token.ProjectID.String() != projectIDInClaim {
return nil, errors.New("API token project mismatch")
if err := token.VerifyClaims(claims); err != nil {
// A row should never disagree with its signed claims. If it does, something wrote the row
// incorrectly. The log line gives the reason and never includes the raw JWT. The caller
// learns only that the token could not be verified.
if errors.Is(err, biz.ErrAPITokenClaimsMismatch) {
logger.Errorw("msg", "[authN] API token row disagrees with its signed claims", "id", claims.ID, "error", err)
return nil, nil, biz.ErrAPITokenClaimsMismatch
}
}

// Same defense in depth for the workflow claim
if workflowIDInClaim != "" {
if token.WorkflowID == nil || token.WorkflowID.String() != workflowIDInClaim {
return nil, errors.New("API token workflow mismatch")
}
return nil, nil, err
}

// Note: Expiration time does not need to be checked because that's done at the JWT
// verification layer, which happens before this middleware is called
if token.RevokedAt != nil {
return nil, errors.New("API token revoked")
return nil, nil, errors.New("API token revoked")
}

// Handle instance admin tokens
Expand All @@ -212,9 +190,9 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
// Load organization from header
org, err := orgUC.FindByName(ctx, orgName)
if err != nil {
return nil, fmt.Errorf("error retrieving the organization: %w", err)
return nil, nil, fmt.Errorf("error retrieving the organization: %w", err)
} else if org == nil {
return nil, errors.New("organization not found")
return nil, nil, errors.New("organization not found")
}

ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended})
Expand All @@ -225,15 +203,18 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
} else {
org, err := orgUC.FindByID(ctx, token.OrganizationID.String())
if err != nil {
return nil, fmt.Errorf("error retrieving the organization: %w", err)
return nil, nil, fmt.Errorf("error retrieving the organization: %w", err)
} else if org == nil {
return nil, errors.New("organization not found")
return nil, nil, errors.New("organization not found")
}

// Set the current organization in the context
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended})
}

// Every value here comes from the row. VerifyClaims checked the scope, project and workflow
// against the signed claims. The policies, the project list and the system flag come only from
// the row.
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
ID: token.ID.String(),
Name: token.Name,
Expand All @@ -243,19 +224,18 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
ProjectName: token.ProjectName,
WorkflowID: token.WorkflowID,
WorkflowName: token.WorkflowName,
// Every value here comes from token.*, i.e. the database row
Scope: token.Scope,
ScopeID: token.ScopeID,
ProjectIDs: token.ProjectIDs,
Policies: token.Policies,
IsSystem: token.IsSystem,
Scope: token.Scope,
ScopeID: token.ScopeID,
ProjectIDs: token.ProjectIDs,
Policies: token.Policies,
IsSystem: token.IsSystem,
})

// Set the authorization subject that will be used to check the policies
subjectAPIToken := authz.SubjectAPIToken{ID: token.ID.String()}
ctx = WithAuthzSubject(ctx, subjectAPIToken.String())

return ctx, nil
return ctx, token, nil
}

func WithAPITokenUsageUpdater(apiTokenUC *biz.APITokenUseCase, logger *log.Helper) middleware.Middleware {
Expand Down
Loading
Loading