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
5 changes: 5 additions & 0 deletions app/controlplane/internal/service/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ func (s *APITokenService) Revoke(ctx context.Context, req *pb.APITokenServiceRev
return nil, handleUseCaseErr(err, s.log)
}

// System tokens are internal and must not be reachable through the public API.
if t.IsSystem {
return nil, errors.NotFound("not found", "API token not found")
}

// 1 - Only admins can manage global contracts
if t.ProjectID == nil && rbacEnabled(ctx) {
return nil, errors.BadRequest("invalid", "you can not manage a global API token")
Expand Down
33 changes: 24 additions & 9 deletions app/controlplane/internal/service/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,22 +710,32 @@ func (s *AttestationService) findWorkflowFromTokenOrNameOrRunID(ctx context.Cont
return nil, biz.NewErrValidationStr("orgID must be provided")
}

// This is the case when the workflow if found by name
if workflowName != "" {
return s.workflowUseCase.FindByNameInOrg(ctx, orgID, projectName, workflowName)
}

// This is the case when the workflow is found by its reference to the run
if runID != "" {
var wf *biz.Workflow
switch {
case workflowName != "":
w, err := s.workflowUseCase.FindByNameInOrg(ctx, orgID, projectName, workflowName)
if err != nil {
return nil, err
}
wf = w
case runID != "":
run, err := s.wrUseCase.GetByIDInOrg(ctx, orgID, runID)
if err != nil {
return nil, fmt.Errorf("error retrieving the workflow run: %w", err)
}
wf = run.Workflow
default:
return nil, biz.NewErrValidationStr("workflowName or workflowRunId must be provided")
}

return run.Workflow, nil
// Workflow-scoped API tokens may only operate on their own workflow.
if apiToken := entities.CurrentAPIToken(ctx); apiToken != nil && apiToken.WorkflowID != nil {
if wf.ID != *apiToken.WorkflowID {
return nil, errors.Forbidden("forbidden", "API token is scoped to a different workflow")
}
}

return nil, biz.NewErrValidationStr("workflowName or workflowRunId must be provided")
return wf, nil
}

func checkAuthRequirements(attToken *usercontext.RobotAccount, workflowName string) error {
Expand All @@ -747,6 +757,11 @@ func (s *AttestationService) FindOrCreateWorkflow(ctx context.Context, req *cpAP
return nil, errors.NotFound("not found", "neither robot account nor API token found")
}

// Workflow-scoped API tokens cannot create or look up other workflows.
if token := entities.CurrentAPIToken(ctx); token != nil && token.WorkflowID != nil {
return nil, errors.Forbidden("forbidden", "API token is workflow-scoped and cannot create or look up other workflows")
}

// try to load project and apply RBAC if needed
if _, err := s.userHasPermissionOnProject(ctx, apiToken.OrgID, &cpAPI.IdentityReference{Name: &req.ProjectName}, authz.PolicyWorkflowCreate); err != nil {
// if the project is not found, check if user can create projects
Expand Down
33 changes: 22 additions & 11 deletions app/controlplane/internal/usercontext/apitoken_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,12 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC
// Project ID is optional
projectID, _ := genericClaims["project_id"].(string)

workflowID, _ := genericClaims["workflow_id"].(string)

// Scope is optional
scope, _ := genericClaims["scope"].(string)

ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, scope)
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, workflowID, scope)
if err != nil {
return nil, fmt.Errorf("error setting current org and user: %w", err)
}
Expand Down Expand Up @@ -132,7 +134,7 @@ func WithAttestationContextFromAPIToken(apiTokenUC *biz.APITokenUseCase, orgUC *
return nil, fmt.Errorf("error extracting organization from APIToken: %w", err)
}

ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.Scope)
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.WorkflowID, claims.Scope)
if err != nil {
return nil, fmt.Errorf("error setting current org and user: %w", err)
}
Expand Down Expand Up @@ -169,7 +171,7 @@ func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUs
}

// Set the current organization and API-Token in the context
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, scope string) (context.Context, error) {
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, workflowIDInClaim, scope string) (context.Context, error) {
if tokenID == "" {
return nil, errors.New("error retrieving the key ID from the API token")
}
Expand All @@ -187,6 +189,13 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
return nil, errors.New("API token project mismatch")
}

// 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")
}
}

