Skip to content

Commit b352e74

Browse files
committed
feat(controlplane): add workflow scope, attestation enforcement, and system flag to API tokens
Closes #3115 Extends the API token model with three internal capabilities: - Workflow scope: tokens may optionally be scoped to a workflow within a project. The workflow is persisted on the apitoken row and embedded as workflow_id/workflow_name JWT claims. Expressed in biz via a new APITokenWithWorkflow(*Workflow) functional option, with validation that the workflow belongs to the requested project. - Attestation enforcement: the auth middleware verifies the workflow_id JWT claim matches the row, and findWorkflowFromTokenOrNameOrRunID rejects when a workflow-scoped token operates on a different workflow. FindOrCreateWorkflow forbids workflow-scoped tokens from minting other workflows. - System tokens: an immutable is_system flag on the row. System tokens mint and validate JWTs normally but are hidden from APITokenService/List by default and return NotFound on Revoke. Opt-in via WithIncludeSystemTokens() in biz; mint via APITokenAsSystem(). No public proto API change. Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 496af27 commit b352e74

25 files changed

Lines changed: 1030 additions & 93 deletions

app/controlplane/internal/service/apitoken.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ func (s *APITokenService) Revoke(ctx context.Context, req *pb.APITokenServiceRev
160160
return nil, handleUseCaseErr(err, s.log)
161161
}
162162

163+
// System tokens are internal and must not be reachable through the public API.
164+
if t.IsSystem {
165+
return nil, errors.NotFound("not found", "API token not found")
166+
}
167+
163168
// 1 - Only admins can manage global contracts
164169
if t.ProjectID == nil && rbacEnabled(ctx) {
165170
return nil, errors.BadRequest("invalid", "you can not manage a global API token")

app/controlplane/internal/service/attestation.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -675,22 +675,32 @@ func (s *AttestationService) findWorkflowFromTokenOrNameOrRunID(ctx context.Cont
675675
return nil, biz.NewErrValidationStr("orgID must be provided")
676676
}
677677

678-
// This is the case when the workflow if found by name
679-
if workflowName != "" {
680-
return s.workflowUseCase.FindByNameInOrg(ctx, orgID, projectName, workflowName)
681-
}
682-
683-
// This is the case when the workflow is found by its reference to the run
684-
if runID != "" {
678+
var wf *biz.Workflow
679+
switch {
680+
case workflowName != "":
681+
w, err := s.workflowUseCase.FindByNameInOrg(ctx, orgID, projectName, workflowName)
682+
if err != nil {
683+
return nil, err
684+
}
685+
wf = w
686+
case runID != "":
685687
run, err := s.wrUseCase.GetByIDInOrg(ctx, orgID, runID)
686688
if err != nil {
687689
return nil, fmt.Errorf("error retrieving the workflow run: %w", err)
688690
}
691+
wf = run.Workflow
692+
default:
693+
return nil, biz.NewErrValidationStr("workflowName or workflowRunId must be provided")
694+
}
689695

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

693-
return nil, biz.NewErrValidationStr("workflowName or workflowRunId must be provided")
703+
return wf, nil
694704
}
695705

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

725+
// Workflow-scoped API tokens cannot create or look up other workflows.
726+
if token := entities.CurrentAPIToken(ctx); token != nil && token.WorkflowID != nil {
727+
return nil, errors.Forbidden("forbidden", "API token is workflow-scoped and cannot create or look up other workflows")
728+
}
729+
715730
// try to load project and apply RBAC if needed
716731
if _, err := s.userHasPermissionOnProject(ctx, apiToken.OrgID, &cpAPI.IdentityReference{Name: &req.ProjectName}, authz.PolicyWorkflowCreate); err != nil {
717732
// if the project is not found, check if user can create projects

app/controlplane/internal/usercontext/apitoken_middleware.go

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2023-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -74,10 +74,12 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC
7474
// Project ID is optional
7575
projectID, _ := genericClaims["project_id"].(string)
7676

77+
workflowID, _ := genericClaims["workflow_id"].(string)
78+
7779
// Scope is optional
7880
scope, _ := genericClaims["scope"].(string)
7981

80-
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, scope)
82+
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, workflowID, scope)
8183
if err != nil {
8284
return nil, fmt.Errorf("error setting current org and user: %w", err)
8385
}
@@ -126,7 +128,7 @@ func WithAttestationContextFromAPIToken(apiTokenUC *biz.APITokenUseCase, orgUC *
126128
return nil, fmt.Errorf("error extracting organization from APIToken: %w", err)
127129
}
128130

