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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,11 +441,13 @@ func buildAPIDependencies(
authnService, serviceUserService, groupService, roleService)

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.
// Setter injection: org/group/project → membership is circular (membership
// needs them for validation; they need membership for resource-by-principal
// listing). Break the cycle post-init.
organizationService.SetMembershipService(membershipService)
serviceUserService.SetMembershipService(membershipService)
groupService.SetMembershipService(membershipService)
projectService.SetMembershipService(membershipService)

orgKycRepository := postgres.NewOrgKycRepository(dbc)
orgKycService := kyc.NewService(orgKycRepository)
Expand Down
43 changes: 20 additions & 23 deletions core/aggregates/orgpats/mocks/project_service.go

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

9 changes: 5 additions & 4 deletions core/aggregates/orgpats/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type Repository interface {
}

type ProjectService interface {
ListByUser(ctx context.Context, principal authenticate.Principal, flt project.Filter) ([]project.Project, error)
List(ctx context.Context, flt project.Filter) ([]project.Project, error)
}

type Service struct {
Expand Down Expand Up @@ -80,8 +80,9 @@ func (s *Service) Search(ctx context.Context, orgID string, query *rql.Query) (O
return result, nil
}

// resolveAllProjectsScope populates ResourceIDs for all-projects scopes by calling SpiceDB.
// Groups PATs by user_id to minimize SpiceDB calls.
// resolveAllProjectsScope populates ResourceIDs for all-projects scopes by
// listing projects the underlying user can see via membership. Groups PATs by
// user_id to minimize project-service calls.
func (s *Service) resolveAllProjectsScope(ctx context.Context, orgID string, pats []AggregatedPAT) error {
// Collect users that have all-projects scopes
type allProjectsRef struct {
Expand All @@ -108,7 +109,7 @@ func (s *Service) resolveAllProjectsScope(ctx context.Context, orgID string, pat
ID: userID,
Type: schema.UserPrincipal,
}
projects, err := s.projectService.ListByUser(ctx, principal, project.Filter{OrgID: orgID})
projects, err := s.projectService.List(ctx, project.Filter{Principal: &principal, OrgID: orgID})
if err != nil {
return err
}
Expand Down
12 changes: 5 additions & 7 deletions core/aggregates/orgpats/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,15 @@ func TestService_Search(t *testing.T) {
}

repo.EXPECT().Search(mock.Anything, orgID, query).Return(repoResult, nil)
projSvc.EXPECT().ListByUser(mock.Anything, mock.Anything, mock.Anything).
Return([]project.Project{{ID: "proj-1"}, {ID: "proj-2"}}, nil).Maybe()
projSvc.EXPECT().List(mock.Anything, mock.MatchedBy(func(f project.Filter) bool {
return f.OrgID == orgID && f.Principal != nil && f.Principal.ID == "user-1" && f.Principal.Type == schema.UserPrincipal
})).Return([]project.Project{{ID: "proj-1"}, {ID: "proj-2"}}, nil).Once()

svc := orgpats.NewService(repo, projSvc)
result, err := svc.Search(ctx, orgID, query)
assert.NoError(t, err)
assert.Len(t, result.PATs, 1)
// After resolution, the all-projects scope should have project IDs
if len(result.PATs[0].Scopes[0].ResourceIDs) > 0 {
assert.Contains(t, result.PATs[0].Scopes[0].ResourceIDs, "proj-1")
}
assert.ElementsMatch(t, []string{"proj-1", "proj-2"}, result.PATs[0].Scopes[0].ResourceIDs)
})

t.Run("skips resolution when no all-projects scopes", func(t *testing.T) {
Expand All @@ -127,7 +125,7 @@ func TestService_Search(t *testing.T) {
}

repo.EXPECT().Search(mock.Anything, orgID, query).Return(repoResult, nil)
// ProjectService.ListByUser should NOT be called
// ProjectService.List should NOT be called
svc := orgpats.NewService(repo, projSvc)
result, err := svc.Search(ctx, orgID, query)
assert.NoError(t, err)
Expand Down
6 changes: 6 additions & 0 deletions core/membership/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,12 @@ func (s *Service) ListGroupsByPrincipal(ctx context.Context, principal authentic
return s.listResourcesForPrincipal(ctx, subjectID, subjectType, schema.GroupNamespace, ResourceFilter{OrgID: orgID})
}

// ListProjectsByPrincipal Shim for the project package (project → membership
// would cycle). Delegates to ListResourcesByPrincipal so PAT scope is intersected.
func (s *Service) ListProjectsByPrincipal(ctx context.Context, principal authenticate.Principal, orgID string, nonInherited bool) ([]string, error) {
return s.ListResourcesByPrincipal(ctx, principal, schema.ProjectNamespace, ResourceFilter{OrgID: orgID, NonInherited: nonInherited})
}

// 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
Expand Down
121 changes: 121 additions & 0 deletions core/membership/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2393,3 +2393,124 @@ func TestService_ListGroupsByPrincipal(t *testing.T) {
})
}
}

