Skip to content

Commit 33886a8

Browse files
authored
feat(controlplane): sign the API token scope and check it against the row (#3530)
Assisted-by: Claude Code Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent d7f5024 commit 33886a8

10 files changed

Lines changed: 1236 additions & 292 deletions

File tree

‎app/controlplane/internal/usercontext/apitoken_middleware.go‎

Lines changed: 51 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,28 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC
7171

7272
// We've received an API-token
7373
if claimsHaveAudience(genericClaims, apitoken.Audience) {
74-
var err error
75-
tokenID, ok := genericClaims["jti"].(string)
76-
if !ok || tokenID == "" {
74+
claims, err := apitoken.ClaimsFromMap(genericClaims)
75+
if err != nil {
76+
// This control plane never signs a claim of the wrong type. The log line never
77+
// includes the raw token or the claims map.
78+
id, _ := genericClaims["jti"].(string)
79+
logger.Errorw("msg", "[authN] API token claims do not decode", "id", id, "error", err)
80+
7781
return nil, errors.New("error mapping the API-token claims")
7882
}
7983

80-
// Project ID is optional
81-
projectID, _ := genericClaims["project_id"].(string)
82-
83-
workflowID, _ := genericClaims["workflow_id"].(string)
84+
if claims.ID == "" {
85+
return nil, errors.New("error mapping the API-token claims")
86+
}
8487

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

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

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

129-
ctx, err := setRobotAccountFromAPIToken(ctx, apiTokenUC, tokenID)
130-
if err != nil {
131-
return nil, fmt.Errorf("error extracting organization from APIToken: %w", err)
132-
}
133-
134-
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.WorkflowID)
134+
ctx, token, err := setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, claims, logger)
135135
if err != nil {
136136
return nil, fmt.Errorf("error setting current org and user: %w", err)
137137
}
138138

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

141144
return handler(ctx, req)
142145
}
143146
}
144147
}
145148

146-
func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, tokenID string) (context.Context, error) {
147-
if tokenID == "" {
148-
return nil, errors.New("error retrieving the key ID from the API token")
149-
}
150-
151-
// Check that the token exists and is not revoked
152-
token, err := apiTokenUC.FindByID(ctx, tokenID)
153-
if err != nil {
154-
return nil, fmt.Errorf("error retrieving the API token: %w", err)
155-
} else if token == nil {
156-
return nil, errors.New("API token not found")
157-
}
158-
159-
// Note: Expiration time does not need to be checked because that's done at the JWT
160-
// verification layer, which happens before this middleware is called
161-
if token.RevokedAt != nil {
162-
return nil, errors.New("API token revoked")
163-
}
164-
165-
ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: token.OrganizationID.String(), ProviderKey: attjwtmiddleware.APITokenProviderKey})
166-
167-
return ctx, nil
168-
}
169-
170-
// Set the current organization and API-Token in the context. The project and workflow claims are
171-
// cross-checked against the token row, never an authorization input: the row decides what the
172-
// token reaches.
173-
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, workflowIDInClaim string) (context.Context, error) {
174-
if tokenID == "" {
175-
return nil, errors.New("error retrieving the key ID from the API token")
149+
// setCurrentOrgAndAPIToken loads the token's row and checks it against the signed claims. Then it
150+
// puts the organization and the token in the context, and returns the row. The claims fix the
151+
// token's scope, organization, project and workflow. The row must match them. The policies, the
152+
// product project list and the revocation come only from the row.
153+
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, claims *apitoken.CustomClaims, logger *log.Helper) (context.Context, *biz.APIToken, error) {
154+
if claims == nil || claims.ID == "" {
155+
return nil, nil, errors.New("error retrieving the key ID from the API token")
176156
}
177157

178158
// Check that the token exists and is not revoked
179-
token, err := apiTokenUC.FindByID(ctx, tokenID)
159+
token, err := apiTokenUC.FindByID(ctx, claims.ID)
180160
if err != nil {
181-
return nil, fmt.Errorf("error retrieving the API token: %w", err)
161+
return nil, nil, fmt.Errorf("error retrieving the API token: %w", err)
182162
} else if token == nil {
183-
return nil, errors.New("API token not found")
163+
return nil, nil, errors.New("API token not found")
184164
}
185165

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

193-
// Same defense in depth for the workflow claim
194-
if workflowIDInClaim != "" {
195-
if token.WorkflowID == nil || token.WorkflowID.String() != workflowIDInClaim {
196-
return nil, errors.New("API token workflow mismatch")
197-
}
175+
return nil, nil, err
198176
}
199177

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

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

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

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

215+
// Every value here comes from the row. VerifyClaims checked the scope, project and workflow
216+
// against the signed claims. The policies, the project list and the system flag come only from
217+
// the row.
237218
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
238219
ID: token.ID.String(),
239220
Name: token.Name,
@@ -243,19 +224,18 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
243224
ProjectName: token.ProjectName,
244225
WorkflowID: token.WorkflowID,
245226
WorkflowName: token.WorkflowName,
246-
// Every value here comes from token.*, i.e. the database row
247-
Scope: token.Scope,
248-
ScopeID: token.ScopeID,
249-
ProjectIDs: token.ProjectIDs,
250-
Policies: token.Policies,
251-
IsSystem: token.IsSystem,
227+
Scope: token.Scope,
228+
ScopeID: token.ScopeID,
229+
ProjectIDs: token.ProjectIDs,
230+
Policies: token.Policies,
231+
IsSystem: token.IsSystem,
252232
})
253233

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

258-
return ctx, nil
238+
return ctx, token, nil
259239
}
260240

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

0 commit comments

Comments
 (0)