// 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 {
Expand Down Expand Up @@ -224,14 +233,16 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
}

ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
ID: token.ID.String(),
Name: token.Name,
CreatedAt: token.CreatedAt,
Token: token.JWT,
ProjectID: token.ProjectID,
ProjectName: token.ProjectName,
Policies: token.Policies,
Scope: scope,
ID: token.ID.String(),
Name: token.Name,
CreatedAt: token.CreatedAt,
Token: token.JWT,
ProjectID: token.ProjectID,
ProjectName: token.ProjectName,
WorkflowID: token.WorkflowID,
WorkflowName: token.WorkflowName,
Policies: token.Policies,
Scope: scope,
})

// Set the authorization subject that will be used to check the policies
Expand Down
68 changes: 57 additions & 11 deletions app/controlplane/internal/usercontext/apitoken_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,28 @@ import (
"github.com/stretchr/testify/require"
)

type middlewareTestCase struct {
name string
receivedToken bool
audience string
tokenExists bool
tokenRevoked bool
orgExist bool
// workflowIDClaim, if non-empty, is the workflow_id claim included on the JWT
workflowIDClaim string
// tokenWorkflowID, if set, is the workflow_id stored on the DB row
tokenWorkflowID *uuid.UUID
// the middleware logic got skipped
skipped bool
wantErr bool
wantErrContains string
}

func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
logger := log.NewHelper(log.NewStdLogger(io.Discard))
testCases := []struct {
name string
receivedToken bool
audience string
tokenExists bool
tokenRevoked bool
orgExist bool
// the middleware logic got skipped
skipped bool
wantErr bool
}{
matchingWorkflowID := uuid.New()
otherWorkflowID := uuid.New()
testCases := []middlewareTestCase{
{
name: "invalid audience", // in this case it gets ignored
receivedToken: true,
Expand Down Expand Up @@ -90,6 +99,34 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
audience: apitoken.Audience,
wantErr: true,
},
{
name: "workflow claim matches DB row",
receivedToken: true,
audience: apitoken.Audience,
tokenExists: true,
orgExist: true,
workflowIDClaim: matchingWorkflowID.String(),
tokenWorkflowID: &matchingWorkflowID,
},
{
name: "workflow claim does not match DB row",
receivedToken: true,
audience: apitoken.Audience,
tokenExists: true,
workflowIDClaim: matchingWorkflowID.String(),
tokenWorkflowID: &otherWorkflowID,
wantErr: true,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
wantErrContains: "workflow mismatch",
},
{
name: "workflow claim present but DB row has none",
receivedToken: true,
audience: apitoken.Audience,
tokenExists: true,
workflowIDClaim: matchingWorkflowID.String(),
wantErr: true,
Comment thread
migmartri marked this conversation as resolved.
wantErrContains: "workflow mismatch",
},
}

for _, tc := range testCases {
Expand All @@ -111,6 +148,9 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
"aud": tc.audience,
"jti": wantToken.ID.String(),
}
if tc.workflowIDClaim != "" {
c["workflow_id"] = tc.workflowIDClaim
}

ctx = jwtmiddleware.NewContext(ctx, c)
}
Expand All @@ -119,6 +159,9 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
if tc.tokenRevoked {
wantToken.RevokedAt = toTimePtr(time.Now())
}
if tc.tokenWorkflowID != nil {
wantToken.WorkflowID = tc.tokenWorkflowID
}

apiTokenRepo.On("FindByID", mock.Anything, wantToken.ID).Return(wantToken, nil)
} else if tc.receivedToken {
Expand Down Expand Up @@ -150,6 +193,9 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {

if tc.wantErr {
assert.Error(t, err)
if tc.wantErrContains != "" {
assert.Contains(t, err.Error(), tc.wantErrContains)
}
} else {
assert.NoError(t, err)
}
Expand Down
14 changes: 8 additions & 6 deletions app/controlplane/internal/usercontext/entities/apitoken.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024-2025 The Chainloop Authors.
// Copyright 2024-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -26,11 +26,13 @@ import (
type APIToken struct {
ID string
// Token Name
Name string
CreatedAt *time.Time
Token string
ProjectID *uuid.UUID
ProjectName *string
Name string
CreatedAt *time.Time
Token string
ProjectID *uuid.UUID
ProjectName *string
WorkflowID *uuid.UUID
WorkflowName *string
// ACL policies for this token. Used for authorization checks.
Policies []*authz.Policy
Scope string
Expand Down
Loading
Loading