func TestService_ListProjectsByPrincipal(t *testing.T) {
ctx := context.Background()

userID := uuid.New().String()
patID := uuid.New().String()
orgA := uuid.New().String()
projDirect := uuid.New().String()
projPATScope := uuid.New().String()

t.Run("user principal with NonInherited=true skips org-inheritance branch", func(t *testing.T) {
mp := mocks.NewPolicyService(t)
// Direct project policies fetch.
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
ResourceType: schema.ProjectNamespace,
RolePermissions: schema.ProjectDirectVisibilityPerms,
}).Return([]policy.Policy{{ResourceID: projDirect}}, nil)
// Group expansion: principal has no groups (NonInherited=true on inner call).
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
ResourceType: schema.GroupNamespace,
}).Return([]policy.Policy{}, nil)
// NO org-inheritance fetch must happen — that's the NonInherited contract.

svc := membership.NewService(
slog.New(slog.NewTextHandler(io.Discard, nil)),
mp,
mocks.NewRelationService(t),
mocks.NewRoleService(t),
mocks.NewOrgService(t),
mocks.NewUserService(t),
mocks.NewProjectService(t),
mocks.NewGroupService(t),
mocks.NewServiceuserService(t),
mocks.NewAuditRecordRepository(t),
)

got, err := svc.ListProjectsByPrincipal(
ctx,
authenticate.Principal{ID: userID, Type: schema.UserPrincipal},
"",
true,
)
assert.NoError(t, err)
assert.ElementsMatch(t, []string{projDirect}, got)
})

t.Run("PAT principal — runs both user-side and PAT-side queries and intersects (unlike groups)", func(t *testing.T) {
mp := mocks.NewPolicyService(t)
// User-side: direct project policies + (no groups) + org-inheritance branch.
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
ResourceType: schema.ProjectNamespace,
RolePermissions: schema.ProjectDirectVisibilityPerms,
}).Return([]policy.Policy{
{ResourceID: projDirect},
{ResourceID: projPATScope},
}, nil)
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
ResourceType: schema.GroupNamespace,
}).Return([]policy.Policy{}, nil)
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
ResourceType: schema.OrganizationNamespace,
RolePermissions: schema.OrganizationProjectInheritPerms,
}).Return([]policy.Policy{}, nil)

// PAT-side: same fanout under PAT principal type — PAT only scopes projPATScope.
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: patID,
PrincipalType: schema.PATPrincipal,
ResourceType: schema.ProjectNamespace,
RolePermissions: schema.ProjectDirectVisibilityPerms,
}).Return([]policy.Policy{{ResourceID: projPATScope}}, nil)
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: patID,
PrincipalType: schema.PATPrincipal,
ResourceType: schema.GroupNamespace,
}).Return([]policy.Policy{}, nil)
mp.EXPECT().List(ctx, policy.Filter{
PrincipalID: patID,
PrincipalType: schema.PATPrincipal,
ResourceType: schema.OrganizationNamespace,
RolePermissions: schema.OrganizationProjectInheritPerms,
}).Return([]policy.Policy{}, nil)

svc := membership.NewService(
slog.New(slog.NewTextHandler(io.Discard, nil)),
mp,
mocks.NewRelationService(t),
mocks.NewRoleService(t),
mocks.NewOrgService(t),
mocks.NewUserService(t),
mocks.NewProjectService(t),
mocks.NewGroupService(t),
mocks.NewServiceuserService(t),
mocks.NewAuditRecordRepository(t),
)

got, err := svc.ListProjectsByPrincipal(
ctx,
authenticate.Principal{
ID: userID,
Type: schema.UserPrincipal,
PAT: &pat.PAT{ID: patID, UserID: userID, OrgID: orgA},
},
"",
false,
)
assert.NoError(t, err)
// PAT narrows: user sees [direct, patScope]; PAT sees [patScope]; intersect → [patScope].
assert.ElementsMatch(t, []string{projPATScope}, got)
})
}
11 changes: 10 additions & 1 deletion core/project/filter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package project

import "github.com/raystack/frontier/pkg/pagination"
import (
"github.com/raystack/frontier/core/authenticate"
"github.com/raystack/frontier/pkg/pagination"
)

type Filter struct {
OrgID string
Expand All @@ -17,4 +20,10 @@ type Filter struct {
// are set, projects must satisfy both (intersection) — typically yields
// no rows unless OrgID is one of OrgIDs.
OrgIDs []string

// Principal narrows results to projects on which the principal has a
// policy (direct, via group membership, or org-inheritance unless
// NonInherited is set). When combined with ProjectIDs the two are
// intersected. Resolved by membership.Service.
Principal *authenticate.Principal
}
Loading
Loading