Skip to content

Commit e2828c6

Browse files
authored
fix(controlplane): enforce project-scoped RBAC on AttestationService/GetContract (#3401)
1 parent e2dde78 commit e2828c6

6 files changed

Lines changed: 191 additions & 5 deletions

File tree

‎app/controlplane/internal/service/attestation.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ func (s *AttestationService) GetContract(ctx context.Context, req *cpAPI.Attesta
131131
return nil, handleUseCaseErr(err, s.log)
132132
}
133133

134+
// Apply RBAC on the project
135+
if _, err = s.userHasPermissionOnProject(ctx, robotAccount.OrgID, &cpAPI.IdentityReference{Name: &req.ProjectName}, authz.PolicyWorkflowRead); err != nil {
136+
return nil, err
137+
}
138+
134139
// Find contract revision
135140
contractVersion, err := s.workflowContractUseCase.Describe(ctx, wf.OrgID.String(), wf.ContractID.String(), int(req.ContractRevision), biz.WithoutReferences())
136141
if err != nil {
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package service
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
23+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext"
24+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/attjwtmiddleware"
25+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
26+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
27+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers"
28+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities"
29+
kerrors "github.com/go-kratos/kratos/v2/errors"
30+
"github.com/stretchr/testify/suite"
31+
)
32+
33+
// Regression tests for PFM-6717: a project-scoped API token must not be able to retrieve
34+
// another project's workflow metadata and contract schema through AttestationService/GetContract.
35+
// Attestation endpoints are skipped by the authz middleware, so the handler is the only
36+
// authorization point.
37+
type getContractRBACIntegrationSuite struct {
38+
testhelpers.UseCasesEachTestSuite
39+
org *biz.Organization
40+
projectA, projectB *biz.Project
41+
workflowA, workflowB *biz.Workflow
42+
projectToken *biz.APIToken
43+
workflowToken *biz.APIToken
44+
orgToken *biz.APIToken
45+
svc *AttestationService
46+
}
47+
48+
func (s *getContractRBACIntegrationSuite) SetupTest() {
49+
s.TestingUseCases = testhelpers.NewTestingUseCases(s.T())
50+
51+
ctx := context.Background()
52+
var err error
53+
54+
s.org, err = s.Organization.Create(ctx, "get-contract-rbac-org")
55+
s.Require().NoError(err)
56+
57+
s.projectA, err = s.Project.Create(ctx, s.org.ID, "project-a")
58+
s.Require().NoError(err)
59+
s.projectB, err = s.Project.Create(ctx, s.org.ID, "project-b")
60+
s.Require().NoError(err)
61+
62+
s.workflowA, err = s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "workflow-a", OrgID: s.org.ID, Project: "project-a"})
63+
s.Require().NoError(err)
64+
s.workflowB, err = s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "workflow-b", OrgID: s.org.ID, Project: "project-b"})
65+
s.Require().NoError(err)
66+
67+
// A token confined to project A, one pinned to workflow A, and an org-wide token.
68+
s.projectToken, err = s.APIToken.Create(ctx, "token-project-a", nil, nil, &s.org.ID, biz.APITokenWithProject(s.projectA))
69+
s.Require().NoError(err)
70+
s.workflowToken, err = s.APIToken.Create(ctx, "token-workflow-a", nil, nil, &s.org.ID, biz.APITokenWithProject(s.projectA), biz.APITokenWithWorkflow(s.workflowA))
71+
s.Require().NoError(err)
72+
s.orgToken, err = s.APIToken.Create(ctx, "token-org", nil, nil, &s.org.ID)
73+
s.Require().NoError(err)
74+
75+
authzUC := biz.NewAuthzUseCase(&biz.AuthzUseCaseConfig{
76+
CasbinEnforcer: s.Enforcer,
77+
APITokenRepo: s.Repos.APITokenRepo,
78+
Logger: s.L,
79+
})
80+
81+
s.svc = NewAttestationService(&NewAttestationServiceOpts{
82+
WorkflowRunUC: s.WorkflowRun,
83+
WorkflowUC: s.Workflow,
84+
WorkflowContractUC: s.WorkflowContract,
85+
OrgUC: s.Organization,
86+
ProjectUC: s.Project,
87+
ProjectVersionUC: s.ProjectVersion,
88+
Opts: []NewOpt{WithProjectUseCase(s.Project), WithEnforcer(authzUC)},
89+
})
90+
}
91+
92+
func (s *getContractRBACIntegrationSuite) TestProjectScopedToken() {
93+
s.Run("cannot read another project's workflow and contract", func() {
94+
_, err := s.svc.GetContract(s.ctxForToken(s.projectToken), &pb.AttestationServiceGetContractRequest{
95+
ProjectName: s.projectB.Name,
96+
WorkflowName: s.workflowB.Name,
97+
})
98+
s.Require().Error(err)
99+
s.True(kerrors.IsForbidden(err), "expected forbidden, got %v", err)
100+
})
101+
102+
s.Run("can read its own project's workflow and contract", func() {
103+
resp, err := s.svc.GetContract(s.ctxForToken(s.projectToken), &pb.AttestationServiceGetContractRequest{
104+
ProjectName: s.projectA.Name,
105+
WorkflowName: s.workflowA.Name,
106+
})
107+
s.Require().NoError(err)
108+
s.Equal(s.workflowA.Name, resp.GetResult().GetWorkflow().GetName())
109+
s.NotNil(resp.GetResult().GetContract())
110+
s.Equal(s.projectA.Name, resp.GetResult().GetWorkflow().GetProject())
111+
})
112+
}
113+
114+
func (s *getContractRBACIntegrationSuite) TestWorkflowScopedToken() {
115+
// Workflow-scoped tokens are already confined by findWorkflowFromTokenOrNameOrRunID;
116+
// this guards against regressions on top of the project-level check.
117+
s.Run("cannot read another project's workflow and contract", func() {
118+
_, err := s.svc.GetContract(s.ctxForToken(s.workflowToken), &pb.AttestationServiceGetContractRequest{
119+
ProjectName: s.projectB.Name,
120+
WorkflowName: s.workflowB.Name,
121+
})
122+
s.Require().Error(err)
123+
s.True(kerrors.IsForbidden(err), "expected forbidden, got %v", err)
124+
})
125+
126+
s.Run("can read its own workflow and contract", func() {
127+
resp, err := s.svc.GetContract(s.ctxForToken(s.workflowToken), &pb.AttestationServiceGetContractRequest{
128+
ProjectName: s.projectA.Name,
129+
WorkflowName: s.workflowA.Name,
130+
})
131+
s.Require().NoError(err)
132+
s.Equal(s.workflowA.Name, resp.GetResult().GetWorkflow().GetName())
133+
s.NotNil(resp.GetResult().GetContract())
134+
})
135+
}
136+
137+
func (s *getContractRBACIntegrationSuite) TestOrgScopedToken() {
138+
// Backward compatibility: org-wide tokens keep reaching every workflow in the organization.
139+
for _, wf := range []*biz.Workflow{s.workflowA, s.workflowB} {
140+
resp, err := s.svc.GetContract(s.ctxForToken(s.orgToken), &pb.AttestationServiceGetContractRequest{
141+
ProjectName: wf.Project,
142+
WorkflowName: wf.Name,
143+
})
144+
s.Require().NoError(err)
145+
s.Equal(wf.Name, resp.GetResult().GetWorkflow().GetName())
146+
s.NotNil(resp.GetResult().GetContract())
147+
}
148+
149+
// The workflow lookup itself requires an explicit project name, for every caller.
150+
_, err := s.svc.GetContract(s.ctxForToken(s.orgToken), &pb.AttestationServiceGetContractRequest{
151+
WorkflowName: s.workflowA.Name,
152+
})
153+
s.Require().Error(err)
154+
s.True(kerrors.IsBadRequest(err), "expected bad request, got %v", err)
155+
}
156+
157+
// ctxForToken builds a context equivalent to the one the attestation middlewares produce for
158+
// an API-token authenticated call: current org, current API token, the api-token authz
159+
// subject and the robot account the attestation handlers read the organization from.
160+
func (s *getContractRBACIntegrationSuite) ctxForToken(token *biz.APIToken) context.Context {
161+
ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name})
162+
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
163+
ID: token.ID.String(),
164+
Name: token.Name,
165+
ProjectID: token.ProjectID,
166+
ProjectName: token.ProjectName,
167+
WorkflowID: token.WorkflowID,
168+
WorkflowName: token.WorkflowName,
169+
})
170+
ctx = usercontext.WithAuthzSubject(ctx, (&authz.SubjectAPIToken{ID: token.ID.String()}).String())
171+
172+
return usercontext.WithRobotAccount(ctx, &usercontext.RobotAccount{
173+
OrgID: s.org.ID,
174+
ProviderKey: attjwtmiddleware.APITokenProviderKey,
175+
})
176+
}
177+
178+
func TestGetContractRBACIntegration(t *testing.T) {
179+
suite.Run(t, new(getContractRBACIntegrationSuite))
180+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUs
165165
return nil, errors.New("API token revoked")
166166
}
167167

