diff --git a/app/controlplane/pkg/biz/casmapping.go b/app/controlplane/pkg/biz/casmapping.go index e7bd9dce9..50307d6c8 100644 --- a/app/controlplane/pkg/biz/casmapping.go +++ b/app/controlplane/pkg/biz/casmapping.go @@ -21,7 +21,6 @@ import ( "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" @@ -100,29 +99,9 @@ func (uc *CASMappingUseCase) FindCASMappingForDownloadByUser(ctx context.Context return nil, NewErrInvalidUUID(err) } - // Load ALL memberships for the given user - memberships, err := uc.membershipRepo.ListAllByUser(ctx, userUUID) + userOrgs, projectIDs, err := getOrgsAndRBACInfoForUser(ctx, userUUID, uc.membershipRepo, uc.projectsRepo) if err != nil { - return nil, fmt.Errorf("failed to list memberships: %w", err) - } - - 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 { - 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 - } - } + return nil, err } mapping, err := uc.FindCASMappingForDownloadByOrg(ctx, digest, userOrgs, projectIDs) diff --git a/app/controlplane/pkg/biz/membership.go b/app/controlplane/pkg/biz/membership.go index c60b9a444..383e609c8 100644 --- a/app/controlplane/pkg/biz/membership.go +++ b/app/controlplane/pkg/biz/membership.go @@ -344,3 +344,32 @@ func (uc *MembershipUseCase) SetProjectOwner(ctx context.Context, projectID, use return nil } + +func getOrgsAndRBACInfoForUser(ctx context.Context, userID uuid.UUID, mRepo MembershipRepo, pRepo ProjectsRepo) ([]uuid.UUID, map[uuid.UUID][]uuid.UUID, error) { + // Load ALL memberships for the given user + memberships, err := mRepo.ListAllByUser(ctx, userID) + if err != nil { + return nil, nil, fmt.Errorf("failed to list memberships: %w", err) + } + + 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 { + 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, pRepo, m.ResourceID, memberships) + if err != nil { + return nil, nil, err + } + // note that appending an empty slice to a nil slice doesn't change it (it's still nil) + projectIDs[m.ResourceID] = orgProjects + } + } + } + + return userOrgs, projectIDs, nil +} diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 421a3b010..e8f3e6c03 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -24,7 +24,6 @@ import ( "time" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" v2 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" "github.com/chainloop-dev/chainloop/pkg/servicelogger" @@ -99,17 +98,21 @@ type StoredReferrer struct { ID uuid.UUID CreatedAt *time.Time // Fully expanded list of 1-level off references - References []*StoredReferrer - OrgIDs, WorkflowIDs []uuid.UUID + References []*StoredReferrer + OrgIDs, WorkflowIDs, ProjectIDs []uuid.UUID } +type OrgID = uuid.UUID +type ProjectID = uuid.UUID + type GetFromRootFilters struct { // RootKind is the kind of the root referrer, i.e ATTESTATION RootKind *string // Wether to filter by visibility or not Public *bool - // If not nil, it will be used to filter the result by project - ProjectIDs []uuid.UUID + // ProjectIDs stores visible projects by org for the requesting user. + // If an org entry doesn't exist, it means that RBAC is not applied, hence all projects in that org are visible + ProjectIDs map[OrgID][]ProjectID } type GetFromRootFilter func(*GetFromRootFilters) @@ -120,7 +123,8 @@ func WithKind(kind string) func(*GetFromRootFilters) { } } -func WithProjectIDs(projectIDs []uuid.UUID) func(*GetFromRootFilters) { +// WithVisibleProjectIDs sets visible projects by org for organizations with RBAC enabled for the user (role is OrgMember) +func WithVisibleProjectIDs(projectIDs map[OrgID][]ProjectID) func(*GetFromRootFilters) { return func(o *GetFromRootFilters) { o.ProjectIDs = projectIDs } @@ -168,46 +172,24 @@ func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, return nil, NewErrInvalidUUID(err) } - // We pass the list of organizationsIDs from where to look for the referrer - // For now we just pass the list of organizations the user is member of - // in the future we will expand this to publicly available orgs and so on. - memberships, err := s.membershipRepo.ListAllByUser(ctx, userUUID) + userOrgs, projectIDs, err := getOrgsAndRBACInfoForUser(ctx, userUUID, s.membershipRepo, s.projectsRepo) if err != nil { - return nil, fmt.Errorf("finding memberships: %w", err) + return nil, err } - orgIDs := make([]uuid.UUID, 0, len(memberships)) - var projectIDs []uuid.UUID - for _, m := range memberships { - if m.ResourceType == authz.ResourceTypeOrganization { - orgIDs = append(orgIDs, m.ResourceID) - // If the role in the org is member, we must enable RBAC for projects. - if m.Role == authz.RoleOrgMember { - // ensure list is initialized - if projectIDs == nil { - projectIDs = make([]uuid.UUID, 0) - } - // get list of projects in org, and match it with the memberships to build a filter - orgProjects, err := getProjectsWithMembership(ctx, s.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 = append(projectIDs, orgProjects...) - } - } - } - - return s.GetFromRoot(ctx, digest, rootKind, orgIDs, projectIDs) + // We pass the list of organizationsIDs from where to look for the referrer + // For now we just pass the list of organizations the user is member of + // in the future we will expand this to publicly available orgs and so on. + return s.GetFromRoot(ctx, digest, rootKind, userOrgs, projectIDs) } -func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs []uuid.UUID) (*StoredReferrer, error) { +func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID) (*StoredReferrer, error) { filters := make([]GetFromRootFilter, 0) if rootKind != "" { filters = append(filters, WithKind(rootKind)) } if projectIDs != nil { - filters = append(filters, WithProjectIDs(projectIDs)) + filters = append(filters, WithVisibleProjectIDs(projectIDs)) } ref, err := s.repo.GetFromRoot(ctx, digest, orgIDs, filters...) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index b40695e5b..af623d692 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -18,6 +18,7 @@ package data import ( "context" "fmt" + "slices" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" @@ -27,6 +28,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/workflow" "github.com/go-kratos/kratos/v2/log" "github.com/google/uuid" + "golang.org/x/exp/maps" ) type ReferrerRepo struct { @@ -152,11 +154,6 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] workflow.DeletedAtIsNil(), workflow.HasOrganizationWith(organization.IDIn(orgIDs...)), } - // Filter by allowed projects - if opts.ProjectIDs != nil { - predicateWF = append(predicateWF, workflow.ProjectIDIn(opts.ProjectIDs...)) - } - // optionally attaching its visibility if opts.Public != nil { predicateWF = append(predicateWF, workflow.Public(*opts.Public)) @@ -183,8 +180,8 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] } // Find the referrer recursively starting from the root - res, err := r.doGet(ctx, refs[0], orgIDs, opts.Public, 0) - if err != nil { + res, err := r.doGet(ctx, refs[0], orgIDs, opts.ProjectIDs, opts.Public, 0) + if err != nil && !biz.IsErrUnauthorized(err) { return nil, fmt.Errorf("failed to get referrer: %w", err) } @@ -196,7 +193,7 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] // we also need to limit this because there might be cycles const maxTraverseLevels = 1 -func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrgs []uuid.UUID, public *bool, level int) (*biz.StoredReferrer, error) { +func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrgs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID, public *bool, level int) (*biz.StoredReferrer, error) { // Assemble the referrer to return res := &biz.StoredReferrer{ ID: root.ID, @@ -213,6 +210,11 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // add additional information related to the workflows hydrateWorkflowsInfo(root, res) + // check that, if RBAC is required, the user has visibility on the artifact in at least 1 org/project + if visible := isReferrerVisible(res, allowedOrgs, visibleProjectsMap); !visible { + return nil, biz.NewErrUnauthorizedStr("referrer not allowed") + } + // Next: We'll find the references recursively up to a max of maxTraverseLevels levels if level >= maxTraverseLevels { return res, nil @@ -245,17 +247,43 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg for _, reference := range refs { // Call recursively the function // we return all the references - ref, err := r.doGet(ctx, reference, allowedOrgs, public, level+1) - if err != nil { + ref, err := r.doGet(ctx, reference, allowedOrgs, visibleProjectsMap, public, level+1) + if err != nil && !biz.IsErrUnauthorized(err) { return nil, fmt.Errorf("failed to get referrer: %w", err) } - res.References = append(res.References, ref) + if ref != nil { + res.References = append(res.References, ref) + } } return res, nil } +func isReferrerVisible(ref *biz.StoredReferrer, allowedOrgs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) bool { + for _, oid := range ref.OrgIDs { + if !slices.Contains(allowedOrgs, oid) { + // skip check in organizations where the user doesn't have access + continue + } + if visibleProjects, ok := visibleProjectsMap[oid]; ok { + // if entry is present, it means we need to apply RBAC + // check if visible projects and referrer projects match + // by checking if any project is visible by the user + for _, pid := range ref.ProjectIDs { + if slices.Contains(visibleProjects, pid) { + return true + } + } + } else { + // if entry is not found in the map, it means that RBAC is not needed for this org, we have finished + return true + } + } + + return false +} + // hydrate the referrer with the following information: // - isPublic: if it has a public workflow associated // - workflowIDs: the list of associated workflows @@ -263,6 +291,7 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg func hydrateWorkflowsInfo(root *ent.Referrer, out *biz.StoredReferrer) { isPublic := false workflowIDs := make([]uuid.UUID, 0, len(root.Edges.Workflows)) + projectIDs := make(map[uuid.UUID]bool, 0) orgIDs := make([]uuid.UUID, 0) orgsMap := make(map[uuid.UUID]struct{}, 0) for _, wf := range root.Edges.Workflows { @@ -273,9 +302,13 @@ func hydrateWorkflowsInfo(root *ent.Referrer, out *biz.StoredReferrer) { if _, ok := orgsMap[wf.OrganizationID]; !ok { orgIDs = append(orgIDs, wf.OrganizationID) } + if _, ok := projectIDs[wf.ProjectID]; !ok { + projectIDs[wf.ProjectID] = true + } orgsMap[wf.OrganizationID] = struct{}{} } + out.ProjectIDs = maps.Keys(projectIDs) out.InPublicWorkflow = isPublic out.WorkflowIDs = workflowIDs out.OrgIDs = orgIDs