You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/07/21 08:48:56 UTC

(airavata-custos) branch 533 created (now 545acf1c4)

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a change to branch 533
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git


      at 545acf1c4 Scope project and allocation reads to membership by default

This branch includes the following new commits:

     new 545acf1c4 Scope project and allocation reads to membership by default

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(airavata-custos) 01/01: Scope project and allocation reads to membership by default

Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch 533
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 545acf1c4c3537bdc7893c74cd547091c9254245
Author: lahiruj <la...@gmail.com>
AuthorDate: Tue Jul 21 04:48:45 2026 -0400

    Scope project and allocation reads to membership by default
    
    Fixes #533
---
 api/core.openapi.yaml                              |  73 ++++++
 internal/server/allocation.go                      |  21 ++
 internal/server/allocation_membership.go           |   2 +
 internal/server/allocation_resource.go             |   2 +
 internal/server/allocation_usage.go                |   4 +
 internal/server/integration_common_test.go         |   9 +
 .../server/membership_scope_integration_test.go    | 282 +++++++++++++++++++++
 internal/server/project.go                         |  22 ++
 internal/server/route_policy.go                    |  88 +++++++
 .../server/route_policy_test.go                    |  31 ++-
 internal/server/server.go                          |  19 +-
 internal/store/compute_allocation_store.go         |  24 ++
 internal/store/project_membership_store.go         |  18 ++
 internal/store/project_store.go                    |  23 +-
 internal/store/store.go                            |  10 +
 pkg/identity/middleware.go                         |  13 +
 pkg/models/project_membership.go                   |  13 +
 pkg/service/compute_allocation.go                  |  14 +
 pkg/service/compute_allocation_membership.go       |  10 +
 pkg/service/interface.go                           |   5 +
 pkg/service/mock.go                                | 268 ++++++++++++++++++++
 pkg/service/project.go                             |  27 ++
 pkg/service/project_membership.go                  |  13 +
 web/src/app/(portal)/allocations/layout.tsx        |   4 +-
 web/src/app/(portal)/projects/PermissionGate.tsx   |  30 ---
 web/src/app/(portal)/projects/layout.tsx           |   4 +-
 web/src/generated/core/index.ts                    |   2 +-
 web/src/generated/core/sdk.gen.ts                  |  22 +-
 web/src/generated/core/types.gen.ts                |  58 +++++
 web/src/shared/layout/nav.ts                       |   6 +-
 30 files changed, 1054 insertions(+), 63 deletions(-)

diff --git a/api/core.openapi.yaml b/api/core.openapi.yaml
index b35fbbdd5..6d5e96fe7 100644
--- a/api/core.openapi.yaml
+++ b/api/core.openapi.yaml
@@ -1991,6 +1991,9 @@ paths:
       - Compute Allocation Usages
   /compute-allocations:
     get:
+      description: Callers without the allocations read privilege get only allocations
+        where they hold a membership or a governance role on the parent project; filters
+        and pagination apply to privileged callers only.
       parameters:
       - description: Filter by project ID
         in: query
@@ -2019,6 +2022,13 @@ paths:
           description: OK
           schema:
             $ref: '#/definitions/ComputeAllocationListResponse'
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
       security:
       - BearerAuth: []
       summary: List compute allocations (filtered + paginated)
@@ -2055,6 +2065,8 @@ paths:
       - Compute Allocations
   /compute-allocations/{id}:
     get:
+      description: Callers without the allocations read privilege must hold a membership
+        on the allocation or a governance role on its project; others return 404.
       parameters:
       - description: Compute allocation ID
         in: path
@@ -2068,6 +2080,13 @@ paths:
           description: OK
           schema:
             $ref: '#/definitions/ComputeAllocation'
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
         "404":
           description: Not Found
           schema:
@@ -2167,6 +2186,8 @@ paths:
       - Compute Allocation Diffs
   /compute-allocations/{id}/memberships:
     get:
+      description: Callers without the allocations read privilege must hold a membership
+        on the allocation or a governance role on its project; others return 404.
       parameters:
       - description: Compute allocation ID
         in: path
@@ -2182,6 +2203,13 @@ paths:
             items:
               $ref: '#/definitions/AllocationMembershipResponse'
             type: array
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
         "404":
           description: Not Found
           schema:
@@ -2196,6 +2224,8 @@ paths:
       - Compute Allocation Memberships
   /compute-allocations/{id}/resources:
     get:
+      description: Callers without the allocations read privilege must hold a membership
+        on the allocation or a governance role on its project; others return 404.
       parameters:
       - description: Compute allocation ID
         in: path
@@ -2211,6 +2241,13 @@ paths:
             items:
               $ref: '#/definitions/ComputeAllocationResourceMapping'
             type: array
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
         "404":
           description: Not Found
           schema:
@@ -2342,6 +2379,8 @@ paths:
       - Compute Allocation Resources
   /compute-allocations/{id}/usages:
     get:
+      description: Callers without the allocations read privilege must hold a membership
+        on the allocation or a governance role on its project; others return 404.
       parameters:
       - description: Compute allocation ID
         in: path
@@ -2357,6 +2396,13 @@ paths:
             items:
               $ref: '#/definitions/ComputeAllocationUsage'
             type: array
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
         "404":
           description: Not Found
           schema:
@@ -2371,6 +2417,8 @@ paths:
       - Compute Allocation Usages
   /compute-allocations/{id}/usages/total:
     get:
+      description: Callers without the allocations read privilege must hold a membership
+        on the allocation or a governance role on its project; others return 404.
       parameters:
       - description: Compute allocation ID
         in: path
@@ -2384,6 +2432,13 @@ paths:
           description: OK
           schema:
             $ref: '#/definitions/AllocationSUTotalResponse'
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
         "404":
           description: Not Found
           schema:
@@ -2866,6 +2921,8 @@ paths:
       - Privileges
   /projects:
     get:
+      description: Callers without the projects read privilege get only the projects
+        they participate in; filters and pagination apply to privileged callers only.
       parameters:
       - description: Filter by PI user ID
         in: query
@@ -2894,6 +2951,13 @@ paths:
           description: OK
           schema:
             $ref: '#/definitions/ProjectListResponse'
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
       security:
       - BearerAuth: []
       summary: List projects (filtered + paginated, PI joined)
@@ -2930,6 +2994,8 @@ paths:
       - Projects
   /projects/{id}:
     get:
+      description: Callers without the projects read privilege only see projects they
+        participate in; others return 404.
       parameters:
       - description: Project ID
         in: path
@@ -2943,6 +3009,13 @@ paths:
           description: OK
           schema:
             $ref: '#/definitions/ProjectResponse'
+        "401":
+          description: Unauthorized
+          schema:
+            properties:
+              error:
+                type: string
+            type: object
         "404":
           description: Not Found
           schema:
diff --git a/internal/server/allocation.go b/internal/server/allocation.go
index 265c3cbbf..69c0053d9 100644
--- a/internal/server/allocation.go
+++ b/internal/server/allocation.go
@@ -22,6 +22,7 @@ import (
 
 	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/common"
+	"github.com/apache/airavata-custos/pkg/identity"
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
@@ -49,11 +50,13 @@ func (s *Server) createComputeAllocation(w http.ResponseWriter, r *http.Request)
 }
 
 // @Summary	Get a compute allocation by ID
+// @Description	Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
 // @Tags	Compute Allocations
 // @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Compute allocation ID"
 // @Success	200	{object}	models.ComputeAllocation
