diff --git a/core/membership/service.go b/core/membership/service.go index 5028d0edb..76a4c1ba6 100644 --- a/core/membership/service.go +++ b/core/membership/service.go @@ -11,6 +11,7 @@ import ( "github.com/raystack/frontier/core/audit" "github.com/raystack/frontier/core/auditrecord" + "github.com/raystack/frontier/core/authenticate" "github.com/raystack/frontier/core/group" "github.com/raystack/frontier/core/organization" "github.com/raystack/frontier/core/policy" @@ -1562,3 +1563,250 @@ func (s *Service) auditGroupMemberRemoved(ctx context.Context, grp group.Group, "group_id": grp.ID, }) } + +// ResourceFilter narrows the results of ListResourcesByPrincipal. +type ResourceFilter struct { + // OrgID restricts project/group results to one org. No-op for orgs. + OrgID string + + // NonInherited suppresses org-inheritance expansion for projects (direct + // + group-expanded only). No-op for orgs and groups. + NonInherited bool +} + +// ListResourcesByPrincipal returns the resource IDs of the given type on which +// the principal has at least one policy. Reads Postgres policies — no SpiceDB. +// With a PAT, runs the algorithm twice (user, then PAT-as-principal) and +// intersects, so the PAT can narrow but never widen the user's visibility. +func (s *Service) ListResourcesByPrincipal(ctx context.Context, principal authenticate.Principal, resourceType string, filter ResourceFilter) ([]string, error) { + subjectID, subjectType := principal.ResolveSubject() + subjectResourceIDs, err := s.listResourcesForPrincipal(ctx, subjectID, subjectType, resourceType, filter) + if err != nil { + return nil, err + } + if principal.PAT == nil { + return subjectResourceIDs, nil + } + + patResourceIDs, err := s.listResourcesForPrincipal(ctx, principal.PAT.ID, schema.PATPrincipal, resourceType, filter) + if err != nil { + return nil, err + } + return utils.Intersection(subjectResourceIDs, patResourceIDs), nil +} + +// listResourcesForPrincipal is the per-principal core; no PAT awareness. +func (s *Service) listResourcesForPrincipal(ctx context.Context, principalID, principalType, resourceType string, filter ResourceFilter) ([]string, error) { + switch resourceType { + case schema.OrganizationNamespace: + return s.listOrgsForPrincipal(ctx, principalID, principalType) + case schema.GroupNamespace: + return s.listGroupsForPrincipal(ctx, principalID, principalType, filter) + case schema.ProjectNamespace: + return s.listProjectsForPrincipal(ctx, principalID, principalType, filter) + default: + return nil, ErrInvalidResourceType + } +} + +// listOrgsForPrincipal returns every org the principal has a policy on. +// Any policy is enough — we don't look at what the role grants. (Project +// listing does check role permissions; orgs and groups don't.) +func (s *Service) listOrgsForPrincipal(ctx context.Context, principalID, principalType string) ([]string, error) { + policies, err := s.policyService.List(ctx, policy.Filter{ + PrincipalID: principalID, + PrincipalType: principalType, + ResourceType: schema.OrganizationNamespace, + }) + if err != nil { + return nil, fmt.Errorf("list org policies: %w", err) + } + ids := make([]string, 0, len(policies)) + for _, pol := range policies { + ids = append(ids, pol.ResourceID) + } + return utils.Deduplicate(ids), nil +} + +// listGroupsForPrincipal returns every group the principal has a policy on. +// Same rule as orgs — any policy is enough, role permissions aren't checked. +func (s *Service) listGroupsForPrincipal(ctx context.Context, principalID, principalType string, filter ResourceFilter) ([]string, error) { + policies, err := s.policyService.List(ctx, policy.Filter{ + PrincipalID: principalID, + PrincipalType: principalType, + ResourceType: schema.GroupNamespace, + }) + if err != nil { + return nil, fmt.Errorf("list group policies: %w", err) + } + ids := make([]string, 0, len(policies)) + for _, pol := range policies { + ids = append(ids, pol.ResourceID) + } + ids = utils.Deduplicate(ids) + + if filter.OrgID != "" && len(ids) > 0 { + ids, err = s.narrowGroupsByOrg(ctx, ids, filter.OrgID) + if err != nil { + return nil, err + } + } + return ids, nil +} + +// narrowGroupsByOrg keeps only group IDs whose org_id matches the given org. +// Performed by re-issuing groupService.List({OrganizationID, GroupIDs: ids}). +func (s *Service) narrowGroupsByOrg(ctx context.Context, ids []string, orgID string) ([]string, error) { + groups, err := s.groupService.List(ctx, group.Filter{ + OrganizationID: orgID, + GroupIDs: ids, + }) + if err != nil { + return nil, fmt.Errorf("narrow groups by org: %w", err) + } + out := make([]string, 0, len(groups)) + for _, g := range groups { + out = append(out, g.ID) + } + return out, nil +} + +// listProjectsForPrincipal unions three sources, dedups, then narrows by +// filter.OrgID if set: +// +// 1. Direct project policies — gated by schema.ProjectDirectVisibilityPerms. +// 2. Group-expanded projects — same gate as direct. Runs even with +// NonInherited=true; a user can be a project member via group. +// 3. Org inheritance (skipped if NonInherited=true) — gated by +// schema.OrganizationProjectInheritPerms so only org roles that grant +// project visibility (Owner, Manager, etc.) expand. Batched via +// project.Filter.OrgIDs to avoid N+1 across multi-org users. +func (s *Service) listProjectsForPrincipal(ctx context.Context, principalID, principalType string, filter ResourceFilter) ([]string, error) { + directIDs, err := s.listDirectProjectIDs(ctx, principalID, principalType) + if err != nil { + return nil, err + } + + groupExpandedIDs, err := s.listGroupExpandedProjectIDs(ctx, principalID, principalType) + if err != nil { + return nil, err + } + + var inheritedIDs []string + if !filter.NonInherited { + inheritedIDs, err = s.listOrgInheritedProjectIDs(ctx, principalID, principalType) + if err != nil { + return nil, err + } + } + + all := make([]string, 0, len(directIDs)+len(groupExpandedIDs)+len(inheritedIDs)) + all = append(all, directIDs...) + all = append(all, groupExpandedIDs...) + all = append(all, inheritedIDs...) + ids := utils.Deduplicate(all) + + if filter.OrgID != "" && len(ids) > 0 { + ids, err = s.narrowProjectsByOrg(ctx, ids, filter.OrgID) + if err != nil { + return nil, err + } + } + return ids, nil +} + +// listDirectProjectIDs returns projects the principal has a direct policy on, +// kept only if the role grants any of the permissions that imply project +// visibility. +func (s *Service) listDirectProjectIDs(ctx context.Context, principalID, principalType string) ([]string, error) { + policies, err := s.policyService.List(ctx, policy.Filter{ + PrincipalID: principalID, + PrincipalType: principalType, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }) + if err != nil { + return nil, fmt.Errorf("list direct project policies: %w", err) + } + return policyResourceIDs(policies), nil +} + +// listGroupExpandedProjectIDs walks: principal → groups → project policies on +// those groups → kept only if the role grants project visibility. +func (s *Service) listGroupExpandedProjectIDs(ctx context.Context, principalID, principalType string) ([]string, error) { + // Use the per-principal helper (not ListResourcesByPrincipal) so the PAT + // pass doesn't trigger another PAT recursion on itself. + groupIDs, err := s.listResourcesForPrincipal(ctx, principalID, principalType, schema.GroupNamespace, ResourceFilter{NonInherited: true}) + if err != nil { + return nil, fmt.Errorf("list principal groups for project expansion: %w", err) + } + if len(groupIDs) == 0 { + return nil, nil + } + policies, err := s.policyService.List(ctx, policy.Filter{ + PrincipalType: schema.GroupPrincipal, + PrincipalIDs: groupIDs, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }) + if err != nil { + return nil, fmt.Errorf("list project policies for principal groups: %w", err) + } + return policyResourceIDs(policies), nil +} + +// listOrgInheritedProjectIDs finds projects a principal can see by virtue of +// holding a strong-enough role on the project's org (e.g. Org Owner sees all +// projects in their org; Org Viewer doesn't). Steps: +// - get the principal's policies on orgs, kept only if the role grants any +// permission that implies org→all-projects inheritance +// - fetch all projects in those orgs in a single batched query +func (s *Service) listOrgInheritedProjectIDs(ctx context.Context, principalID, principalType string) ([]string, error) { + policies, err := s.policyService.List(ctx, policy.Filter{ + PrincipalID: principalID, + PrincipalType: principalType, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }) + if err != nil { + return nil, fmt.Errorf("list org policies for inheritance: %w", err) + } + inheritingOrgIDs := policyResourceIDs(policies) + if len(inheritingOrgIDs) == 0 { + return nil, nil + } + projects, err := s.projectService.List(ctx, project.Filter{OrgIDs: inheritingOrgIDs}) + if err != nil { + return nil, fmt.Errorf("list inherited projects: %w", err) + } + ids := make([]string, 0, len(projects)) + for _, p := range projects { + ids = append(ids, p.ID) + } + return ids, nil +} + +// policyResourceIDs plucks the deduped resource IDs from a policy slice. +func policyResourceIDs(policies []policy.Policy) []string { + ids := make([]string, 0, len(policies)) + for _, pol := range policies { + ids = append(ids, pol.ResourceID) + } + return utils.Deduplicate(ids) +} + +// narrowProjectsByOrg keeps only IDs whose org_id matches orgID (single query). +func (s *Service) narrowProjectsByOrg(ctx context.Context, ids []string, orgID string) ([]string, error) { + projects, err := s.projectService.List(ctx, project.Filter{ + OrgID: orgID, + ProjectIDs: ids, + }) + if err != nil { + return nil, fmt.Errorf("narrow projects by org: %w", err) + } + out := make([]string, 0, len(projects)) + for _, p := range projects { + out = append(out, p.ID) + } + return out, nil +} diff --git a/core/membership/service_test.go b/core/membership/service_test.go index 02e7d404d..36701b484 100644 --- a/core/membership/service_test.go +++ b/core/membership/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/raystack/frontier/core/auditrecord" + "github.com/raystack/frontier/core/authenticate" "github.com/raystack/frontier/core/group" "github.com/raystack/frontier/core/membership" "github.com/raystack/frontier/core/membership/mocks" @@ -20,6 +21,7 @@ import ( "github.com/raystack/frontier/core/role" "github.com/raystack/frontier/core/serviceuser" "github.com/raystack/frontier/core/user" + pat "github.com/raystack/frontier/core/userpat/models" "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -1784,3 +1786,506 @@ func TestService_OnGroupDeleted(t *testing.T) { assert.ErrorIs(t, svc.OnGroupDeleted(ctx, groupID), group.ErrNotExist) }) } + +// TestService_ListResourcesByPrincipal covers each resource type, role-based +// visibility filtering, group expansion, OrgID narrowing, and PAT intersection. +func TestService_ListResourcesByPrincipal(t *testing.T) { + ctx := context.Background() + + // fixture IDs + userID := uuid.New().String() + suID := uuid.New().String() + patID := uuid.New().String() + orgA := uuid.New().String() + orgB := uuid.New().String() + project1, project2, project3 := uuid.New().String(), uuid.New().String(), uuid.New().String() + groupA := uuid.New().String() + + roleOrgViewerID := uuid.New().String() + roleOrgManagerID := uuid.New().String() + roleOrgOwnerID := uuid.New().String() + roleOrgCustomID := uuid.New().String() + roleProjectViewerID := uuid.New().String() + roleProjectOwnerID := uuid.New().String() + + type mockSet struct { + policy *mocks.PolicyService + role *mocks.RoleService + project *mocks.ProjectService + group *mocks.GroupService + } + + tests := []struct { + name string + principal authenticate.Principal + resourceType string + filter membership.ResourceFilter + setup func(m *mockSet) + want []string + wantErrIs error + }{ + { + name: "rejects unsupported resource type", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: "app/unknown", + setup: func(m *mockSet) {}, + wantErrIs: membership.ErrInvalidResourceType, + }, + { + name: "lists orgs from direct policies without role-permission filter", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.OrganizationNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgViewerID}, + {ResourceID: orgB, RoleID: roleOrgManagerID}, + }, nil) + }, + want: []string{orgA, orgB}, + }, + { + name: "deduplicates org IDs across multiple policies on the same org", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.OrganizationNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgViewerID}, + {ResourceID: orgA, RoleID: roleOrgOwnerID}, + }, nil) + }, + want: []string{orgA}, + }, + { + name: "stale-relation regression: returns empty when no policies, ignoring any SpiceDB state", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.OrganizationNamespace, + setup: func(m *mockSet) { + // Even if SpiceDB still had an org#owner@U tuple from a + // pre-demotion state, this method only consults policies. + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + }).Return([]policy.Policy{}, nil) + }, + want: []string{}, + }, + { + name: "lists groups from direct policies, no inheritance", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.GroupNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{ + {ResourceID: groupA, RoleID: uuid.New().String()}, + }, nil) + }, + want: []string{groupA}, + }, + { + name: "project listing: direct policy with role granting project visibility is returned", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + filter: membership.ResourceFilter{NonInherited: true}, + setup: func(m *mockSet) { + // direct project policies — gated by RolePermissions at policy.Filter + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{ + {ResourceID: project1, RoleID: roleProjectViewerID}, + }, nil) + // group expansion: principal has no groups + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + // NonInherited=true → org-inheritance branch skipped + }, + want: []string{project1}, + }, + { + name: "project listing: owner role on org expands to all org projects via inheritance", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgOwnerID}, + }, nil) + m.project.EXPECT().List(ctx, project.Filter{OrgIDs: []string{orgA}}).Return([]project.Project{ + {ID: project1}, {ID: project2}, {ID: project3}, + }, nil) + }, + want: []string{project1, project2, project3}, + }, + { + name: "project listing: manager role on org expands via app_project_get inheritance", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgManagerID}, + }, nil) + m.project.EXPECT().List(ctx, project.Filter{OrgIDs: []string{orgA}}).Return([]project.Project{ + {ID: project1}, {ID: project2}, + }, nil) + }, + want: []string{project1, project2}, + }, + { + name: "project listing: viewer role on org does NOT expand (no inheritance)", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + // SQL filter excludes the viewer's policy (role doesn't grant + // any OrganizationProjectInheritPerms) — empty result, no + // follow-up projectService.List call. + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{}, nil) + }, + want: []string{}, + }, + { + name: "project listing: custom org role with app_project_administer expands", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgCustomID}, + }, nil) + m.project.EXPECT().List(ctx, project.Filter{OrgIDs: []string{orgA}}).Return([]project.Project{ + {ID: project1}, + }, nil) + }, + want: []string{project1}, + }, + { + name: "project listing: group expansion adds group-policied projects (even with NonInherited=true)", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + filter: membership.ResourceFilter{NonInherited: true}, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{}, nil) + // recursion to list groups for the user (no RolePermissions — + // group listing isn't role-permission-gated) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{ + {ResourceID: groupA, RoleID: uuid.New().String()}, + }, nil) + // then project policies on those groups, also gated + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalType: schema.GroupPrincipal, + PrincipalIDs: []string{groupA}, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{ + {ResourceID: project2, RoleID: roleProjectViewerID}, + }, nil) + }, + want: []string{project2}, + }, + { + name: "project listing: OrgID narrows the result set via projectService.List", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + filter: membership.ResourceFilter{OrgID: orgA, NonInherited: true}, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{ + {ResourceID: project1, RoleID: roleProjectViewerID}, + {ResourceID: project2, RoleID: roleProjectViewerID}, + }, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + // narrowing: re-issue projectService.List with the OrgID filter, + // returning only project1 (project2 was filtered out by org_id). + m.project.EXPECT().List(ctx, mock.MatchedBy(func(f project.Filter) bool { + return f.OrgID == orgA && len(f.ProjectIDs) == 2 + })).Return([]project.Project{{ID: project1}}, nil) + }, + want: []string{project1}, + }, + { + name: "serviceuser principal: org listing uses ServiceUserPrincipal type", + principal: authenticate.Principal{ID: suID, Type: schema.ServiceUserPrincipal}, + resourceType: schema.OrganizationNamespace, + setup: func(m *mockSet) { + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: suID, + PrincipalType: schema.ServiceUserPrincipal, + ResourceType: schema.OrganizationNamespace, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgViewerID}, + }, nil) + }, + want: []string{orgA}, + }, + { + name: "no-PAT path: Principal{Type: UserPrincipal, PAT: nil} skips the recursive PAT pass", + principal: authenticate.Principal{ + ID: userID, + Type: schema.UserPrincipal, + PAT: nil, + }, + resourceType: schema.ProjectNamespace, + filter: membership.ResourceFilter{NonInherited: true}, + setup: func(m *mockSet) { + // only the user-pass queries fire; no second list under the PAT principal type + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{ + {ResourceID: project1, RoleID: roleProjectViewerID}, + }, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + }, + want: []string{project1}, + }, + { + name: "PAT all-projects scope with ProjectOwner role resolves via org inheritance", + principal: authenticate.Principal{ + ID: userID, + Type: schema.UserPrincipal, + PAT: &pat.PAT{ID: patID, UserID: userID, OrgID: orgA}, + }, + resourceType: schema.ProjectNamespace, + setup: func(m *mockSet) { + // user pass — user is org owner, expands via inheritance + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgOwnerID}, + }, nil) + m.project.EXPECT().List(ctx, project.Filter{OrgIDs: []string{orgA}}).Return([]project.Project{ + {ID: project1}, {ID: project2}, {ID: project3}, + }, nil) + // PAT pass — all-projects scope is one pat_granted policy on the org + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{ + // grant_relation here would be pat_granted in production; + // listing doesn't filter on it, so the value doesn't matter + // for behavior — only the role's permissions do. + {ResourceID: orgA, RoleID: roleProjectOwnerID}, + }, nil) + m.project.EXPECT().List(ctx, project.Filter{OrgIDs: []string{orgA}}).Return([]project.Project{ + {ID: project1}, {ID: project2}, {ID: project3}, + }, nil) + }, + // PAT can see all of OrgA. User can also see all. Intersection = all. + want: []string{project1, project2, project3}, + }, + { + name: "PAT narrows: user is org viewer with direct P1, PAT scoped to P2 only → empty intersection", + principal: authenticate.Principal{ + ID: userID, + Type: schema.UserPrincipal, + PAT: &pat.PAT{ID: patID, UserID: userID, OrgID: orgA}, + }, + resourceType: schema.ProjectNamespace, + setup: func(m *mockSet) { + // user pass — viewer role on org doesn't pass the inheritance + // gate, so the org-inheritance query returns [] + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{ + {ResourceID: project1, RoleID: roleProjectViewerID}, + }, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{}, nil) + // PAT pass + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + ResourceType: schema.ProjectNamespace, + RolePermissions: schema.ProjectDirectVisibilityPerms, + }).Return([]policy.Policy{ + {ResourceID: project2, RoleID: roleProjectViewerID}, + }, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + ResourceType: schema.GroupNamespace, + }).Return([]policy.Policy{}, nil) + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + ResourceType: schema.OrganizationNamespace, + RolePermissions: schema.OrganizationProjectInheritPerms, + }).Return([]policy.Policy{}, nil) + }, + // user sees [P1], PAT sees [P2], intersection = [] + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mp := mocks.NewPolicyService(t) + mr := mocks.NewRoleService(t) + mpr := mocks.NewProjectService(t) + mg := mocks.NewGroupService(t) + + tt.setup(&mockSet{policy: mp, role: mr, project: mpr, group: mg}) + + svc := membership.NewService( + slog.New(slog.NewTextHandler(io.Discard, nil)), + mp, + mocks.NewRelationService(t), + mr, + mocks.NewOrgService(t), + mocks.NewUserService(t), + mpr, + mg, + mocks.NewServiceuserService(t), + mocks.NewAuditRecordRepository(t), + ) + + got, err := svc.ListResourcesByPrincipal(ctx, tt.principal, tt.resourceType, tt.filter) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + return + } + assert.NoError(t, err) + assert.ElementsMatch(t, tt.want, got) + }) + } +} diff --git a/core/policy/filter.go b/core/policy/filter.go index ee7743e13..ac51bbcc4 100644 --- a/core/policy/filter.go +++ b/core/policy/filter.go @@ -11,4 +11,6 @@ type Filter struct { PrincipalID string PrincipalIDs []string ResourceType string + + RolePermissions []string } diff --git a/core/project/filter.go b/core/project/filter.go index df4acae12..10e4dd7b4 100644 --- a/core/project/filter.go +++ b/core/project/filter.go @@ -10,4 +10,11 @@ type Filter struct { // NonInherited filters out projects that are inherited from access given through an organization NonInherited bool Pagination *pagination.Pagination + + // OrgIDs narrows results to projects whose org_id is in this list. Used by + // membership listing to batch-expand all projects across the orgs a + // principal can inherit project visibility from. If both OrgID and OrgIDs + // are set, projects must satisfy both (intersection) — typically yields + // no rows unless OrgID is one of OrgIDs. + OrgIDs []string } diff --git a/internal/bootstrap/schema/inheritance_perms_test.go b/internal/bootstrap/schema/inheritance_perms_test.go new file mode 100644 index 000000000..a73fbd923 --- /dev/null +++ b/internal/bootstrap/schema/inheritance_perms_test.go @@ -0,0 +1,118 @@ +// Drift guard for the inheritance perm constants: regex-scans base_schema.zed +// and asserts ProjectDirectVisibilityPerms and OrganizationProjectInheritPerms +// match the granted-> / pat_granted-> arrows they're supposed to mirror. +package schema_test + +import ( + "regexp" + "sort" + "strings" + "testing" + + "github.com/raystack/frontier/internal/bootstrap/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInheritancePerms_MatchSchema(t *testing.T) { + cases := []struct { + name string + objectName string + permissionName string + includePATGranted bool + want []string + }{ + { + name: "ProjectDirectVisibilityPerms matches app/project.get granted-> arrows", + objectName: "app/project", + permissionName: "get", + includePATGranted: false, + want: schema.ProjectDirectVisibilityPerms, + }, + { + name: "OrganizationProjectInheritPerms matches app/organization.project_get granted-> and pat_granted-> arrows", + objectName: "app/organization", + permissionName: "project_get", + includePATGranted: true, + want: schema.OrganizationProjectInheritPerms, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := arrowsFromSchemaSource(t, schema.BaseSchemaZed, tc.objectName, tc.permissionName, tc.includePATGranted) + want := append([]string(nil), tc.want...) + sort.Strings(got) + sort.Strings(want) + assert.Equal(t, want, got, + "drifted from %s.%s in base_schema.zed; update the constant or the schema", + tc.objectName, tc.permissionName) + }) + } +} + +// arrowsFromSchemaSource finds `permission = ...` inside `definition +// { ... }` and returns the deduped relation names on its granted-> +// (and optionally pat_granted->) arrows. +func arrowsFromSchemaSource(t *testing.T, source, objectName, permissionName string, includePATGranted bool) []string { + t.Helper() + body := definitionBody(t, source, objectName) + body = regexp.MustCompile(`//[^\n]*`).ReplaceAllString(body, "") + + permRe := regexp.MustCompile(`(?m)^\s*permission\s+` + regexp.QuoteMeta(permissionName) + `\s*=(.+)$`) + matches := permRe.FindAllStringSubmatch(body, -1) + require.Len(t, matches, 1, "expected exactly one `permission %s = ...` line in %q", permissionName, objectName) + + expr := strings.TrimSpace(matches[0][1]) + require.False(t, + strings.HasSuffix(expr, "+") || strings.HasSuffix(expr, "&") || strings.HasSuffix(expr, "-"), + "oracle assumption broken: `permission %s` in %q wraps across lines — rewrite the regex", + permissionName, objectName) + // Only Union (+) matches filterByRolePermissions's any-of semantics. Strip + // arrows first so the `-` in `->` doesn't trip the check. + opsOnly := strings.ReplaceAll(expr, "->", "") + require.False(t, + strings.ContainsAny(opsOnly, "&-"), + "`permission %s` in %q uses intersection/exclusion — extend the gating logic before updating the constants", + permissionName, objectName) + + arrowRe := regexp.MustCompile(`(granted|pat_granted)->([A-Za-z0-9_]+)`) + out := make([]string, 0) + seen := make(map[string]struct{}) + for _, m := range arrowRe.FindAllStringSubmatch(expr, -1) { + kind, name := m[1], m[2] + if kind == "pat_granted" && !includePATGranted { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out +} + +// definitionBody returns the body of `definition { ... }` by +// brace-walking — robust to nested braces if the schema ever grows them. +func definitionBody(t *testing.T, source, objectName string) string { + t.Helper() + header := "definition " + objectName + " {" + start := strings.Index(source, header) + require.GreaterOrEqual(t, start, 0, "definition %q not found in source", objectName) + open := start + len(header) - 1 + depth := 0 + for i := open; i < len(source); i++ { + switch source[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return source[open+1 : i] + } + } + } + t.Fatalf("definition %q has no matching closing brace", objectName) + return "" +} diff --git a/internal/bootstrap/schema/schema.go b/internal/bootstrap/schema/schema.go index 3beb2650d..5279907d4 100644 --- a/internal/bootstrap/schema/schema.go +++ b/internal/bootstrap/schema/schema.go @@ -97,6 +97,25 @@ var ( //go:embed base_schema.zed var BaseSchemaZed string +// ProjectDirectVisibilityPerms — role permissions that make a project visible +// when held on a direct project or group policy. Mirrors the granted-> arrows +// of app/project.get in base_schema.zed. +var ProjectDirectVisibilityPerms = []string{ + "app_project_administer", + "app_project_get", + "app_project_update", +} + +// OrganizationProjectInheritPerms — role permissions that, on an org-level +// policy, grant the principal visibility into every project in that org. +// Mirrors the granted-> and pat_granted-> arrows of +// app/organization.project_get in base_schema.zed. +var OrganizationProjectInheritPerms = []string{ + "app_organization_administer", + "app_project_get", + "app_project_administer", +} + var ( ErrMigration = errors.New("error in migrating authz schema") ErrBadNamespace = errors.New("bad namespace, format should namespace:uuid") diff --git a/internal/store/postgres/policy_repository.go b/internal/store/postgres/policy_repository.go index aba751860..e7295934d 100644 --- a/internal/store/postgres/policy_repository.go +++ b/internal/store/postgres/policy_repository.go @@ -11,6 +11,7 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/jmoiron/sqlx" + "github.com/lib/pq" "github.com/raystack/frontier/core/namespace" "github.com/raystack/frontier/core/policy" "github.com/raystack/frontier/internal/bootstrap/schema" @@ -126,6 +127,20 @@ func applyListFilter(stmt *goqu.SelectDataset, flt policy.Filter) *goqu.SelectDa "role_id": flt.RoleIDs, }) } + if len(flt.RolePermissions) > 0 { + // Join the roles table to keep only policies whose role grants at + // least one of the listed permission names. + stmt = stmt. + Join( + goqu.T(TABLE_ROLES).As("r"), + goqu.On(goqu.I("r.id").Eq(goqu.I("p.role_id"))), + ). + Where(goqu.Func( + "jsonb_exists_any", + goqu.I("r.permissions"), + pq.Array(flt.RolePermissions), + )) + } return stmt } diff --git a/internal/store/postgres/project_repository.go b/internal/store/postgres/project_repository.go index 34d747b8d..cbd4b9e0a 100644 --- a/internal/store/postgres/project_repository.go +++ b/internal/store/postgres/project_repository.go @@ -161,6 +161,11 @@ func (r ProjectRepository) List(ctx context.Context, flt project.Filter) ([]proj "org_id": flt.OrgID, }) } + if len(flt.OrgIDs) > 0 { + stmt = stmt.Where(goqu.Ex{ + "org_id": goqu.Op{"in": flt.OrgIDs}, + }) + } if len(flt.ProjectIDs) > 0 { stmt = stmt.Where(goqu.Ex{ "id": goqu.Op{"in": flt.ProjectIDs},