129-
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.Scope)
131+
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.WorkflowID, claims.Scope)
130132
if err != nil {
131133
return nil, fmt.Errorf("error setting current org and user: %w", err)
132134
}
@@ -163,7 +165,7 @@ func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUs
163165
}
164166

165167
// Set the current organization and API-Token in the context
166-
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, scope string) (context.Context, error) {
168+
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, workflowIDInClaim, scope string) (context.Context, error) {
167169
if tokenID == "" {
168170
return nil, errors.New("error retrieving the key ID from the API token")
169171
}
@@ -181,6 +183,13 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
181183
return nil, errors.New("API token project mismatch")
182184
}
183185

186+
// Same defense in depth for the workflow claim
187+
if workflowIDInClaim != "" {
188+
if token.WorkflowID == nil || token.WorkflowID.String() != workflowIDInClaim {
189+
return nil, errors.New("API token workflow mismatch")
190+
}
191+
}
192+
184193
// Note: Expiration time does not need to be checked because that's done at the JWT
185194
// verification layer, which happens before this middleware is called
186195
if token.RevokedAt != nil {
@@ -218,14 +227,16 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
218227
}
219228

220229
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
221-
ID: token.ID.String(),
222-
Name: token.Name,
223-
CreatedAt: token.CreatedAt,
224-
Token: token.JWT,
225-
ProjectID: token.ProjectID,
226-
ProjectName: token.ProjectName,
227-
Policies: token.Policies,
228-
Scope: scope,
230+
ID: token.ID.String(),
231+
Name: token.Name,
232+
CreatedAt: token.CreatedAt,
233+
Token: token.JWT,
234+
ProjectID: token.ProjectID,
235+
ProjectName: token.ProjectName,
236+
WorkflowID: token.WorkflowID,
237+
WorkflowName: token.WorkflowName,
238+
Policies: token.Policies,
239+
Scope: scope,
229240
})
230241

231242
// Set the authorization subject that will be used to check the policies

app/controlplane/internal/usercontext/apitoken_middleware_test.go

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -34,19 +34,27 @@ import (
3434
"github.com/stretchr/testify/require"
3535
)
3636