168-
ctx = withRobotAccount(ctx, &RobotAccount{OrgID: token.OrganizationID.String(), ProviderKey: attjwtmiddleware.APITokenProviderKey})
168+
ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: token.OrganizationID.String(), ProviderKey: attjwtmiddleware.APITokenProviderKey})
169169

170170
return ctx, nil
171171
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func WithAttestationContextFromUser(userUC *biz.UserUseCase, orgUC *biz.Organiza
139139
return nil, fmt.Errorf("your user doesn't have permissions to perform attestations in this organization, role=%s, orgID=%s", subject, org.ID)
140140
}
141141

142-
ctx = withRobotAccount(ctx, &RobotAccount{OrgID: org.ID, ProviderKey: attjwtmiddleware.UserTokenProviderKey})
142+
ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: org.ID, ProviderKey: attjwtmiddleware.UserTokenProviderKey})
143143
logger.Infow("msg", "[authN] processed credentials", "type", attjwtmiddleware.UserTokenProviderKey)
144144

145145
return handler(ctx, req)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func WithAttestationContextFromFederatedInfo(orgUC *biz.OrganizationUseCase, log
5151

5252
orgID := (*claims)["orgId"].(string)
5353

54-
ctx = withRobotAccount(ctx, &RobotAccount{OrgID: orgID, ProviderKey: attjwtmiddleware.FederatedProviderKey})
54+
ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: orgID, ProviderKey: attjwtmiddleware.FederatedProviderKey})
5555
// Find the associated organization
5656
org, err := orgUC.FindByID(ctx, orgID)
5757
if err != nil {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ type RobotAccount struct {
3232
ID, WorkflowID, OrgID, ProviderKey string
3333
}
3434

35-
func withRobotAccount(ctx context.Context, acc *RobotAccount) context.Context {
35+
// Set the current robot account in the context
36+
func WithRobotAccount(ctx context.Context, acc *RobotAccount) context.Context {
3637
return context.WithValue(ctx, currentRobotAccountCtxKey{}, acc)
3738
}
3839

@@ -121,7 +122,7 @@ func WithAttestationContextFromRobotAccount(robotAccountUseCase *biz.RobotAccoun
121122
}
122123

123124
// Set the robot account in the context
124-
ctx = withRobotAccount(ctx, &RobotAccount{
125+
ctx = WithRobotAccount(ctx, &RobotAccount{
125126
ID: account.ID.String(), WorkflowID: workflowID, OrgID: orgID, ProviderKey: authInfo.ProviderKey,
126127
})
127128

0 commit comments

Comments
 (0)