Skip to content

refactor(project): migrate ListByUser callers to membership, delete legacy method#1648

Merged
AmanGIT07 merged 5 commits into
mainfrom
migrate-project-listbyuser-to-membership
May 26, 2026
Merged

refactor(project): migrate ListByUser callers to membership, delete legacy method#1648
AmanGIT07 merged 5 commits into
mainfrom
migrate-project-listbyuser-to-membership

Conversation

@AmanGIT07
Copy link
Copy Markdown
Contributor

@AmanGIT07 AmanGIT07 commented May 25, 2026

Summary

Switches every reader of "projects a principal sees" off project.Service.ListByUser (SpiceDB LookupResources) onto Postgres policy reads via membership.Service.ListProjectsByPrincipal. Adds project.Filter.Principal so the composition lives inside Service.List. Three handler call sites and two internal callers migrated. Parent: #1478.

Changes

  • Add project.Filter.Principal and membership.Service.ListProjectsByPrincipal(ctx, principal, orgID, nonInherited). Service.List branches on Filter.Principal: shim → intersect with ProjectIDs → short-circuit empty → fall through to repo.
  • Migrate handlers: ListProjectsByUser, ListProjectsByCurrentUser, ListServiceUserProjects.
  • Migrate internal callers: orgpats.resolveAllProjectsScope, userpat.validateProjectAccess.
  • Delete project.Service.ListByUser, listNonInheritedProjectIDs, intersectPATScope, and RelationService.LookupResources from the project package (LookupSubjects stays — still used by serviceuser.Service.ListByOrg).
  • Drop ListByUser from orgpats.ProjectService, userpat.ProjectService, and v1beta1connect.ProjectService interfaces.
  • Trim project.GroupService interface to Get only (still consumed by validatePrincipal).
  • Wire projectService.SetMembershipService(membershipService) in cmd/serve.go.

Test Plan

  • make build
  • make lint — 0 issues
  • make test -race — green on core/, internal/, cmd/
  • core/membership/service_test.go::TestService_ListProjectsByPrincipal — PAT-aware delegation + NonInherited contract
  • core/project/service_test.go::TestService_List_WithPrincipal — 7 cases on the delegation glue
  • Handler tests migrated to the new mock shape
  • test/e2e/regression/api_test.go::TestProjectAPI_StaleRelationRegression — org owner demoted to viewer no longer sees the project
  • make e2e-test on a clean Docker network

…egacy method

Switches every reader of "projects a principal sees" off `project.Service.ListByUser`
(SpiceDB `LookupResources` + repo enrichment + intersectPATScope) onto Postgres policy
reads via `membership.Service.ListProjectsByPrincipal`. Adds `project.Filter.Principal`
so the composition lives inside `Service.List` instead of the handler. Three handler
call sites and two internal callers migrated; old `ListByUser`, `listNonInheritedProjectIDs`,
`intersectPATScope`, and the project service's `RelationService.LookupResources`
dependency are deleted. Hardens `policy.Service.List` to refuse principal-narrowed
filters with empty PrincipalID, closing a mass-disclosure path exposed by the move
from SpiceDB to policy reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment May 26, 2026 6:02am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 18a6acfe-e9c4-4a86-9c18-244b0d69e44c

📥 Commits

Reviewing files that changed from the base of the PR and between 00aef47 and 5f04214.

📒 Files selected for processing (4)
  • internal/api/v1beta1connect/serviceuser.go
  • internal/api/v1beta1connect/serviceuser_test.go
  • internal/api/v1beta1connect/user.go
  • internal/api/v1beta1connect/user_test.go

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a regression where users could still see inherited projects after their organizational role was downgraded.
  • Improvements

    • Project listing now consistently respects a principal-based filter, improving permission enforcement and reliability across user, service-user, and PAT flows.
    • Handlers now validate empty IDs and return proper invalid-argument errors for missing user/service-user IDs.

Walkthrough

Refactors project listing to pass principals via project.Filter.Principal and delegates visibility resolution to membership.Service.ListProjectsByPrincipal. ProjectService.List behavior updated to use the membership shim; mocks, handlers, tests, and cmd/serve dependency wiring updated to the new contract.

Changes

Project Listing Principal-Based Refactor

