Skip to content

Commit a640f13

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 b900a69 commit a640f13

25 files changed

Lines changed: 1031 additions & 91 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
@@ -710,22 +710,32 @@ func (s *AttestationService) findWorkflowFromTokenOrNameOrRunID(ctx context.Cont
710710
return nil, biz.NewErrValidationStr("orgID must be provided")
711711
}
712712

713-
// This is the case when the workflow if found by name
714-
if workflowName != "" {
715-
return s.workflowUseCase.FindByNameInOrg(ctx, orgID, projectName, workflowName)
716-
}
717-
718-
// This is the case when the workflow is found by its reference to the run
719-
if runID != "" {
713+
var wf *biz.Workflow
714+
switch {
715+
case workflowName != "":
716+
w, err := s.workflowUseCase.FindByNameInOrg(ctx, orgID, projectName, workflowName)
717+
if err != nil {
718+
return nil, err
719+
}
720+
wf = w
721+
case runID != "":
720722
run, err := s.wrUseCase.GetByIDInOrg(ctx, orgID, runID)
721723
if err != nil {
722724
return nil, fmt.Errorf("error retrieving the workflow run: %w", err)
723725
}
726+
wf = run.Workflow
727+
default:
728+
return nil, biz.NewErrValidationStr("workflowName or workflowRunId must be provided")
729+
}
724730

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

728-
return nil, biz.NewErrValidationStr("workflowName or workflowRunId must be provided")
738+
return wf, nil
729739
}
730740

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

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

app/controlplane/internal/usercontext/apitoken_middleware.go

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,12 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC
8080
// Project ID is optional
8181
projectID, _ := genericClaims["project_id"].(string)
8282

83+
workflowID, _ := genericClaims["workflow_id"].(string)
84+
8385
// Scope is optional
8486
scope, _ := genericClaims["scope"].(string)
8587

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

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

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

192+
// Same defense in depth for the workflow claim
193+
if workflowIDInClaim != "" {
194+
if token.WorkflowID == nil || token.WorkflowID.String() != workflowIDInClaim {
195+
return nil, errors.New("API token workflow mismatch")
196+
}
197+
}
198+
190199
// Note: Expiration time does not need to be checked because that's done at the JWT
191200
// verification layer, which happens before this middleware is called
192201
if token.RevokedAt != nil {
@@ -224,14 +233,16 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
224233
}
225234

226235
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
227-
ID: token.ID.String(),
228-
Name: token.Name,
229-
CreatedAt: token.CreatedAt,
230-
Token: token.JWT,
231-
ProjectID: token.ProjectID,
232-
ProjectName: token.ProjectName,
233-
Policies: token.Policies,
234-
Scope: scope,
236+
ID: token.ID.String(),
237+
Name: token.Name,
238+
CreatedAt: token.CreatedAt,
239+
Token: token.JWT,
240+
ProjectID: token.ProjectID,
241+
ProjectName: token.ProjectName,
242+
WorkflowID: token.WorkflowID,
243+
WorkflowName: token.WorkflowName,
244+
Policies: token.Policies,
245+
Scope: scope,
235246
})
236247

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

app/controlplane/internal/usercontext/apitoken_middleware_test.go

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,27 @@ import (
3535
"github.com/stretchr/testify/require"
3636
)
3737

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

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

115152
ctx = jwtmiddleware.NewContext(ctx, c)
116153
}
@@ -119,6 +156,9 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) {
119156
if tc.tokenRevoked {
120157
wantToken.RevokedAt = toTimePtr(time.Now())
121158
}
159+
if tc.tokenWorkflowID != nil {
160+
wantToken.WorkflowID = tc.tokenWorkflowID
161+
}
122162

123163
apiTokenRepo.On("FindByID", mock.Anything, wantToken.ID).Return(wantToken, nil)
124164
} 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
@@ -65,12 +65,17 @@ type APIToken struct {
6565
// If the token is scoped to a project
6666
ProjectID *uuid.UUID
6767
ProjectName *string
68+
// If the token is scoped to a specific workflow within a project
69+
WorkflowID *uuid.UUID
70+
WorkflowName *string
6871
// ACL policies for this token
6972
Policies []*authz.Policy
73+
// IsSystem marks tokens minted by internal code paths; these are hidden from the public API.
74+
IsSystem bool
7075
}
7176

