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/13 23:50:27 UTC

(airavata-custos) branch master updated: Return the caller's roles from /me endpoint (#526)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 7a32a3881 Return the caller's roles from /me endpoint (#526)
7a32a3881 is described below

commit 7a32a3881caac07dfd747d93f9ab9aa9915ec2c8
Author: Lahiru Jayathilake <la...@gmail.com>
AuthorDate: Mon Jul 13 19:50:21 2026 -0400

    Return the caller's roles from /me endpoint (#526)
    
    * Return the caller's roles from /me endpoint
    
    * Show the roles in the user's settings page
---
 api/core.openapi.yaml                              | 19 +++++++-
 internal/server/privilege.go                       | 39 ++++++++++++---
 internal/server/privilege_integration_test.go      | 56 ++++++++++++++++++++++
 internal/store/store.go                            |  1 +
 internal/store/user_role_store.go                  | 48 +++++++++++++++++++
 pkg/service/user_role.go                           |  8 ++++
 web/src/features/core/identity/api.ts              |  5 +-
 .../core/identity/components/AccessCard.tsx        | 17 ++-----
 .../core/identity/components/SettingsPage.tsx      |  2 +-
 .../components/__tests__/AccessCard.test.tsx       | 26 +++++++++-
 web/src/features/core/identity/queries.ts          | 37 ++++++++++----
 web/src/features/core/identity/schemas.ts          | 16 ++++++-
 web/src/generated/core/index.ts                    |  2 +-
 web/src/generated/core/sdk.gen.ts                  |  4 +-
 web/src/generated/core/types.gen.ts                |  7 +++
 web/src/generated/core/zod.gen.ts                  |  9 ++++
 web/src/mocks/handlers/identity.ts                 | 13 ++++-
 17 files changed, 274 insertions(+), 35 deletions(-)

diff --git a/api/core.openapi.yaml b/api/core.openapi.yaml
index 32ac8892a..b35fbbdd5 100644
--- a/api/core.openapi.yaml
+++ b/api/core.openapi.yaml
@@ -46,9 +46,24 @@ definitions:
         items:
           $ref: '#/definitions/PrivilegeKey'
         type: array
+      roles:
+        items:
+          $ref: '#/definitions/CallerRoleGrant'
+        type: array
       user:
         $ref: '#/definitions/User'
     type: object
+  CallerRoleGrant:
+    properties:
+      granted_at:
+        type: string
+      privileges:
+        items:
+          $ref: '#/definitions/PrivilegeKey'
+        type: array
+      role:
+        $ref: '#/definitions/Role'
+    type: object
   ComputeAllocation:
     properties:
       compute_cluster_id:
@@ -2675,6 +2690,8 @@ paths:
       - Compute Cluster Users
   /me:
     get:
+      description: Roles are the caller's own grants; unlike /users/{id}/roles this
+        needs no admin privilege.
       produces:
       - application/json
       responses:
@@ -2691,7 +2708,7 @@ paths:
             type: object
       security:
       - BearerAuth: []
-      summary: Get the caller's user record and effective privileges
+      summary: Get the caller's user record, effective privileges, and roles
       tags:
       - Caller
   /organizations:
diff --git a/internal/server/privilege.go b/internal/server/privilege.go
index 7ecf25f71..e2eeeb114 100644
--- a/internal/server/privilege.go
+++ b/internal/server/privilege.go
@@ -20,6 +20,7 @@ package server
 import (
 	"errors"
 	"net/http"
+	"time"
 
 	"github.com/apache/airavata-custos/pkg/common"
 	"github.com/apache/airavata-custos/pkg/identity"
@@ -45,14 +46,24 @@ func (s *Server) getCallerPrivileges(w http.ResponseWriter, r *http.Request) {
 	common.WriteJSON(w, http.StatusOK, map[string]any{"privileges": keys})
 }
 
+// CallerRoleGrant is a role held by the caller, together with the privileges
+// it grants and when it was granted.
+type CallerRoleGrant struct {
+	Role       models.Role           `json:"role"`
+	Privileges []models.PrivilegeKey `json:"privileges"`
+	GrantedAt  time.Time             `json:"granted_at"`
+}
+
 // CallerProfileResponse bundles the caller's user row with the effective
-// privilege set.
+// privilege set and held roles.
 type CallerProfileResponse struct {
 	User       *models.User          `json:"user"`
 	Privileges []models.PrivilegeKey `json:"privileges"`
+	Roles      []CallerRoleGrant     `json:"roles"`
 }
 
-// @Summary	Get the caller's user record and effective privileges
+// @Summary	Get the caller's user record, effective privileges, and roles
+// @Description	Roles are the caller's own grants; unlike /users/{id}/roles this needs no admin privilege.
 // @Tags	Caller
 // @Security	BearerAuth
 // @Produce	json
@@ -69,11 +80,27 @@ func (s *Server) getCallerProfile(w http.ResponseWriter, r *http.Request) {
 		common.WriteServiceError(w, err)
 		return
 	}
-	keys := identity.PrivilegesFromContext(r.Context())
-	if keys == nil {
-		keys = []models.PrivilegeKey{}
+	// Read fresh instead of using the cached context set, so roles and the
+	// effective privileges in one response always agree with each other.
+	keys, err := s.svc.EffectivePrivileges(r.Context(), caller.UserID)
+	if err != nil {
+		common.WriteServiceError(w, err)
+		return
+	}
+	details, err := s.svc.ListUserRolesWithPrivileges(r.Context(), caller.UserID)
+	if err != nil {
+		common.WriteServiceError(w, err)
+		return
+	}
+	roles := make([]CallerRoleGrant, 0, len(details))
+	for _, d := range details {
+		roles = append(roles, CallerRoleGrant{Role: d.Role, Privileges: d.Privileges, GrantedAt: d.GrantedAt})
 	}
-	common.WriteJSON(w, http.StatusOK, CallerProfileResponse{User: user, Privileges: keys})
+	common.WriteJSON(w, http.StatusOK, CallerProfileResponse{
+		User:       user,
+		Privileges: keys,
+		Roles:      roles,
+	})
 }
 
 // @Summary	List the declared privilege catalog
diff --git a/internal/server/privilege_integration_test.go b/internal/server/privilege_integration_test.go
index 682cdec95..4d225d124 100644
--- a/internal/server/privilege_integration_test.go
+++ b/internal/server/privilege_integration_test.go
@@ -95,6 +95,9 @@ func TestGetCallerProfile_NoCaller_401(t *testing.T) {
 func TestGetCallerProfile_ReturnsUserAndPrivileges(t *testing.T) {
 	database, _, srv := setupTestStack(t)
 	user := seedUser(t, database, "user@example.edu")
+	// /me computes the effective set fresh from the DB, not from the request
+	// context, so the grant must exist as a row.
+	seedPrivilegesGrant(t, database, user)
 	rr := httptest.NewRecorder()
 	req := httptest.NewRequest(http.MethodGet, "/me", nil)
 	req = withTestCaller(req, user, models.PrivilegesGrant)
@@ -112,6 +115,59 @@ func TestGetCallerProfile_ReturnsUserAndPrivileges(t *testing.T) {
 	if len(body.Privileges) != 1 || body.Privileges[0] != models.PrivilegesGrant {
 		t.Errorf("privileges: got %v, want [%s]", body.Privileges, models.PrivilegesGrant)
 	}
+	if body.Roles == nil || len(body.Roles) != 0 {
+		t.Errorf("roles: got %v, want empty array", body.Roles)
+	}
+}
+
+func TestGetCallerProfile_IncludesOwnRoles(t *testing.T) {
+	database, _, srv := setupTestStack(t)
+	user := seedUser(t, database, "roleholder@example.edu")
+	if _, err := database.Exec(
+		"INSERT INTO roles (id, name, description, is_system) VALUES (?, ?, ?, ?)",
+		"role-me-test", "Reviewer", "Reads reports", false,
+	); err != nil {
+		t.Fatalf("seed role: %v", err)
+	}
+	if _, err := database.Exec(
+		"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)",
+		user, "role-me-test",
+	); err != nil {
+		t.Fatalf("seed grant: %v", err)
+	}
+	if _, err := database.Exec(
+		"INSERT INTO role_privileges (role_id, privilege) VALUES (?, ?)",
+		"role-me-test", "core:traces:read",
+	); err != nil {
+		t.Fatalf("seed role privilege: %v", err)
+	}
+
+	rr := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, "/me", nil)
+	// No admin privileges on purpose: /me must return the caller's own roles.
+	req = withTestCaller(req, user)
+	srv.ServeHTTP(rr, req)
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
+	}
+	var body CallerProfileResponse
+	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if len(body.Roles) != 1 || body.Roles[0].Role.Name != "Reviewer" {
+		t.Fatalf("roles: got %+v, want the Reviewer role", body.Roles)
+	}
+	if body.Roles[0].GrantedAt.IsZero() {
+		t.Errorf("granted_at is zero")
+	}
+	if len(body.Roles[0].Privileges) != 1 || body.Roles[0].Privileges[0] != "core:traces:read" {
+		t.Errorf("role privileges: got %v, want [core:traces:read]", body.Roles[0].Privileges)
+	}
+	// The effective set is computed fresh, so the role-carried key shows up
+	// without any cache warm-up or context injection.
+	if len(body.Privileges) != 1 || body.Privileges[0] != "core:traces:read" {
+		t.Errorf("effective privileges: got %v, want [core:traces:read]", body.Privileges)
+	}
 }
 
 func TestRequirePrivilege_NoGrants_403(t *testing.T) {
diff --git a/internal/store/store.go b/internal/store/store.go
index 819d8cedd..7c884c0a6 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -448,6 +448,7 @@ type UserRoleStore interface {
 	Find(ctx context.Context, userID, roleID string) (*models.UserRole, error)
 	FindForUpdate(ctx context.Context, tx *sql.Tx, userID, roleID string) (*models.UserRole, error)
 	ListByUser(ctx context.Context, userID string) ([]models.UserRole, error)
+	ListDetailedByUser(ctx context.Context, userID string) ([]UserRoleDetail, error)
 	ListByRole(ctx context.Context, roleID string) ([]models.UserRole, error)
 	ListUserIDsByRole(ctx context.Context, roleID string) ([]string, error)
 	Create(ctx context.Context, tx *sql.Tx, r *models.UserRole) error
diff --git a/internal/store/user_role_store.go b/internal/store/user_role_store.go
index e4ad37581..1ec5468a3 100644
--- a/internal/store/user_role_store.go
+++ b/internal/store/user_role_store.go
@@ -21,12 +21,21 @@ import (
 	"context"
 	"database/sql"
 	"errors"
+	"time"
 
 	"github.com/jmoiron/sqlx"
 
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
+// UserRoleDetail is the joined view of one held role: the role row, the
+// privileges it carries, and when it was granted to the user.
+type UserRoleDetail struct {
+	Role       models.Role
+	Privileges []models.PrivilegeKey
+	GrantedAt  time.Time
+}
+
 const userRoleColumns = "user_id, role_id, granted_by, granted_at, reason"
 
 type mysqlUserRoleStore struct {
@@ -113,6 +122,45 @@ func (s *mysqlUserRoleStore) Delete(ctx context.Context, tx *sql.Tx, userID, rol
 	return err
 }
 
+// ListDetailedByUser returns the user's roles with their privileges and
+// grant dates in one query.
+func (s *mysqlUserRoleStore) ListDetailedByUser(ctx context.Context, userID string) ([]UserRoleDetail, error) {
+	var rows []struct {
+		models.Role
+		UserGrantedAt time.Time      `db:"user_granted_at"`
+		Privilege     sql.NullString `db:"privilege"`
+	}
+	err := s.db.SelectContext(ctx, &rows,
+		`SELECT r.id, r.name, r.description, r.is_system, r.created_at,
+		        ur.granted_at AS user_granted_at, rp.privilege
+		 		FROM user_roles ur
+		 JOIN roles r ON r.id = ur.role_id
+		 LEFT JOIN role_privileges rp ON rp.role_id = r.id
+		 WHERE ur.user_id = ?
+		 ORDER BY r.name, rp.privilege`, userID)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]UserRoleDetail, 0)
+	index := make(map[string]int)
+	for _, row := range rows {
+		i, seen := index[row.Role.ID]
+		if !seen {
+			out = append(out, UserRoleDetail{
+				Role:       row.Role,
+				Privileges: []models.PrivilegeKey{},
+				GrantedAt:  row.UserGrantedAt,
+			})
+			i = len(out) - 1
+			index[row.Role.ID] = i
+		}
+		if row.Privilege.Valid {
+			out[i].Privileges = append(out[i].Privileges, models.PrivilegeKey(row.Privilege.String))
+		}
+	}
+	return out, nil
+}
+
 // PrivilegesForUser returns the union of privileges from every role the
 // user holds.
 func (s *mysqlUserRoleStore) PrivilegesForUser(ctx context.Context, userID string) ([]models.PrivilegeKey, error) {
diff --git a/pkg/service/user_role.go b/pkg/service/user_role.go
index 9adda6aa9..8feccbd8a 100644
--- a/pkg/service/user_role.go
+++ b/pkg/service/user_role.go
@@ -24,6 +24,7 @@ import (
 	"fmt"
 	"log/slog"
 
+	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
@@ -138,6 +139,13 @@ func (s *Service) ListUserRoles(ctx context.Context, userID string) ([]models.Us
 	return s.userRoles.ListByUser(ctx, userID)
 }
 
+func (s *Service) ListUserRolesWithPrivileges(ctx context.Context, userID string) ([]store.UserRoleDetail, error) {
+	if userID == "" {
+		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+	}
+	return s.userRoles.ListDetailedByUser(ctx, userID)
+}
+
 func (s *Service) ListRoleHolders(ctx context.Context, roleID string) ([]models.UserRole, error) {
 	if roleID == "" {
 		return nil, fmt.Errorf("%w: role_id is required", ErrInvalidInput)
diff --git a/web/src/features/core/identity/api.ts b/web/src/features/core/identity/api.ts
index 13933f09f..118df395c 100644
--- a/web/src/features/core/identity/api.ts
+++ b/web/src/features/core/identity/api.ts
@@ -26,6 +26,7 @@ import {
   userSchema,
 } from "./schemas";
 import type {
+  CallerRoleGrant,
   RoleWithPrivileges,
   UserIdentity,
   UserNameUpdate,
@@ -39,11 +40,11 @@ export async function getPrivileges(): Promise<Privilege[]> {
   return privilegesResponseSchema.parse(raw);
 }
 
-export type Me = { user: UserProfile; privileges: Privilege[] };
+export type Me = { user: UserProfile; privileges: Privilege[]; roles: CallerRoleGrant[] };
 
 export async function getMe(): Promise<Me> {
   const parsed = meResponseSchema.parse(await apiFetch("/me"));
-  return { user: parsed.user, privileges: parsed.privileges ?? [] };
+  return { user: parsed.user, privileges: parsed.privileges ?? [], roles: parsed.roles };
 }
 
 export async function getMyIdentities(userId: string): Promise<UserIdentity[]> {
diff --git a/web/src/features/core/identity/components/AccessCard.tsx b/web/src/features/core/identity/components/AccessCard.tsx
index 424ac9ec3..87cd1ed37 100644
--- a/web/src/features/core/identity/components/AccessCard.tsx
+++ b/web/src/features/core/identity/components/AccessCard.tsx
@@ -68,18 +68,11 @@ export function AccessCard({ access }: { access: MyAccess }) {
       <div className="grid grid-cols-1 border-t border-border md:grid-cols-2">
         <section className="px-6 py-5 md:border-r md:border-border">
           <h4 className={columnHeading}>Roles</h4>
-          {access.provenance ? (
-            <RolesColumn
-              roles={access.roles}
-              activeRole={activeRole}
-              onHover={setActiveRole}
-            />
-          ) : (
-            <p className="text-sm text-muted-foreground">
-              Role details are not visible with your current access. Your effective
-              privileges are listed on the right.
-            </p>
-          )}
+          <RolesColumn
+            roles={access.roles}
+            activeRole={activeRole}
+            onHover={setActiveRole}
+          />
         </section>
 
         <section className="border-t border-border px-6 py-5 md:border-t-0">
diff --git a/web/src/features/core/identity/components/SettingsPage.tsx b/web/src/features/core/identity/components/SettingsPage.tsx
index 77d86d77d..83cb12dc6 100644
--- a/web/src/features/core/identity/components/SettingsPage.tsx
+++ b/web/src/features/core/identity/components/SettingsPage.tsx
@@ -31,7 +31,7 @@ export function SettingsPage() {
   // session.user.id may be an email fallback.
   const userId = me.data?.user.id;
   const identities = useMyIdentities(userId);
-  const access = useMyAccess(userId, me.data?.privileges ?? []);
+  const access = useMyAccess(userId, me.data?.privileges ?? [], me.data?.roles ?? []);
 
   return (
     <div className="mx-auto w-full max-w-[1080px] px-6 py-8">
diff --git a/web/src/features/core/identity/components/__tests__/AccessCard.test.tsx b/web/src/features/core/identity/components/__tests__/AccessCard.test.tsx
index b1c0c9449..7235fc4a4 100644
--- a/web/src/features/core/identity/components/__tests__/AccessCard.test.tsx
+++ b/web/src/features/core/identity/components/__tests__/AccessCard.test.tsx
@@ -105,10 +105,34 @@ describe("AccessCard", () => {
         }}
       />,
     );
-    expect(screen.getByText(/role details are not visible/i)).toBeInTheDocument();
+    expect(screen.getByText(/no roles assigned/i)).toBeInTheDocument();
     expect(screen.getByText("core:users")).toBeInTheDocument();
     expect(screen.getByText("amie:packets")).toBeInTheDocument();
     // Never claim "Direct grant" without the provenance data.
     expect(screen.queryByText("Direct grant")).not.toBeInTheDocument();
   });
+
+  it("lists held roles with attribution even without provenance", () => {
+    render(
+      <AccessCard
+        access={{
+          provenance: false,
+          roles: [
+            {
+              role: { id: "role-reviewer", name: "Reviewer", description: "Reads reports." },
+              privileges: ["core:users:read"],
+              grant: { role_id: "role-reviewer", granted_at: "2026-07-01T09:00:00Z" },
+            },
+          ],
+          direct: [],
+          privileges: ["core:users:read", "core:traces:read"],
+        }}
+      />,
+    );
+    expect(roleCard("role-reviewer")).toHaveTextContent("Reviewer");
+    expect(roleCard("role-reviewer")).toHaveTextContent("Granted");
+    expect(rowFor("core:users")).toHaveTextContent("Reviewer");
+    // A key not carried by any visible role must not claim "Direct grant".
+    expect(rowFor("core:traces")).not.toHaveTextContent("Direct grant");
+  });
 });
diff --git a/web/src/features/core/identity/queries.ts b/web/src/features/core/identity/queries.ts
index 4e2d3e58f..adb0d451c 100644
--- a/web/src/features/core/identity/queries.ts
+++ b/web/src/features/core/identity/queries.ts
@@ -28,7 +28,12 @@ import {
   updateMyName,
 } from "./api";
 import { privilegesResponseSchema } from "./schemas";
-import type { RoleWithPrivileges, UserNameUpdate, UserPrivilege } from "./schemas";
+import type {
+  CallerRoleGrant,
+  RoleWithPrivileges,
+  UserNameUpdate,
+  UserPrivilege,
+} from "./schemas";
 import type { CurrentUser, Privilege } from "./types";
 
 export const identityKeys = {
@@ -95,22 +100,38 @@ export type MyAccess = {
   roles: RoleWithPrivileges[];
   direct: UserPrivilege[];
   privileges: Privilege[];
-  // False when the caller lacks the role/privilege read gates; the card then
-  // shows effective privileges without provenance.
+  // False when the caller lacks the admin read gates; the card then never
+  // labels a privilege row "Direct grant".
   provenance: boolean;
 };
 
-// The roles and grants reads are admin-gated; run them only when the caller's
-// own privileges imply access. Everyone still gets the effective set from /me.
-export function useMyAccess(userId: string | undefined, effective: Privilege[]) {
+// Direct-grant reads are admin-gated; without them roles come from /me and
+// unattributed keys stay unlabeled instead of claiming "Direct grant".
+export function useMyAccess(
+  userId: string | undefined,
+  effective: Privilege[],
+  heldRoles: CallerRoleGrant[],
+) {
   const provenance =
     effective.includes("core:roles:manage") && effective.includes("core:privileges:grant");
+  const heldKey = heldRoles.map((g) => g.role.id ?? "").join(",");
   return useQuery({
     queryKey: userId
-      ? [...identityKeys.access(userId), provenance]
+      ? [...identityKeys.access(userId), provenance, heldKey]
       : [...identityKeys.all, "access", "none"],
     queryFn: async (): Promise<MyAccess> => {
-      if (!provenance) return { roles: [], direct: [], privileges: effective, provenance };
+      if (!provenance) {
+        const roles = heldRoles.map((g) => ({
+          role: g.role,
+          privileges: g.privileges,
+          grant: {
+            user_id: userId as string,
+            role_id: g.role.id ?? "",
+            granted_at: g.granted_at,
+          },
+        }));
+        return { roles, direct: [], privileges: effective, provenance };
+      }
       const [roles, direct] = await Promise.all([
         getMyRolesWithPrivileges(userId as string),
         getMyDirectPrivileges(userId as string),
diff --git a/web/src/features/core/identity/schemas.ts b/web/src/features/core/identity/schemas.ts
index 05cbf9b5b..31e2f147f 100644
--- a/web/src/features/core/identity/schemas.ts
+++ b/web/src/features/core/identity/schemas.ts
@@ -54,10 +54,23 @@ export const privilegesResponseSchema = callerPrivilegesSchema.transform(
   (value) => value.privileges ?? [],
 );
 
-// GET /me — profile plus effective privilege keys.
+export const callerRoleGrantSchema = z.object({
+  role: zRole,
+  privileges: z
+    .array(privilegeKeySchema)
+    .nullish()
+    .transform((value) => value ?? []),
+  granted_at: z.string(),
+});
+
+// GET /me: profile plus effective privilege keys and held roles.
 export const meResponseSchema = z.object({
   user: zUser,
   privileges: z.array(privilegeKeySchema).optional(),
+  roles: z
+    .array(callerRoleGrantSchema)
+    .nullish()
+    .transform((value) => value ?? []),
 });
 
 // Backend may send null instead of [] for empty lists; tolerate it.
@@ -94,4 +107,5 @@ export type UserIdentity = z.infer<typeof zUserIdentity>;
 export type UserRole = z.infer<typeof userRoleSchema>;
 export type UserPrivilege = z.infer<typeof userPrivilegeSchema>;
 export type RoleWithPrivileges = z.infer<typeof roleWithPrivilegesSchema>;
+export type CallerRoleGrant = z.infer<typeof callerRoleGrantSchema>;
 export type UserNameUpdate = z.infer<typeof userNameUpdateSchema>;
diff --git a/web/src/generated/core/index.ts b/web/src/generated/core/index.ts
index 609051d12..d5a9089f4 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, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocationResourceSummary,  [...]
+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 5c476e63f..d1f0baef9 100644
--- a/web/src/generated/core/sdk.gen.ts
+++ b/web/src/generated/core/sdk.gen.ts
@@ -663,7 +663,9 @@ export const getComputeClustersByIdUsersByUserId = <ThrowOnError extends boolean
 });
 
 /**
- * Get the caller's user record and effective privileges
+ * Get the caller's user record, effective privileges, and roles
+ *
+ * Roles are the caller's own grants; unlike /users/{id}/roles this needs no admin privilege.
  */
 export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>): RequestResult<GetMeResponses, GetMeErrors, ThrowOnError> => (options?.client ?? client).get<GetMeResponses, GetMeErrors, 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 8ac6d5f35..4c23caf38 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -28,9 +28,16 @@ export type AllocationStatus = 'ACTIVE' | 'INACTIVE' | 'DELETED';
 
 export type CallerProfileResponse = {
     privileges?: Array<PrivilegeKey>;
+    roles?: Array<CallerRoleGrant>;
     user?: User;
 };
 
+export type CallerRoleGrant = {
+    granted_at?: string;
+    privileges?: Array<PrivilegeKey>;
+    role?: Role;
+};
+
 export type ComputeAllocation = {
     /**
      * The ID of the compute cluster where the allocation is provisioned.
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index 898f0518c..959dbf89d 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -287,6 +287,14 @@ export const zRole = z.object({
 
 export type roleZodType = z.infer<typeof zRole>;
 
+export const zCallerRoleGrant = z.object({
+    granted_at: z.string().optional(),
+    privileges: z.array(zPrivilegeKey).optional(),
+    role: zRole.optional()
+});
+
+export type callerRoleGrantZodType = z.infer<typeof zCallerRoleGrant>;
+
 export const zTraceEvent = z.object({
     created_at: z.string().optional(),
     description: z.string().optional(),
@@ -403,6 +411,7 @@ export type userZodType = z.infer<typeof zUser>;
 
 export const zCallerProfileResponse = z.object({
     privileges: z.array(zPrivilegeKey).optional(),
+    roles: z.array(zCallerRoleGrant).optional(),
     user: zUser.optional()
 });
 
diff --git a/web/src/mocks/handlers/identity.ts b/web/src/mocks/handlers/identity.ts
index 0fd9d2c73..9ad87ec55 100644
--- a/web/src/mocks/handlers/identity.ts
+++ b/web/src/mocks/handlers/identity.ts
@@ -27,7 +27,18 @@ let user = { ...fixture.user };
 
 export const identityHandlers = [
   http.get("*/api/v1/me", () =>
-    HttpResponse.json({ user, privileges: effectivePrivileges() }),
+    HttpResponse.json({
+      user,
+      privileges: effectivePrivileges(),
+      roles: fixture.roles.flatMap((grant) => {
+        const detail = (fixture.roleDetails as Record<string, RoleDetail | undefined>)[
+          grant.role_id
+        ];
+        return detail?.role
+          ? [{ role: detail.role, privileges: detail.privileges, granted_at: grant.granted_at }]
+          : [];
+      }),
+    }),
   ),
   http.get("*/api/v1/users/:id/user-identities", () =>
     HttpResponse.json(fixture.identities),