Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controlplane/cmd/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions app/controlplane/internal/service/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,11 @@ func (s *AttestationService) storeAttestation(ctx context.Context, envelope []by
}

for _, ref := range references {
s.log.Infow("msg", "creating CAS mapping", "name", ref.Name, "digest", ref.Digest, "workflowRun", workflowRunID, "casBackend", casBackend.ID.String())
if _, err := s.casMappingUseCase.Create(ctx, ref.Digest, casBackend.ID.String(), workflowRunID); err != nil {
s.log.Infow("msg", "creating CAS mapping", "name", ref.Name, "digest", ref.Digest, "project", wf.ProjectID.String(), "workflowRun", workflowRunID, "casBackend", casBackend.ID.String())
if _, err := s.casMappingUseCase.Create(ctx, ref.Digest, casBackend.ID.String(), &biz.CASMappingCreateOpts{
WorkflowRunID: &wfRun.ID,
ProjectID: &wf.ProjectID,
}); err != nil {
return nil, handleUseCaseErr(err, s.log)
}
}
Expand Down
33 changes: 25 additions & 8 deletions app/controlplane/internal/service/cascredential.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
errors "github.com/go-kratos/kratos/v2/errors"
"github.com/google/uuid"

"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
Expand Down Expand Up @@ -83,23 +84,24 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS
return nil, errors.Forbidden("forbidden", "not allowed to perform this operation")
}

// Load the default CAS backend, we'll use it for uploads and as fallback on downloads
backend, err := s.casBackendUC.FindDefaultBackend(ctx, currentOrg.ID)
if err != nil && !biz.IsNotFound(err) {
return nil, handleUseCaseErr(err, s.log)
} else if backend == nil {
return nil, errors.NotFound("not found", "main CAS backend not found")
}
var backend *biz.CASBackend

// Try to find the proper backend where the artifact is stored
if role == casJWT.Downloader {
var mapping *biz.CASMapping

// If we are logged in as a user, we'll try to find a mapping for that user
if currentUser != nil {
mapping, err = s.casMappingUC.FindCASMappingForDownloadByUser(ctx, req.Digest, currentUser.ID)
// otherwise, we'll try to find a mapping for the current API token associated orgs
} else if currentAPIToken != nil {
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []string{currentOrg.ID})
var orgID uuid.UUID
orgID, err = uuid.Parse(currentOrg.ID)
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
// TODO: pass projectIDs when RBAC for API tokens is supported
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, nil)
}

// If we can't find a mapping, we'll use the default backend
Expand All @@ -116,6 +118,21 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS
}
}

// If the backend was not found, use the default backend for the current org, only if the user is admin and RBAC doesn't apply.
if backend == nil {
if currentAuthzSubject == string(authz.RoleAdmin) || currentAuthzSubject == string(authz.RoleOwner) {
// Load the default CAS backend, we'll use it for uploads and as fallback on downloads
backend, err = s.casBackendUC.FindDefaultBackend(ctx, currentOrg.ID)
if err != nil && !biz.IsNotFound(err) {
return nil, handleUseCaseErr(err, s.log)
} else if backend == nil {
return nil, errors.NotFound("not found", "main CAS backend not found")
}
} else {
return nil, errors.Forbidden("forbidden", "operation not allowed")
}
}

// inline backends don't have a download URL
if backend.Inline {
return nil, errors.BadRequest("invalid argument", "cannot upload or download artifacts from an inline CAS backend")
Expand Down
8 changes: 7 additions & 1 deletion app/controlplane/internal/service/casredirect.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
kerrors "github.com/go-kratos/kratos/v2/errors"
khttp "github.com/go-kratos/kratos/v2/transport/http"
"github.com/google/uuid"
)

const (
Expand Down Expand Up @@ -82,7 +83,12 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown
if currentUser != nil {
mapping, err = s.casMappingUC.FindCASMappingForDownloadByUser(ctx, req.Digest, currentUser.ID)
} else if currentAPIToken != nil {
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []string{currentOrg.ID})
var orgID uuid.UUID
orgID, err = uuid.Parse(currentOrg.ID)
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
mapping, err = s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, req.Digest, []uuid.UUID{orgID}, nil)
}

