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),