7277
type APITokenRepo interface {
73-
Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*APIToken, error)
78+
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)
7479
List(ctx context.Context, orgID *uuid.UUID, filters *APITokenListFilters) ([]*APIToken, error)
7580
Revoke(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error
7681
// FindInactive returns tokens in an organization that have been inactive since the given cutoff time.
@@ -143,9 +148,10 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig,
143148
}
144149

145150
type apiTokenOptions struct {
146-
project *Project
147-
showOnlySystemTokens bool
148-
policies []*authz.Policy
151+
project *Project
152+
workflow *Workflow
153+
policies []*authz.Policy
154+
isSystem bool
149155
}
150156

151157
type APITokenCreateOpt func(*apiTokenOptions)
@@ -156,12 +162,28 @@ func APITokenWithProject(project *Project) APITokenCreateOpt {
156162
}
157163
}
158164

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

179+
// APITokenAsSystem marks the token as system-managed (internal). System tokens
180+
// are hidden from the public API.
181+
func APITokenAsSystem() APITokenCreateOpt {
182+
return func(o *apiTokenOptions) {
183+
o.isSystem = true
184+
}
185+
}
186+
165187
// expires in is a string that can be parsed by time.ParseDuration
166188
func (uc *APITokenUseCase) Create(ctx context.Context, name string, description *string, expiresIn *time.Duration, orgID *string, opts ...APITokenCreateOpt) (*APIToken, error) {
167189
ctx, span := otelx.Start(ctx, apiTokenTracer, "APITokenUseCase.Create")
@@ -212,6 +234,17 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
212234
projectID = ToPtr(options.project.ID)
213235
}
214236

237+
var workflowID *uuid.UUID
238+
if options.workflow != nil {
239+
if options.project == nil {
240+
return nil, NewErrValidationStr("workflow scope requires a project scope")
241+
}
242+
if options.workflow.ProjectID != options.project.ID {
243+
return nil, NewErrValidationStr("workflow does not belong to the requested project")
244+
}
245+
workflowID = ToPtr(options.workflow.ID)
246+
}
247+
215248
// Use provided policies if present, otherwise use defaults
216249
policies := options.policies
217250
if policies == nil {
@@ -225,7 +258,7 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
225258

226259
// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
227260
// We store it since Chainloop will not have access to the JWT to check the expiration once created
228-
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies)
261+
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, workflowID, policies, options.isSystem)
229262
if err != nil {
230263
if IsErrAlreadyExists(err) {
231264
return nil, NewErrAlreadyExistsStr("name already taken")
@@ -252,6 +285,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
252285
generationOpts.ProjectName = ToPtr(options.project.Name)
253286
}
254287

288+
if options.workflow != nil {
289+
generationOpts.WorkflowID = ToPtr(options.workflow.ID)
290+
generationOpts.WorkflowName = ToPtr(options.workflow.Name)
291+
}
292+
255293
// generate the JWT
256294
token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts)
257295
if err != nil {
@@ -341,6 +379,14 @@ func WithAPITokenScope(scope APITokenScope) APITokenListOpt {
341379
}
342380
}
343381

382+
// WithIncludeSystemTokens opts the listing in to also return system-managed tokens.
383+
// By default, system tokens are hidden.
384+
func WithIncludeSystemTokens() APITokenListOpt {
385+
return func(opts *APITokenListFilters) {
386+
opts.IncludeSystem = true
387+
}
388+
}
389+
344390
type APITokenScope string
345391

346392
const (
@@ -376,6 +422,9 @@ type APITokenListFilters struct {
376422
StatusFilter APITokenStatusFilter
377423
// FilterByScope is used to filter the result by the scope of the token
378424
FilterByScope APITokenScope
425+
// IncludeSystem controls whether system-managed tokens are returned.
426+
// Defaults to false (system tokens are hidden).
427+
IncludeSystem bool
379428
}
380429

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

0 commit comments

Comments
 (0)