From 8b16ccd63fbe792fca5b913fe5763efa7680ceec Mon Sep 17 00:00:00 2001 From: aman Date: Thu, 14 May 2026 17:34:11 +0530 Subject: [PATCH 1/2] feat(membership): add ListResourcesByPrincipal with schema-derived inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds membership.Service.ListResourcesByPrincipal(principal, resourceType, filter) — a policy-driven replacement for org/project/group.ListByUser that reads from Postgres policies instead of SpiceDB relations. Why: today's ListByUser methods call relation.LookupResources, which reads the SpiceDB membership permission (member + owner). When a user's role on an org/group is demoted, the policy is updated but the direct SpiceDB owner/member relation lingers — so demoted users keep appearing in listings. Policy-driven listing makes Postgres policies the single source of truth. Highlights: - internal/bootstrap/schema/inheritance.go (new) — Inheritance struct with ProjectDirectVisibility and OrganizationToProjectInherit lists extracted from base_schema.zed at MigrateSchema time. Walks both granted-> and pat_granted-> arrows; errors loudly on non-Union rewrites. - internal/bootstrap/service.go — populateInheritance runs after the effective schema is finalized. inheritance pointer is threaded through DI so membership reads the canonical lists without drift. - core/membership/service.go — ListResourcesByPrincipal (top-level, PAT-aware) plus listResourcesForPrincipal (per-principal core). Three project branches: direct project policies gated by ProjectDirectVisibility, group expansion, org inheritance gated by OrganizationToProjectInherit and batched via project.Filter.OrgIDs. - core/project/filter.go — adds OrgIDs []string for the batched cross-org expansion (avoids N+1 for users in many orgs). - core/membership/service_test.go — 16 table-driven cases including stale-relation regression, NonInherited, group expansion, OrgID narrowing, PAT all-projects scope, no-PAT short-circuit, and PAT-narrows-user-access. No callers migrate in this commit — purely additive. Org/group/project listing migrations follow in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/serve.go | 9 +- core/membership/service.go | 309 ++++++++++ core/membership/service_test.go | 566 +++++++++++++++++- core/project/filter.go | 7 + internal/bootstrap/schema/inheritance.go | 133 ++++ internal/bootstrap/schema/inheritance_test.go | 182 ++++++ internal/bootstrap/service.go | 41 ++ internal/store/postgres/project_repository.go | 5 + 8 files changed, 1233 insertions(+), 19 deletions(-) create mode 100644 internal/bootstrap/schema/inheritance.go create mode 100644 internal/bootstrap/schema/inheritance_test.go diff --git a/cmd/serve.go b/cmd/serve.go index 64bd9e482..055829c01 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -80,6 +80,7 @@ import ( "github.com/raystack/frontier/core/permission" "github.com/raystack/frontier/internal/bootstrap" + "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/raystack/frontier/core/deleter" @@ -440,7 +441,12 @@ func buildAPIDependencies( projectService := project.NewService(projectRepository, relationService, userService, policyService, authnService, serviceUserService, groupService, roleService) - membershipService := membership.NewService(logger, policyService, relationService, roleService, organizationService, userService, projectService, groupService, serviceUserService, auditRecordRepository) + // inheritance is populated in-place by bootstrap.Service.MigrateSchema below. + // Sharing the pointer with membership.Service keeps the two views in sync + // without setter injection or a second pass. + inheritance := &schema.Inheritance{} + + membershipService := membership.NewService(logger, policyService, relationService, roleService, organizationService, userService, projectService, groupService, serviceUserService, auditRecordRepository, inheritance) // Setter injection: org/group → membership is circular (membership needs them // for validation; they need membership for Create). Break the cycle post-init. organizationService.SetMembershipService(membershipService) @@ -571,6 +577,7 @@ func buildAPIDependencies( cfg.App.PAT.DeniedPermissionsSet(), planService, planBlobRepository, + inheritance, ) cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService, diff --git a/core/membership/service.go b/core/membership/service.go index 5028d0edb..aac50ce3f 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" @@ -78,8 +79,20 @@ type Service struct { groupService GroupService serviceuserService ServiceuserService auditRecordRepository AuditRecordRepository + + // inheritance is the role-permission map extracted from base_schema.zed at + // bootstrap time. ListResourcesByPrincipal consults it to mirror today's + // SpiceDB project.get chain in Go without re-reading SpiceDB. + // + // Held as a pointer so the same value can be shared with bootstrap.Service + // (which writes through it during MigrateSchema). cmd/serve.go allocates + // one *schema.Inheritance and passes it to both services. + inheritance *schema.Inheritance } +// NewService wires the membership service. The inheritance pointer must be +// shared with bootstrap.Service so the schema-derived permission lists stay +// in sync; pass nil only in tests that don't exercise ListResourcesByPrincipal. func NewService( logger *slog.Logger, policyService PolicyService, @@ -91,7 +104,11 @@ func NewService( groupService GroupService, serviceuserService ServiceuserService, auditRecordRepository AuditRecordRepository, + inheritance *schema.Inheritance, ) *Service { + if inheritance == nil { + panic("membership: inheritance pointer must be non-nil; share with bootstrap.NewBootstrapService via cmd/serve.go (tests not exercising ListResourcesByPrincipal can pass &schema.Inheritance{})") + } return &Service{ log: logger, policyService: policyService, @@ -103,6 +120,7 @@ func NewService( groupService: groupService, serviceuserService: serviceuserService, auditRecordRepository: auditRecordRepository, + inheritance: inheritance, } } @@ -1562,3 +1580,294 @@ 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 org IDs where the principal has any policy. +// Not role-permission-gated; mirrors today's relation-based org.membership +// check (member + owner). +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 group IDs where the principal has any policy. +// Not role-permission-gated; mirrors today's group.membership (member + owner). +// No inheritance branch — group.membership has no org-> chain today. +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 ProjectDirectVisibility. +// 2. Group-expanded projects (also gated). Runs even with NonInherited=true, +// matching today's listNonInheritedProjectIDs. +// 3. Org inheritance (skipped if NonInherited=true), gated by +// OrganizationToProjectInherit. Batched via project.Filter.OrgIDs. +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 project IDs from the principal's direct +// project policies whose role grants any ProjectDirectVisibility permission. +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, + }) + if err != nil { + return nil, fmt.Errorf("list direct project policies: %w", err) + } + return s.filterByRolePermissions(ctx, policies, s.inheritance.ProjectDirectVisibility) +} + +// listGroupExpandedProjectIDs: principal → groups → project policies on those +// groups → filtered by ProjectDirectVisibility. +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, + }) + if err != nil { + return nil, fmt.Errorf("list project policies for principal groups: %w", err) + } + return s.filterByRolePermissions(ctx, policies, s.inheritance.ProjectDirectVisibility) +} + +// listOrgInheritedProjectIDs: principal's org policies → orgs whose role +// grants OrganizationToProjectInherit → all projects in those orgs. Batched +// via project.Filter.OrgIDs to avoid N+1 across multi-org users. +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, + }) + if err != nil { + return nil, fmt.Errorf("list org policies for inheritance: %w", err) + } + inheritingOrgIDs, err := s.filterByRolePermissions(ctx, policies, s.inheritance.OrganizationToProjectInherit) + if err != nil { + return nil, err + } + 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 +} + +// 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 +} + +// filterByRolePermissions returns ResourceIDs from policies whose role grants +// at least one of the given permissions. Roles are fetched in a single +// batched roleService.List call — O(1) round-trips regardless of policy count. +// +// Note: ignores policy.GrantRelation. Today's permission lists already cover +// both granted-> and pat_granted-> arrows for org.project_get, and project.get +// / group.get have no pat_granted-> arrows. If the schema ever gains +// pat_granted-> at project or group level, dispatch on GrantRelation here. +func (s *Service) filterByRolePermissions(ctx context.Context, policies []policy.Policy, permissions []string) ([]string, error) { + if len(policies) == 0 || len(permissions) == 0 { + return nil, nil + } + + wanted := make(map[string]struct{}, len(permissions)) + for _, p := range permissions { + wanted[p] = struct{}{} + } + + roleIDSet := make(map[string]struct{}, len(policies)) + for _, pol := range policies { + if pol.RoleID == "" { + continue + } + roleIDSet[pol.RoleID] = struct{}{} + } + if len(roleIDSet) == 0 { + return nil, nil + } + roleIDs := make([]string, 0, len(roleIDSet)) + for id := range roleIDSet { + roleIDs = append(roleIDs, id) + } + + roles, err := s.roleService.List(ctx, role.Filter{IDs: roleIDs}) + if err != nil { + return nil, fmt.Errorf("list roles for permission filter: %w", err) + } + rolePermits := make(map[string]bool, len(roles)) + for _, r := range roles { + grants := false + for _, p := range r.Permissions { + if _, ok := wanted[p]; ok { + grants = true + break + } + } + rolePermits[r.ID] = grants + } + + out := make([]string, 0, len(policies)) + for _, pol := range policies { + if rolePermits[pol.RoleID] { + out = append(out, pol.ResourceID) + } + } + return utils.Deduplicate(out), nil +} diff --git a/core/membership/service_test.go b/core/membership/service_test.go index 02e7d404d..f9f4ca9ac 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" @@ -259,7 +261,7 @@ func TestService_AddOrganizationMember(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) principalType := tt.principalType if principalType == "" { @@ -302,7 +304,7 @@ func TestService_AddOrganizationMember_ServiceUser(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, mock.Anything).Return(relation.Relation{}, nil) mockAuditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo, &schema.Inheritance{}) err := svc.AddOrganizationMember(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.NoError(t, err) }) @@ -314,7 +316,7 @@ func TestService_AddOrganizationMember_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: "other-org", State: string(serviceuser.Enabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) err := svc.AddOrganizationMember(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, membership.ErrPrincipalNotInOrg) }) @@ -326,7 +328,7 @@ func TestService_AddOrganizationMember_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: orgID, State: string(serviceuser.Disabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) err := svc.AddOrganizationMember(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, serviceuser.ErrDisabled) }) @@ -521,7 +523,7 @@ func TestService_SetOrganizationMemberRole(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) principalType := tt.principalType if principalType == "" { @@ -569,7 +571,7 @@ func TestService_SetOrganizationMemberRole_ServiceUser(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, mock.Anything).Return(relation.Relation{}, nil) mockAuditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo, &schema.Inheritance{}) err := svc.SetOrganizationMemberRole(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.NoError(t, err) }) @@ -581,7 +583,7 @@ func TestService_SetOrganizationMemberRole_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: "other-org", State: string(serviceuser.Enabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) err := svc.SetOrganizationMemberRole(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, membership.ErrPrincipalNotInOrg) }) @@ -593,7 +595,7 @@ func TestService_SetOrganizationMemberRole_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: orgID, State: string(serviceuser.Disabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) err := svc.SetOrganizationMemberRole(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, serviceuser.ErrDisabled) }) @@ -806,7 +808,7 @@ func TestService_RemoveOrganizationMember(t *testing.T) { tt.setup(d) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), d.policySvc, d.relSvc, d.roleSvc, d.orgSvc, mocks.NewUserService(t), d.projSvc, d.grpSvc, mocks.NewServiceuserService(t), d.auditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), d.policySvc, d.relSvc, d.roleSvc, d.orgSvc, mocks.NewUserService(t), d.projSvc, d.grpSvc, mocks.NewServiceuserService(t), d.auditRepo, &schema.Inheritance{}) principalType := tt.principalType if principalType == "" { @@ -947,7 +949,7 @@ func TestService_SetProjectMemberRole(t *testing.T) { tt.setup(mockPolicySvc, mockRoleSvc, mockPrjSvc, mockUserSvc, mockSuSvc, mockGrpSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mockPrjSvc, mockGrpSvc, mockSuSvc, mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mockPrjSvc, mockGrpSvc, mockSuSvc, mockAuditRepo, &schema.Inheritance{}) err := svc.SetProjectMemberRole(ctx, projectID, tt.principalID, tt.principalType, tt.roleID) if tt.wantErr != nil { @@ -1028,7 +1030,7 @@ func TestService_RemoveProjectMember(t *testing.T) { tt.setup(mockPolicySvc, mockPrjSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mockPrjSvc, mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mockPrjSvc, mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) err := svc.RemoveProjectMember(ctx, projectID, tt.principalID, tt.principalType) if tt.wantErr != nil { @@ -1220,7 +1222,7 @@ func TestService_ListPrincipalsByResource(t *testing.T) { tt.setup(mockPolicySvc, mockRoleSvc) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) got, err := svc.ListPrincipalsByResource(ctx, tt.resourceID, tt.resourceType, tt.filter) if tt.wantErrIs != nil { @@ -1394,7 +1396,7 @@ func TestService_SetGroupMemberRole(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockGrpSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) principalType := tt.principalType if principalType == "" { @@ -1464,7 +1466,7 @@ func TestService_OnGroupCreated(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, creatorOwnerRelation).Return(relation.Relation{}, nil) mockAuditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.NoError(t, err) @@ -1476,7 +1478,7 @@ func TestService_OnGroupCreated(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, groupOrgRelation).Return(relation.Relation{}, errors.New("spicedb unavailable")) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.ErrorContains(t, err, "link group to org") @@ -1491,7 +1493,7 @@ func TestService_OnGroupCreated(t *testing.T) { // rollback: delete the first hierarchy relation mockRelSvc.EXPECT().Delete(ctx, groupOrgRelation).Return(nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.ErrorContains(t, err, "add group as org member") @@ -1519,7 +1521,7 @@ func TestService_OnGroupCreated(t *testing.T) { mockRelSvc.EXPECT().Delete(ctx, groupOrgRelation).Return(nil) mockRelSvc.EXPECT().Delete(ctx, orgGroupMemberRelation).Return(nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.ErrorContains(t, err, "db down") @@ -1645,7 +1647,7 @@ func TestService_RemoveGroupMember(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockGrpSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) principalType := tt.principalType if principalType == "" { @@ -1784,3 +1786,531 @@ func TestService_OnGroupDeleted(t *testing.T) { assert.ErrorIs(t, svc.OnGroupDeleted(ctx, groupID), group.ErrNotExist) }) } + +// TestService_ListResourcesByPrincipal exercises the policy-driven listing +// path that replaces today's SpiceDB LookupResources-based ListByUser methods. +// Table-driven, mirroring TestService_ListPrincipalsByResource above. +// +// The shared inheritance fixture mirrors the canonical lists extracted from +// base_schema.zed at runtime; see internal/bootstrap/schema/inheritance.go. +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() + + // roles with permissions that mirror the canonical predefined roles. + orgViewerRole := role.Role{ID: roleOrgViewerID, Name: schema.RoleOrganizationViewer, Permissions: []string{"app_organization_get"}} + orgManagerRole := role.Role{ID: roleOrgManagerID, Name: schema.RoleOrganizationManager, Permissions: []string{ + "app_organization_update", "app_organization_get", "app_project_get", "app_project_update", + }} + orgOwnerRole := role.Role{ID: roleOrgOwnerID, Name: schema.RoleOrganizationOwner, Permissions: []string{"app_organization_administer"}} + orgCustomRole := role.Role{ID: roleOrgCustomID, Name: "custom_app_project_admin", Permissions: []string{"app_project_administer"}} + projectViewerRole := role.Role{ID: roleProjectViewerID, Name: schema.RoleProjectViewer, Permissions: []string{"app_project_get"}} + projectOwnerRole := role.Role{ID: roleProjectOwnerID, Name: schema.RoleProjectOwner, Permissions: []string{"app_project_administer"}} + + inheritance := &schema.Inheritance{ + ProjectDirectVisibility: []string{"app_project_administer", "app_project_get", "app_project_update"}, + OrganizationToProjectInherit: []string{"app_organization_administer", "app_project_get", "app_project_administer"}, + } + + 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 gated by ProjectDirectVisibility, viewer role kept", + principal: authenticate.Principal{ID: userID, Type: schema.UserPrincipal}, + resourceType: schema.ProjectNamespace, + filter: membership.ResourceFilter{NonInherited: true}, + setup: func(m *mockSet) { + // direct project policies + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + }).Return([]policy.Policy{ + {ResourceID: project1, RoleID: roleProjectViewerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.MatchedBy(func(f role.Filter) bool { + return len(f.IDs) == 1 && f.IDs[0] == roleProjectViewerID + })).Return([]role.Role{projectViewerRole}, 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, + }).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, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgOwnerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.MatchedBy(func(f role.Filter) bool { + return len(f.IDs) == 1 && f.IDs[0] == roleOrgOwnerID + })).Return([]role.Role{orgOwnerRole}, 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, + }).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, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgManagerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.Anything).Return([]role.Role{orgManagerRole}, 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, + }).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, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgViewerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.Anything).Return([]role.Role{orgViewerRole}, nil) + // no projectService.List call expected — inheritingOrgIDs is empty + }, + 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, + }).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, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgCustomID}, + }, nil) + m.role.EXPECT().List(ctx, mock.Anything).Return([]role.Role{orgCustomRole}, 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, + }).Return([]policy.Policy{}, nil) + // recursion to list groups for the user + 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 + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalType: schema.GroupPrincipal, + PrincipalIDs: []string{groupA}, + ResourceType: schema.ProjectNamespace, + }).Return([]policy.Policy{ + {ResourceID: project2, RoleID: roleProjectViewerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.MatchedBy(func(f role.Filter) bool { + return len(f.IDs) == 1 && f.IDs[0] == roleProjectViewerID + })).Return([]role.Role{projectViewerRole}, 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, + }).Return([]policy.Policy{ + {ResourceID: project1, RoleID: roleProjectViewerID}, + {ResourceID: project2, RoleID: roleProjectViewerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.MatchedBy(func(f role.Filter) bool { + return len(f.IDs) == 1 + })).Return([]role.Role{projectViewerRole}, 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, + }).Return([]policy.Policy{ + {ResourceID: project1, RoleID: roleProjectViewerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.Anything).Return([]role.Role{projectViewerRole}, 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, + }).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, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgOwnerID}, + }, nil) + m.role.EXPECT().List(ctx, mock.MatchedBy(func(f role.Filter) bool { + return len(f.IDs) == 1 && f.IDs[0] == roleOrgOwnerID + })).Return([]role.Role{orgOwnerRole}, 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, + }).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, + }).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.role.EXPECT().List(ctx, mock.MatchedBy(func(f role.Filter) bool { + return len(f.IDs) == 1 && f.IDs[0] == roleProjectOwnerID + })).Return([]role.Role{projectOwnerRole}, 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 + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: userID, + PrincipalType: schema.UserPrincipal, + ResourceType: schema.ProjectNamespace, + }).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, + }).Return([]policy.Policy{ + {ResourceID: orgA, RoleID: roleOrgViewerID}, + }, nil) + // PAT pass + m.policy.EXPECT().List(ctx, policy.Filter{ + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + ResourceType: schema.ProjectNamespace, + }).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, + }).Return([]policy.Policy{}, nil) + // role lookups required: both passes hit filterByRolePermissions, + // without which the empty-intersection assertion would pass + // for the wrong reason. + m.role.EXPECT().List(ctx, mock.Anything).Return([]role.Role{projectViewerRole, orgViewerRole}, 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), + inheritance, + ) + + 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/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.go b/internal/bootstrap/schema/inheritance.go new file mode 100644 index 000000000..ecb3b764a --- /dev/null +++ b/internal/bootstrap/schema/inheritance.go @@ -0,0 +1,133 @@ +package schema + +import ( + "fmt" + + azcore "github.com/authzed/spicedb/pkg/proto/core/v1" + "github.com/authzed/spicedb/pkg/schemadsl/compiler" +) + +// Inheritance is the schema-derived permission lists membership listing needs +// to mirror SpiceDB's project.get / org.project_get chains in Go. Extracted +// from the effective schema at bootstrap so the lists can't drift. +type Inheritance struct { + // ProjectDirectVisibility: role permissions that make a directly-policied + // project visible. Matches granted-> arrows of app/project.get. + ProjectDirectVisibility []string + + // OrganizationToProjectInherit: role permissions on an org policy that + // expand to all projects in that org. Matches granted-> AND pat_granted-> + // arrows of app/organization.project_get — both walked because PAT + // all-projects scopes are stored as one pat_granted org policy and need + // the PAT-only arrows to resolve in the recursive PAT pass. + OrganizationToProjectInherit []string +} + +// ExtractInheritance walks the compiled effective schema. Call after +// ApplyServiceDefinitionOverAZSchema so any custom-resource overlays are in. +func ExtractInheritance(compiled *compiler.CompiledSchema) (Inheritance, error) { + direct, err := extractInheritanceArrows(compiled, ProjectNamespace, GetPermission) + if err != nil { + return Inheritance{}, fmt.Errorf("extract %s.%s arrows: %w", ProjectNamespace, GetPermission, err) + } + orgInherit, err := extractInheritanceArrows(compiled, OrganizationNamespace, "project_get") + if err != nil { + return Inheritance{}, fmt.Errorf("extract %s.project_get arrows: %w", OrganizationNamespace, err) + } + return Inheritance{ + ProjectDirectVisibility: direct, + OrganizationToProjectInherit: orgInherit, + }, nil +} + +// extractInheritanceArrows returns the role-relation names on every granted-> +// or pat_granted-> arrow under .. Errors loudly on +// non-Union rewrites (Intersection/Exclusion would need different handling). +func extractInheritanceArrows(compiled *compiler.CompiledSchema, objectName, permissionName string) ([]string, error) { + if compiled == nil { + return nil, fmt.Errorf("compiled schema is nil") + } + + var def *azcore.NamespaceDefinition + for _, d := range compiled.ObjectDefinitions { + if d.GetName() == objectName { + def = d + break + } + } + if def == nil { + return nil, fmt.Errorf("object %q not found in schema", objectName) + } + + var rel *azcore.Relation + for _, r := range def.GetRelation() { + if r.GetName() == permissionName { + rel = r + break + } + } + if rel == nil { + return nil, fmt.Errorf("permission %q not found on %q", permissionName, objectName) + } + + rewrite := rel.GetUsersetRewrite() + if rewrite == nil { + return nil, fmt.Errorf("%s.%s is not a permission (no userset_rewrite)", objectName, permissionName) + } + + seen := make(map[string]struct{}) + var arrows []string + if err := collectGrantedArrows(rewrite, objectName, permissionName, &arrows, seen); err != nil { + return nil, err + } + return arrows, nil +} + +// collectGrantedArrows walks a UsersetRewrite tree and collects the computed +// userset names from TupleToUserset children whose tupleset relation is +// granted or pat_granted. Recurses into nested rewrites; errors on non-Union. +func collectGrantedArrows(rewrite *azcore.UsersetRewrite, objectName, permissionName string, out *[]string, seen map[string]struct{}) error { + union := rewrite.GetUnion() + if union == nil { + op := "unknown" + switch { + case rewrite.GetIntersection() != nil: + op = "intersection" + case rewrite.GetExclusion() != nil: + op = "exclusion" + } + return fmt.Errorf("%s.%s uses %s; only union is supported for inheritance extraction", objectName, permissionName, op) + } + + for _, child := range union.GetChild() { + switch { + case child.GetTupleToUserset() != nil: + ttu := child.GetTupleToUserset() + tuplesetRel := ttu.GetTupleset().GetRelation() + if tuplesetRel != RoleGrantRelationName && tuplesetRel != PATGrantRelationName { + continue + } + cu := ttu.GetComputedUserset() + if cu == nil { + continue + } + permName := cu.GetRelation() + if permName == "" { + continue + } + if _, ok := seen[permName]; ok { + continue + } + seen[permName] = struct{}{} + *out = append(*out, permName) + case child.GetUsersetRewrite() != nil: + if err := collectGrantedArrows(child.GetUsersetRewrite(), objectName, permissionName, out, seen); err != nil { + return err + } + default: + // _this, computed_userset, _nil: not granted->/pat_granted-> arrows, + // nothing to collect. + } + } + return nil +} diff --git a/internal/bootstrap/schema/inheritance_test.go b/internal/bootstrap/schema/inheritance_test.go new file mode 100644 index 000000000..63617b374 --- /dev/null +++ b/internal/bootstrap/schema/inheritance_test.go @@ -0,0 +1,182 @@ +package schema_test + +import ( + "regexp" + "sort" + "strings" + "testing" + + "github.com/authzed/spicedb/pkg/schemadsl/compiler" + "github.com/raystack/frontier/internal/bootstrap/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const inheritanceTestTenant = "frontier" + +func compileBaseSchema(t *testing.T) *compiler.CompiledSchema { + t.Helper() + compiled, err := compiler.Compile(compiler.InputSchema{ + Source: "base_schema.zed", + SchemaString: schema.BaseSchemaZed, + }, compiler.ObjectTypePrefix(inheritanceTestTenant)) + require.NoError(t, err) + return compiled +} + +// TestExtractInheritance_Sanity asserts the canonical permissions which the +// schema embeds today actually show up in the extracted maps. If the schema +// changes, this test changes alongside it. +func TestExtractInheritance_Sanity(t *testing.T) { + compiled := compileBaseSchema(t) + got, err := schema.ExtractInheritance(compiled) + require.NoError(t, err) + + t.Run("project direct visibility includes administer/get/update", func(t *testing.T) { + assert.ElementsMatch(t, []string{ + "app_project_administer", + "app_project_get", + "app_project_update", + }, got.ProjectDirectVisibility) + }) + + t.Run("org->project inheritance includes both granted and pat_granted arrows", func(t *testing.T) { + assert.ElementsMatch(t, []string{ + "app_organization_administer", + "app_project_get", + "app_project_administer", + }, got.OrganizationToProjectInherit) + }) +} + +// TestExtractInheritance_OracleParity is the drift guard. A regex oracle reads +// the raw base_schema.zed source and computes the same lists by string parsing +// — if the AST walker ever misses an arrow (or picks up an extra one), the two +// disagree and this test fails. +// +// The oracle strips `//`-to-EOL comments and asserts each permission lives on +// a single line. If the schema ever wraps a permission across multiple lines, +// the assertion panics with a clear message so a human chooses how to update +// the oracle rather than letting the test silently produce wrong results. +func TestExtractInheritance_OracleParity(t *testing.T) { + compiled := compileBaseSchema(t) + got, err := schema.ExtractInheritance(compiled) + require.NoError(t, err) + + projectGet := oracleArrows(t, schema.BaseSchemaZed, "app/project", "get", false) + orgProjectGet := oracleArrows(t, schema.BaseSchemaZed, "app/organization", "project_get", true) + + assert.ElementsMatch(t, projectGet, got.ProjectDirectVisibility, + "AST walker drifted from regex oracle for project.get") + assert.ElementsMatch(t, orgProjectGet, got.OrganizationToProjectInherit, + "AST walker drifted from regex oracle for org.project_get") +} + +// oracleArrows is a deliberately dumb regex-based extractor: it locates the +// definition for `objectName`, finds the line beginning with `permission +// permissionName =`, and pulls out the relation names appearing after +// `granted->` (and `pat_granted->` if includePATGranted is set). +func oracleArrows(t *testing.T, source, objectName, permissionName string, includePATGranted bool) []string { + t.Helper() + body := definitionBody(t, source, objectName) + + commentRe := regexp.MustCompile(`//[^\n]*`) + cleanedBody := commentRe.ReplaceAllString(body, "") + + permRe := regexp.MustCompile(`(?m)^\s*permission\s+` + regexp.QuoteMeta(permissionName) + `\s*=(.+)$`) + matches := permRe.FindAllStringSubmatch(cleanedBody, -1) + if len(matches) == 0 { + t.Fatalf("oracle could not find `permission %s = ...` inside definition %q", permissionName, objectName) + } + if len(matches) > 1 { + t.Fatalf("oracle expected exactly one `permission %s = ...` line inside definition %q, found %d", permissionName, objectName, len(matches)) + } + // The regex anchors at line start/end with (?m). If the permission ever wraps + // across lines, the captured group will be incomplete; panic to flag that + // the oracle no longer matches the schema layout. + expr := strings.TrimSpace(matches[0][1]) + if strings.HasSuffix(expr, "+") || strings.HasSuffix(expr, "&") || strings.HasSuffix(expr, "-") { + t.Fatalf("oracle assumption violated: `permission %s` in %q appears to wrap across lines; update the oracle", 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) + } + sort.Strings(out) + return out +} + +// definitionBody returns the body of a `definition { ... }` block +// from raw zed source. Matches by braces rather than indentation. +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 "" +} + +// TestExtractInheritance_RejectsNonUnion exercises the "fail loud" path: a +// permission defined with an intersection rather than a union must error so +// callers don't silently get the wrong inheritance list. +func TestExtractInheritance_RejectsNonUnion(t *testing.T) { + src := ` +definition app/role { + relation app_project_get: app/user +} + +definition app/rolebinding { + relation bearer: app/user + relation role: app/role + + permission app_project_get = bearer & role->app_project_get +} + +definition app/project { + relation org: app/organization + relation granted: app/rolebinding + + permission get = granted->app_project_get & granted->app_project_get +} + +definition app/organization { + relation granted: app/rolebinding + + permission project_get = granted->app_project_get +} +` + compiled, err := compiler.Compile(compiler.InputSchema{ + Source: "test", + SchemaString: src, + }, compiler.ObjectTypePrefix(inheritanceTestTenant)) + require.NoError(t, err) + + _, err = schema.ExtractInheritance(compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "intersection") +} diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go index 0dcbb6477..00acae782 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -10,6 +10,7 @@ import ( "github.com/raystack/frontier/billing/plan" azcore "github.com/authzed/spicedb/pkg/proto/core/v1" + "github.com/authzed/spicedb/pkg/schemadsl/compiler" "github.com/raystack/frontier/core/namespace" "github.com/raystack/frontier/core/permission" @@ -105,8 +106,18 @@ type Service struct { planService PlanService planLocalRepo BillingPlanRepository + + // inheritance is populated by MigrateSchema; the same pointer is shared + // with membership.Service via cmd/serve.go. Pointer (not value) so the + // value-receiver populateInheritance can mutate through it. Written once + // during startup, read-only afterward — concurrent reads from request + // handlers are safe without a lock. + inheritance *schema.Inheritance } +// NewBootstrapService wires the bootstrap service. The inheritance pointer is +// shared with membership.Service so MigrateSchema can write through it and +// membership sees the result at call time — no setter, no second pass. func NewBootstrapService( logger *slog.Logger, config AdminConfig, @@ -122,7 +133,11 @@ func NewBootstrapService( patDeniedPerms map[string]struct{}, planService PlanService, planLocalRepo BillingPlanRepository, + inheritance *schema.Inheritance, ) *Service { + if inheritance == nil { + panic("bootstrap: inheritance pointer must be non-nil; share with membership.NewService via cmd/serve.go") + } return &Service{ logger: logger, adminConfig: config, @@ -138,6 +153,7 @@ func NewBootstrapService( policyService: policyService, serviceuserRepo: serviceuserRepo, patDeniedPerms: patDeniedPerms, + inheritance: inheritance, } } @@ -213,6 +229,12 @@ func (s Service) applySchema(ctx context.Context, customServiceDefinition *schem return fmt.Errorf("migrateServiceDefinitionToDB : %w", err) } + // derive the inheritance map before mutating SpiceDB: if extraction fails + // (e.g. unsupported rewrite shape), fail before any persistent change. + if err = s.populateInheritance(authzedSchemaSource); err != nil { + return fmt.Errorf("populateInheritance: %w", err) + } + // apply azSchema to engine if err = s.authzEngine.WriteSchema(ctx, authzedSchemaSource); err != nil { return fmt.Errorf("%w: %s", schema.ErrMigration, err.Error()) @@ -221,6 +243,25 @@ func (s Service) applySchema(ctx context.Context, customServiceDefinition *schem return nil } +// populateInheritance recompiles the finalized schema and extracts the +// role-permission lists membership listing needs. Recompile (not reuse) so the +// input matches what SpiceDB itself ingests. +func (s Service) populateInheritance(authzedSchemaSource string) error { + compiled, err := compiler.Compile(compiler.InputSchema{ + Source: "effective_schema.zed", + SchemaString: authzedSchemaSource, + }, compiler.ObjectTypePrefix("frontier")) + if err != nil { + return fmt.Errorf("compile finalized schema: %w", err) + } + inh, err := schema.ExtractInheritance(compiled) + if err != nil { + return fmt.Errorf("extract inheritance: %w", err) + } + *s.inheritance = inh + return nil +} + func filterDefaultAppNamespacePermissions(permissions []schema.ResourcePermission) []schema.ResourcePermission { var filteredPermissions []schema.ResourcePermission for _, permission := range permissions { 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}, From 737326f326de6412b47b6221f877c0754b9c1d04 Mon Sep 17 00:00:00 2001 From: aman Date: Mon, 18 May 2026 17:07:55 +0530 Subject: [PATCH 2/2] refactor(membership): hardcode inheritance perms, drop AST walker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the runtime SpiceDB-AST-walker-extracted inheritance map with two small hardcoded constants in internal/bootstrap/schema/schema.go and a regex-based drift test that scans base_schema.zed at build time. Why the simpler approach is the right one: - The two permission lists never change in normal feature work. If they ever do, it's a major authz rewrite — at that point a hardcoded list is the least of the maintainer's worries. - The AST walker required importing SpiceDB compiler internals into app code. Vendor-internal imports make upgrades harder and tie us to a specific SpiceDB version. - ~150 lines of recursive proto-tree traversal in inheritance.go were a maintenance liability for anyone debugging future schema issues. - The regex drift guard is ~80 lines, fails loudly when arrows change, and rejects non-Union expressions (intersection/exclusion) since those would silently break filterByRolePermissions's any-of semantics. Changes: - internal/bootstrap/schema/schema.go — new ProjectDirectVisibilityPerms and OrganizationProjectInheritPerms vars. - internal/bootstrap/schema/inheritance_perms_test.go — drift guard (renamed from inheritance_test.go). - internal/bootstrap/schema/inheritance.go — deleted. - internal/bootstrap/service.go — populateInheritance, inheritance pointer field, panic guard, compiler import all removed. - core/membership/service.go — inheritance field and constructor param removed; helpers reference the new schema constants directly. - core/membership/service_test.go — inheritance arg dropped from all NewService call sites. - cmd/serve.go — shared &schema.Inheritance{} allocation and the args to both constructors removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/serve.go | 9 +- core/membership/service.go | 53 ++--- core/membership/service_test.go | 47 ++--- internal/bootstrap/schema/inheritance.go | 133 ------------- .../schema/inheritance_perms_test.go | 118 ++++++++++++ internal/bootstrap/schema/inheritance_test.go | 182 ------------------ internal/bootstrap/schema/schema.go | 19 ++ internal/bootstrap/service.go | 41 ---- 8 files changed, 180 insertions(+), 422 deletions(-) delete mode 100644 internal/bootstrap/schema/inheritance.go create mode 100644 internal/bootstrap/schema/inheritance_perms_test.go delete mode 100644 internal/bootstrap/schema/inheritance_test.go diff --git a/cmd/serve.go b/cmd/serve.go index 055829c01..64bd9e482 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -80,7 +80,6 @@ import ( "github.com/raystack/frontier/core/permission" "github.com/raystack/frontier/internal/bootstrap" - "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/raystack/frontier/core/deleter" @@ -441,12 +440,7 @@ func buildAPIDependencies( projectService := project.NewService(projectRepository, relationService, userService, policyService, authnService, serviceUserService, groupService, roleService) - // inheritance is populated in-place by bootstrap.Service.MigrateSchema below. - // Sharing the pointer with membership.Service keeps the two views in sync - // without setter injection or a second pass. - inheritance := &schema.Inheritance{} - - membershipService := membership.NewService(logger, policyService, relationService, roleService, organizationService, userService, projectService, groupService, serviceUserService, auditRecordRepository, inheritance) + membershipService := membership.NewService(logger, policyService, relationService, roleService, organizationService, userService, projectService, groupService, serviceUserService, auditRecordRepository) // Setter injection: org/group → membership is circular (membership needs them // for validation; they need membership for Create). Break the cycle post-init. organizationService.SetMembershipService(membershipService) @@ -577,7 +571,6 @@ func buildAPIDependencies( cfg.App.PAT.DeniedPermissionsSet(), planService, planBlobRepository, - inheritance, ) cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService, diff --git a/core/membership/service.go b/core/membership/service.go index aac50ce3f..660aca314 100644 --- a/core/membership/service.go +++ b/core/membership/service.go @@ -79,20 +79,8 @@ type Service struct { groupService GroupService serviceuserService ServiceuserService auditRecordRepository AuditRecordRepository - - // inheritance is the role-permission map extracted from base_schema.zed at - // bootstrap time. ListResourcesByPrincipal consults it to mirror today's - // SpiceDB project.get chain in Go without re-reading SpiceDB. - // - // Held as a pointer so the same value can be shared with bootstrap.Service - // (which writes through it during MigrateSchema). cmd/serve.go allocates - // one *schema.Inheritance and passes it to both services. - inheritance *schema.Inheritance } -// NewService wires the membership service. The inheritance pointer must be -// shared with bootstrap.Service so the schema-derived permission lists stay -// in sync; pass nil only in tests that don't exercise ListResourcesByPrincipal. func NewService( logger *slog.Logger, policyService PolicyService, @@ -104,11 +92,7 @@ func NewService( groupService GroupService, serviceuserService ServiceuserService, auditRecordRepository AuditRecordRepository, - inheritance *schema.Inheritance, ) *Service { - if inheritance == nil { - panic("membership: inheritance pointer must be non-nil; share with bootstrap.NewBootstrapService via cmd/serve.go (tests not exercising ListResourcesByPrincipal can pass &schema.Inheritance{})") - } return &Service{ log: logger, policyService: policyService, @@ -120,7 +104,6 @@ func NewService( groupService: groupService, serviceuserService: serviceuserService, auditRecordRepository: auditRecordRepository, - inheritance: inheritance, } } @@ -1692,11 +1675,13 @@ func (s *Service) narrowGroupsByOrg(ctx context.Context, ids []string, orgID str // listProjectsForPrincipal unions three sources, dedups, then narrows by // filter.OrgID if set: // -// 1. Direct project policies, gated by ProjectDirectVisibility. -// 2. Group-expanded projects (also gated). Runs even with NonInherited=true, -// matching today's listNonInheritedProjectIDs. -// 3. Org inheritance (skipped if NonInherited=true), gated by -// OrganizationToProjectInherit. Batched via project.Filter.OrgIDs. +// 1. Direct project policies — gated by schema.ProjectDirectVisibilityPerms. +// 2. Group-expanded projects — same gate as direct. Runs even with +// NonInherited=true, matching today's listNonInheritedProjectIDs. +// 3. Org inheritance (skipped if NonInherited=true) — gated by +// schema.OrganizationProjectInheritPerms so only org roles that actually 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 { @@ -1732,7 +1717,8 @@ func (s *Service) listProjectsForPrincipal(ctx context.Context, principalID, pri } // listDirectProjectIDs returns project IDs from the principal's direct -// project policies whose role grants any ProjectDirectVisibility permission. +// project policies whose role grants any schema.ProjectDirectVisibilityPerms. +// Mirrors today's LookupResources(project, ..., project.get) gating. func (s *Service) listDirectProjectIDs(ctx context.Context, principalID, principalType string) ([]string, error) { policies, err := s.policyService.List(ctx, policy.Filter{ PrincipalID: principalID, @@ -1742,11 +1728,12 @@ func (s *Service) listDirectProjectIDs(ctx context.Context, principalID, princip if err != nil { return nil, fmt.Errorf("list direct project policies: %w", err) } - return s.filterByRolePermissions(ctx, policies, s.inheritance.ProjectDirectVisibility) + return s.filterByRolePermissions(ctx, policies, schema.ProjectDirectVisibilityPerms) } // listGroupExpandedProjectIDs: principal → groups → project policies on those -// groups → filtered by ProjectDirectVisibility. +// groups → gated by schema.ProjectDirectVisibilityPerms (same rule as direct +// project policies). 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. @@ -1765,12 +1752,14 @@ func (s *Service) listGroupExpandedProjectIDs(ctx context.Context, principalID, if err != nil { return nil, fmt.Errorf("list project policies for principal groups: %w", err) } - return s.filterByRolePermissions(ctx, policies, s.inheritance.ProjectDirectVisibility) + return s.filterByRolePermissions(ctx, policies, schema.ProjectDirectVisibilityPerms) } // listOrgInheritedProjectIDs: principal's org policies → orgs whose role -// grants OrganizationToProjectInherit → all projects in those orgs. Batched -// via project.Filter.OrgIDs to avoid N+1 across multi-org users. +// grants schema.OrganizationProjectInheritPerms → all projects in those orgs. Batched +// via project.Filter.OrgIDs to avoid N+1 across multi-org users. Unlike +// direct/group, org policies need the role-permission gate: not every org +// role implies project visibility (Org Viewer doesn't; Org Owner does). func (s *Service) listOrgInheritedProjectIDs(ctx context.Context, principalID, principalType string) ([]string, error) { policies, err := s.policyService.List(ctx, policy.Filter{ PrincipalID: principalID, @@ -1780,7 +1769,7 @@ func (s *Service) listOrgInheritedProjectIDs(ctx context.Context, principalID, p if err != nil { return nil, fmt.Errorf("list org policies for inheritance: %w", err) } - inheritingOrgIDs, err := s.filterByRolePermissions(ctx, policies, s.inheritance.OrganizationToProjectInherit) + inheritingOrgIDs, err := s.filterByRolePermissions(ctx, policies, schema.OrganizationProjectInheritPerms) if err != nil { return nil, err } @@ -1819,9 +1808,9 @@ func (s *Service) narrowProjectsByOrg(ctx context.Context, ids []string, orgID s // batched roleService.List call — O(1) round-trips regardless of policy count. // // Note: ignores policy.GrantRelation. Today's permission lists already cover -// both granted-> and pat_granted-> arrows for org.project_get, and project.get -// / group.get have no pat_granted-> arrows. If the schema ever gains -// pat_granted-> at project or group level, dispatch on GrantRelation here. +// both granted-> and pat_granted-> arrows for org.project_get; project.get +// has no pat_granted-> arrows. If the schema ever gains pat_granted-> at +// project or group level, dispatch on GrantRelation here. func (s *Service) filterByRolePermissions(ctx context.Context, policies []policy.Policy, permissions []string) ([]string, error) { if len(policies) == 0 || len(permissions) == 0 { return nil, nil diff --git a/core/membership/service_test.go b/core/membership/service_test.go index f9f4ca9ac..f93b2907c 100644 --- a/core/membership/service_test.go +++ b/core/membership/service_test.go @@ -261,7 +261,7 @@ func TestService_AddOrganizationMember(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) principalType := tt.principalType if principalType == "" { @@ -304,7 +304,7 @@ func TestService_AddOrganizationMember_ServiceUser(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, mock.Anything).Return(relation.Relation{}, nil) mockAuditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo) err := svc.AddOrganizationMember(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.NoError(t, err) }) @@ -316,7 +316,7 @@ func TestService_AddOrganizationMember_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: "other-org", State: string(serviceuser.Enabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) err := svc.AddOrganizationMember(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, membership.ErrPrincipalNotInOrg) }) @@ -328,7 +328,7 @@ func TestService_AddOrganizationMember_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: orgID, State: string(serviceuser.Disabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) err := svc.AddOrganizationMember(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, serviceuser.ErrDisabled) }) @@ -523,7 +523,7 @@ func TestService_SetOrganizationMemberRole(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) principalType := tt.principalType if principalType == "" { @@ -571,7 +571,7 @@ func TestService_SetOrganizationMemberRole_ServiceUser(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, mock.Anything).Return(relation.Relation{}, nil) mockAuditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mockAuditRepo) err := svc.SetOrganizationMemberRole(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.NoError(t, err) }) @@ -583,7 +583,7 @@ func TestService_SetOrganizationMemberRole_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: "other-org", State: string(serviceuser.Enabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) err := svc.SetOrganizationMemberRole(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, membership.ErrPrincipalNotInOrg) }) @@ -595,7 +595,7 @@ func TestService_SetOrganizationMemberRole_ServiceUser(t *testing.T) { mockOrgSvc.EXPECT().Get(ctx, orgID).Return(enabledOrg, nil) mockSuSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: orgID, State: string(serviceuser.Disabled)}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mocks.NewPolicyService(t), mocks.NewRelationService(t), mocks.NewRoleService(t), mockOrgSvc, mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mockSuSvc, mocks.NewAuditRecordRepository(t)) err := svc.SetOrganizationMemberRole(ctx, orgID, suID, schema.ServiceUserPrincipal, viewerRoleID) assert.ErrorIs(t, err, serviceuser.ErrDisabled) }) @@ -808,7 +808,7 @@ func TestService_RemoveOrganizationMember(t *testing.T) { tt.setup(d) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), d.policySvc, d.relSvc, d.roleSvc, d.orgSvc, mocks.NewUserService(t), d.projSvc, d.grpSvc, mocks.NewServiceuserService(t), d.auditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), d.policySvc, d.relSvc, d.roleSvc, d.orgSvc, mocks.NewUserService(t), d.projSvc, d.grpSvc, mocks.NewServiceuserService(t), d.auditRepo) principalType := tt.principalType if principalType == "" { @@ -949,7 +949,7 @@ func TestService_SetProjectMemberRole(t *testing.T) { tt.setup(mockPolicySvc, mockRoleSvc, mockPrjSvc, mockUserSvc, mockSuSvc, mockGrpSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mockPrjSvc, mockGrpSvc, mockSuSvc, mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mockPrjSvc, mockGrpSvc, mockSuSvc, mockAuditRepo) err := svc.SetProjectMemberRole(ctx, projectID, tt.principalID, tt.principalType, tt.roleID) if tt.wantErr != nil { @@ -1030,7 +1030,7 @@ func TestService_RemoveProjectMember(t *testing.T) { tt.setup(mockPolicySvc, mockPrjSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mockPrjSvc, mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mockPrjSvc, mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) err := svc.RemoveProjectMember(ctx, projectID, tt.principalID, tt.principalType) if tt.wantErr != nil { @@ -1222,7 +1222,7 @@ func TestService_ListPrincipalsByResource(t *testing.T) { tt.setup(mockPolicySvc, mockRoleSvc) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) got, err := svc.ListPrincipalsByResource(ctx, tt.resourceID, tt.resourceType, tt.filter) if tt.wantErrIs != nil { @@ -1396,7 +1396,7 @@ func TestService_SetGroupMemberRole(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockGrpSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo) principalType := tt.principalType if principalType == "" { @@ -1466,7 +1466,7 @@ func TestService_OnGroupCreated(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, creatorOwnerRelation).Return(relation.Relation{}, nil) mockAuditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.NoError(t, err) @@ -1478,7 +1478,7 @@ func TestService_OnGroupCreated(t *testing.T) { mockRelSvc.EXPECT().Create(ctx, groupOrgRelation).Return(relation.Relation{}, errors.New("spicedb unavailable")) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.ErrorContains(t, err, "link group to org") @@ -1493,7 +1493,7 @@ func TestService_OnGroupCreated(t *testing.T) { // rollback: delete the first hierarchy relation mockRelSvc.EXPECT().Delete(ctx, groupOrgRelation).Return(nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.ErrorContains(t, err, "add group as org member") @@ -1521,7 +1521,7 @@ func TestService_OnGroupCreated(t *testing.T) { mockRelSvc.EXPECT().Delete(ctx, groupOrgRelation).Return(nil) mockRelSvc.EXPECT().Delete(ctx, orgGroupMemberRelation).Return(nil) - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t)) err := svc.OnGroupCreated(ctx, groupID, orgID, creatorID, schema.UserPrincipal) assert.ErrorContains(t, err, "db down") @@ -1647,7 +1647,7 @@ func TestService_RemoveGroupMember(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockGrpSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo, &schema.Inheritance{}) + svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), mockPolicySvc, mockRelSvc, mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mocks.NewProjectService(t), mockGrpSvc, mocks.NewServiceuserService(t), mockAuditRepo) principalType := tt.principalType if principalType == "" { @@ -1791,8 +1791,9 @@ func TestService_OnGroupDeleted(t *testing.T) { // path that replaces today's SpiceDB LookupResources-based ListByUser methods. // Table-driven, mirroring TestService_ListPrincipalsByResource above. // -// The shared inheritance fixture mirrors the canonical lists extracted from -// base_schema.zed at runtime; see internal/bootstrap/schema/inheritance.go. +// Org-inheritance gating is keyed off schema.OrganizationProjectInheritPerms (the +// hardcoded constant whose schema-parity is enforced by inheritance_test.go). +// Direct project / group-expanded policies are not role-permission-gated. func TestService_ListResourcesByPrincipal(t *testing.T) { ctx := context.Background() @@ -1822,11 +1823,6 @@ func TestService_ListResourcesByPrincipal(t *testing.T) { projectViewerRole := role.Role{ID: roleProjectViewerID, Name: schema.RoleProjectViewer, Permissions: []string{"app_project_get"}} projectOwnerRole := role.Role{ID: roleProjectOwnerID, Name: schema.RoleProjectOwner, Permissions: []string{"app_project_administer"}} - inheritance := &schema.Inheritance{ - ProjectDirectVisibility: []string{"app_project_administer", "app_project_get", "app_project_update"}, - OrganizationToProjectInherit: []string{"app_organization_administer", "app_project_get", "app_project_administer"}, - } - type mockSet struct { policy *mocks.PolicyService role *mocks.RoleService @@ -2301,7 +2297,6 @@ func TestService_ListResourcesByPrincipal(t *testing.T) { mg, mocks.NewServiceuserService(t), mocks.NewAuditRecordRepository(t), - inheritance, ) got, err := svc.ListResourcesByPrincipal(ctx, tt.principal, tt.resourceType, tt.filter) diff --git a/internal/bootstrap/schema/inheritance.go b/internal/bootstrap/schema/inheritance.go deleted file mode 100644 index ecb3b764a..000000000 --- a/internal/bootstrap/schema/inheritance.go +++ /dev/null @@ -1,133 +0,0 @@ -package schema - -import ( - "fmt" - - azcore "github.com/authzed/spicedb/pkg/proto/core/v1" - "github.com/authzed/spicedb/pkg/schemadsl/compiler" -) - -// Inheritance is the schema-derived permission lists membership listing needs -// to mirror SpiceDB's project.get / org.project_get chains in Go. Extracted -// from the effective schema at bootstrap so the lists can't drift. -type Inheritance struct { - // ProjectDirectVisibility: role permissions that make a directly-policied - // project visible. Matches granted-> arrows of app/project.get. - ProjectDirectVisibility []string - - // OrganizationToProjectInherit: role permissions on an org policy that - // expand to all projects in that org. Matches granted-> AND pat_granted-> - // arrows of app/organization.project_get — both walked because PAT - // all-projects scopes are stored as one pat_granted org policy and need - // the PAT-only arrows to resolve in the recursive PAT pass. - OrganizationToProjectInherit []string -} - -// ExtractInheritance walks the compiled effective schema. Call after -// ApplyServiceDefinitionOverAZSchema so any custom-resource overlays are in. -func ExtractInheritance(compiled *compiler.CompiledSchema) (Inheritance, error) { - direct, err := extractInheritanceArrows(compiled, ProjectNamespace, GetPermission) - if err != nil { - return Inheritance{}, fmt.Errorf("extract %s.%s arrows: %w", ProjectNamespace, GetPermission, err) - } - orgInherit, err := extractInheritanceArrows(compiled, OrganizationNamespace, "project_get") - if err != nil { - return Inheritance{}, fmt.Errorf("extract %s.project_get arrows: %w", OrganizationNamespace, err) - } - return Inheritance{ - ProjectDirectVisibility: direct, - OrganizationToProjectInherit: orgInherit, - }, nil -} - -// extractInheritanceArrows returns the role-relation names on every granted-> -// or pat_granted-> arrow under .. Errors loudly on -// non-Union rewrites (Intersection/Exclusion would need different handling). -func extractInheritanceArrows(compiled *compiler.CompiledSchema, objectName, permissionName string) ([]string, error) { - if compiled == nil { - return nil, fmt.Errorf("compiled schema is nil") - } - - var def *azcore.NamespaceDefinition - for _, d := range compiled.ObjectDefinitions { - if d.GetName() == objectName { - def = d - break - } - } - if def == nil { - return nil, fmt.Errorf("object %q not found in schema", objectName) - } - - var rel *azcore.Relation - for _, r := range def.GetRelation() { - if r.GetName() == permissionName { - rel = r - break - } - } - if rel == nil { - return nil, fmt.Errorf("permission %q not found on %q", permissionName, objectName) - } - - rewrite := rel.GetUsersetRewrite() - if rewrite == nil { - return nil, fmt.Errorf("%s.%s is not a permission (no userset_rewrite)", objectName, permissionName) - } - - seen := make(map[string]struct{}) - var arrows []string - if err := collectGrantedArrows(rewrite, objectName, permissionName, &arrows, seen); err != nil { - return nil, err - } - return arrows, nil -} - -// collectGrantedArrows walks a UsersetRewrite tree and collects the computed -// userset names from TupleToUserset children whose tupleset relation is -// granted or pat_granted. Recurses into nested rewrites; errors on non-Union. -func collectGrantedArrows(rewrite *azcore.UsersetRewrite, objectName, permissionName string, out *[]string, seen map[string]struct{}) error { - union := rewrite.GetUnion() - if union == nil { - op := "unknown" - switch { - case rewrite.GetIntersection() != nil: - op = "intersection" - case rewrite.GetExclusion() != nil: - op = "exclusion" - } - return fmt.Errorf("%s.%s uses %s; only union is supported for inheritance extraction", objectName, permissionName, op) - } - - for _, child := range union.GetChild() { - switch { - case child.GetTupleToUserset() != nil: - ttu := child.GetTupleToUserset() - tuplesetRel := ttu.GetTupleset().GetRelation() - if tuplesetRel != RoleGrantRelationName && tuplesetRel != PATGrantRelationName { - continue - } - cu := ttu.GetComputedUserset() - if cu == nil { - continue - } - permName := cu.GetRelation() - if permName == "" { - continue - } - if _, ok := seen[permName]; ok { - continue - } - seen[permName] = struct{}{} - *out = append(*out, permName) - case child.GetUsersetRewrite() != nil: - if err := collectGrantedArrows(child.GetUsersetRewrite(), objectName, permissionName, out, seen); err != nil { - return err - } - default: - // _this, computed_userset, _nil: not granted->/pat_granted-> arrows, - // nothing to collect. - } - } - return nil -} 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/inheritance_test.go b/internal/bootstrap/schema/inheritance_test.go deleted file mode 100644 index 63617b374..000000000 --- a/internal/bootstrap/schema/inheritance_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package schema_test - -import ( - "regexp" - "sort" - "strings" - "testing" - - "github.com/authzed/spicedb/pkg/schemadsl/compiler" - "github.com/raystack/frontier/internal/bootstrap/schema" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const inheritanceTestTenant = "frontier" - -func compileBaseSchema(t *testing.T) *compiler.CompiledSchema { - t.Helper() - compiled, err := compiler.Compile(compiler.InputSchema{ - Source: "base_schema.zed", - SchemaString: schema.BaseSchemaZed, - }, compiler.ObjectTypePrefix(inheritanceTestTenant)) - require.NoError(t, err) - return compiled -} - -// TestExtractInheritance_Sanity asserts the canonical permissions which the -// schema embeds today actually show up in the extracted maps. If the schema -// changes, this test changes alongside it. -func TestExtractInheritance_Sanity(t *testing.T) { - compiled := compileBaseSchema(t) - got, err := schema.ExtractInheritance(compiled) - require.NoError(t, err) - - t.Run("project direct visibility includes administer/get/update", func(t *testing.T) { - assert.ElementsMatch(t, []string{ - "app_project_administer", - "app_project_get", - "app_project_update", - }, got.ProjectDirectVisibility) - }) - - t.Run("org->project inheritance includes both granted and pat_granted arrows", func(t *testing.T) { - assert.ElementsMatch(t, []string{ - "app_organization_administer", - "app_project_get", - "app_project_administer", - }, got.OrganizationToProjectInherit) - }) -} - -// TestExtractInheritance_OracleParity is the drift guard. A regex oracle reads -// the raw base_schema.zed source and computes the same lists by string parsing -// — if the AST walker ever misses an arrow (or picks up an extra one), the two -// disagree and this test fails. -// -// The oracle strips `//`-to-EOL comments and asserts each permission lives on -// a single line. If the schema ever wraps a permission across multiple lines, -// the assertion panics with a clear message so a human chooses how to update -// the oracle rather than letting the test silently produce wrong results. -func TestExtractInheritance_OracleParity(t *testing.T) { - compiled := compileBaseSchema(t) - got, err := schema.ExtractInheritance(compiled) - require.NoError(t, err) - - projectGet := oracleArrows(t, schema.BaseSchemaZed, "app/project", "get", false) - orgProjectGet := oracleArrows(t, schema.BaseSchemaZed, "app/organization", "project_get", true) - - assert.ElementsMatch(t, projectGet, got.ProjectDirectVisibility, - "AST walker drifted from regex oracle for project.get") - assert.ElementsMatch(t, orgProjectGet, got.OrganizationToProjectInherit, - "AST walker drifted from regex oracle for org.project_get") -} - -// oracleArrows is a deliberately dumb regex-based extractor: it locates the -// definition for `objectName`, finds the line beginning with `permission -// permissionName =`, and pulls out the relation names appearing after -// `granted->` (and `pat_granted->` if includePATGranted is set). -func oracleArrows(t *testing.T, source, objectName, permissionName string, includePATGranted bool) []string { - t.Helper() - body := definitionBody(t, source, objectName) - - commentRe := regexp.MustCompile(`//[^\n]*`) - cleanedBody := commentRe.ReplaceAllString(body, "") - - permRe := regexp.MustCompile(`(?m)^\s*permission\s+` + regexp.QuoteMeta(permissionName) + `\s*=(.+)$`) - matches := permRe.FindAllStringSubmatch(cleanedBody, -1) - if len(matches) == 0 { - t.Fatalf("oracle could not find `permission %s = ...` inside definition %q", permissionName, objectName) - } - if len(matches) > 1 { - t.Fatalf("oracle expected exactly one `permission %s = ...` line inside definition %q, found %d", permissionName, objectName, len(matches)) - } - // The regex anchors at line start/end with (?m). If the permission ever wraps - // across lines, the captured group will be incomplete; panic to flag that - // the oracle no longer matches the schema layout. - expr := strings.TrimSpace(matches[0][1]) - if strings.HasSuffix(expr, "+") || strings.HasSuffix(expr, "&") || strings.HasSuffix(expr, "-") { - t.Fatalf("oracle assumption violated: `permission %s` in %q appears to wrap across lines; update the oracle", 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) - } - sort.Strings(out) - return out -} - -// definitionBody returns the body of a `definition { ... }` block -// from raw zed source. Matches by braces rather than indentation. -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 "" -} - -// TestExtractInheritance_RejectsNonUnion exercises the "fail loud" path: a -// permission defined with an intersection rather than a union must error so -// callers don't silently get the wrong inheritance list. -func TestExtractInheritance_RejectsNonUnion(t *testing.T) { - src := ` -definition app/role { - relation app_project_get: app/user -} - -definition app/rolebinding { - relation bearer: app/user - relation role: app/role - - permission app_project_get = bearer & role->app_project_get -} - -definition app/project { - relation org: app/organization - relation granted: app/rolebinding - - permission get = granted->app_project_get & granted->app_project_get -} - -definition app/organization { - relation granted: app/rolebinding - - permission project_get = granted->app_project_get -} -` - compiled, err := compiler.Compile(compiler.InputSchema{ - Source: "test", - SchemaString: src, - }, compiler.ObjectTypePrefix(inheritanceTestTenant)) - require.NoError(t, err) - - _, err = schema.ExtractInheritance(compiled) - require.Error(t, err) - assert.Contains(t, err.Error(), "intersection") -} 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/bootstrap/service.go b/internal/bootstrap/service.go index 00acae782..0dcbb6477 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -10,7 +10,6 @@ import ( "github.com/raystack/frontier/billing/plan" azcore "github.com/authzed/spicedb/pkg/proto/core/v1" - "github.com/authzed/spicedb/pkg/schemadsl/compiler" "github.com/raystack/frontier/core/namespace" "github.com/raystack/frontier/core/permission" @@ -106,18 +105,8 @@ type Service struct { planService PlanService planLocalRepo BillingPlanRepository - - // inheritance is populated by MigrateSchema; the same pointer is shared - // with membership.Service via cmd/serve.go. Pointer (not value) so the - // value-receiver populateInheritance can mutate through it. Written once - // during startup, read-only afterward — concurrent reads from request - // handlers are safe without a lock. - inheritance *schema.Inheritance } -// NewBootstrapService wires the bootstrap service. The inheritance pointer is -// shared with membership.Service so MigrateSchema can write through it and -// membership sees the result at call time — no setter, no second pass. func NewBootstrapService( logger *slog.Logger, config AdminConfig, @@ -133,11 +122,7 @@ func NewBootstrapService( patDeniedPerms map[string]struct{}, planService PlanService, planLocalRepo BillingPlanRepository, - inheritance *schema.Inheritance, ) *Service { - if inheritance == nil { - panic("bootstrap: inheritance pointer must be non-nil; share with membership.NewService via cmd/serve.go") - } return &Service{ logger: logger, adminConfig: config, @@ -153,7 +138,6 @@ func NewBootstrapService( policyService: policyService, serviceuserRepo: serviceuserRepo, patDeniedPerms: patDeniedPerms, - inheritance: inheritance, } } @@ -229,12 +213,6 @@ func (s Service) applySchema(ctx context.Context, customServiceDefinition *schem return fmt.Errorf("migrateServiceDefinitionToDB : %w", err) } - // derive the inheritance map before mutating SpiceDB: if extraction fails - // (e.g. unsupported rewrite shape), fail before any persistent change. - if err = s.populateInheritance(authzedSchemaSource); err != nil { - return fmt.Errorf("populateInheritance: %w", err) - } - // apply azSchema to engine if err = s.authzEngine.WriteSchema(ctx, authzedSchemaSource); err != nil { return fmt.Errorf("%w: %s", schema.ErrMigration, err.Error()) @@ -243,25 +221,6 @@ func (s Service) applySchema(ctx context.Context, customServiceDefinition *schem return nil } -// populateInheritance recompiles the finalized schema and extracts the -// role-permission lists membership listing needs. Recompile (not reuse) so the -// input matches what SpiceDB itself ingests. -func (s Service) populateInheritance(authzedSchemaSource string) error { - compiled, err := compiler.Compile(compiler.InputSchema{ - Source: "effective_schema.zed", - SchemaString: authzedSchemaSource, - }, compiler.ObjectTypePrefix("frontier")) - if err != nil { - return fmt.Errorf("compile finalized schema: %w", err) - } - inh, err := schema.ExtractInheritance(compiled) - if err != nil { - return fmt.Errorf("extract inheritance: %w", err) - } - *s.inheritance = inh - return nil -} - func filterDefaultAppNamespacePermissions(permissions []schema.ResourcePermission) []schema.ResourcePermission { var filteredPermissions []schema.ResourcePermission for _, permission := range permissions {