Layer / File(s) Summary
Project Filter Principal Field
core/project/filter.go
Filter gains Principal *authenticate.Principal; imports adjusted.
Membership Service Project Visibility Shim
core/membership/service.go, core/membership/service_test.go
Added ListProjectsByPrincipal delegating to ListResourcesByPrincipal; tests added for NonInherited and PAT/user intersections.
ProjectService Interface and Core Refactor
core/project/service.go, core/aggregates/orgpats/service.go, core/userpat/service.go
Replaced ListByUser with List(filter) across services; added MembershipService interface and SetMembershipService; Service.List now delegates principal resolution to membership shim and intersects ProjectIDs. Removed old relation/group/PAT resolution helpers.
Dependency Wiring
cmd/serve.go
Injected membershipService into projectService (post-init setter) to break circular dependency.
ProjectService Principal-Based Tests
core/project/service_test.go
New TestService_List_WithPrincipal covers wiring, validation, ID intersection, short-circuiting, error propagation, and member-count enrichment.
OrgPATs Integration Tests
core/aggregates/orgpats/service_test.go
Updated mock expectations to use ProjectService.List with Filter.Principal; tightened scope assertions.
Mock Files
core/project/mocks/*, core/aggregates/orgpats/mocks/*, core/userpat/mocks/*, internal/api/v1beta1connect/mocks/*
Regenerated ProjectService mocks for List(filter); added MembershipService mock; removed obsolete GroupService/GetByIDs and RelationService/LookupResources mocks.
API Handler Implementations
internal/api/v1beta1connect/user.go, internal/api/v1beta1connect/serviceuser.go
Handlers now build project.Filter{Principal: &authenticate.Principal{...}} and call projectService.List; added empty-ID validation returning InvalidArgument.
Handler Tests
internal/api/v1beta1connect/user_test.go, internal/api/v1beta1connect/serviceuser_test.go
Updated tests to assert ProjectService.List is called with Filter.Principal; added invalid-argument cases when ID is empty.
UserPAT Service Tests
core/userpat/service_test.go
PAT creation and validation tests updated to expect ProjectService.List with Filter.Principal and OrgID.
Documentation and E2E Regression
internal/store/postgres/project_repository.go, internal/store/postgres/user_projects_repository.go, test/e2e/regression/api_test.go
TODOs added about ID-filter capping and query limitations; e2e regression test added for stale inherited project visibility.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • rohilsurana
  • whoAbhishekSah
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coveralls
Copy link
Copy Markdown

coveralls commented May 25, 2026

Coverage Report for CI Build 26435250899

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.04%) to 42.808%

Details

  • Coverage decreased (-0.04%) from the base build.
  • Patch coverage: 4 uncovered changes across 1 file (41 of 45 lines covered, 91.11%).
  • 2 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
cmd/serve.go 4 0 0.0%
Total (7 files) 45 41 91.11%

Coverage Regressions

2 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/project/service.go 2 84.3%

Coverage Stats

Coverage Status
Relevant Lines: 37848
Covered Lines: 16202
Line Coverage: 42.81%
Coverage Strength: 11.98 hits per line

💛 - Coveralls

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/aggregates/orgpats/service.go (1)

83-85: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update stale comment about SpiceDB usage.

The comment says this path calls SpiceDB, but Line 111 now goes through projectService.List with principal filter (membership/policy-backed path). Please update wording to avoid misleading future maintenance.

🧹 Nitpick comments (3)
core/aggregates/orgpats/service_test.go (1)

93-103: ⚡ Quick win

Make the all-projects resolution test deterministic.

This test can still pass when resolution is skipped because List is optional (Maybe) and the assertion is conditional. Tighten it so the migration path is actually verified.

🔧 Suggested test hardening
-		projSvc.EXPECT().List(mock.Anything, mock.Anything).
-			Return([]project.Project{{ID: "proj-1"}, {ID: "proj-2"}}, nil).Maybe()
+		projSvc.EXPECT().List(mock.Anything, mock.Anything).
+			Return([]project.Project{{ID: "proj-1"}, {ID: "proj-2"}}, nil).Once()

@@
-		// 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")
-		}
+		// After resolution, the all-projects scope should have project IDs
+		assert.ElementsMatch(t, []string{"proj-1", "proj-2"}, result.PATs[0].Scopes[0].ResourceIDs)
core/userpat/service_test.go (1)

649-651: ⚡ Quick win

Assert principal-scoped filter shape in ProjectService.List mocks.

These expectations currently accept any project.Filter, so wiring regressions in Principal/OrgID won’t fail tests.

🔧 Suggested matcher pattern
-	projSvc.On("List", mock.Anything, mock.Anything).Return([]project.Project{
+	projSvc.On("List", mock.Anything, mock.MatchedBy(func(f project.Filter) bool {
+		return f.OrgID == "org-1" &&
+			f.Principal != nil &&
+			f.Principal.ID == "user-1" &&
+			f.Principal.Type == schema.UserPrincipal
+	})).Return([]project.Project{
 		{ID: "proj-a"}, {ID: "proj-b"},
 	}, nil).Maybe()

Also applies to: 1173-1175, 2562-2564, 2600-2602

internal/api/v1beta1connect/serviceuser_test.go (1)

1341-1342: ⚡ Quick win

Add one org-scoped filter assertion for ListServiceUserProjects.

These updates verify Principal, but not OrgID forwarding. Add at least one case with OrgId set and assert project.Filter.OrgID to lock tenant scoping behavior.

Also applies to: 1357-1358, 1402-1403


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: db4fc029-bf0d-4089-be24-6d0d0c2c00d5

📥 Commits

Reviewing files that changed from the base of the PR and between 9e2e75a and c41b99b.

📒 Files selected for processing (27)
  • cmd/serve.go
  • core/aggregates/orgpats/mocks/project_service.go
  • core/aggregates/orgpats/service.go
  • core/aggregates/orgpats/service_test.go
  • core/membership/service.go
  • core/membership/service_test.go
  • core/policy/errors.go
  • core/policy/service.go
  • core/policy/service_test.go
  • core/project/filter.go
  • core/project/mocks/group_service.go
  • core/project/mocks/membership_service.go
  • core/project/mocks/relation_service.go
  • core/project/service.go
  • core/project/service_test.go
  • core/userpat/mocks/project_service.go
  • core/userpat/service.go
  • core/userpat/service_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/project_service.go
  • internal/api/v1beta1connect/serviceuser.go
  • internal/api/v1beta1connect/serviceuser_test.go
  • internal/api/v1beta1connect/user.go
  • internal/api/v1beta1connect/user_test.go
  • internal/store/postgres/project_repository.go
  • internal/store/postgres/user_projects_repository.go
  • test/e2e/regression/api_test.go
💤 Files with no reviewable changes (3)
  • internal/api/v1beta1connect/interfaces.go
  • core/project/mocks/group_service.go
  • core/project/mocks/relation_service.go

Comment thread core/project/service.go
The previous commit added validation to refuse `policy.Filter{PrincipalType: X}`
without a matching `PrincipalID` or `PrincipalIDs`. That over-restricted the
legitimate `ListPrincipalsByResource` shape (`{OrgID/ProjectID/GroupID, PrincipalType}`),
breaking 14 regression tests across `ListGroupUsers`, `ListOrganizationUsers`,
and `ListProjectUsers`.

The behaviour the validation was meant to gate — `ListProjectsByUser(id: "")`
returning every project — is consistent with the codebase convention that
listing RPCs return all rows when called without a narrowing filter, and the
RPC is already super-admin gated by the interceptor. Not a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AmanGIT07
Copy link
Copy Markdown
Contributor Author

AmanGIT07 commented May 25, 2026

RPC test results

Tested the three direct call sites + two indirect call sites, across cookie / PAT / service-account auth. Seed: org with three projects (direct-proj via direct user policy, group-proj via group membership, inherit-proj via org-owner inheritance), one regular user, one service user with a direct policy on direct-proj.

FrontierService/ListProjectsByCurrentUser (cookie, user with all 3 paths)

# Case Result
H1 default, no filter 3 (direct+group+inherit) ✓
H2 nonInherited=true 2 (direct+group) ✓
H3 orgId=<test> 3 ✓
H4 withMemberCount=true mc populated (direct=2, group=1, inherit=1), count=3
E1 no cookie unauthenticated
E2 bogus cookie unauthenticated
E3 orgId=<foreign-org> {}
E4 orgId=00000000-... {}
Q1 pageSize=2,pageNum=1 2, count=3
Q2 pageSize=2,pageNum=2 1, count=3
Q3 pageSize=0,pageNum=0 (IGNORE_IF_ZERO) 3, count=3
Q4 pageSize=-1 invalid_argument
Q5 pageNum=5 (past last) 0, count=3
Q6 pageSize=1+nonInherited+withMemberCount 1, count=2 (count matches post-NonInherited total) ✓
Q7 withPermissions=["get","delete"] dup access_pairs (see #1647) ⚠
Q8 withPermissions=["bogus_perm"] internal (see #1647) ⚠
Q9 withMemberCount + withPermissions combined both populated correctly ✓

ListProjectsByCurrentUser (PAT bearer)

# PAT scope nonInherited Result
P1 [direct] false 1 (direct) ✓
P2 [direct] true 1 (direct) ✓
P3 [direct], no orgId false 1 ✓
P4 [direct, group] false 2 ✓
P5 [] (org-wide) false 3 (all PAT owner can see) ✓
P6 [inherit] false 1 (inherit) ✓
P7 [inherit] true 0 (correct — intersection empty) ✓
P8 [direct, group] + pageSize=1 1, count=2
P9 [direct, group] + withMemberCount + withPermissions=["get"] matches ✓

ListProjectsByCurrentUser (service-account Basic auth)

# Case Result
SA1 GetCurrentUser as svcuser serviceuser block returned ✓
SA2 List with orgId 1 (direct-proj) ✓
SA3 List without orgId 1 (direct-proj) ✓

FrontierService/ListProjectsByUser (superadmin)

# Case Result
H1 admin → user's projects 3 ✓
E1 non-existent UUID {}
E2 garbage id internal (see #1647) ⚠
E3 non-admin caller unavailable (interceptor) ✓
E4 regular user, own id unavailable (admin-only) ✓

FrontierService/ListServiceUserProjects (superadmin)

# Case Result
H1 admin → svcuser's projects 1 (direct-proj) ✓
E1 omit orgId 400 validation (min_len=3) ✓
E2 non-existent svcuser not_found
E3 empty id, valid orgId internal (see #1647) ⚠
E4 empty body 400 validation ✓
Q1 withPermissions=["get","delete"] dup access_pairs (see #1647) ⚠
Q2 withPermissions=["bogus"] not_found (see #1647) ⚠
SA1 svcuser → own listing via Basic permission_denied (no manage perm) ✓

FrontierService/CreateCurrentUserPAT — indirect (userpat.validateProjectAccess)

# Scope Result
C1 [direct] created ✓
C2 [direct, group] created ✓
C3 [] (org-wide) created ✓
C4 [inherit] (org-inherited project) created ✓
C5 [foreign-proj] (different org) invalid_argument: user does not have access to one or more specified projects
C6 [direct, foreign-proj] rejected ✓
C7 [00000000-...] rejected ✓

AdminService/SearchOrganizationPATs — indirect (orgpats.resolveAllProjectsScope)

# PAT scope at create resource_ids in Search response
S1 [inherit] (literal) 1 (preserved) ✓
S2 [] (org-wide) 3 (resolved from membership) ✓
S3 [direct, group] (literal) 2 (preserved) ✓
S4 [direct] (literal) 1 (preserved) ✓

Bugs surfaced

Three pre-existing issues showed up while testing — all filed under #1647. None are caused by this refactor; they live in the same handlers and are independent of the ListByUser → List(Filter.Principal) migration.

ID Where Symptom
B2 ListProjectsByUser, ListServiceUserProjects malformed / empty idInternal instead of InvalidArgument
B3 ListProjectsByCurrentUser, ListServiceUserProjects with_permissions=[a,b] emits each project once per permission instead of grouping (N×M entries)
B4 same unknown permission name → Internal / NotFound instead of empty access_pairs

B1 retest (empty id leak)

After the handler guard + service-level Filter.Principal.ID/Type check:

# Case Result
B1.1 ListProjectsByUser id="" invalid_argument ✓ (was leaking 430 projects before)
B1.2 ListProjectsByUser valid id 3 ✓
B1.3 ListServiceUserProjects id="" + valid orgId internal (leak path closed; remaining 500 is the interceptor's GetServiceUser("") shape — same class as B2 in #1647)
B1.4 ListServiceUserProjects valid id 1 ✓

Review feedback addressed:

- core/project/service.go: refuse Filter.Principal pointers with empty ID
  or Type. Catches programmer error without affecting the admin path,
  which now passes Filter{} (no Principal) instead of an empty struct.
- internal/api/v1beta1connect/user.go::ListProjectsByUser: build the
  Filter.Principal only when the request id is non-empty; empty id keeps
  the listing-returns-all convention.
- internal/api/v1beta1connect/serviceuser.go::ListServiceUserProjects:
  same conditional-Principal pattern.
- core/aggregates/orgpats: update the stale SpiceDB comment now that
  resolution goes through membership/policy; tighten test to assert the
  Principal+OrgID filter shape and call count.
- core/userpat/service_test.go: switch four List mocks to MatchedBy on
  the Principal+OrgID filter so wiring regressions fail loudly.
- internal/api/v1beta1connect/serviceuser_test.go: add a case that
  exercises the OrgId forwarding path explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
core/userpat/service_test.go (2)

649-651: ⚡ Quick win

Make ProjectService.List mandatory in the specific-project scope test.

For this scenario, access validation depends on calling ProjectService.List; using .Maybe() can let a missed call slip through.

Suggested test tightening
-	projSvc.On("List", mock.Anything, mock.MatchedBy(func(f project.Filter) bool {
+	projSvc.On("List", mock.Anything, mock.MatchedBy(func(f project.Filter) bool {
 		return f.OrgID == "org-1" && f.Principal != nil && f.Principal.ID == "user-1" && f.Principal.Type == schema.UserPrincipal
-	})).Return([]project.Project{
+	})).Return([]project.Project{
 		{ID: "proj-a"}, {ID: "proj-b"},
-	}, nil).Maybe()
+	}, nil).Once()

2566-2568: ⚡ Quick win

Use strict call expectations in both ValidateProjectAccess subtests.

These subtests are asserting project-access validation behavior; .Maybe() weakens that contract and can miss regressions where visibility checks are skipped.

Suggested test tightening
-	projSvc.On("List", mock.Anything, mock.MatchedBy(func(f project.Filter) bool {
+	projSvc.On("List", mock.Anything, mock.MatchedBy(func(f project.Filter) bool {
 		return f.OrgID == "org-1" && f.Principal != nil && f.Principal.ID == "user-1" && f.Principal.Type == schema.UserPrincipal
-	})).Return([]project.Project{
+	})).Return([]project.Project{
 		{ID: "proj-in-org"},
-	}, nil)
+	}, nil).Once()

Also applies to: 2606-2608


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5fe65e9b-7676-48c7-8109-a44c68f3d013

📥 Commits

Reviewing files that changed from the base of the PR and between c41b99b and 00aef47.

📒 Files selected for processing (8)
  • core/aggregates/orgpats/service.go
  • core/aggregates/orgpats/service_test.go
  • core/project/service.go
  • core/project/service_test.go
  • core/userpat/service_test.go
  • internal/api/v1beta1connect/serviceuser.go
  • internal/api/v1beta1connect/serviceuser_test.go
  • internal/api/v1beta1connect/user.go

Comment thread internal/api/v1beta1connect/serviceuser.go Outdated
Comment thread internal/api/v1beta1connect/user.go Outdated
…ojects

Both handlers previously built a project.Filter without a Principal when the
request id was empty, falling through to a plain repository.List that returned
every project in the system. ListProjectsByUser is parameterized by a specific
user id, not a generic filtered listing — empty id is malformed input, not "no
filter". AdminService/ListProjects is the dedicated all-projects endpoint.

Reject empty id upfront with InvalidArgument. ListServiceUserProjects had the
same shape; currently masked by the auth interceptor erroring on
GetServiceUser(""), but the unsafe pattern is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread internal/store/postgres/project_repository.go Outdated
Comment thread internal/store/postgres/user_projects_repository.go Outdated
- project_repository.go: the Postgres bind-parameter ceiling (32767) is
  real, but ~10 other call sites use the same goqu.Op{"in"} pattern
  without per-site caps. A targeted cap here would be asymmetric; the
  fix is structural (switch to = ANY($1::uuid[]) globally) if the limit
  ever bites.
- user_projects_repository.go: the divergence with
  project.Service.List(Filter{Principal}) is a tracked product/UX
  question and belongs on the issue tracker, not as a source TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AmanGIT07 AmanGIT07 enabled auto-merge (squash) May 26, 2026 06:02
@AmanGIT07 AmanGIT07 merged commit f1d968b into main May 26, 2026
8 checks passed
@AmanGIT07 AmanGIT07 deleted the migrate-project-listbyuser-to-membership branch May 26, 2026 06:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants