diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index b354590c2..d4723d637 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -115,12 +115,7 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS return nil, handleUseCaseErr(err, s.log) } - // restrict the lookup to what the token can see, a nil result meaning no RBAC applies - scopes := make(biz.RBACScopes) - if visibleProjects := s.visibleProjects(ctx); visibleProjects != nil { - scopes[orgID] = biz.RBACScope{ProjectIDs: visibleProjects} - } - mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, scopes) + mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, s.rbacScopesForOrg(ctx, orgID)) if err != nil && !biz.IsNotFound(err) { if biz.IsErrValidation(err) { return nil, errors.BadRequest("invalid", err.Error()) diff --git a/app/controlplane/internal/service/casredirect.go b/app/controlplane/internal/service/casredirect.go index 9f0dc136c..8f7a02d76 100644 --- a/app/controlplane/internal/service/casredirect.go +++ b/app/controlplane/internal/service/casredirect.go @@ -88,7 +88,7 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown if err != nil { return nil, handleUseCaseErr(err, s.log) } - mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, nil) + mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, s.rbacScopesForOrg(ctx, orgID)) } if err != nil { diff --git a/app/controlplane/internal/service/casredirect_integration_test.go b/app/controlplane/internal/service/casredirect_integration_test.go new file mode 100644 index 000000000..7beafd5c4 --- /dev/null +++ b/app/controlplane/internal/service/casredirect_integration_test.go @@ -0,0 +1,214 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" + creds "github.com/chainloop-dev/chainloop/pkg/credentials/mocks" + kerrors "github.com/go-kratos/kratos/v2/errors" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +const ( + digestProjectA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + digestProjectB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + digestOrgWide = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +) + +// Regression tests for PFM-6716: a project-scoped API token must not be able to resolve a CAS +// mapping outside its project through any of the two download endpoints, GetDownloadURL and +// CASCredentialsService.Get, which share the same mapping lookup. +type casDownloadRBACIntegrationSuite struct { + testhelpers.UseCasesEachTestSuite + org *biz.Organization + projectA, projectB *biz.Project + backend *biz.CASBackend + projectToken *biz.APIToken + orgToken *biz.APIToken + redirectSvc *CASRedirectService + credentialsSvc *CASCredentialsService +} + +func (s *casDownloadRBACIntegrationSuite) SetupTest() { + credsWriter := creds.NewReaderWriter(s.T()) + credsWriter.On("SaveCredentials", mock.Anything, mock.Anything, mock.Anything).Return("stored-OCI-secret", nil) + + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T(), testhelpers.WithCredsReaderWriter(credsWriter)) + + ctx := context.Background() + var err error + + s.org, err = s.Organization.Create(ctx, "cas-download-rbac-org") + s.Require().NoError(err) + + s.projectA, err = s.Project.Create(ctx, s.org.ID, "project-a") + s.Require().NoError(err) + s.projectB, err = s.Project.Create(ctx, s.org.ID, "project-b") + s.Require().NoError(err) + + s.backend, err = s.CASBackend.Create(ctx, s.org.ID, "rbac-backend", "my-location", "backend for RBAC tests", oci.ProviderID, nil, true, false, nil) + s.Require().NoError(err) + + // A token confined to project A and an org-wide token, both carrying the default + // artifact-download policy. + s.projectToken, err = s.APIToken.Create(ctx, "token-project-a", nil, nil, &s.org.ID, biz.APITokenWithProject(s.projectA)) + s.Require().NoError(err) + s.orgToken, err = s.APIToken.Create(ctx, "token-org", nil, nil, &s.org.ID) + s.Require().NoError(err) + + // Artifacts scoped to each project plus one with no scope at all, reachable with + // organization-wide access only. + for digest, project := range map[string]*biz.Project{ + digestProjectA: s.projectA, + digestProjectB: s.projectB, + } { + _, err = s.CASMapping.Create(ctx, digest, s.backend.ID.String(), &biz.CASMappingCreateOpts{ProjectID: &project.ID}) + s.Require().NoError(err) + } + + _, err = s.CASMapping.Create(ctx, digestOrgWide, s.backend.ID.String(), nil) + s.Require().NoError(err) + + // Services wired as in production, with a throwaway EC key for the CAS JWT builder. + casUC, err := biz.NewCASCredentialsUseCase(&conf.Auth{CasRobotAccountPrivateKeyPath: s.writeECPrivateKey()}) + s.Require().NoError(err) + + authzUC := biz.NewAuthzUseCase(&biz.AuthzUseCaseConfig{ + CasbinEnforcer: s.Enforcer, + APITokenRepo: s.Repos.APITokenRepo, + Logger: s.L, + }) + + s.redirectSvc, err = NewCASRedirectService(s.CASMapping, casUC, &conf.Bootstrap_CASServer{DownloadUrl: "https://cas.example.test/download"}) + s.Require().NoError(err) + + s.credentialsSvc = NewCASCredentialsService(casUC, s.CASMapping, s.CASBackend, authzUC) +} + +func (s *casDownloadRBACIntegrationSuite) TestGetDownloadURL() { + s.Run("project-scoped token cannot download another project's artifact", func() { + _, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.projectToken), &pb.GetDownloadURLRequest{Digest: digestProjectB}) + s.Require().Error(err) + s.True(kerrors.IsNotFound(err), "expected not found, got %v", err) + }) + + s.Run("project-scoped token cannot download an org-wide artifact", func() { + _, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.projectToken), &pb.GetDownloadURLRequest{Digest: digestOrgWide}) + s.Require().Error(err) + s.True(kerrors.IsNotFound(err), "expected not found, got %v", err) + }) + + s.Run("project-scoped token can download its own project's artifact", func() { + resp, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.projectToken), &pb.GetDownloadURLRequest{Digest: digestProjectA}) + s.Require().NoError(err) + s.Contains(resp.GetResult().GetUrl(), digestProjectA) + }) + + s.Run("org-scoped token still reaches the whole organization", func() { + for _, digest := range []string{digestProjectA, digestProjectB, digestOrgWide} { + resp, err := s.redirectSvc.GetDownloadURL(s.ctxForToken(s.orgToken), &pb.GetDownloadURLRequest{Digest: digest}) + s.Require().NoError(err) + s.Contains(resp.GetResult().GetUrl(), digest) + } + }) +} + +func (s *casDownloadRBACIntegrationSuite) TestCASCredentialsGet() { + s.Run("project-scoped token cannot get downloader credentials for another project's artifact", func() { + _, err := s.credentialsSvc.Get(s.ctxForToken(s.projectToken), &pb.CASCredentialsServiceGetRequest{ + Digest: digestProjectB, + Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER, + }) + s.Require().Error(err) + s.True(kerrors.IsForbidden(err), "expected forbidden, got %v", err) + }) + + s.Run("project-scoped token cannot get downloader credentials for an org-wide artifact", func() { + _, err := s.credentialsSvc.Get(s.ctxForToken(s.projectToken), &pb.CASCredentialsServiceGetRequest{ + Digest: digestOrgWide, + Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER, + }) + s.Require().Error(err) + s.True(kerrors.IsForbidden(err), "expected forbidden, got %v", err) + }) + + s.Run("project-scoped token can get downloader credentials for its own project's artifact", func() { + resp, err := s.credentialsSvc.Get(s.ctxForToken(s.projectToken), &pb.CASCredentialsServiceGetRequest{ + Digest: digestProjectA, + Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER, + }) + s.Require().NoError(err) + s.NotEmpty(resp.GetResult().GetToken()) + }) + + s.Run("org-scoped token still reaches the whole organization", func() { + resp, err := s.credentialsSvc.Get(s.ctxForToken(s.orgToken), &pb.CASCredentialsServiceGetRequest{ + Digest: digestProjectB, + Role: pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER, + }) + s.Require().NoError(err) + s.NotEmpty(resp.GetResult().GetToken()) + }) +} + +// ctxForToken builds a context equivalent to the one the authz middlewares produce for an +// API-token authenticated call: current org, current API token and the api-token authz subject. +func (s *casDownloadRBACIntegrationSuite) ctxForToken(token *biz.APIToken) context.Context { + ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name}) + ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ + ID: token.ID.String(), + Name: token.Name, + ProjectID: token.ProjectID, + }) + + return usercontext.WithAuthzSubject(ctx, (&authz.SubjectAPIToken{ID: token.ID.String()}).String()) +} + +func (s *casDownloadRBACIntegrationSuite) writeECPrivateKey() string { + // The CAS JWT builder signs with ES512, which requires a P-521 curve. + key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + s.Require().NoError(err) + + der, err := x509.MarshalECPrivateKey(key) + s.Require().NoError(err) + + path := filepath.Join(s.T().TempDir(), "cas-key.pem") + s.Require().NoError(os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), 0o600)) + + return path +} + +func TestCASDownloadRBACIntegration(t *testing.T) { + suite.Run(t, new(casDownloadRBACIntegrationSuite)) +} diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index 3631f27c2..9e72f1319 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -328,6 +328,20 @@ func (s *service) visibleProjects(ctx context.Context) []uuid.UUID { return projects } +// rbacScopesForOrg returns the resource-level RBAC scopes of the current caller within the given +// organization, ready to be passed to the use cases that honour them. When RBAC applies to the +// caller the organization is present in the result, limited to the caller's visible projects; +// when it does not (legacy roles, org-scoped API tokens) the organization is absent from the +// result, meaning the whole organization is reachable. +func (s *service) rbacScopesForOrg(ctx context.Context, orgID uuid.UUID) biz.RBACScopes { + scopes := make(biz.RBACScopes) + if visibleProjects := s.visibleProjects(ctx); visibleProjects != nil { + scopes[orgID] = biz.RBACScope{ProjectIDs: visibleProjects} + } + + return scopes +} + // checkPolicy Checks a policy against a user or a token func (s *service) checkPolicy(ctx context.Context, policy *authz.Policy) error { // Token case diff --git a/app/controlplane/internal/service/service_test.go b/app/controlplane/internal/service/service_test.go index d0ca08689..7c4a73087 100644 --- a/app/controlplane/internal/service/service_test.go +++ b/app/controlplane/internal/service/service_test.go @@ -21,9 +21,12 @@ import ( "fmt" "testing" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" kerrors "github.com/go-kratos/kratos/v2/errors" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -257,3 +260,63 @@ func TestRequireCurrentUserOrAPIToken(t *testing.T) { }) } } + +func TestRBACScopesForOrg(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + projectID := uuid.New() + + testCases := []struct { + name string + ctx context.Context + want biz.RBACScopes + }{ + { + name: "project-scoped API token is limited to its project", + ctx: entities.WithCurrentAPIToken(context.Background(), &entities.APIToken{ProjectID: &projectID}), + want: biz.RBACScopes{orgID: biz.RBACScope{ProjectIDs: []uuid.UUID{projectID}}}, + }, + { + name: "org-scoped API token has no RBAC filter", + ctx: entities.WithCurrentAPIToken(context.Background(), &entities.APIToken{}), + want: biz.RBACScopes{}, + }, + { + name: "user with an RBAC-enabled role is limited to its memberships", + ctx: usercontext.WithAuthzSubject( + entities.WithMembership(context.Background(), &entities.Membership{ + Resources: []*entities.ResourceMembership{ + {ResourceType: authz.ResourceTypeProject, ResourceID: projectID, Role: authz.RoleProjectViewer}, + }, + }), + string(authz.RoleOrgMember), + ), + want: biz.RBACScopes{orgID: biz.RBACScope{ProjectIDs: []uuid.UUID{projectID}}}, + }, + { + // the org key being present with an empty scope denies everything; it must not + // be confused with the absent key that grants the whole organization + name: "RBAC-enabled user with no project memberships reaches nothing", + ctx: usercontext.WithAuthzSubject( + entities.WithMembership(context.Background(), &entities.Membership{}), + string(authz.RoleOrgMember), + ), + want: biz.RBACScopes{orgID: biz.RBACScope{ProjectIDs: []uuid.UUID{}}}, + }, + { + name: "user with a role without RBAC has no filter", + ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleViewer)), + want: biz.RBACScopes{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := newService() + assert.Equal(t, tc.want, s.rbacScopesForOrg(tc.ctx, orgID)) + }) + } +}