if err != nil {
Expand Down
1 change: 0 additions & 1 deletion app/controlplane/pkg/authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ var rolesMap = map[Role][]*Policy{
PolicyWorkflowRunRead,

PolicyArtifactDownload,
PolicyArtifactUpload,

PolicyCASBackendList,

Expand Down
91 changes: 59 additions & 32 deletions app/controlplane/pkg/biz/casmapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ package biz
import (
"context"
"fmt"
"slices"
"time"

"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
"github.com/chainloop-dev/chainloop/pkg/servicelogger"
"github.com/go-kratos/kratos/v2/log"
Expand All @@ -34,59 +36,62 @@ type CASMapping struct {
Digest string
CreatedAt *time.Time
// A public mapping means that the material/attestation can be downloaded by anyone
Public bool
Public bool
ProjectID uuid.UUID
}

type CASMappingFindOptions struct {
Orgs []uuid.UUID
ProjectIDs []uuid.UUID
}

type CASMappingRepo interface {
// Create a mapping with an optional workflow run id
Create(ctx context.Context, digest string, casBackendID uuid.UUID, workflowRunID *uuid.UUID) (*CASMapping, error)
Create(ctx context.Context, digest string, casBackendID uuid.UUID, opts *CASMappingCreateOpts) (*CASMapping, error)
// List all the CAS mappings for the given digest
FindByDigest(ctx context.Context, digest string) ([]*CASMapping, error)
}

type CASMappingUseCase struct {
repo CASMappingRepo
membershipRepo MembershipRepo
projectsRepo ProjectsRepo
logger *log.Helper
}

func NewCASMappingUseCase(repo CASMappingRepo, mRepo MembershipRepo, logger log.Logger) *CASMappingUseCase {
return &CASMappingUseCase{repo, mRepo, servicelogger.ScopedHelper(logger, "cas-mapping-usecase")}
func NewCASMappingUseCase(repo CASMappingRepo, mRepo MembershipRepo, pRepo ProjectsRepo, logger log.Logger) *CASMappingUseCase {
return &CASMappingUseCase{repo, mRepo, pRepo, servicelogger.ScopedHelper(logger, "cas-mapping-usecase")}
}

type CASMappingCreateOpts struct {
WorkflowRunID *uuid.UUID
ProjectID *uuid.UUID
}

// Create a mapping with an optional workflow run id
func (uc *CASMappingUseCase) Create(ctx context.Context, digest string, casBackendID string, workflowRunID string) (*CASMapping, error) {
func (uc *CASMappingUseCase) Create(ctx context.Context, digest string, casBackendID string, opts *CASMappingCreateOpts) (*CASMapping, error) {
casBackendUUID, err := uuid.Parse(casBackendID)
if err != nil {
return nil, NewErrInvalidUUID(err)
}

var workflowRunUUID *uuid.UUID
if workflowRunID != "" {
runUUID, err := uuid.Parse(workflowRunID)
if err != nil {
return nil, NewErrInvalidUUID(err)
}
workflowRunUUID = &runUUID
}

// parse the digest to make sure is a valid sha256 sum
if _, err = cr_v1.NewHash(digest); err != nil {
return nil, NewErrValidation(fmt.Errorf("invalid digest format: %w", err))
}

return uc.repo.Create(ctx, digest, casBackendUUID, workflowRunUUID)
return uc.repo.Create(ctx, digest, casBackendUUID, opts)
}

func (uc *CASMappingUseCase) FindByDigest(ctx context.Context, digest string) ([]*CASMapping, error) {
return uc.repo.FindByDigest(ctx, digest)
}

// FindCASMappingForDownloadByUser returns the CASMapping appropriate for the given digest and user
// This means, in order
// 1 - Any mapping that points to an organization which the user is member of
// 1.1 If there are multiple mappings, it will pick the default one or the first one
// 2 - Any mapping that is public
// FindCASMappingForDownloadByUser returns the CASMapping appropriate for the given digest and user.
// This means, in order:
// 1 - Any mapping that points to an organization which the user is member of.
// 1.1 If there are multiple mappings, it will pick the default one or the first one.
// 2 - Any mapping that is public.
func (uc *CASMappingUseCase) FindCASMappingForDownloadByUser(ctx context.Context, digest string, userID string) (*CASMapping, error) {
uc.logger.Infow("msg", "finding cas mapping for download", "digest", digest, "user", userID)

Expand All @@ -95,26 +100,42 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByUser(ctx context.Context
return nil, NewErrInvalidUUID(err)
}

// Load organizations for the given user
memberships, err := uc.membershipRepo.FindByUser(ctx, userUUID)
// Load ALL memberships for the given user
memberships, err := uc.membershipRepo.ListAllByUser(ctx, userUUID)
if err != nil {
return nil, fmt.Errorf("failed to list memberships: %w", err)
}

userOrgs := make([]string, 0, len(memberships))
userOrgs := make([]uuid.UUID, 0)
// This map holds the list of project IDs by org with RBAC active (user is org "member")
projectIDs := make(map[uuid.UUID][]uuid.UUID)
for _, m := range memberships {
userOrgs = append(userOrgs, m.OrganizationID.String())
if m.ResourceType == authz.ResourceTypeOrganization {
userOrgs = append(userOrgs, m.ResourceID)
// If the role in the org is member, we must enable RBAC for projects.
if m.Role == authz.RoleOrgMember {
// get list of projects in org, and match it with the memberships to build a filter
orgProjects, err := getProjectsWithMembership(ctx, uc.projectsRepo, m.ResourceID, memberships)
if err != nil {
return nil, err
}
// note that appending an empty slice to a nil slice doesn't change it (it's still nil)
projectIDs[m.ResourceID] = orgProjects
}
}
}

mapping, err := uc.FindCASMappingForDownloadByOrg(ctx, digest, userOrgs)
mapping, err := uc.FindCASMappingForDownloadByOrg(ctx, digest, userOrgs, projectIDs)
if err != nil {
return nil, fmt.Errorf("failed to find cas mapping for download: %w", err)
}

return mapping, nil
}

func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context, digest string, orgs []string) (result *CASMapping, err error) {
// FindCASMappingForDownloadByOrg looks for the CAS mapping to download the referenced artifact in one of the passed organizations.
// The result will get filtered out if RBAC is enabled (projectIDs is not Nil)
func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context, digest string, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) (result *CASMapping, err error) {
if _, err := cr_v1.NewHash(digest); err != nil {
return nil, NewErrValidation(fmt.Errorf("invalid digest format: %w", err))
}
Expand Down Expand Up @@ -143,8 +164,8 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context,
return nil, NewErrNotFound("digest not found in any mapping")
}

// 2 - CAS mappings associated with the given list of orgs
orgMappings, err := filterByOrgs(mappings, orgs)
// 2 - CAS mappings associated with the given list of orgs and project IDs
orgMappings, err := filterByOrgs(mappings, orgs, projectIDs)
if err != nil {
return nil, fmt.Errorf("failed to load mappings associated to an user: %w", err)
} else if len(orgMappings) > 0 {
Expand All @@ -163,14 +184,20 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByOrg(ctx context.Context,
return defaultOrFirst(publicMappings), nil
}

// Extract only the mappings associated with a list of orgs
func filterByOrgs(mappings []*CASMapping, orgs []string) ([]*CASMapping, error) {
// Extract only the mappings associated with a list of orgs and optionally a list of projects
func filterByOrgs(mappings []*CASMapping, orgs []uuid.UUID, projectIDs map[uuid.UUID][]uuid.UUID) ([]*CASMapping, error) {
result := make([]*CASMapping, 0)

for _, mapping := range mappings {
for _, o := range orgs {
if mapping.OrgID.String() == o {
result = append(result, mapping)
if mapping.OrgID == o {
if visibleProjects, ok := projectIDs[mapping.OrgID]; ok {
if slices.Contains(visibleProjects, mapping.ProjectID) {
result = append(result, mapping)
}
} else {
result = append(result, mapping)
}
}
}
}
Expand Down
41 changes: 18 additions & 23 deletions app/controlplane/pkg/biz/casmapping_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ func (s *casMappingIntegrationSuite) TestCASMappingForDownloadUser() {
// 3. Digest: validDigest2, CASBackend: casBackend2, WorkflowRunID: workflowRun
// 4. Digest: validDigest3, CASBackend: casBackend3, WorkflowRunID: workflowRun
// 4. Digest: validDigestPublic, CASBackend: casBackend3, WorkflowRunID: workflowRunPublic
_, err := s.CASMapping.Create(context.TODO(), validDigest, s.casBackend1.ID.String(), s.workflowRun.ID.String())
_, err := s.CASMapping.Create(context.TODO(), validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend2.ID.String(), s.workflowRun.ID.String())
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend2.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(context.TODO(), validDigest2, s.casBackend2.ID.String(), s.workflowRun.ID.String())
_, err = s.CASMapping.Create(context.TODO(), validDigest2, s.casBackend2.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(context.TODO(), validDigest3, s.casBackend3.ID.String(), s.workflowRun.ID.String())
_, err = s.CASMapping.Create(context.TODO(), validDigest3, s.casBackend3.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(context.TODO(), validDigestPublic, s.casBackend3.ID.String(), s.publicWorkflowRun.ID.String())
_, err = s.CASMapping.Create(context.TODO(), validDigestPublic, s.casBackend3.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID})
require.NoError(s.T(), err)

// Since the userOrg1And2 is member of org1 and org2, she should be able to download
Expand Down Expand Up @@ -118,41 +118,41 @@ func (s *casMappingIntegrationSuite) TestCASMappingForDownloadUser() {

func (s *casMappingIntegrationSuite) TestCASMappingForDownloadByOrg() {
ctx := context.Background()
_, err := s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), s.workflowRun.ID.String())
_, err := s.CASMapping.Create(ctx, validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(ctx, validDigestPublic, s.casBackend3.ID.String(), s.publicWorkflowRun.ID.String())
_, err = s.CASMapping.Create(ctx, validDigestPublic, s.casBackend3.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(ctx, validDigestWithoutRun, s.casBackend3.ID.String(), "")
_, err = s.CASMapping.Create(ctx, validDigestWithoutRun, s.casBackend3.ID.String(), nil)
require.NoError(s.T(), err)

// both validDigest and validDigest2 from two different orgs
s.Run("validDigest is in org1", func() {
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []string{s.org1.ID})
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigest, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil)
s.NoError(err)
s.NotNil(mapping)
s.Equal(s.casBackend1.ID, mapping.CASBackend.ID)
})

s.Run("validDigestPublic is available from any org", func() {
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestPublic, []string{uuid.NewString()})
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestPublic, []uuid.UUID{uuid.New()}, nil)
s.NoError(err)
s.NotNil(mapping)
s.Equal(s.casBackend3.ID, mapping.CASBackend.ID)
})

s.Run("validDigestWithoutRun is available only to org 3", func() {
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestWithoutRun, []string{s.casBackend3.OrganizationID.String()})
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestWithoutRun, []uuid.UUID{s.casBackend3.OrganizationID}, nil)
s.NoError(err)
s.NotNil(mapping)
s.Equal(s.casBackend3.ID, mapping.CASBackend.ID)

mapping, err = s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestWithoutRun, []string{s.org1.ID})
mapping, err = s.CASMapping.FindCASMappingForDownloadByOrg(ctx, validDigestWithoutRun, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil)
s.Error(err)
s.Nil(mapping)
})

s.Run("can't find an invalid digest", func() {
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, invalidDigest, []string{s.org1.ID})
mapping, err := s.CASMapping.FindCASMappingForDownloadByOrg(ctx, invalidDigest, []uuid.UUID{uuid.MustParse(s.org1.ID)}, nil)
s.Error(err)
s.Nil(mapping)
})
Expand All @@ -163,13 +163,13 @@ func (s *casMappingIntegrationSuite) TestFindByDigest() {
// 2. Digest: validDigest2, CASBackend: casBackend1, WorkflowRunID: workflowRun
// 3. Digest: validDigest, CASBackend: casBackend2, WorkflowRunID: workflowRun
// 4. Digest: validDigest, CASBackend: casBackend3, WorkflowRunID: publicWorkflowRun
_, err := s.CASMapping.Create(context.TODO(), validDigest, s.casBackend1.ID.String(), s.workflowRun.ID.String())
_, err := s.CASMapping.Create(context.TODO(), validDigest, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(context.TODO(), validDigest2, s.casBackend1.ID.String(), s.workflowRun.ID.String())
_, err = s.CASMapping.Create(context.TODO(), validDigest2, s.casBackend1.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend2.ID.String(), s.workflowRun.ID.String())
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend2.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.workflowRun.ID})
require.NoError(s.T(), err)
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend3.ID.String(), s.publicWorkflowRun.ID.String())
_, err = s.CASMapping.Create(context.TODO(), validDigest, s.casBackend3.ID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: &s.publicWorkflowRun.ID})
require.NoError(s.T(), err)

testcases := []struct {
Expand Down Expand Up @@ -320,12 +320,7 @@ func (s *casMappingIntegrationSuite) TestCreate() {
}

s.Run(tc.name, func() {
var workflowRunID string
if tc.workflowRunID != nil {
workflowRunID = tc.workflowRunID.String()
}

got, err := s.CASMapping.Create(context.TODO(), tc.digest, tc.casBackendID.String(), workflowRunID)
got, err := s.CASMapping.Create(context.TODO(), tc.digest, tc.casBackendID.String(), &biz.CASMappingCreateOpts{WorkflowRunID: tc.workflowRunID})
if tc.wantErr {
s.Error(err)
} else {
Expand Down
Loading
Loading