From 0f4e257ddde2fa6dfa8541853aacac3a92aa5a6e Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 26 Jun 2025 17:07:32 +0200 Subject: [PATCH 1/4] correcly apply RBAC to referrers if needed Signed-off-by: Jose I. Paris --- app/controlplane/pkg/biz/casmapping.go | 25 ++------------ app/controlplane/pkg/biz/membership.go | 30 ++++++++++++++++ app/controlplane/pkg/biz/referrer.go | 47 +++++++------------------ app/controlplane/pkg/data/referrer.go | 48 ++++++++++++++++++++------ 4 files changed, 81 insertions(+), 69 deletions(-) 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..a7726deb0 100644 --- a/app/controlplane/pkg/biz/membership.go +++ b/app/controlplane/pkg/biz/membership.go @@ -344,3 +344,33 @@ 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..fc9795c28 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,8 +98,8 @@ 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 GetFromRootFilters struct { @@ -108,8 +107,8 @@ type GetFromRootFilters struct { 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 + ProjectIDs map[uuid.UUID][]uuid.UUID } type GetFromRootFilter func(*GetFromRootFilters) @@ -120,7 +119,7 @@ func WithKind(kind string) func(*GetFromRootFilters) { } } -func WithProjectIDs(projectIDs []uuid.UUID) func(*GetFromRootFilters) { +func WithProjectIDs(projectIDs map[uuid.UUID][]uuid.UUID) func(*GetFromRootFilters) { return func(o *GetFromRootFilters) { o.ProjectIDs = projectIDs } @@ -168,40 +167,18 @@ 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) - } - - 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 nil, err } - 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[uuid.UUID][]uuid.UUID) (*StoredReferrer, error) { filters := make([]GetFromRootFilter, 0) if rootKind != "" { filters = append(filters, WithKind(rootKind)) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index b40695e5b..5f5aaac13 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, projectIDs 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,28 @@ 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 + var found bool + for _, oid := range res.OrgIDs { + if visibleProjects, ok := projectIDs[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 res.ProjectIDs { + if slices.Contains(visibleProjects, pid) { + found = true + } + } + } else { + // if entry is not found, RBAC is not needed for this org, we have finished + found = true + break + } + } + if !found { + 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,12 +264,14 @@ 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, projectIDs, 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 @@ -263,6 +284,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 +295,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 From 10e04092fc8328a43572565a58f678cc079fdac2 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 26 Jun 2025 17:39:14 +0200 Subject: [PATCH 2/4] lint Signed-off-by: Jose I. Paris --- app/controlplane/pkg/biz/membership.go | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controlplane/pkg/biz/membership.go b/app/controlplane/pkg/biz/membership.go index a7726deb0..383e609c8 100644 --- a/app/controlplane/pkg/biz/membership.go +++ b/app/controlplane/pkg/biz/membership.go @@ -346,7 +346,6 @@ func (uc *MembershipUseCase) SetProjectOwner(ctx context.Context, projectID, use } 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 { From 4296ae39df72b6afe46a118a47957db09fc22acd Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 27 Jun 2025 12:53:26 +0200 Subject: [PATCH 3/4] apply suggestions Signed-off-by: Jose I. Paris --- app/controlplane/pkg/biz/referrer.go | 12 ++++--- app/controlplane/pkg/data/referrer.go | 47 +++++++++++++++------------ 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index fc9795c28..705f4c745 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -102,13 +102,16 @@ type StoredReferrer struct { 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 // ProjectIDs stores visible projects by org for the requesting user - ProjectIDs map[uuid.UUID][]uuid.UUID + ProjectIDs map[OrgID][]ProjectID } type GetFromRootFilter func(*GetFromRootFilters) @@ -119,7 +122,8 @@ func WithKind(kind string) func(*GetFromRootFilters) { } } -func WithProjectIDs(projectIDs map[uuid.UUID][]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 } @@ -178,13 +182,13 @@ func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, return s.GetFromRoot(ctx, digest, rootKind, userOrgs, projectIDs) } -func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs map[uuid.UUID][]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 5f5aaac13..af623d692 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -193,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, projectIDs map[uuid.UUID][]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, @@ -211,24 +211,7 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg hydrateWorkflowsInfo(root, res) // check that, if RBAC is required, the user has visibility on the artifact in at least 1 org/project - var found bool - for _, oid := range res.OrgIDs { - if visibleProjects, ok := projectIDs[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 res.ProjectIDs { - if slices.Contains(visibleProjects, pid) { - found = true - } - } - } else { - // if entry is not found, RBAC is not needed for this org, we have finished - found = true - break - } - } - if !found { + if visible := isReferrerVisible(res, allowedOrgs, visibleProjectsMap); !visible { return nil, biz.NewErrUnauthorizedStr("referrer not allowed") } @@ -264,7 +247,7 @@ 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, projectIDs, public, level+1) + 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) } @@ -277,6 +260,30 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg 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 From d4c811375c61a7e8cba41afc5655b75523efc004 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 27 Jun 2025 12:57:08 +0200 Subject: [PATCH 4/4] add comment Signed-off-by: Jose I. Paris --- app/controlplane/pkg/biz/referrer.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 705f4c745..e8f3e6c03 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -110,7 +110,8 @@ type GetFromRootFilters struct { RootKind *string // Wether to filter by visibility or not Public *bool - // ProjectIDs stores visible projects by org for the requesting user + // 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 }