Skip to content

Commit e2dde78

Browse files
authored
fix(controlplane): enforce project RBAC on CAS download redirect lookups (PFM-6716) (#3400)
1 parent d2c4dd0 commit e2dde78

5 files changed

Lines changed: 293 additions & 7 deletions

File tree

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,7 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS
115115
return nil, handleUseCaseErr(err, s.log)
116116
}
117117

118-
// restrict the lookup to what the token can see, a nil result meaning no RBAC applies
119-
scopes := make(biz.RBACScopes)
120-
if visibleProjects := s.visibleProjects(ctx); visibleProjects != nil {
121-
scopes[orgID] = biz.RBACScope{ProjectIDs: visibleProjects}
122-
}
123-
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, scopes)
118+
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, s.rbacScopesForOrg(ctx, orgID))
124119
if err != nil && !biz.IsNotFound(err) {
125120
if biz.IsErrValidation(err) {
126121
return nil, errors.BadRequest("invalid", err.Error())

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown
8888
if err != nil {
8989
return nil, handleUseCaseErr(err, s.log)
9090
}
91-
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, nil)
91+
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, s.rbacScopesForOrg(ctx, orgID))
9292
}
9393

9494
if err != nil {
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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+
"crypto/ecdsa"
21+
"crypto/elliptic"
22+
"crypto/rand"
23+
"crypto/x509"
24+
"encoding/pem"
25+
"os"
26+
"path/filepath"
27+
"testing"
28+
29+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
30+
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
31+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext"
32+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
33+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
34+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers"
35+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities"
36+
"github.com/chainloop-dev/chainloop/pkg/blobmanager/oci"
37+
creds "github.com/chainloop-dev/chainloop/pkg/credentials/mocks"
38+
kerrors "github.com/go-kratos/kratos/v2/errors"
39+
"github.com/stretchr/testify/mock"
40+
"github.com/stretchr/testify/suite"
41+
)
42+
43+
const (
44+
digestProjectA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
45+
digestProjectB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
46+
digestOrgWide = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
47+
)
48+
49+
// Regression tests for PFM-6716: a project-scoped API token must not be able to resolve a CAS
50+
// mapping outside its project through any of the two download endpoints, GetDownloadURL and
51+
// CASCredentialsService.Get, which share the same mapping lookup.
52+
type casDownloadRBACIntegrationSuite struct {
53+
testhelpers.UseCasesEachTestSuite
54+
org *biz.Organization
55+
projectA, projectB *biz.Project
56+
backend *biz.CASBackend
57+
projectToken *biz.APIToken
58+
orgToken *biz.APIToken
59+
redirectSvc *CASRedirectService
60+
credentialsSvc *CASCredentialsService
61+
}
62+
63+
func (s *casDownloadRBACIntegrationSuite) SetupTest() {
64+
credsWriter := creds.NewReaderWriter(s.T())
65+
credsWriter.On("SaveCredentials", mock.Anything, mock.Anything, mock.Anything).Return("stored-OCI-secret", nil)
66+
67+
s.TestingUseCases = testhelpers.NewTestingUseCases(s.T(), testhelpers.WithCredsReaderWriter(credsWriter))
68+
69+
ctx := context.Background()
70+
var err error
71+
72+
s.org, err = s.Organization.Create(ctx, "cas-download-rbac-org")
73+
s.Require().NoError(err)
74+
75+
s.projectA, err = s.Project.Create(ctx, s.org.ID, "project-a")
76+
s.Require().NoError(err)
77+
s.projectB, err = s.Project.Create(ctx, s.org.ID, "project-b")
78+
s.Require().NoError(err)
79+
80+
s.backend, err = s.CASBackend.Create(ctx, s.org.ID, "rbac-backend", "my-location", "backend for RBAC tests", oci.ProviderID, nil, true, false, nil)
81+
s.Require().NoError(err)
82+
83+
// A token confined to project A and an org-wide token, both carrying the default
84+
// artifact-download policy.
85+
s.projectToken, err = s.APIToken.Create(ctx, "token-project-a", nil, nil, &s.org.ID, biz.APITokenWithProject(s.projectA))
86+
s.Require().NoError(err)
87+
s.orgToken, err = s.APIToken.Create(ctx, "token-org", nil, nil, &s.org.ID)
88+
s.Require().NoError(err)
89+
90+
// Artifacts scoped to each project plus one with no scope at all, reachable with
91+
// organization-wide access only.
92+
for digest, project := range map[string]*biz.Project{
93+
digestProjectA: s.projectA,
94+
digestProjectB: s.projectB,
95+
} {
96+
_, err = s.CASMapping.Create(ctx, digest, s.backend.ID.String(), &biz.CASMappingCreateOpts{ProjectID: &project.ID})
97+
s.Require().NoError(err)
98+
}
99+
100+
_, err = s.CASMapping.Create(ctx, digestOrgWide, s.backend.ID.String(), nil)
101+
s.Require().NoError(err)
102+
103+
// Services wired as in production, with a throwaway EC key for the CAS JWT builder.
104+
casUC, err := biz.NewCASCredentialsUseCase(&conf.Auth{CasRobotAccountPrivateKeyPath: s.writeECPrivateKey()})
105+
s.Require().NoError(err)
106+
107+
authzUC := biz.NewAuthzUseCase(&biz.AuthzUseCaseConfig{
108+
CasbinEnforcer: s.Enforcer,
109+
APITokenRepo: s.Repos.APITokenRepo,
110+
Logger: s.L,
111+
})
112+
113+
s.redirectSvc, err = NewCASRedirectService(s.CASMapping, casUC, &conf.Bootstrap_CASServer{DownloadUrl: "https://cas.example.test/download"})
114+
s.Require().NoError(err)
115+
116+
s.credentialsSvc = NewCASCredentialsService(casUC, s.CASMapping, s.CASBackend, authzUC)
117+
}
118+
119+
func (s *casDownloadRBACIntegrationSuite) TestGetDownloadURL() {
120+
s.Run("project-scoped token cannot download another project's artifact", func() {
121+
_, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.projectToken), &pb.GetDownloadURLRequest{Digest: digestProjectB})
122+
s.Require().Error(err)
123+
s.True(kerrors.IsNotFound(err), "expected not found, got %v", err)
124+
})
125+
126+
s.Run("project-scoped token cannot download an org-wide artifact", func() {
127+
_, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.projectToken), &pb.GetDownloadURLRequest{Digest: digestOrgWide})
128+
s.Require().Error(err)
129+
s.True(kerrors.IsNotFound(err), "expected not found, got %v", err)
130+
})
131+
132+
s.Run("project-scoped token can download its own project's artifact", func() {
133+
resp, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.projectToken), &pb.GetDownloadURLRequest{Digest: digestProjectA})
134+
s.Require().NoError(err)
135+
s.Contains(resp.GetResult().GetUrl(), digestProjectA)
136+
})
137+
138+
s.Run("org-scoped token still reaches the whole organization", func() {
139+
for _, digest := range []string{digestProjectA, digestProjectB, digestOrgWide} {
140+
resp, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.orgToken), &pb.GetDownloadURLRequest{Digest: digest})
141+
s.Require().NoError(err)
142+
s.Contains(resp.GetResult().GetUrl(), digest)
143+
}
144+
})
145+
}
146+
147+
func (s *casDownloadRBACIntegrationSuite) TestCASCredentialsGet() {
148+
s.Run("project-scoped token cannot get downloader credentials for another project's artifact", func() {
149+
_, err := s.credentialsSvc.Get(s.ctxForToken(s.projectToken), &pb.CASCredentialsServiceGetRequest{
150+
Digest: digestProjectB,
151+
Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER,
152+
})
153+
s.Require().Error(err)
154+
s.True(kerrors.IsForbidden(err), "expected forbidden, got %v", err)
155+
})
156+
157+
s.Run("project-scoped token cannot get downloader credentials for an org-wide artifact", func() {
158+
_, err := s.credentialsSvc.Get(s.ctxForToken(s.projectToken), &pb.CASCredentialsServiceGetRequest{
159+
Digest: digestOrgWide,
160+
Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER,
161+
})
162+
s.Require().Error(err)
163+
s.True(kerrors.IsForbidden(err), "expected forbidden, got %v", err)
164+
})
165+
166+
s.Run("project-scoped token can get downloader credentials for its own project's artifact", func() {
167+
resp, err := s.credentialsSvc.Get(s.ctxForToken(s.projectToken), &pb.CASCredentialsServiceGetRequest{
168+
Digest: digestProjectA,
169+
Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER,
170+
})
171+
s.Require().NoError(err)
172+
s.NotEmpty(resp.GetResult().GetToken())
173+
})
174+
175+
s.Run("org-scoped token still reaches the whole organization", func() {
176+
resp, err := s.credentialsSvc.Get(s.ctxForToken(s.orgToken), &pb.CASCredentialsServiceGetRequest{
177+
Digest: digestProjectB,
178+
Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER,
179+
})
180+
s.Require().NoError(err)
181+
s.NotEmpty(resp.GetResult().GetToken())
182+
})
183+
}
184+
185+
// ctxForToken builds a context equivalent to the one the authz middlewares produce for an
186+
// API-token authenticated call: current org, current API token and the api-token authz subject.
187+
func (s *casDownloadRBACIntegrationSuite) ctxForToken(token *biz.APIToken) context.Context {
188+
ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name})
189+
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
190+
ID: token.ID.String(),
191+
Name: token.Name,
192+
ProjectID: token.ProjectID,
193+
})
194+
195+
return usercontext.WithAuthzSubject(ctx, (&authz.SubjectAPIToken{ID: token.ID.String()}).String())
196+
}
197+
198+
func (s *casDownloadRBACIntegrationSuite) writeECPrivateKey() string {
199+
// The CAS JWT builder signs with ES512, which requires a P-521 curve.
200+
key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
201+
s.Require().NoError(err)
202+
203+
der, err := x509.MarshalECPrivateKey(key)
204+
s.Require().NoError(err)
205+
206+
path := filepath.Join(s.T().TempDir(), "cas-key.pem")
207+
s.Require().NoError(os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), 0o600))
208+
209+
return path
210+
}
211+
212+
func TestCASDownloadRBACIntegration(t *testing.T) {
213+
suite.Run(t, new(casDownloadRBACIntegrationSuite))
214+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,20 @@ func (s *service) visibleProjects(ctx context.Context) []uuid.UUID {
328328
return projects
329329
}
330330

331+
// rbacScopesForOrg returns the resource-level RBAC scopes of the current caller within the given
332+
// organization, ready to be passed to the use cases that honour them. When RBAC applies to the
333+
// caller the organization is present in the result, limited to the caller's visible projects;
334+
// when it does not (legacy roles, org-scoped API tokens) the organization is absent from the
335+
// result, meaning the whole organization is reachable.
336+
func (s *service) rbacScopesForOrg(ctx context.Context, orgID uuid.UUID) biz.RBACScopes {
337+
scopes := make(biz.RBACScopes)
338+
if visibleProjects := s.visibleProjects(ctx); visibleProjects != nil {
339+
scopes[orgID] = biz.RBACScope{ProjectIDs: visibleProjects}
340+
}
341+
342+
return scopes
343+
}
344+
331345
// checkPolicy Checks a policy against a user or a token
332346
func (s *service) checkPolicy(ctx context.Context, policy *authz.Policy) error {
333347
// Token case

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ import (
2121
"fmt"
2222
"testing"
2323

24+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext"
25+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2426
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
2527
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities"
2628
kerrors "github.com/go-kratos/kratos/v2/errors"
29+
"github.com/google/uuid"
2730
"github.com/stretchr/testify/assert"
2831
"github.com/stretchr/testify/require"
2932
"google.golang.org/grpc/codes"
@@ -257,3 +260,63 @@ func TestRequireCurrentUserOrAPIToken(t *testing.T) {
257260
})
258261
}
259262
}
263+
264+
func TestRBACScopesForOrg(t *testing.T) {
265+
t.Parallel()
266+
267+
orgID := uuid.New()
268+
projectID := uuid.New()
269+
270+
testCases := []struct {
271+
name string
272+
ctx context.Context
273+
want biz.RBACScopes
274+
}{
275+
{
276+
name: "project-scoped API token is limited to its project",
277+
ctx: entities.WithCurrentAPIToken(context.Background(), &entities.APIToken{ProjectID: &projectID}),
278+
want: biz.RBACScopes{orgID: biz.RBACScope{ProjectIDs: []uuid.UUID{projectID}}},
279+
},
280+
{
281+
name: "org-scoped API token has no RBAC filter",
282+
ctx: entities.WithCurrentAPIToken(context.Background(), &entities.APIToken{}),
283+
want: biz.RBACScopes{},
284+
},
285+
{
286+
name: "user with an RBAC-enabled role is limited to its memberships",
287+
ctx: usercontext.WithAuthzSubject(
288+
entities.WithMembership(context.Background(), &entities.Membership{
289+
Resources: []*entities.ResourceMembership{
290+
{ResourceType: authz.ResourceTypeProject, ResourceID: projectID, Role: authz.RoleProjectViewer},
291+
},
292+
}),
293+
string(authz.RoleOrgMember),
294+
),
295+
want: biz.RBACScopes{orgID: biz.RBACScope{ProjectIDs: []uuid.UUID{projectID}}},
296+
},
297+
{
298+
// the org key being present with an empty scope denies everything; it must not
299+
// be confused with the absent key that grants the whole organization
300+
name: "RBAC-enabled user with no project memberships reaches nothing",
301+
ctx: usercontext.WithAuthzSubject(
302+
entities.WithMembership(context.Background(), &entities.Membership{}),
303+
string(authz.RoleOrgMember),
304+
),
305+
want: biz.RBACScopes{orgID: biz.RBACScope{ProjectIDs: []uuid.UUID{}}},
306+
},
307+
{
308+
name: "user with a role without RBAC has no filter",
309+
ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleViewer)),
310+
want: biz.RBACScopes{},
311+
},
312+
}
313+
314+
for _, tc := range testCases {
315+
t.Run(tc.name, func(t *testing.T) {
316+
t.Parallel()
317+
318+
s := newService()
319+
assert.Equal(t, tc.want, s.rbacScopesForOrg(tc.ctx, orgID))
320+
})
321+
}
322+
}

0 commit comments

Comments
 (0)