37+
type middlewareTestCase struct {
38+
name string
39+
receivedToken bool
40+
audience string
41+
tokenExists bool
42+
tokenRevoked bool
43+
orgExist bool
44+
// workflowIDClaim, if non-empty, is the workflow_id claim included on the JWT
45+
workflowIDClaim string
46+
// tokenWorkflowID, if set, is the workflow_id stored on the DB row
47+
tokenWorkflowID *uuid.UUID
48+
// the middleware logic got skipped
49+
skipped bool
50+
wantErr bool
51+
}
52+
3753
func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
3854
logger := log.NewHelper(log.NewStdLogger(io.Discard))
39-
testCases := []struct {
40-
name string
41-
receivedToken bool
42-
audience string
43-
tokenExists bool
44-
tokenRevoked bool
45-
orgExist bool
46-
// the middleware logic got skipped
47-
skipped bool
48-
wantErr bool
49-
}{
55+
matchingWorkflowID := uuid.New()
56+
otherWorkflowID := uuid.New()
57+
testCases := []middlewareTestCase{
5058
{
5159
name: "invalid audience", // in this case it gets ignored
5260
receivedToken: true,
@@ -89,6 +97,32 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
8997
audience: apitoken.Audience,
9098
wantErr: true,
9199
},
100+
{
101+
name: "workflow claim matches DB row",
102+
receivedToken: true,
103+
audience: apitoken.Audience,
104+
tokenExists: true,
105+
orgExist: true,
106+
workflowIDClaim: matchingWorkflowID.String(),
107+
tokenWorkflowID: &matchingWorkflowID,
108+
},
109+
{
110+
name: "workflow claim does not match DB row",
111+
receivedToken: true,
112+
audience: apitoken.Audience,
113+
tokenExists: true,
114+
workflowIDClaim: matchingWorkflowID.String(),
115+
tokenWorkflowID: &otherWorkflowID,
116+
wantErr: true,
117+
},
118+
{
119+
name: "workflow claim present but DB row has none",
120+
receivedToken: true,
121+
audience: apitoken.Audience,
122+
tokenExists: true,
123+
workflowIDClaim: matchingWorkflowID.String(),
124+
wantErr: true,
125+
},
92126
}
93127

94128
for _, tc := range testCases {
@@ -110,6 +144,9 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
110144
"aud": tc.audience,
111145
"jti": wantToken.ID.String(),
112146
}
147+
if tc.workflowIDClaim != "" {
148+
c["workflow_id"] = tc.workflowIDClaim
149+
}
113150

114151
ctx = jwtmiddleware.NewContext(ctx, c)
115152
}
@@ -118,6 +155,9 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
118155
if tc.tokenRevoked {
119156
wantToken.RevokedAt = toTimePtr(time.Now())
120157
}
158+
if tc.tokenWorkflowID != nil {
159+
wantToken.WorkflowID = tc.tokenWorkflowID
160+
}
121161