+// @Failure	401	{object}	object{error=string}
 // @Failure	404	{object}	object{error=string}
 // @Router	/compute-allocations/{id} [get]
 func (s *Server) getComputeAllocation(w http.ResponseWriter, r *http.Request) {
@@ -157,6 +160,7 @@ func (s *Server) getLatestDiffForAllocation(w http.ResponseWriter, r *http.Reque
 }
 
 // @Summary	List compute allocations (filtered + paginated)
+// @Description	Callers without the allocations read privilege get only allocations where they hold a membership or a governance role on the parent project; filters and pagination apply to privileged callers only.
 // @Tags	Compute Allocations
 // @Security	BearerAuth
 // @Produce	json
@@ -166,8 +170,25 @@ func (s *Server) getLatestDiffForAllocation(w http.ResponseWriter, r *http.Reque
 // @Param	limit	query	integer	false	"Page size"
 // @Param	offset	query	integer	false	"Page offset"
 // @Success	200	{object}	ComputeAllocationListResponse
+// @Failure	401	{object}	object{error=string}
 // @Router	/compute-allocations [get]
 func (s *Server) listComputeAllocations(w http.ResponseWriter, r *http.Request) {
+	caller := requireCaller(w, r)
+	if caller == nil {
+		return
+	}
+	if !identity.HasPrivilege(r.Context(), models.AllocationsRead) {
+		rows, err := s.svc.ListComputeAllocationsForParticipant(r.Context(), caller.UserID)
+		if err != nil {
+			common.WriteServiceError(w, err)
+			return
+		}
+		if rows == nil {
+			rows = []models.ComputeAllocation{}
+		}
+		common.WriteJSON(w, http.StatusOK, ComputeAllocationListResponse{Items: rows, Total: len(rows)})
+		return
+	}
 	q := r.URL.Query()
 	f := store.AllocationListFilter{
 		ProjectID: q.Get("project_id"),
diff --git a/internal/server/allocation_membership.go b/internal/server/allocation_membership.go
index 62e5e819c..994dd4bf4 100644
--- a/internal/server/allocation_membership.go
+++ b/internal/server/allocation_membership.go
@@ -133,11 +133,13 @@ func (s *Server) deleteComputeAllocationMembership(w http.ResponseWriter, r *htt
 }
 
 // @Summary	List members of a compute allocation
+// @Description	Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
 // @Tags	Compute Allocation Memberships
 // @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Compute allocation ID"
 // @Success	200	{array}	AllocationMembershipResponse
+// @Failure	401	{object}	object{error=string}
 // @Failure	404	{object}	object{error=string}
 // @Router	/compute-allocations/{id}/memberships [get]
 func (s *Server) listMembersForAllocation(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/server/allocation_resource.go b/internal/server/allocation_resource.go
index 5bb8251e6..9426a1981 100644
--- a/internal/server/allocation_resource.go
+++ b/internal/server/allocation_resource.go
@@ -181,11 +181,13 @@ func (s *Server) detachResourceFromAllocation(w http.ResponseWriter, r *http.Req
 }
 
 // @Summary	List resources attached to a compute allocation
+// @Description	Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
 // @Tags	Compute Allocation Resources
 // @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Compute allocation ID"
 // @Success	200	{array}	models.ComputeAllocationResourceMapping
+// @Failure	401	{object}	object{error=string}
 // @Failure	404	{object}	object{error=string}
 // @Router	/compute-allocations/{id}/resources [get]
 func (s *Server) listResourcesForAllocation(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/server/allocation_usage.go b/internal/server/allocation_usage.go
index 25f6c1f97..04d0d41ec 100644
--- a/internal/server/allocation_usage.go
+++ b/internal/server/allocation_usage.go
@@ -80,11 +80,13 @@ func (s *Server) deleteComputeAllocationUsage(w http.ResponseWriter, r *http.Req
 }
 
 // @Summary	List usages for a compute allocation
+// @Description	Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
 // @Tags	Compute Allocation Usages
 // @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Compute allocation ID"
 // @Success	200	{array}	models.ComputeAllocationUsage
+// @Failure	401	{object}	object{error=string}
 // @Failure	404	{object}	object{error=string}
 // @Router	/compute-allocations/{id}/usages [get]
 func (s *Server) listUsagesForAllocation(w http.ResponseWriter, r *http.Request) {
@@ -114,11 +116,13 @@ func (s *Server) listUsagesByUser(w http.ResponseWriter, r *http.Request) {
 }
 
 // @Summary	Get total SU usage for a compute allocation
+// @Description	Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
 // @Tags	Compute Allocation Usages
 // @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Compute allocation ID"
 // @Success	200	{object}	AllocationSUTotalResponse
+// @Failure	401	{object}	object{error=string}
 // @Failure	404	{object}	object{error=string}
 // @Router	/compute-allocations/{id}/usages/total [get]
 func (s *Server) getTotalSUUsageForAllocation(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/server/integration_common_test.go b/internal/server/integration_common_test.go
index df27b67b9..f1a6ef04e 100644
--- a/internal/server/integration_common_test.go
+++ b/internal/server/integration_common_test.go
@@ -83,6 +83,15 @@ func truncateAll(t *testing.T, database *sqlx.DB) {
 		"roles",
 		"user_privileges",
 		"audit_events",
+		"compute_allocation_usages",
+		"compute_allocation_memberships",
+		"compute_allocation_resource_mappings",
+		"compute_allocation_resources",
+		"compute_allocations",
+		"project_memberships",
+		"projects",
+		"compute_cluster_users",
+		"compute_clusters",
 		"user_identities",
 		"users",
 		"organizations",
diff --git a/internal/server/membership_scope_integration_test.go b/internal/server/membership_scope_integration_test.go
new file mode 100644
index 000000000..8f698633e
--- /dev/null
+++ b/internal/server/membership_scope_integration_test.go
@@ -0,0 +1,282 @@
+//go:build integration
+
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package server
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/jmoiron/sqlx"
+
+	"github.com/apache/airavata-custos/pkg/models"
+	"github.com/apache/airavata-custos/pkg/service"
+)
+
+// scopedFixtures is two projects with one allocation each; memberID holds an
+// active membership on allocA only, piID is PI of projectA only.
+type scopedFixtures struct {
+	memberID string
+	piID     string
+	projectA *models.Project
+	projectB *models.Project
+	allocA   *models.ComputeAllocation
+	allocB   *models.ComputeAllocation
+}
+
+func seedScopedFixtures(t *testing.T, database *sqlx.DB, svc *service.Service) scopedFixtures {
+	t.Helper()
+	suffix := time.Now().UnixNano()
+	memberID := seedUser(t, database, fmt.Sprintf("scope.member+%d@example.edu", suffix))
+	piID := seedUser(t, database, fmt.Sprintf("scope.pi+%d@example.edu", suffix))
+
+	cluster, err := svc.CreateComputeCluster(t.Context(), &models.ComputeCluster{
+		Name: fmt.Sprintf("scope-cluster-%d", suffix),
+	})
+	if err != nil {
+		t.Fatalf("seed cluster: %v", err)
+	}
+
+	projectA, err := svc.CreateProject(t.Context(), &models.Project{
+		Title: fmt.Sprintf("Scope Project A %d", suffix), ProjectPIID: piID,
+	})
+	if err != nil {
+		t.Fatalf("seed project A: %v", err)
+	}
+	projectB, err := svc.CreateProject(t.Context(), &models.Project{
+		Title: fmt.Sprintf("Scope Project B %d", suffix), ProjectPIID: piID,
+	})
+	if err != nil {
+		t.Fatalf("seed project B: %v", err)
+	}
+	if err := svc.EnsureProjectMembership(t.Context(), projectA.ID, piID, "PI"); err != nil {
+		t.Fatalf("seed PI membership: %v", err)
+	}
+
+	allocA, err := svc.CreateComputeAllocation(t.Context(), &models.ComputeAllocation{
+		Name: fmt.Sprintf("scope-alloc-a-%d", suffix), ProjectID: projectA.ID, ComputeClusterID: cluster.ID,
+	})
+	if err != nil {
+		t.Fatalf("seed allocation A: %v", err)
+	}
+	allocB, err := svc.CreateComputeAllocation(t.Context(), &models.ComputeAllocation{
+		Name: fmt.Sprintf("scope-alloc-b-%d", suffix), ProjectID: projectB.ID, ComputeClusterID: cluster.ID,
+	})
+	if err != nil {
+		t.Fatalf("seed allocation B: %v", err)
+	}
+
+	if _, err := svc.CreateComputeAllocationMembership(t.Context(), &models.ComputeAllocationMembership{
+		ComputeAllocationID: allocA.ID, UserID: memberID,
+	}); err != nil {
+		t.Fatalf("seed membership: %v", err)
+	}
+
+	return scopedFixtures{
+		memberID: memberID, piID: piID,
+		projectA: projectA, projectB: projectB,
+		allocA: allocA, allocB: allocB,
+	}
+}
+
+func doGet(t *testing.T, srv *Server, path, userID string, privs ...models.PrivilegeKey) *httptest.ResponseRecorder {
+	t.Helper()
+	rr := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, path, nil)
+	req = withTestCaller(req, userID, privs...)
+	srv.ServeHTTP(rr, req)
+	return rr
+}
+
+func decodeAllocationList(t *testing.T, rr *httptest.ResponseRecorder) ComputeAllocationListResponse {
+	t.Helper()
+	var body ComputeAllocationListResponse
+	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	return body
+}
+
+func TestListComputeAllocations_MemberSeesOnlyOwn(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	rr := doGet(t, srv, "/compute-allocations", fx.memberID)
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	body := decodeAllocationList(t, rr)
+	if len(body.Items) != 1 || body.Total != 1 {
+		t.Fatalf("expected exactly the member's allocation, got total=%d items=%d", body.Total, len(body.Items))
+	}
+	if body.Items[0].ID != fx.allocA.ID {
+		t.Errorf("allocation id: got %s, want %s", body.Items[0].ID, fx.allocA.ID)
+	}
+}
+
+func TestListComputeAllocations_GovernanceRoleSeesProjectAllocations(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	rr := doGet(t, srv, "/compute-allocations", fx.piID)
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	body := decodeAllocationList(t, rr)
+	if len(body.Items) != 1 || body.Items[0].ID != fx.allocA.ID {
+		t.Fatalf("expected only project A's allocation for the PI, got %d items", len(body.Items))
+	}
+}
+
+func TestListComputeAllocations_PrivilegedSeesFullListing(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	rr := doGet(t, srv, "/compute-allocations", fx.memberID, models.AllocationsRead)
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	body := decodeAllocationList(t, rr)
+	found := map[string]bool{}
+	for _, a := range body.Items {
+		found[a.ID] = true
+	}
+	if !found[fx.allocA.ID] || !found[fx.allocB.ID] {
+		t.Errorf("privileged listing missing allocations: A=%v B=%v", found[fx.allocA.ID], found[fx.allocB.ID])
+	}
+}
+
+func TestGetComputeAllocation_MembershipScoped(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	if rr := doGet(t, srv, "/compute-allocations/"+fx.allocA.ID, fx.memberID); rr.Code != http.StatusOK {
+		t.Errorf("own allocation: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	if rr := doGet(t, srv, "/compute-allocations/"+fx.allocB.ID, fx.memberID); rr.Code != http.StatusNotFound {
+		t.Errorf("foreign allocation: got %d, want 404 (%s)", rr.Code, rr.Body.String())
+	}
+	if rr := doGet(t, srv, "/compute-allocations/"+fx.allocB.ID, fx.memberID, models.AllocationsRead); rr.Code != http.StatusOK {
+		t.Errorf("privileged foreign allocation: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+}
+
+func TestAllocationSubresources_MembershipScoped(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	for _, sub := range []string{"/memberships", "/resources", "/usages", "/usages/total"} {
+		if rr := doGet(t, srv, "/compute-allocations/"+fx.allocA.ID+sub, fx.memberID); rr.Code != http.StatusOK {
+			t.Errorf("own allocation %s: got %d, want 200 (%s)", sub, rr.Code, rr.Body.String())
+		}
+		if rr := doGet(t, srv, "/compute-allocations/"+fx.allocB.ID+sub, fx.memberID); rr.Code != http.StatusNotFound {
+			t.Errorf("foreign allocation %s: got %d, want 404 (%s)", sub, rr.Code, rr.Body.String())
+		}
+	}
+}
+
+func TestListProjects_ParticipantViaAllocationMembership(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	rr := doGet(t, srv, "/projects", fx.memberID)
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	var body ProjectListResponse
+	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if len(body.Items) != 1 || body.Total != 1 {
+		t.Fatalf("expected exactly the parent project, got total=%d items=%d", body.Total, len(body.Items))
+	}
+	if body.Items[0].ID != fx.projectA.ID {
+		t.Errorf("project id: got %s, want %s", body.Items[0].ID, fx.projectA.ID)
+	}
+}
+
+func TestGetProject_MembershipScoped(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	if rr := doGet(t, srv, "/projects/"+fx.projectA.ID, fx.memberID); rr.Code != http.StatusOK {
+		t.Errorf("participant project: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	if rr := doGet(t, srv, "/projects/"+fx.projectB.ID, fx.memberID); rr.Code != http.StatusNotFound {
+		t.Errorf("foreign project: got %d, want 404 (%s)", rr.Code, rr.Body.String())
+	}
+	if rr := doGet(t, srv, "/projects/"+fx.projectB.ID, fx.memberID, models.ProjectsRead); rr.Code != http.StatusOK {
+		t.Errorf("privileged foreign project: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+}
+
+func TestListProjects_PrivilegedSeesFullListing(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	rr := doGet(t, srv, "/projects?limit=200", fx.memberID, models.ProjectsRead)
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	var body ProjectListResponse
+	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	found := map[string]bool{}
+	for _, p := range body.Items {
+		found[p.ID] = true
+	}
+	if !found[fx.projectA.ID] || !found[fx.projectB.ID] {
+		t.Errorf("privileged listing missing projects: A=%v B=%v", found[fx.projectA.ID], found[fx.projectB.ID])
+	}
+}
+
+// The PI holds a governance role on projectA but no membership row on allocA,
+// so this passes only through the role path of the scoped wrapper.
+func TestGetComputeAllocation_GovernanceRoleWithoutMembership(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	if rr := doGet(t, srv, "/compute-allocations/"+fx.allocA.ID, fx.piID); rr.Code != http.StatusOK {
+		t.Errorf("PI on parent project: got %d, want 200 (%s)", rr.Code, rr.Body.String())
+	}
+	if rr := doGet(t, srv, "/compute-allocations/"+fx.allocB.ID, fx.piID); rr.Code != http.StatusNotFound {
+		t.Errorf("PI of another project: got %d, want 404 (%s)", rr.Code, rr.Body.String())
+	}
+}
+
+// A missing allocation and a foreign allocation must be indistinguishable.
+func TestGetComputeAllocation_DenialMatchesMissing(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	fx := seedScopedFixtures(t, database, svc)
+
+	missing := doGet(t, srv, "/compute-allocations/does-not-exist", fx.memberID)
+	foreign := doGet(t, srv, "/compute-allocations/"+fx.allocB.ID, fx.memberID)
+	if missing.Code != http.StatusNotFound || foreign.Code != http.StatusNotFound {
+		t.Fatalf("codes: missing=%d foreign=%d, want 404/404", missing.Code, foreign.Code)
+	}
+	if missing.Body.String() != foreign.Body.String() {
+		t.Errorf("denial body differs from missing-id body:\n  missing: %s\n  foreign: %s",
+			missing.Body.String(), foreign.Body.String())
+	}
+}
diff --git a/internal/server/project.go b/internal/server/project.go
index cfd4ad459..90ae002e8 100644
--- a/internal/server/project.go
+++ b/internal/server/project.go
@@ -23,6 +23,7 @@ import (
 
 	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/common"
+	"github.com/apache/airavata-custos/pkg/identity"
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
@@ -50,11 +51,13 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
 }
 
 // @Summary	Get a project by ID
+// @Description	Callers without the projects read privilege only see projects they participate in; others return 404.
 // @Tags	Projects
 // @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Project ID"
 // @Success	200	{object}	ProjectResponse
+// @Failure	401	{object}	object{error=string}
 // @Failure	404	{object}	object{error=string}
 // @Router	/projects/{id} [get]
 func (s *Server) getProject(w http.ResponseWriter, r *http.Request) {
@@ -103,6 +106,7 @@ func (s *Server) updateProjectStatus(w http.ResponseWriter, r *http.Request) {
 }
 
 // @Summary	List projects (filtered + paginated, PI joined)
+// @Description	Callers without the projects read privilege get only the projects they participate in; filters and pagination apply to privileged callers only.
 // @Tags	Projects
 // @Security	BearerAuth
 // @Produce	json
@@ -112,8 +116,26 @@ func (s *Server) updateProjectStatus(w http.ResponseWriter, r *http.Request) {
 // @Param	limit	query	integer	false	"Page size"
 // @Param	offset	query	integer	false	"Page offset"
 // @Success	200	{object}	ProjectListResponse
+// @Failure	401	{object}	object{error=string}
 // @Router	/projects [get]
 func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
+	caller := requireCaller(w, r)
+	if caller == nil {
+		return
+	}
+	if !identity.HasPrivilege(r.Context(), models.ProjectsRead) {
+		rows, err := s.svc.ListProjectsForParticipant(r.Context(), caller.UserID)
+		if err != nil {
+			common.WriteServiceError(w, err)
+			return
+		}
+		items := make([]ProjectResponse, 0, len(rows))
+		for i := range rows {
+			items = append(items, projectResponseFrom(&rows[i]))
+		}
+		common.WriteJSON(w, http.StatusOK, ProjectListResponse{Items: items, Total: len(items)})
+		return
+	}
 	q := r.URL.Query()
 	f := store.ProjectListFilter{
 		PIID:   q.Get("pi_id"),
diff --git a/internal/server/route_policy.go b/internal/server/route_policy.go
new file mode 100644
index 000000000..a4bd52a00
--- /dev/null
+++ b/internal/server/route_policy.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package server
+
+import (
+	"net/http"
+
+	"github.com/apache/airavata-custos/pkg/common"
+	"github.com/apache/airavata-custos/pkg/identity"
+	"github.com/apache/airavata-custos/pkg/models"
+	"github.com/apache/airavata-custos/pkg/service"
+)
+
+// canReadAllocation is the RequireScoped policy for allocation reads keyed by
+// the {id} path value. It admits the read privilege, a governance role on the
+// parent project, or an active membership on the allocation. Everyone else
+// gets the same 404 as a missing id, so ids cannot be probed.
+func (s *Server) canReadAllocation(w http.ResponseWriter, r *http.Request) bool {
+	caller := requireCaller(w, r)
+	if caller == nil {
+		return false
+	}
+	if identity.HasPrivilege(r.Context(), models.AllocationsRead) {
+		return true
+	}
+	alloc, err := s.svc.GetComputeAllocation(r.Context(), r.PathValue("id"))
+	if err != nil {
+		common.WriteServiceError(w, err)
+		return false
+	}
+	role, err := s.svc.ProjectRoleForUser(r.Context(), alloc.ProjectID, caller.UserID)
+	if err != nil {
+		common.WriteServiceError(w, err)
+		return false
+	}
+	if models.IsGovernanceRole(role) {
+		return true
+	}
+	member, err := s.svc.IsAllocationMember(r.Context(), alloc.ID, caller.UserID)
+	if err != nil {
+		common.WriteServiceError(w, err)
+		return false
+	}
+	if !member {
+		common.WriteServiceError(w, service.ErrNotFound)
+		return false
+	}
+	return true
+}
+
+// canReadProject is the RequireScoped policy for project reads keyed by the
+// {id} path value. It admits the read privilege or a project participant (a
+// governance role or an active allocation membership). Everyone else gets the
+// same 404 as a missing id.
+func (s *Server) canReadProject(w http.ResponseWriter, r *http.Request) bool {
+	caller := requireCaller(w, r)
+	if caller == nil {
+		return false
+	}
+	if identity.HasPrivilege(r.Context(), models.ProjectsRead) {
+		return true
+	}
+	ok, err := s.svc.IsProjectParticipant(r.Context(), r.PathValue("id"), caller.UserID)
+	if err != nil {
+		common.WriteServiceError(w, err)
+		return false
+	}
+	if !ok {
+		common.WriteServiceError(w, service.ErrNotFound)
+		return false
+	}
+	return true
+}
diff --git a/web/src/app/(portal)/allocations/PermissionGate.tsx b/internal/server/route_policy_test.go
similarity index 52%
rename from web/src/app/(portal)/allocations/PermissionGate.tsx
rename to internal/server/route_policy_test.go
index c4787add9..ddc736e74 100644
--- a/web/src/app/(portal)/allocations/PermissionGate.tsx
+++ b/internal/server/route_policy_test.go
@@ -15,16 +15,27 @@
 // specific language governing permissions and limitations
 // under the License.
 
-"use client";
+package server
 
-import type { ReactNode } from "react";
-import { useAbility } from "@/shared/casl/AbilityProvider";
-import { ErrorState } from "@/shared/ui/ErrorState";
+import (
+	"os"
+	"regexp"
+	"strings"
+	"testing"
+)
 
-export function AllocationsPermissionGate({ children }: { children: ReactNode }) {
-  const ability = useAbility();
-  if (ability.cannot("read", "Allocation")) {
-    return <ErrorState message="Not permitted." />;
-  }
-  return <>{children}</>;
+// A RequireAuth route with a path id would ship without any per-resource
+// check: RequireAuth verifies the caller only. Id-scoped reads must register
+// through RequireScoped (or RequirePrivilege when admin-only).
+func TestNoRequireAuthOnIdScopedRoutes(t *testing.T) {
+	src, err := os.ReadFile("server.go")
+	if err != nil {
+		t.Fatalf("read server.go: %v", err)
+	}
+	re := regexp.MustCompile(`RequireAuth\("[A-Z]+ [^"]*\{[^"]*"`)
+	for _, line := range strings.Split(string(src), "\n") {
+		if re.MatchString(line) {
+			t.Errorf("id-scoped route registered with RequireAuth (needs RequireScoped or RequirePrivilege): %s", strings.TrimSpace(line))
+		}
+	}
 }
diff --git a/internal/server/server.go b/internal/server/server.go
index aaf0c0cf3..18e0af764 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -46,7 +46,6 @@ func New(svc *service.Service, router *identity.Router) *Server {
 	return s
 }
 
-// ServeHTTP satisfies http.Handler.
 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	s.router.ServeHTTP(w, r)
 }
@@ -76,9 +75,9 @@ func (s *Server) routes() {
 	s.router.RequirePrivilege("PUT /users/{id}/status", models.UsersWrite, s.updateUserStatus)
 	s.router.RequirePrivilege("POST /users/merge", models.UsersWrite, s.mergeUsers)
 
-	s.router.RequirePrivilege("GET /projects", models.ProjectsRead, s.listProjects)
+	s.router.RequireAuth("GET /projects", s.listProjects)
 	s.router.RequirePrivilege("POST /projects", models.ProjectsWrite, s.createProject)
-	s.router.RequirePrivilege("GET /projects/{id}", models.ProjectsRead, s.getProject)
+	s.router.RequireScoped("GET /projects/{id}", s.canReadProject, s.getProject)
 	s.router.RequirePrivilege("PUT /projects/{id}/status", models.ProjectsWrite, s.updateProjectStatus)
 	s.router.RequirePrivilege("GET /projects/{id}/members", models.ProjectsRead, s.listProjectMembers)
 
@@ -94,16 +93,16 @@ func (s *Server) routes() {
 	s.router.RequirePrivilege("GET /compute-clusters/{id}/users/{userId}", models.ClustersRead, s.getComputeClusterUserByPair)
 	s.router.RequirePrivilege("GET /users/{id}/compute-cluster-users", models.ClustersRead, s.listComputeClusterUsersByUser)
 
-	s.router.RequirePrivilege("GET /compute-allocations", models.AllocationsRead, s.listComputeAllocations)
+	s.router.RequireAuth("GET /compute-allocations", s.listComputeAllocations)
 	s.router.RequirePrivilege("POST /compute-allocations", models.AllocationsWrite, s.createComputeAllocation)
-	s.router.RequirePrivilege("GET /compute-allocations/{id}", models.AllocationsRead, s.getComputeAllocation)
+	s.router.RequireScoped("GET /compute-allocations/{id}", s.canReadAllocation, s.getComputeAllocation)
 
 	s.router.RequirePrivilege("POST /compute-allocation-resources", models.AllocationsWrite, s.createComputeAllocationResource)
 	s.router.RequirePrivilege("GET /compute-allocation-resources", models.AllocationsRead, s.listComputeAllocationResources)
 	s.router.RequirePrivilege("GET /compute-allocation-resources/summary", models.AllocationsRead, s.listComputeAllocationResourceSummaries)
 	s.router.RequirePrivilege("GET /compute-allocation-resources/{id}", models.AllocationsRead, s.getComputeAllocationResource)
 
-	s.router.RequirePrivilege("GET /compute-allocations/{id}/resources", models.AllocationsRead, s.listResourcesForAllocation)
+	s.router.RequireScoped("GET /compute-allocations/{id}/resources", s.canReadAllocation, s.listResourcesForAllocation)
 	s.router.RequirePrivilege("POST /compute-allocations/{id}/resources", models.AllocationsWrite, s.attachResourceToAllocation)
 	s.router.RequirePrivilege("PUT /compute-allocations/{id}/resources/{resourceId}", models.AllocationsWrite, s.updateAllocationResourceMapping)
 	s.router.RequirePrivilege("DELETE /compute-allocations/{id}/resources/{resourceId}", models.AllocationsWrite, s.detachResourceFromAllocation)
@@ -139,7 +138,7 @@ func (s *Server) routes() {
 	s.router.RequirePrivilege("PUT /compute-allocation-memberships/{id}", models.AllocationsWrite, s.updateComputeAllocationMembership)
 	s.router.RequirePrivilege("PUT /compute-allocation-memberships/{id}/status", models.AllocationsWrite, s.updateMembershipStatus)
 	s.router.RequirePrivilege("DELETE /compute-allocation-memberships/{id}", models.AllocationsWrite, s.deleteComputeAllocationMembership)
-	s.router.RequirePrivilege("GET /compute-allocations/{id}/memberships", models.AllocationsRead, s.listMembersForAllocation)
+	s.router.RequireScoped("GET /compute-allocations/{id}/memberships", s.canReadAllocation, s.listMembersForAllocation)
 	s.router.RequirePrivilege("GET /users/{id}/compute-allocation-memberships", models.AllocationsRead, s.listAllocationsForUser)
 	s.router.RequirePrivilege("GET /compute-allocation-memberships/{id}/resource-overrides", models.AllocationsRead, s.listOverridesForMembership)
 
@@ -152,8 +151,8 @@ func (s *Server) routes() {
 	s.router.RequirePrivilege("POST /compute-allocation-usages", models.AllocationsWrite, s.createComputeAllocationUsage)
 	s.router.RequirePrivilege("GET /compute-allocation-usages/{id}", models.AllocationsRead, s.getComputeAllocationUsage)
 	s.router.RequirePrivilege("DELETE /compute-allocation-usages/{id}", models.AllocationsWrite, s.deleteComputeAllocationUsage)
-	s.router.RequirePrivilege("GET /compute-allocations/{id}/usages", models.AllocationsRead, s.listUsagesForAllocation)
-	s.router.RequirePrivilege("GET /compute-allocations/{id}/usages/total", models.AllocationsRead, s.getTotalSUUsageForAllocation)
+	s.router.RequireScoped("GET /compute-allocations/{id}/usages", s.canReadAllocation, s.listUsagesForAllocation)
+	s.router.RequireScoped("GET /compute-allocations/{id}/usages/total", s.canReadAllocation, s.getTotalSUUsageForAllocation)
 	s.router.RequirePrivilege("GET /compute-allocations/{id}/users/{userId}/usages/total", models.AllocationsRead, s.getTotalSUUsageForUserInAllocation)
 	s.router.RequirePrivilege("GET /users/{id}/compute-allocation-usages", models.AllocationsRead, s.listUsagesByUser)
 
@@ -165,7 +164,7 @@ func (s *Server) routes() {
 	s.router.RequirePrivilege("GET /user-identities/oidc-subjects/{oidcSub}", models.UsersRead, s.getUserIdentityByOIDCSub)
 	s.router.RequirePrivilege("GET /users/{id}/user-identities", models.UsersRead, s.listUserIdentitiesForUser)
 
-	// Any authenticated caller may read their own profile and effective privilege set with no privilege check.
+	// A caller reads their own profile; no privilege needed.
 	s.router.RequireAuth("GET /user/privileges", s.getCallerPrivileges)
 	s.router.RequireAuth("GET /me", s.getCallerProfile)
 	s.router.RequirePrivilege("GET /privileges/catalog", models.PrivilegesGrant, s.getPrivilegeCatalog)
diff --git a/internal/store/compute_allocation_store.go b/internal/store/compute_allocation_store.go
index 714f88261..861225f73 100644
--- a/internal/store/compute_allocation_store.go
+++ b/internal/store/compute_allocation_store.go
@@ -96,6 +96,30 @@ func (s *mysqlComputeAllocationStore) Delete(ctx context.Context, tx *sql.Tx, id
 	return err
 }
 
+// FindByParticipant returns the allocations where the user holds an active
+// membership, or a governance role on the parent project. Ordered like List
+// (start_time descending).
+func (s *mysqlComputeAllocationStore) FindByParticipant(ctx context.Context, userID string) ([]models.ComputeAllocation, error) {
+	var rows []models.ComputeAllocation
+	query, args, err := sqlx.In(
+		`SELECT `+computeAllocationColumns+` FROM compute_allocations a
+		  WHERE EXISTS (SELECT 1 FROM compute_allocation_memberships cam
+		                 WHERE cam.compute_allocation_id = a.id AND cam.user_id = ?
+		                   AND cam.membership_status = 'ACTIVE')
+		     OR EXISTS (SELECT 1 FROM project_memberships pm
+		                 WHERE pm.project_id = a.project_id AND pm.user_id = ?
+		                   AND pm.role IN (?))
+		  ORDER BY start_time DESC`, userID, userID, models.GovernanceRoles)
+	if err != nil {
+		return nil, err
+	}
+	err = s.db.SelectContext(ctx, &rows, s.db.Rebind(query), args...)
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
 func (s *mysqlComputeAllocationStore) List(ctx context.Context, f AllocationListFilter) ([]models.ComputeAllocation, int, error) {
 	where := []string{}
 	args := []any{}
diff --git a/internal/store/project_membership_store.go b/internal/store/project_membership_store.go
index f82729df9..5373fe1b7 100644
--- a/internal/store/project_membership_store.go
+++ b/internal/store/project_membership_store.go
@@ -82,6 +82,24 @@ func (s *mysqlProjectMembershipStore) FindPIByProject(ctx context.Context, proje
 	return &pm, nil
 }
 
+// IsParticipant reports whether the user has a project_memberships row or an
+// active membership on any of the project's allocations.
+func (s *mysqlProjectMembershipStore) IsParticipant(ctx context.Context, projectID, userID string) (bool, error) {
+	var participant bool
+	err := s.db.GetContext(ctx, &participant,
+		`SELECT EXISTS (SELECT 1 FROM project_memberships
+		                 WHERE project_id = ? AND user_id = ?)
+		     OR EXISTS (SELECT 1 FROM compute_allocation_memberships cam
+		                  JOIN compute_allocations ca ON ca.id = cam.compute_allocation_id
+		                 WHERE ca.project_id = ? AND cam.user_id = ?
+		                   AND cam.membership_status = 'ACTIVE')`,
+		projectID, userID, projectID, userID)
+	if err != nil {
+		return false, err
+	}
+	return participant, nil
+}
+
 func (s *mysqlProjectMembershipStore) Create(ctx context.Context, tx *sql.Tx, pm *models.ProjectMembership) error {
 	_, err := tx.ExecContext(ctx,
 		`INSERT INTO project_memberships (project_id, user_id, role, added_time)
diff --git a/internal/store/project_store.go b/internal/store/project_store.go
index 2babb9bf9..010d609c6 100644
--- a/internal/store/project_store.go
+++ b/internal/store/project_store.go
@@ -53,7 +53,9 @@ type ProjectWithPI struct {
 // portal needs to render a project row.
 const projectWithPISelect = `SELECT p.id, p.originated_id, p.title, p.origination, p.project_pi_id,
         p.status, p.created_time,
-        u.first_name AS pi_first_name, u.last_name AS pi_last_name, u.email AS pi_email
+        COALESCE(u.first_name, '') AS pi_first_name,
+        COALESCE(u.last_name, '')  AS pi_last_name,
+        COALESCE(u.email, '')      AS pi_email
    FROM projects p
    LEFT JOIN users u ON u.id = p.project_pi_id`
 
@@ -187,6 +189,25 @@ func (s *mysqlProjectStore) List(ctx context.Context, f ProjectListFilter) ([]mo
 	return rows, total, nil
 }
 
+// ListWithPIForParticipant returns the projects where the user holds a
+// project membership or an active allocation membership, with the PI joined,
+// newest first.
+func (s *mysqlProjectStore) ListWithPIForParticipant(ctx context.Context, userID string) ([]ProjectWithPI, error) {
+	var rows []ProjectWithPI
+	err := s.db.SelectContext(ctx, &rows, projectWithPISelect+`
+	  WHERE EXISTS (SELECT 1 FROM project_memberships pm
+	                 WHERE pm.project_id = p.id AND pm.user_id = ?)
+	     OR EXISTS (SELECT 1 FROM compute_allocation_memberships cam
+	                  JOIN compute_allocations ca ON ca.id = cam.compute_allocation_id
+	                 WHERE ca.project_id = p.id AND cam.user_id = ?
+	                   AND cam.membership_status = 'ACTIVE')
+	  ORDER BY p.created_time DESC`, userID, userID)
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
 // ListWithPI is List joined with the PI user. Replaces per-row GetUser calls
 // the handler used to fan out across the result set.
 func (s *mysqlProjectStore) ListWithPI(ctx context.Context, f ProjectListFilter) ([]ProjectWithPI, int, error) {
diff --git a/internal/store/store.go b/internal/store/store.go
index 7c884c0a6..5a80228dd 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -155,6 +155,10 @@ type ProjectStore interface {
 	// ListWithPI is List joined with the PI user, replacing the per-row
 	// GetUser fan-out the handler would otherwise need.
 	ListWithPI(ctx context.Context, f ProjectListFilter) ([]ProjectWithPI, int, error)
+	// ListWithPIForParticipant returns the projects where the user holds a
+	// project membership or an active allocation membership, PI joined,
+	// newest first.
+	ListWithPIForParticipant(ctx context.Context, userID string) ([]ProjectWithPI, error)
 }
 
 // ProjectListFilter selects which projects ProjectStore.List returns.
@@ -183,6 +187,9 @@ type ComputeAllocationStore interface {
 	// List returns a paginated, filtered slice of allocations plus the total
 	// count matching the filter (ignoring limit/offset).
 	List(ctx context.Context, f AllocationListFilter) ([]models.ComputeAllocation, int, error)
+	// FindByParticipant returns the allocations where the user holds an active
+	// membership, or a governance role on the parent project. Newest first.
+	FindByParticipant(ctx context.Context, userID string) ([]models.ComputeAllocation, error)
 }
 
 // AllocationListFilter selects which allocations ComputeAllocationStore.List returns.
@@ -342,6 +349,9 @@ type ProjectMembershipStore interface {
 	FindByProject(ctx context.Context, projectID string) ([]models.ProjectMembership, error)
 	// FindPIByProject returns the PI row, or nil if the project has no PI yet.
 	FindPIByProject(ctx context.Context, projectID string) (*models.ProjectMembership, error)
+	// IsParticipant reports whether the user has a project_memberships row or
+	// an active membership on any of the project's allocations.
+	IsParticipant(ctx context.Context, projectID, userID string) (bool, error)
 	// Create inserts a new row within the provided transaction.
 	Create(ctx context.Context, tx *sql.Tx, pm *models.ProjectMembership) error
 	// UpdateRole changes the role of an existing (project, user) row.
diff --git a/pkg/identity/middleware.go b/pkg/identity/middleware.go
index 148461813..9e984aa9b 100644
--- a/pkg/identity/middleware.go
+++ b/pkg/identity/middleware.go
@@ -101,6 +101,19 @@ func (r *Router) RequireAuth(pattern string, handler http.HandlerFunc) {
 	r.mux.HandleFunc(pattern, handler)
 }
 
+// RequireScoped registers a route whose access depends on the specific
+// resource, not a site privilege. authorize runs first: it writes the response
+// and returns false to deny, or returns true to run handler. The policy is a
+// required argument, so a scoped route cannot be registered without one.
+func (r *Router) RequireScoped(pattern string, authorize func(http.ResponseWriter, *http.Request) bool, handler http.HandlerFunc) {
+	r.mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) {
+		if !authorize(w, req) {
+			return
+		}
+		handler(w, req)
+	})
+}
+
 // PublicPaths returns the path components of every Public() registration,
 // suitable for handing to Middleware.
 func (r *Router) PublicPaths() []string { return r.publicPaths }
diff --git a/pkg/models/project_membership.go b/pkg/models/project_membership.go
index e781bd171..08a64f57f 100644
--- a/pkg/models/project_membership.go
+++ b/pkg/models/project_membership.go
@@ -30,6 +30,19 @@ const (
 	ProjectRoleAllocationManager ProjectRole = "ALLOCATION_MANAGER"
 )
 
+// GovernanceRoles are the project roles that manage allocations.
+var GovernanceRoles = []ProjectRole{ProjectRolePI, ProjectRoleCoPI, ProjectRoleAllocationManager}
+
+// IsGovernanceRole reports whether a role is in GovernanceRoles.
+func IsGovernanceRole(role ProjectRole) bool {
+	for _, r := range GovernanceRoles {
+		if r == role {
+			return true
+		}
+	}
+	return false
+}
+
 // ProjectMembership ties a user to a project-level governance role.
 type ProjectMembership struct {
 	ProjectID string      `json:"project_id" db:"project_id"`
diff --git a/pkg/service/compute_allocation.go b/pkg/service/compute_allocation.go
index 424d1d2f0..4c4da06c5 100644
--- a/pkg/service/compute_allocation.go
+++ b/pkg/service/compute_allocation.go
@@ -37,6 +37,20 @@ func (s *Service) ListComputeAllocations(ctx context.Context, f store.Allocation
 	return rows, total, nil
 }
 
+// ListComputeAllocationsForParticipant returns the allocations where the
+// user holds an active membership, or a governance role on the parent
+// project.
+func (s *Service) ListComputeAllocationsForParticipant(ctx context.Context, userID string) ([]models.ComputeAllocation, error) {
+	if userID == "" {
+		return nil, fmt.Errorf("%w: user id is required", ErrInvalidInput)
+	}
+	rows, err := s.allocs.FindByParticipant(ctx, userID)
+	if err != nil {
+		return nil, fmt.Errorf("list compute allocations for participant: %w", err)
+	}
+	return rows, nil
+}
+
 // CreateComputeAllocation persists a new compute allocation. The referenced
 // project and compute cluster must already exist. If alloc.ID is empty a new
 // UUID is generated; if Status is empty it defaults to ACTIVE.
diff --git a/pkg/service/compute_allocation_membership.go b/pkg/service/compute_allocation_membership.go
index aa6ea6fcb..8b82152e4 100644
--- a/pkg/service/compute_allocation_membership.go
+++ b/pkg/service/compute_allocation_membership.go
@@ -89,6 +89,16 @@ func (s *Service) GetComputeAllocationMembership(ctx context.Context, id string)
 	return m, nil
 }
 
+// IsAllocationMember reports whether the user holds an active membership on
+// the allocation.
+func (s *Service) IsAllocationMember(ctx context.Context, allocationID, userID string) (bool, error) {
+	m, err := s.memberships.FindByPair(ctx, allocationID, userID)
+	if err != nil {
+		return false, fmt.Errorf("lookup compute allocation membership: %w", err)
+	}
+	return m != nil && m.MembershipStatus == models.ACTIVE, nil
+}
+
 // ListMembersForAllocation returns memberships for an allocation joined with
 // users so each row carries display_name and email. Display fields live on
 // store.MembershipWithUser, not on the core entity.
diff --git a/pkg/service/interface.go b/pkg/service/interface.go
index 5d7d5e74e..aedebca43 100644
--- a/pkg/service/interface.go
+++ b/pkg/service/interface.go
@@ -79,6 +79,8 @@ type ProjectService interface {
 	GetProject(ctx context.Context, id string) (*models.Project, error)
 	GetProjectByOriginatedID(ctx context.Context, originatedID string) (*models.Project, error)
 	ListProjectsByPI(ctx context.Context, piUserID string) ([]models.Project, error)
+	ListProjectsForParticipant(ctx context.Context, userID string) ([]store.ProjectWithPI, error)
+	IsProjectParticipant(ctx context.Context, projectID, userID string) (bool, error)
 	UpdateProject(ctx context.Context, project *models.Project) error
 	UpdateProjectStatus(ctx context.Context, id string, status models.ProjectStatus) (*models.Project, error)
 	DeleteProject(ctx context.Context, id string) error
@@ -112,6 +114,7 @@ type ComputeAllocationService interface {
 	GetComputeAllocation(ctx context.Context, id string) (*models.ComputeAllocation, error)
 	ListComputeAllocationsByProject(ctx context.Context, projectID string) ([]models.ComputeAllocation, error)
 	ListComputeAllocationsByCluster(ctx context.Context, clusterID string) ([]models.ComputeAllocation, error)
+	ListComputeAllocationsForParticipant(ctx context.Context, userID string) ([]models.ComputeAllocation, error)
 	UpdateComputeAllocation(ctx context.Context, alloc *models.ComputeAllocation) error
 	DeleteComputeAllocation(ctx context.Context, id string) error
 }
@@ -179,6 +182,7 @@ type ComputeAllocationChangeRequestEventService interface {
 type ProjectMembershipService interface {
 	EnsureProjectMembership(ctx context.Context, projectID, userID, role string) error
 	ListProjectMemberships(ctx context.Context, projectID string) ([]models.ProjectMembership, error)
+	ProjectRoleForUser(ctx context.Context, projectID, userID string) (models.ProjectRole, error)
 }
 
 // ComputeAllocationMembershipService exposes allocation memberships.
@@ -188,6 +192,7 @@ type ComputeAllocationMembershipService interface {
 	ListMembersForAllocation(ctx context.Context, allocationID string) ([]store.MembershipWithUser, error)
 	ListMembersForProject(ctx context.Context, projectID string) ([]store.MembershipWithUser, error)
 	ListAllocationsForUser(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error)
+	IsAllocationMember(ctx context.Context, allocationID, userID string) (bool, error)
 	UpdateComputeAllocationMembership(ctx context.Context, m *models.ComputeAllocationMembership) (*models.ComputeAllocationMembership, error)
 	UpdateMembershipStatus(ctx context.Context, id string, status models.AllocationStatus) (*models.ComputeAllocationMembership, error)
 	DeleteComputeAllocationMembership(ctx context.Context, id string) error
diff --git a/pkg/service/mock.go b/pkg/service/mock.go
index ce7e55014..aae202451 100644
--- a/pkg/service/mock.go
+++ b/pkg/service/mock.go
@@ -252,6 +252,12 @@ var _ CoreService = &CoreServiceMock{}
 //			HasPrivilegeFunc: func(ctx context.Context, userID string, privilege models.PrivilegeKey) (bool, error) {
 //				panic("mock out the HasPrivilege method")
 //			},
+//			IsAllocationMemberFunc: func(ctx context.Context, allocationID string, userID string) (bool, error) {
+//				panic("mock out the IsAllocationMember method")
+//			},
+//			IsProjectParticipantFunc: func(ctx context.Context, projectID string, userID string) (bool, error) {
+//				panic("mock out the IsProjectParticipant method")
+//			},
 //			ListAllAuditEventsFunc: func(ctx context.Context) ([]*models.AuditEvent, error) {
 //				panic("mock out the ListAllAuditEvents method")
 //			},
@@ -288,6 +294,9 @@ var _ CoreService = &CoreServiceMock{}
 //			ListComputeAllocationsByProjectFunc: func(ctx context.Context, projectID string) ([]models.ComputeAllocation, error) {
 //				panic("mock out the ListComputeAllocationsByProject method")
 //			},
+//			ListComputeAllocationsForParticipantFunc: func(ctx context.Context, userID string) ([]models.ComputeAllocation, error) {
+//				panic("mock out the ListComputeAllocationsForParticipant method")
+//			},
 //			ListComputeClusterUsersByClusterFunc: func(ctx context.Context, clusterID string) ([]models.ComputeClusterUser, error) {
 //				panic("mock out the ListComputeClusterUsersByCluster method")
 //			},
@@ -324,6 +333,9 @@ var _ CoreService = &CoreServiceMock{}
 //			ListProjectsByPIFunc: func(ctx context.Context, piUserID string) ([]models.Project, error) {
 //				panic("mock out the ListProjectsByPI method")
 //			},
+//			ListProjectsForParticipantFunc: func(ctx context.Context, userID string) ([]store.ProjectWithPI, error) {
+//				panic("mock out the ListProjectsForParticipant method")
+//			},
 //			ListRatesForResourceFunc: func(ctx context.Context, resourceID string) ([]models.ComputeAllocationResourceRate, error) {
 //				panic("mock out the ListRatesForResource method")
 //			},
@@ -363,6 +375,9 @@ var _ CoreService = &CoreServiceMock{}
 //			PrivilegeCatalogFunc: func() []models.PrivilegeKey {
 //				panic("mock out the PrivilegeCatalog method")
 //			},
+//			ProjectRoleForUserFunc: func(ctx context.Context, projectID string, userID string) (models.ProjectRole, error) {
+//				panic("mock out the ProjectRoleForUser method")
+//			},
 //			RemovePrivilegeFromRoleFunc: func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
 //				panic("mock out the RemovePrivilegeFromRole method")
 //			},
@@ -661,6 +676,12 @@ type CoreServiceMock struct {
 	// HasPrivilegeFunc mocks the HasPrivilege method.
 	HasPrivilegeFunc func(ctx context.Context, userID string, privilege models.PrivilegeKey) (bool, error)
 
+	// IsAllocationMemberFunc mocks the IsAllocationMember method.
+	IsAllocationMemberFunc func(ctx context.Context, allocationID string, userID string) (bool, error)
+
+	// IsProjectParticipantFunc mocks the IsProjectParticipant method.
+	IsProjectParticipantFunc func(ctx context.Context, projectID string, userID string) (bool, error)
+
 	// ListAllAuditEventsFunc mocks the ListAllAuditEvents method.
 	ListAllAuditEventsFunc func(ctx context.Context) ([]*models.AuditEvent, error)
 
@@ -697,6 +718,9 @@ type CoreServiceMock struct {
 	// ListComputeAllocationsByProjectFunc mocks the ListComputeAllocationsByProject method.
 	ListComputeAllocationsByProjectFunc func(ctx context.Context, projectID string) ([]models.ComputeAllocation, error)
 
+	// ListComputeAllocationsForParticipantFunc mocks the ListComputeAllocationsForParticipant method.
+	ListComputeAllocationsForParticipantFunc func(ctx context.Context, userID string) ([]models.ComputeAllocation, error)
+
 	// ListComputeClusterUsersByClusterFunc mocks the ListComputeClusterUsersByCluster method.
 	ListComputeClusterUsersByClusterFunc func(ctx context.Context, clusterID string) ([]models.ComputeClusterUser, error)
 
@@ -733,6 +757,9 @@ type CoreServiceMock struct {
 	// ListProjectsByPIFunc mocks the ListProjectsByPI method.
 	ListProjectsByPIFunc func(ctx context.Context, piUserID string) ([]models.Project, error)
 
+	// ListProjectsForParticipantFunc mocks the ListProjectsForParticipant method.
+	ListProjectsForParticipantFunc func(ctx context.Context, userID string) ([]store.ProjectWithPI, error)
+
 	// ListRatesForResourceFunc mocks the ListRatesForResource method.
 	ListRatesForResourceFunc func(ctx context.Context, resourceID string) ([]models.ComputeAllocationResourceRate, error)
 
@@ -772,6 +799,9 @@ type CoreServiceMock struct {
 	// PrivilegeCatalogFunc mocks the PrivilegeCatalog method.
 	PrivilegeCatalogFunc func() []models.PrivilegeKey
 
+	// ProjectRoleForUserFunc mocks the ProjectRoleForUser method.
+	ProjectRoleForUserFunc func(ctx context.Context, projectID string, userID string) (models.ProjectRole, error)
+
 	// RemovePrivilegeFromRoleFunc mocks the RemovePrivilegeFromRole method.
 	RemovePrivilegeFromRoleFunc func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error
 
@@ -1429,6 +1459,24 @@ type CoreServiceMock struct {
 			// Privilege is the privilege argument value.
 			Privilege models.PrivilegeKey
 		}
+		// IsAllocationMember holds details about calls to the IsAllocationMember method.
+		IsAllocationMember []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// AllocationID is the allocationID argument value.
+			AllocationID string
+			// UserID is the userID argument value.
+			UserID string
+		}
+		// IsProjectParticipant holds details about calls to the IsProjectParticipant method.
+		IsProjectParticipant []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// ProjectID is the projectID argument value.
+			ProjectID string
+			// UserID is the userID argument value.
+			UserID string
+		}
 		// ListAllAuditEvents holds details about calls to the ListAllAuditEvents method.
 		ListAllAuditEvents []struct {
 			// Ctx is the ctx argument value.
@@ -1509,6 +1557,13 @@ type CoreServiceMock struct {
 			// ProjectID is the projectID argument value.
 			ProjectID string
 		}
+		// ListComputeAllocationsForParticipant holds details about calls to the ListComputeAllocationsForParticipant method.
+		ListComputeAllocationsForParticipant []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// UserID is the userID argument value.
+			UserID string
+		}
 		// ListComputeClusterUsersByCluster holds details about calls to the ListComputeClusterUsersByCluster method.
 		ListComputeClusterUsersByCluster []struct {
 			// Ctx is the ctx argument value.
@@ -1591,6 +1646,13 @@ type CoreServiceMock struct {
 			// PiUserID is the piUserID argument value.
 			PiUserID string
 		}
+		// ListProjectsForParticipant holds details about calls to the ListProjectsForParticipant method.
+		ListProjectsForParticipant []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// UserID is the userID argument value.
+			UserID string
+		}
 		// ListRatesForResource holds details about calls to the ListRatesForResource method.
 		ListRatesForResource []struct {
 			// Ctx is the ctx argument value.
@@ -1678,6 +1740,15 @@ type CoreServiceMock struct {
 		// PrivilegeCatalog holds details about calls to the PrivilegeCatalog method.
 		PrivilegeCatalog []struct {
 		}
+		// ProjectRoleForUser holds details about calls to the ProjectRoleForUser method.
+		ProjectRoleForUser []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// ProjectID is the projectID argument value.
+			ProjectID string
+			// UserID is the userID argument value.
+			UserID string
+		}
 		// RemovePrivilegeFromRole holds details about calls to the RemovePrivilegeFromRole method.
 		RemovePrivilegeFromRole []struct {
 			// Ctx is the ctx argument value.
@@ -1930,6 +2001,8 @@ type CoreServiceMock struct {
 	lockGrantPrivilege                                         sync.RWMutex
 	lockGrantRoleToUser                                        sync.RWMutex
 	lockHasPrivilege                                           sync.RWMutex
+	lockIsAllocationMember                                     sync.RWMutex
+	lockIsProjectParticipant                                   sync.RWMutex
 	lockListAllAuditEvents                                     sync.RWMutex
 	lockListAllocationsForResource                             sync.RWMutex
 	lockListAllocationsForUser                                 sync.RWMutex
@@ -1942,6 +2015,7 @@ type CoreServiceMock struct {
 	lockListComputeAllocationResourcesByTypeAndCluster         sync.RWMutex
 	lockListComputeAllocationsByCluster                        sync.RWMutex
 	lockListComputeAllocationsByProject                        sync.RWMutex
+	lockListComputeAllocationsForParticipant                   sync.RWMutex
 	lockListComputeClusterUsersByCluster                       sync.RWMutex
 	lockListComputeClusterUsersByUser                          sync.RWMutex
 	lockListComputeClusters                                    sync.RWMutex
@@ -1954,6 +2028,7 @@ type CoreServiceMock struct {
 	lockListPrivilegeHolders                                   sync.RWMutex
 	lockListProjectMemberships                                 sync.RWMutex
 	lockListProjectsByPI                                       sync.RWMutex
+	lockListProjectsForParticipant                             sync.RWMutex
 	lockListRatesForResource                                   sync.RWMutex
 	lockListResourcesForAllocation                             sync.RWMutex
 	lockListRoleHolders                                        sync.RWMutex
@@ -1967,6 +2042,7 @@ type CoreServiceMock struct {
 	lockListUsersByOrganization                                sync.RWMutex
 	lockMergeUsers                                             sync.RWMutex
 	lockPrivilegeCatalog                                       sync.RWMutex
+	lockProjectRoleForUser                                     sync.RWMutex
 	lockRemovePrivilegeFromRole                                sync.RWMutex
 	lockRevokePrivilege                                        sync.RWMutex
 	lockRevokeRoleFromUser                                     sync.RWMutex
@@ -4873,6 +4949,86 @@ func (mock *CoreServiceMock) HasPrivilegeCalls() []struct {
 	return calls
 }
 
+// IsAllocationMember calls IsAllocationMemberFunc.
+func (mock *CoreServiceMock) IsAllocationMember(ctx context.Context, allocationID string, userID string) (bool, error) {
+	if mock.IsAllocationMemberFunc == nil {
+		panic("CoreServiceMock.IsAllocationMemberFunc: method is nil but CoreService.IsAllocationMember was just called")
+	}
+	callInfo := struct {
+		Ctx          context.Context
+		AllocationID string
+		UserID       string
+	}{
+		Ctx:          ctx,
+		AllocationID: allocationID,
+		UserID:       userID,
+	}
+	mock.lockIsAllocationMember.Lock()
+	mock.calls.IsAllocationMember = append(mock.calls.IsAllocationMember, callInfo)
+	mock.lockIsAllocationMember.Unlock()
+	return mock.IsAllocationMemberFunc(ctx, allocationID, userID)
+}
+
+// IsAllocationMemberCalls gets all the calls that were made to IsAllocationMember.
+// Check the length with:
+//
+//	len(mockedCoreService.IsAllocationMemberCalls())
+func (mock *CoreServiceMock) IsAllocationMemberCalls() []struct {
+	Ctx          context.Context
+	AllocationID string
+	UserID       string
+} {
+	var calls []struct {
+		Ctx          context.Context
+		AllocationID string
+		UserID       string
+	}
+	mock.lockIsAllocationMember.RLock()
+	calls = mock.calls.IsAllocationMember
+	mock.lockIsAllocationMember.RUnlock()
+	return calls
+}
+
+// IsProjectParticipant calls IsProjectParticipantFunc.
+func (mock *CoreServiceMock) IsProjectParticipant(ctx context.Context, projectID string, userID string) (bool, error) {
+	if mock.IsProjectParticipantFunc == nil {
+		panic("CoreServiceMock.IsProjectParticipantFunc: method is nil but CoreService.IsProjectParticipant was just called")
+	}
+	callInfo := struct {
+		Ctx       context.Context
+		ProjectID string
+		UserID    string
+	}{
+		Ctx:       ctx,
+		ProjectID: projectID,
+		UserID:    userID,
+	}
+	mock.lockIsProjectParticipant.Lock()
+	mock.calls.IsProjectParticipant = append(mock.calls.IsProjectParticipant, callInfo)
+	mock.lockIsProjectParticipant.Unlock()
+	return mock.IsProjectParticipantFunc(ctx, projectID, userID)
+}
+
+// IsProjectParticipantCalls gets all the calls that were made to IsProjectParticipant.
+// Check the length with:
+//
+//	len(mockedCoreService.IsProjectParticipantCalls())
+func (mock *CoreServiceMock) IsProjectParticipantCalls() []struct {
+	Ctx       context.Context
+	ProjectID string
+	UserID    string
+} {
+	var calls []struct {
+		Ctx       context.Context
+		ProjectID string
+		UserID    string
+	}
+	mock.lockIsProjectParticipant.RLock()
+	calls = mock.calls.IsProjectParticipant
+	mock.lockIsProjectParticipant.RUnlock()
+	return calls
+}
+
 // ListAllAuditEvents calls ListAllAuditEventsFunc.
 func (mock *CoreServiceMock) ListAllAuditEvents(ctx context.Context) ([]*models.AuditEvent, error) {
 	if mock.ListAllAuditEventsFunc == nil {
@@ -5297,6 +5453,42 @@ func (mock *CoreServiceMock) ListComputeAllocationsByProjectCalls() []struct {
 	return calls
 }
 
+// ListComputeAllocationsForParticipant calls ListComputeAllocationsForParticipantFunc.
+func (mock *CoreServiceMock) ListComputeAllocationsForParticipant(ctx context.Context, userID string) ([]models.ComputeAllocation, error) {
+	if mock.ListComputeAllocationsForParticipantFunc == nil {
+		panic("CoreServiceMock.ListComputeAllocationsForParticipantFunc: method is nil but CoreService.ListComputeAllocationsForParticipant was just called")
+	}
+	callInfo := struct {
+		Ctx    context.Context
+		UserID string
+	}{
+		Ctx:    ctx,
+		UserID: userID,
+	}
+	mock.lockListComputeAllocationsForParticipant.Lock()
+	mock.calls.ListComputeAllocationsForParticipant = append(mock.calls.ListComputeAllocationsForParticipant, callInfo)
+	mock.lockListComputeAllocationsForParticipant.Unlock()
+	return mock.ListComputeAllocationsForParticipantFunc(ctx, userID)
+}
+
+// ListComputeAllocationsForParticipantCalls gets all the calls that were made to ListComputeAllocationsForParticipant.
+// Check the length with:
+//
+//	len(mockedCoreService.ListComputeAllocationsForParticipantCalls())
+func (mock *CoreServiceMock) ListComputeAllocationsForParticipantCalls() []struct {
+	Ctx    context.Context
+	UserID string
+} {
+	var calls []struct {
+		Ctx    context.Context
+		UserID string
+	}
+	mock.lockListComputeAllocationsForParticipant.RLock()
+	calls = mock.calls.ListComputeAllocationsForParticipant
+	mock.lockListComputeAllocationsForParticipant.RUnlock()
+	return calls
+}
+
 // ListComputeClusterUsersByCluster calls ListComputeClusterUsersByClusterFunc.
 func (mock *CoreServiceMock) ListComputeClusterUsersByCluster(ctx context.Context, clusterID string) ([]models.ComputeClusterUser, error) {
 	if mock.ListComputeClusterUsersByClusterFunc == nil {
@@ -5725,6 +5917,42 @@ func (mock *CoreServiceMock) ListProjectsByPICalls() []struct {
 	return calls
 }
 
+// ListProjectsForParticipant calls ListProjectsForParticipantFunc.
+func (mock *CoreServiceMock) ListProjectsForParticipant(ctx context.Context, userID string) ([]store.ProjectWithPI, error) {
+	if mock.ListProjectsForParticipantFunc == nil {
+		panic("CoreServiceMock.ListProjectsForParticipantFunc: method is nil but CoreService.ListProjectsForParticipant was just called")
+	}
+	callInfo := struct {
+		Ctx    context.Context
+		UserID string
+	}{
+		Ctx:    ctx,
+		UserID: userID,
+	}
+	mock.lockListProjectsForParticipant.Lock()
+	mock.calls.ListProjectsForParticipant = append(mock.calls.ListProjectsForParticipant, callInfo)
+	mock.lockListProjectsForParticipant.Unlock()
+	return mock.ListProjectsForParticipantFunc(ctx, userID)
+}
+
+// ListProjectsForParticipantCalls gets all the calls that were made to ListProjectsForParticipant.
+// Check the length with:
+//
+//	len(mockedCoreService.ListProjectsForParticipantCalls())
+func (mock *CoreServiceMock) ListProjectsForParticipantCalls() []struct {
+	Ctx    context.Context
+	UserID string
+} {
+	var calls []struct {
+		Ctx    context.Context
+		UserID string
+	}
+	mock.lockListProjectsForParticipant.RLock()
+	calls = mock.calls.ListProjectsForParticipant
+	mock.lockListProjectsForParticipant.RUnlock()
+	return calls
+}
+
 // ListRatesForResource calls ListRatesForResourceFunc.
 func (mock *CoreServiceMock) ListRatesForResource(ctx context.Context, resourceID string) ([]models.ComputeAllocationResourceRate, error) {
 	if mock.ListRatesForResourceFunc == nil {
@@ -6184,6 +6412,46 @@ func (mock *CoreServiceMock) PrivilegeCatalogCalls() []struct {
 	return calls
 }
 
+// ProjectRoleForUser calls ProjectRoleForUserFunc.
+func (mock *CoreServiceMock) ProjectRoleForUser(ctx context.Context, projectID string, userID string) (models.ProjectRole, error) {
+	if mock.ProjectRoleForUserFunc == nil {
+		panic("CoreServiceMock.ProjectRoleForUserFunc: method is nil but CoreService.ProjectRoleForUser was just called")
+	}
+	callInfo := struct {
+		Ctx       context.Context
+		ProjectID string
+		UserID    string
+	}{
+		Ctx:       ctx,
+		ProjectID: projectID,
+		UserID:    userID,
+	}
+	mock.lockProjectRoleForUser.Lock()
+	mock.calls.ProjectRoleForUser = append(mock.calls.ProjectRoleForUser, callInfo)
+	mock.lockProjectRoleForUser.Unlock()
+	return mock.ProjectRoleForUserFunc(ctx, projectID, userID)
+}
+
+// ProjectRoleForUserCalls gets all the calls that were made to ProjectRoleForUser.
+// Check the length with:
+//
+//	len(mockedCoreService.ProjectRoleForUserCalls())
+func (mock *CoreServiceMock) ProjectRoleForUserCalls() []struct {
+	Ctx       context.Context
+	ProjectID string
+	UserID    string
+} {
+	var calls []struct {
+		Ctx       context.Context
+		ProjectID string
+		UserID    string
+	}
+	mock.lockProjectRoleForUser.RLock()
+	calls = mock.calls.ProjectRoleForUser
+	mock.lockProjectRoleForUser.RUnlock()
+	return calls
+}
+
 // RemovePrivilegeFromRole calls RemovePrivilegeFromRoleFunc.
 func (mock *CoreServiceMock) RemovePrivilegeFromRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
 	if mock.RemovePrivilegeFromRoleFunc == nil {
diff --git a/pkg/service/project.go b/pkg/service/project.go
index 3a54d1c15..ed3117eee 100644
--- a/pkg/service/project.go
+++ b/pkg/service/project.go
@@ -140,6 +140,33 @@ func (s *Service) ListProjectsWithPI(ctx context.Context, f store.ProjectListFil
 	return rows, total, nil
 }
 
+// ListProjectsForParticipant returns the projects where the user holds a
+// project membership or an active allocation membership, PI joined, newest
+// first.
+func (s *Service) ListProjectsForParticipant(ctx context.Context, userID string) ([]store.ProjectWithPI, error) {
+	if userID == "" {
+		return nil, fmt.Errorf("%w: user id is required", ErrInvalidInput)
+	}
+	rows, err := s.projs.ListWithPIForParticipant(ctx, userID)
+	if err != nil {
+		return nil, fmt.Errorf("list projects for participant: %w", err)
+	}
+	return rows, nil
+}
+
+// IsProjectParticipant reports whether the user participates in the project
+// through a project_memberships row or an active allocation membership.
+func (s *Service) IsProjectParticipant(ctx context.Context, projectID, userID string) (bool, error) {
+	if projectID == "" || userID == "" {
+		return false, fmt.Errorf("%w: project id and user id are required", ErrInvalidInput)
+	}
+	ok, err := s.projMemberships.IsParticipant(ctx, projectID, userID)
+	if err != nil {
+		return false, fmt.Errorf("check project participant: %w", err)
+	}
+	return ok, nil
+}
+
 // UpdateProject persists changes to an existing project. Fields left
 // blank/zero on the supplied record fall back to the stored value.
 func (s *Service) UpdateProject(ctx context.Context, project *models.Project) error {
diff --git a/pkg/service/project_membership.go b/pkg/service/project_membership.go
index ef9501764..b7abf593c 100644
--- a/pkg/service/project_membership.go
+++ b/pkg/service/project_membership.go
@@ -102,6 +102,19 @@ func (s *Service) EnsureProjectMembership(ctx context.Context, projectID, userID
 	})
 }
 
+// ProjectRoleForUser returns the user's governance role on a project, or an
+// empty role when they hold none.
+func (s *Service) ProjectRoleForUser(ctx context.Context, projectID, userID string) (models.ProjectRole, error) {
+	pm, err := s.projMemberships.FindByPair(ctx, projectID, userID)
+	if err != nil {
+		return "", fmt.Errorf("lookup project role: %w", err)
+	}
+	if pm == nil {
+		return "", nil
+	}
+	return pm.Role, nil
+}
+
 // ListProjectMemberships returns every project_memberships row for the project.
 func (s *Service) ListProjectMemberships(ctx context.Context, projectID string) ([]models.ProjectMembership, error) {
 	if projectID == "" {
diff --git a/web/src/app/(portal)/allocations/layout.tsx b/web/src/app/(portal)/allocations/layout.tsx
index a251aefbf..c49bcb507 100644
--- a/web/src/app/(portal)/allocations/layout.tsx
+++ b/web/src/app/(portal)/allocations/layout.tsx
@@ -16,8 +16,8 @@
 // under the License.
 
 import type { ReactNode } from "react";
-import { AllocationsPermissionGate } from "./PermissionGate";
 
+// No gate: the backend scopes these reads to the caller's memberships.
 export default function AllocationsLayout({ children }: { children: ReactNode }) {
-  return <AllocationsPermissionGate>{children}</AllocationsPermissionGate>;
+  return <>{children}</>;
 }
diff --git a/web/src/app/(portal)/projects/PermissionGate.tsx b/web/src/app/(portal)/projects/PermissionGate.tsx
deleted file mode 100644
index 2e4a15f5b..000000000
--- a/web/src/app/(portal)/projects/PermissionGate.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-"use client";
-
-import type { ReactNode } from "react";
-import { useAbility } from "@/shared/casl/AbilityProvider";
-import { ErrorState } from "@/shared/ui/ErrorState";
-
-export function ProjectsPermissionGate({ children }: { children: ReactNode }) {
-  const ability = useAbility();
-  if (ability.cannot("read", "Project")) {
-    return <ErrorState message="Not permitted." />;
-  }
-  return <>{children}</>;
-}
diff --git a/web/src/app/(portal)/projects/layout.tsx b/web/src/app/(portal)/projects/layout.tsx
index 29929ff73..904e36af2 100644
--- a/web/src/app/(portal)/projects/layout.tsx
+++ b/web/src/app/(portal)/projects/layout.tsx
@@ -16,8 +16,8 @@
 // under the License.
 
 import type { ReactNode } from "react";
-import { ProjectsPermissionGate } from "./PermissionGate";
 
+// No gate: the backend scopes these reads to the caller's memberships.
 export default function ProjectsLayout({ children }: { children: ReactNode }) {
-  return <ProjectsPermissionGate>{children}</ProjectsPermissionGate>;
+  return <>{children}</>;
 }
diff --git a/web/src/generated/core/index.ts b/web/src/generated/core/index.ts
index d5a9089f4..379e6c28b 100644
--- a/web/src/generated/core/index.ts
+++ b/web/src/generated/core/index.ts
@@ -1,4 +1,4 @@
 // This file is auto-generated by @hey-api/openapi-ts
 
 export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
-export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, CallerRoleGrant, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocation [...]
+export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, CallerRoleGrant, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocation [...]
diff --git a/web/src/generated/core/sdk.gen.ts b/web/src/generated/core/sdk.gen.ts
index d1f0baef9..149b028f8 100644
--- a/web/src/generated/core/sdk.gen.ts
+++ b/web/src/generated/core/sdk.gen.ts
@@ -2,7 +2,7 @@
 
 import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client';
 import { client } from './client.gen';
-import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
+import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
 
 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
     /**
@@ -431,8 +431,10 @@ export const getComputeAllocationUsagesById = <ThrowOnError extends boolean = fa
 
 /**
  * List compute allocations (filtered + paginated)
+ *
+ * Callers without the allocations read privilege get only allocations where they hold a membership or a governance role on the parent project; filters and pagination apply to privileged callers only.
  */
-export const getComputeAllocations = <ThrowOnError extends boolean = false>(options?: Options<GetComputeAllocationsData, ThrowOnError>): RequestResult<GetComputeAllocationsResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetComputeAllocationsResponses, unknown, ThrowOnError>({
+export const getComputeAllocations = <ThrowOnError extends boolean = false>(options?: Options<GetComputeAllocationsData, ThrowOnError>): RequestResult<GetComputeAllocationsResponses, GetComputeAllocationsErrors, ThrowOnError> => (options?.client ?? client).get<GetComputeAllocationsResponses, GetComputeAllocationsErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
     url: '/compute-allocations',
     ...options
@@ -453,6 +455,8 @@ export const postComputeAllocations = <ThrowOnError extends boolean = false>(opt
 
 /**
  * Get a compute allocation by ID
+ *
+ * Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
  */
 export const getComputeAllocationsById = <ThrowOnError extends boolean = false>(options: Options<GetComputeAllocationsByIdData, ThrowOnError>): RequestResult<GetComputeAllocationsByIdResponses, GetComputeAllocationsByIdErrors, ThrowOnError> => (options.client ?? client).get<GetComputeAllocationsByIdResponses, GetComputeAllocationsByIdErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
@@ -489,6 +493,8 @@ export const getComputeAllocationsByIdDiffsLatest = <ThrowOnError extends boolea
 
 /**
  * List members of a compute allocation
+ *
+ * Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
  */
 export const getComputeAllocationsByIdMemberships = <ThrowOnError extends boolean = false>(options: Options<GetComputeAllocationsByIdMembershipsData, ThrowOnError>): RequestResult<GetComputeAllocationsByIdMembershipsResponses, GetComputeAllocationsByIdMembershipsErrors, ThrowOnError> => (options.client ?? client).get<GetComputeAllocationsByIdMembershipsResponses, GetComputeAllocationsByIdMembershipsErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
@@ -498,6 +504,8 @@ export const getComputeAllocationsByIdMemberships = <ThrowOnError extends boolea
 
 /**
  * List resources attached to a compute allocation
+ *
+ * Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
  */
 export const getComputeAllocationsByIdResources = <ThrowOnError extends boolean = false>(options: Options<GetComputeAllocationsByIdResourcesData, ThrowOnError>): RequestResult<GetComputeAllocationsByIdResourcesResponses, GetComputeAllocationsByIdResourcesErrors, ThrowOnError> => (options.client ?? client).get<GetComputeAllocationsByIdResourcesResponses, GetComputeAllocationsByIdResourcesErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
@@ -544,6 +552,8 @@ export const putComputeAllocationsByIdResourcesByResourceId = <ThrowOnError exte
 
 /**
  * List usages for a compute allocation
+ *
+ * Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
  */
 export const getComputeAllocationsByIdUsages = <ThrowOnError extends boolean = false>(options: Options<GetComputeAllocationsByIdUsagesData, ThrowOnError>): RequestResult<GetComputeAllocationsByIdUsagesResponses, GetComputeAllocationsByIdUsagesErrors, ThrowOnError> => (options.client ?? client).get<GetComputeAllocationsByIdUsagesResponses, GetComputeAllocationsByIdUsagesErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
@@ -553,6 +563,8 @@ export const getComputeAllocationsByIdUsages = <ThrowOnError extends boolean = f
 
 /**
  * Get total SU usage for a compute allocation
+ *
+ * Callers without the allocations read privilege must hold a membership on the allocation or a governance role on its project; others return 404.
  */
 export const getComputeAllocationsByIdUsagesTotal = <ThrowOnError extends boolean = false>(options: Options<GetComputeAllocationsByIdUsagesTotalData, ThrowOnError>): RequestResult<GetComputeAllocationsByIdUsagesTotalResponses, GetComputeAllocationsByIdUsagesTotalErrors, ThrowOnError> => (options.client ?? client).get<GetComputeAllocationsByIdUsagesTotalResponses, GetComputeAllocationsByIdUsagesTotalErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
@@ -726,8 +738,10 @@ export const getPrivilegesCatalog = <ThrowOnError extends boolean = false>(optio
 
 /**
  * List projects (filtered + paginated, PI joined)
+ *
+ * Callers without the projects read privilege get only the projects they participate in; filters and pagination apply to privileged callers only.
  */
-export const getProjects = <ThrowOnError extends boolean = false>(options?: Options<GetProjectsData, ThrowOnError>): RequestResult<GetProjectsResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetProjectsResponses, unknown, ThrowOnError>({
+export const getProjects = <ThrowOnError extends boolean = false>(options?: Options<GetProjectsData, ThrowOnError>): RequestResult<GetProjectsResponses, GetProjectsErrors, ThrowOnError> => (options?.client ?? client).get<GetProjectsResponses, GetProjectsErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
     url: '/projects',
     ...options
@@ -748,6 +762,8 @@ export const postProjects = <ThrowOnError extends boolean = false>(options: Opti
 
 /**
  * Get a project by ID
+ *
+ * Callers without the projects read privilege only see projects they participate in; others return 404.
  */
 export const getProjectsById = <ThrowOnError extends boolean = false>(options: Options<GetProjectsByIdData, ThrowOnError>): RequestResult<GetProjectsByIdResponses, GetProjectsByIdErrors, ThrowOnError> => (options.client ?? client).get<GetProjectsByIdResponses, GetProjectsByIdErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
diff --git a/web/src/generated/core/types.gen.ts b/web/src/generated/core/types.gen.ts
index 4c23caf38..58c9f2310 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -1892,6 +1892,17 @@ export type GetComputeAllocationsData = {
     url: '/compute-allocations';
 };
 
+export type GetComputeAllocationsErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
+};
+
+export type GetComputeAllocationsError = GetComputeAllocationsErrors[keyof GetComputeAllocationsErrors];
+
 export type GetComputeAllocationsResponses = {
     /**
      * OK
@@ -1944,6 +1955,12 @@ export type GetComputeAllocationsByIdData = {
 };
 
 export type GetComputeAllocationsByIdErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
     /**
      * Not Found
      */
@@ -2072,6 +2089,12 @@ export type GetComputeAllocationsByIdMembershipsData = {
 };
 
 export type GetComputeAllocationsByIdMembershipsErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
     /**
      * Not Found
      */
@@ -2104,6 +2127,12 @@ export type GetComputeAllocationsByIdResourcesData = {
 };
 
 export type GetComputeAllocationsByIdResourcesErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
     /**
      * Not Found
      */
@@ -2256,6 +2285,12 @@ export type GetComputeAllocationsByIdUsagesData = {
 };
 
 export type GetComputeAllocationsByIdUsagesErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
     /**
      * Not Found
      */
@@ -2288,6 +2323,12 @@ export type GetComputeAllocationsByIdUsagesTotalData = {
 };
 
 export type GetComputeAllocationsByIdUsagesTotalErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
     /**
      * Not Found
      */
@@ -2840,6 +2881,17 @@ export type GetProjectsData = {
     url: '/projects';
 };
 
+export type GetProjectsErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
+};
+
+export type GetProjectsError = GetProjectsErrors[keyof GetProjectsErrors];
+
 export type GetProjectsResponses = {
     /**
      * OK
@@ -2892,6 +2944,12 @@ export type GetProjectsByIdData = {
 };
 
 export type GetProjectsByIdErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
     /**
      * Not Found
      */
diff --git a/web/src/shared/layout/nav.ts b/web/src/shared/layout/nav.ts
index 96469e3f6..bd8617df9 100644
--- a/web/src/shared/layout/nav.ts
+++ b/web/src/shared/layout/nav.ts
@@ -45,23 +45,21 @@ export const NAV_GROUP_LABELS: Record<NavGroup, string> = {
 };
 
 export const portalNav: NavItem[] = [
+  // No ability gates on this group: the backend scopes these reads to the
+  // caller's own memberships.
   {
     href: "/allocations",
     label: "Allocations",
     icon: Server,
     group: "allocations",
-    ability: { action: "read", subject: "Allocation" },
   },
   {
     href: "/projects",
     label: "Projects",
     icon: FolderKanban,
     group: "allocations",
-    ability: { action: "read", subject: "Project" },
   },
   {
-    // No ability gate: every signed-in user can see their own usage. Access is
-    // membership-scoped by the backend (empty state when they belong to nothing).
     href: "/analytics",
     label: "Analytics",
     icon: ChartColumn,