122162
apiTokenRepo.On("FindByID", ctx, wantToken.ID).Return(wantToken, nil)
123163
} else if tc.receivedToken {

app/controlplane/internal/usercontext/entities/apitoken.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -26,11 +26,13 @@ import (
2626
type APIToken struct {
2727
ID string
2828
// Token Name
29-
Name string
30-
CreatedAt *time.Time
31-
Token string
32-
ProjectID *uuid.UUID
33-
ProjectName *string
29+
Name string
30+
CreatedAt *time.Time
31+
Token string
32+
ProjectID *uuid.UUID
33+
ProjectName *string
34+
WorkflowID *uuid.UUID
35+
WorkflowName *string
3436
// ACL policies for this token. Used for authorization checks.
3537
Policies []*authz.Policy
3638
Scope string

app/controlplane/pkg/biz/apitoken.go

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,17 @@ type APIToken struct {
6262
// If the token is scoped to a project
6363
ProjectID *uuid.UUID
6464
ProjectName *string
65+
// If the token is scoped to a specific workflow within a project
66+
WorkflowID *uuid.UUID
67+
WorkflowName *string
6568
// ACL policies for this token
6669
Policies []*authz.Policy
70+
// IsSystem marks tokens minted by internal code paths; these are hidden from the public API.
71+
IsSystem bool
6772
}
6873

6974
type APITokenRepo interface {
70-
Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*APIToken, error)
75+
Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, workflowID *uuid.UUID, policies []*authz.Policy, isSystem bool) (*APIToken, error)
7176
List(ctx context.Context, orgID *uuid.UUID, filters *APITokenListFilters) ([]*APIToken, error)
7277
Revoke(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error
7378
// FindInactive returns tokens in an organization that have been inactive since the given cutoff time.
@@ -140,9 +145,10 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig,
140145
}
141146

142147
type apiTokenOptions struct {
143-
project *Project
144-
showOnlySystemTokens bool
145-
policies []*authz.Policy
148+
project *Project
149+
workflow *Workflow
150+
policies []*authz.Policy
151+
isSystem bool
146152
}
147153

148154
type APITokenCreateOpt func(*apiTokenOptions)
@@ -153,12 +159,28 @@ func APITokenWithProject(project *Project) APITokenCreateOpt {
153159
}
154160
}
155161

162+
// APITokenWithWorkflow scopes the token to a specific workflow within a project.
163+
// Must be combined with APITokenWithProject; the workflow's project must match.
164+
func APITokenWithWorkflow(workflow *Workflow) APITokenCreateOpt {
165+
return func(o *apiTokenOptions) {
166+
o.workflow = workflow
167+
}
168+
}
169+
156170
func APITokenWithPolicies(policies []*authz.Policy) APITokenCreateOpt {
157171
return func(o *apiTokenOptions) {
158172
o.policies = policies
159173
}
160174
}
161175

176+
// APITokenAsSystem marks the token as system-managed (internal). System tokens
177+
// are hidden from the public API.
178+
func APITokenAsSystem() APITokenCreateOpt {
179+
return func(o *apiTokenOptions) {
180+
o.isSystem = true
181+
}
182+
}
183+
162184
// expires in is a string that can be parsed by time.ParseDuration
163185
func (uc *APITokenUseCase) Create(ctx context.Context, name string, description *string, expiresIn *time.Duration, orgID *string, opts ...APITokenCreateOpt) (*APIToken, error) {
164186
options := &apiTokenOptions{}
@@ -206,6 +228,17 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
206228
projectID = ToPtr(options.project.ID)
207229
}
208230

231+
var workflowID *uuid.UUID
232+
if options.workflow != nil {
233+
if options.project == nil {
234+
return nil, NewErrValidationStr("workflow scope requires a project scope")
235+
}
236+
if options.workflow.ProjectID != options.project.ID {
237+
return nil, NewErrValidationStr("workflow does not belong to the requested project")
238+
}
239+
workflowID = ToPtr(options.workflow.ID)
240+
}
241+
209242
// Use provided policies if present, otherwise use defaults
210243
policies := options.policies
211244
if policies == nil {
@@ -219,7 +252,7 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
219252

220253
// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
221254
// We store it since Chainloop will not have access to the JWT to check the expiration once created
222-
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies)
255+
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, workflowID, policies, options.isSystem)
223256
if err != nil {
224257
if IsErrAlreadyExists(err) {
225258
return nil, NewErrAlreadyExistsStr("name already taken")
@@ -246,6 +279,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
246279
generationOpts.ProjectName = ToPtr(options.project.Name)
247280
}
248281

282+
if options.workflow != nil {
283+
generationOpts.WorkflowID = ToPtr(options.workflow.ID)
284+
generationOpts.WorkflowName = ToPtr(options.workflow.Name)
285+
}
286+
249287
// generate the JWT
250288
token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts)
251289
if err != nil {
@@ -332,6 +370,14 @@ func WithAPITokenScope(scope APITokenScope) APITokenListOpt {
332370
}
333371
}
334372

373+
// WithIncludeSystemTokens opts the listing in to also return system-managed tokens.
374+
// By default, system tokens are hidden.
375+
func WithIncludeSystemTokens() APITokenListOpt {
376+
return func(opts *APITokenListFilters) {
377+
opts.IncludeSystem = true
378+
}
379+
}
380+
335381
type APITokenScope string
336382

337383
const (
@@ -367,6 +413,9 @@ type APITokenListFilters struct {
367413
StatusFilter APITokenStatusFilter
368414
// FilterByScope is used to filter the result by the scope of the token
369415
FilterByScope APITokenScope
416+
// IncludeSystem controls whether system-managed tokens are returned.
417+
// Defaults to false (system tokens are hidden).
418+
IncludeSystem bool
370419
}
371420

372421
func (uc *APITokenUseCase) List(ctx context.Context, orgID string, opts ...APITokenListOpt) ([]*APIToken, error) {

0 commit comments

Comments
 (0)