diff --git a/react-compiler.config.js b/react-compiler.config.js index a8d9589f08..d63b8f8d00 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -56,6 +56,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/services/projects/useProjectInstructions.ts", "src/services/projects/useProjectRuns.ts", "src/services/projects/useWorkspaceChoice.ts", + "src/services/teams", "src/services/usePipelineSpec.ts", "src/components/shared/FavoriteToggle.tsx", "src/components/shared/LocalPipelineMigration.tsx", diff --git a/src/config/teamImpersonation.test.ts b/src/config/teamImpersonation.test.ts new file mode 100644 index 0000000000..2c4316bb8f --- /dev/null +++ b/src/config/teamImpersonation.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { getTeamImpersonationHeaderName } from "./teamImpersonation"; + +describe("getTeamImpersonationHeaderName", () => { + afterEach(() => { + delete window.__TANGLE_TEAM_IMPERSONATION__; + }); + + it("is null when the host page configures nothing", () => { + expect(getTeamImpersonationHeaderName()).toBeNull(); + }); + + it("returns the configured header name, trimmed", () => { + window.__TANGLE_TEAM_IMPERSONATION__ = { + headerName: " x-tangle-impersonate-team-id ", + }; + + expect(getTeamImpersonationHeaderName()).toBe( + "x-tangle-impersonate-team-id", + ); + }); + + it("is null for a header name fetch would reject", () => { + window.__TANGLE_TEAM_IMPERSONATION__ = { headerName: "x team id" }; + + expect(getTeamImpersonationHeaderName()).toBeNull(); + }); + + it("is null for an empty or missing header name", () => { + window.__TANGLE_TEAM_IMPERSONATION__ = { headerName: " " }; + expect(getTeamImpersonationHeaderName()).toBeNull(); + + window.__TANGLE_TEAM_IMPERSONATION__ = {} as never; + expect(getTeamImpersonationHeaderName()).toBeNull(); + }); +}); diff --git a/src/config/teamImpersonation.ts b/src/config/teamImpersonation.ts new file mode 100644 index 0000000000..09607813e3 --- /dev/null +++ b/src/config/teamImpersonation.ts @@ -0,0 +1,23 @@ +import { isRecord } from "@/utils/typeGuards"; + +interface TeamImpersonationConfig { + headerName: string; +} + +declare global { + interface Window { + __TANGLE_TEAM_IMPERSONATION__?: TeamImpersonationConfig; + } +} + +const HTTP_HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + +export function getTeamImpersonationHeaderName(): string | null { + if (typeof window === "undefined") return null; + + const config = window.__TANGLE_TEAM_IMPERSONATION__; + if (!isRecord(config) || typeof config.headerName !== "string") return null; + + const headerName = config.headerName.trim(); + return HTTP_HEADER_NAME.test(headerName) ? headerName : null; +} diff --git a/src/services/teams/errors.ts b/src/services/teams/errors.ts new file mode 100644 index 0000000000..cfd1488cc8 --- /dev/null +++ b/src/services/teams/errors.ts @@ -0,0 +1,21 @@ +import { ApiError } from "@/services/projects/errors"; +import { describeErrorBody } from "@/services/requestFailureMessage"; + +const IMPERSONATION_REFUSAL_PREFIX = "Impersonation error:"; + +export class TeamIdentityError extends ApiError { + constructor(message: string, status: number) { + super("TeamIdentityError", message, status); + } +} + +export class TeamImpersonationError extends ApiError { + constructor(message: string, status: number) { + super("TeamImpersonationError", message, status); + } +} + +export function readImpersonationRefusal(body: unknown): string | undefined { + const reason = describeErrorBody(body); + return reason?.startsWith(IMPERSONATION_REFUSAL_PREFIX) ? reason : undefined; +} diff --git a/src/services/teams/teamIdentityService.test.ts b/src/services/teams/teamIdentityService.test.ts new file mode 100644 index 0000000000..f886044192 --- /dev/null +++ b/src/services/teams/teamIdentityService.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/api/sdk.gen", () => ({ + getCurrentUserApiUsersMeGet: vi.fn(), +})); + +import { getCurrentUserApiUsersMeGet } from "@/api/sdk.gen"; + +import { TeamIdentityError, TeamImpersonationError } from "./errors"; +import { fetchTeamIdentity } from "./teamIdentityService"; + +const TEAM_ID = "ml-team@project.iam.gserviceaccount.com"; +const HEADER_NAME = "x-tangle-impersonate-team-id"; + +const respond = (status: number, body: { data?: unknown; error?: unknown }) => + vi.mocked(getCurrentUserApiUsersMeGet).mockResolvedValue({ + data: body.data, + error: body.error, + request: new Request("http://localhost/api/users/me"), + response: new Response(null, { status }), + } as never); + +describe("fetchTeamIdentity", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.__TANGLE_TEAM_IMPERSONATION__ = { headerName: HEADER_NAME }; + }); + + afterEach(() => { + delete window.__TANGLE_TEAM_IMPERSONATION__; + }); + + it("asks who the caller is while acting as the team", async () => { + respond(200, { data: { id: TEAM_ID, permissions: ["read", "write"] } }); + + await expect(fetchTeamIdentity(TEAM_ID)).resolves.toEqual({ + id: TEAM_ID, + permissions: ["read", "write"], + }); + expect(getCurrentUserApiUsersMeGet).toHaveBeenCalledWith({ + headers: { [HEADER_NAME]: TEAM_ID }, + }); + }); + + it("throws a TeamImpersonationError when the backend refuses the team", async () => { + const refusal = `Impersonation error: User alice@example.com is not a member of the team ${TEAM_ID}`; + respond(401, { error: { detail: refusal } }); + + const failure = fetchTeamIdentity(TEAM_ID); + + await expect(failure).rejects.toBeInstanceOf(TeamImpersonationError); + await expect(failure).rejects.toMatchObject({ + message: refusal, + status: 401, + }); + }); + + it("throws a TeamIdentityError carrying the reason for any other failure", async () => { + respond(401, { error: { detail: "Authentication required" } }); + + const failure = fetchTeamIdentity(TEAM_ID); + + await expect(failure).rejects.toBeInstanceOf(TeamIdentityError); + await expect(failure).rejects.toMatchObject({ + message: `Failed to check access to team ${TEAM_ID}: Authentication required`, + status: 401, + }); + }); +}); diff --git a/src/services/teams/teamIdentityService.ts b/src/services/teams/teamIdentityService.ts new file mode 100644 index 0000000000..5ebb24ecb5 --- /dev/null +++ b/src/services/teams/teamIdentityService.ts @@ -0,0 +1,33 @@ +import { getCurrentUserApiUsersMeGet } from "@/api/sdk.gen"; +import { describeErrorBody } from "@/services/requestFailureMessage"; + +import { + readImpersonationRefusal, + TeamIdentityError, + TeamImpersonationError, +} from "./errors"; +import { teamRequestHeaders } from "./teamRequestHeaders"; +import type { TeamIdentity } from "./types"; + +export async function fetchTeamIdentity(teamId: string): Promise { + const result = await getCurrentUserApiUsersMeGet({ + headers: teamRequestHeaders(teamId), + }); + + if (result.data) { + return { id: result.data.id, permissions: result.data.permissions }; + } + + const status = result.response.status; + const refusal = readImpersonationRefusal(result.error); + if (refusal) { + throw new TeamImpersonationError(refusal, status); + } + + const failure = `Failed to check access to team ${teamId}`; + const reason = describeErrorBody(result.error); + throw new TeamIdentityError( + reason ? `${failure}: ${reason}` : failure, + status, + ); +} diff --git a/src/services/teams/teamMemberships.test.ts b/src/services/teams/teamMemberships.test.ts new file mode 100644 index 0000000000..543c16d97b --- /dev/null +++ b/src/services/teams/teamMemberships.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { readTeamMemberships } from "./teamMemberships"; + +const TEAM_ID = "ml-team@project.iam.gserviceaccount.com"; +const OTHER_TEAM_ID = "data-team@project.iam.gserviceaccount.com"; + +const userWithTeams = (teams: unknown) => ({ + id: "alice@example.com", + permissions: ["read", "write"], + teams, +}); + +describe("readTeamMemberships", () => { + it("lists every team the user belongs to, with its roles", () => { + expect( + readTeamMemberships( + userWithTeams({ + [TEAM_ID]: { roles: ["editor"] }, + [OTHER_TEAM_ID]: { roles: ["reader"] }, + }), + ), + ).toEqual([ + { teamId: TEAM_ID, roles: ["editor"] }, + { teamId: OTHER_TEAM_ID, roles: ["reader"] }, + ]); + }); + + it("is empty when the deployment has no teams or the user is in none", () => { + expect( + readTeamMemberships({ id: "alice@example.com", permissions: [] }), + ).toEqual([]); + expect(readTeamMemberships(userWithTeams(null))).toEqual([]); + }); + + it("keeps a membership whose roles are malformed, with no roles", () => { + expect( + readTeamMemberships( + userWithTeams({ + [TEAM_ID]: { roles: "editor" }, + [OTHER_TEAM_ID]: { roles: ["reader", 7] }, + }), + ), + ).toEqual([ + { teamId: TEAM_ID, roles: [] }, + { teamId: OTHER_TEAM_ID, roles: ["reader"] }, + ]); + }); +}); diff --git a/src/services/teams/teamMemberships.ts b/src/services/teams/teamMemberships.ts new file mode 100644 index 0000000000..70738165bc --- /dev/null +++ b/src/services/teams/teamMemberships.ts @@ -0,0 +1,21 @@ +import type { GetUserResponse } from "@/api/types.gen"; +import { isRecord } from "@/utils/typeGuards"; + +import type { TeamMembership } from "./types"; + +function readRoles(membership: unknown): string[] { + if (!isRecord(membership) || !Array.isArray(membership.roles)) return []; + return membership.roles.filter( + (role): role is string => typeof role === "string", + ); +} + +export function readTeamMemberships(user: GetUserResponse): TeamMembership[] { + const teams = "teams" in user ? user.teams : undefined; + if (!isRecord(teams)) return []; + + return Object.entries(teams).map(([teamId, membership]) => ({ + teamId, + roles: readRoles(membership), + })); +} diff --git a/src/services/teams/teamRequestHeaders.test.ts b/src/services/teams/teamRequestHeaders.test.ts new file mode 100644 index 0000000000..a4739e4d61 --- /dev/null +++ b/src/services/teams/teamRequestHeaders.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { teamRequestHeaders } from "./teamRequestHeaders"; + +const TEAM_ID = "ml-team@project.iam.gserviceaccount.com"; + +describe("teamRequestHeaders", () => { + afterEach(() => { + delete window.__TANGLE_TEAM_IMPERSONATION__; + }); + + it("names the team in the configured header", () => { + window.__TANGLE_TEAM_IMPERSONATION__ = { + headerName: "x-tangle-impersonate-team-id", + }; + + expect(teamRequestHeaders(TEAM_ID)).toEqual({ + "x-tangle-impersonate-team-id": TEAM_ID, + }); + }); + + it("adds nothing without a team", () => { + window.__TANGLE_TEAM_IMPERSONATION__ = { + headerName: "x-tangle-impersonate-team-id", + }; + + expect(teamRequestHeaders(undefined)).toEqual({}); + }); + + it("adds nothing when the host page configures no header", () => { + expect(teamRequestHeaders(TEAM_ID)).toEqual({}); + }); +}); diff --git a/src/services/teams/teamRequestHeaders.ts b/src/services/teams/teamRequestHeaders.ts new file mode 100644 index 0000000000..f7e58fc3d7 --- /dev/null +++ b/src/services/teams/teamRequestHeaders.ts @@ -0,0 +1,9 @@ +import { getTeamImpersonationHeaderName } from "@/config/teamImpersonation"; + +export function teamRequestHeaders( + teamId: string | undefined, +): Record { + const headerName = getTeamImpersonationHeaderName(); + if (!headerName || !teamId) return {}; + return { [headerName]: teamId }; +} diff --git a/src/services/teams/teamResolution.test.ts b/src/services/teams/teamResolution.test.ts new file mode 100644 index 0000000000..0e7112c672 --- /dev/null +++ b/src/services/teams/teamResolution.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { TeamIdentityError, TeamImpersonationError } from "./errors"; +import { actingTeamId, resolveTeam, teamAccess } from "./teamResolution"; +import type { TeamIdentity, TeamMembership } from "./types"; + +const TEAM_ID = "ml-team@project.iam.gserviceaccount.com"; +const OTHER_TEAM_ID = "data-team@project.iam.gserviceaccount.com"; +const team = { id: TEAM_ID, label: "ML Research" }; +const otherTeam = { id: OTHER_TEAM_ID, label: "Data" }; + +const settled = { isPending: false, error: null }; +const member: TeamMembership[] = [{ teamId: TEAM_ID, roles: ["editor"] }]; +const editor: TeamIdentity = { id: TEAM_ID, permissions: ["read", "write"] }; + +const resolve = (overrides: Partial[0]> = {}) => + resolveTeam({ + lookups: [settled], + teams: [team], + memberships: { data: member, error: null }, + identity: { data: editor, error: null }, + ...overrides, + }); + +describe("teamAccess", () => { + it("grants write to an editor acting as the team", () => { + expect(teamAccess(TEAM_ID, editor)).toBe("write"); + }); + + it("grants read to a reader acting as the team", () => { + expect(teamAccess(TEAM_ID, { id: TEAM_ID, permissions: ["read"] })).toBe( + "read", + ); + }); + + it("grants nothing when the backend answered as someone else", () => { + expect( + teamAccess(TEAM_ID, { + id: "developer@localhost", + permissions: ["read", "write", "admin"], + }), + ).toBe("none"); + }); + + it("grants nothing to a role without read", () => { + expect(teamAccess(TEAM_ID, { id: TEAM_ID, permissions: [] })).toBe("none"); + }); +}); + +describe("resolveTeam", () => { + it("is active, with write, for an editor of the projects' team", () => { + expect(resolve()).toEqual({ + status: "active", + team: { ...team, canWrite: true }, + }); + }); + + it("is active, without write, for a reader", () => { + expect( + resolve({ + identity: { data: { id: TEAM_ID, permissions: ["read"] }, error: null }, + }), + ).toEqual({ status: "active", team: { ...team, canWrite: false } }); + }); + + it("is resolving while a project or workspace is still loading", () => { + expect( + resolve({ lookups: [settled, { isPending: true, error: null }] }), + ).toEqual({ status: "resolving" }); + }); + + it("reports a failed project or workspace lookup", () => { + const error = new Error("Failed to fetch workspace w1"); + + expect(resolve({ lookups: [{ isPending: false, error }] })).toEqual({ + status: "error", + error, + }); + }); + + it("is none when no project's workspace names a team", () => { + expect(resolve({ teams: [] })).toEqual({ status: "none" }); + }); + + it("is a conflict when the projects belong to different teams", () => { + expect(resolve({ teams: [team, otherTeam] })).toEqual({ + status: "conflict", + teams: [team, otherTeam], + }); + }); + + it("is resolving until the user's teams are known", () => { + expect(resolve({ memberships: { data: undefined, error: null } })).toEqual({ + status: "resolving", + }); + }); + + it("is not-member when the user is not in the team", () => { + expect( + resolve({ + memberships: { + data: [{ teamId: OTHER_TEAM_ID, roles: ["editor"] }], + error: null, + }, + }), + ).toEqual({ status: "not-member", team }); + }); + + it("is resolving until the backend confirms the team", () => { + expect(resolve({ identity: { data: undefined, error: null } })).toEqual({ + status: "resolving", + }); + }); + + it("is unavailable when the backend refuses to act as the team", () => { + expect( + resolve({ + identity: { + data: undefined, + error: new TeamImpersonationError( + `Impersonation error: Unknown team ${TEAM_ID}`, + 401, + ), + }, + }), + ).toEqual({ status: "unavailable", team }); + }); + + it("is unavailable when the backend ignores the team", () => { + expect( + resolve({ + identity: { + data: { id: "developer@localhost", permissions: ["read", "write"] }, + error: null, + }, + }), + ).toEqual({ status: "unavailable", team }); + }); + + it("reports any other failure to confirm the team", () => { + const error = new TeamIdentityError("Failed to check access", 500); + + expect(resolve({ identity: { data: undefined, error } })).toEqual({ + status: "error", + error, + }); + }); +}); + +describe("actingTeamId", () => { + it("is the team's id only while it is active", () => { + expect(actingTeamId(resolve())).toBe(TEAM_ID); + expect(actingTeamId({ status: "not-member", team })).toBeUndefined(); + expect(actingTeamId({ status: "resolving" })).toBeUndefined(); + }); +}); diff --git a/src/services/teams/teamResolution.ts b/src/services/teams/teamResolution.ts new file mode 100644 index 0000000000..5083318ee1 --- /dev/null +++ b/src/services/teams/teamResolution.ts @@ -0,0 +1,71 @@ +import { TeamImpersonationError } from "./errors"; +import type { + TeamAccess, + TeamIdentity, + TeamMembership, + TeamResolution, + TeamSummary, +} from "./types"; + +interface LookupState { + isPending: boolean; + error: Error | null; +} + +interface LoadedState { + data: TData | undefined; + error: Error | null; +} + +interface TeamResolutionInputs { + lookups: LookupState[]; + teams: TeamSummary[]; + memberships: LoadedState; + identity: LoadedState; +} + +export function teamAccess(teamId: string, identity: TeamIdentity): TeamAccess { + if (identity.id !== teamId || !identity.permissions.includes("read")) { + return "none"; + } + return identity.permissions.includes("write") ? "write" : "read"; +} + +export function resolveTeam({ + lookups, + teams, + memberships, + identity, +}: TeamResolutionInputs): TeamResolution { + const failedLookup = lookups.find((lookup) => lookup.error !== null); + if (failedLookup?.error) { + return { status: "error", error: failedLookup.error }; + } + if (lookups.some((lookup) => lookup.isPending)) { + return { status: "resolving" }; + } + + if (teams.length === 0) return { status: "none" }; + if (teams.length > 1) return { status: "conflict", teams }; + const [team] = teams; + + if (memberships.error) return { status: "error", error: memberships.error }; + if (!memberships.data) return { status: "resolving" }; + if (!memberships.data.some((membership) => membership.teamId === team.id)) { + return { status: "not-member", team }; + } + + if (identity.error instanceof TeamImpersonationError) { + return { status: "unavailable", team }; + } + if (identity.error) return { status: "error", error: identity.error }; + if (!identity.data) return { status: "resolving" }; + + const access = teamAccess(team.id, identity.data); + if (access === "none") return { status: "unavailable", team }; + return { status: "active", team: { ...team, canWrite: access === "write" } }; +} + +export function actingTeamId(resolution: TeamResolution): string | undefined { + return resolution.status === "active" ? resolution.team.id : undefined; +} diff --git a/src/services/teams/types.ts b/src/services/teams/types.ts new file mode 100644 index 0000000000..d3c3b00d5d --- /dev/null +++ b/src/services/teams/types.ts @@ -0,0 +1,33 @@ +export interface TeamMembership { + teamId: string; + roles: string[]; +} + +export interface TeamIdentity { + id: string | null; + permissions: string[]; +} + +export type TeamAccess = "none" | "read" | "write"; + +export interface TeamSummary { + id: string; + label: string; +} + +interface ActiveTeam extends TeamSummary { + canWrite: boolean; +} + +export type TeamResolution = + | { status: "none" } + | { status: "resolving" } + | { status: "active"; team: ActiveTeam } + | { status: "not-member"; team: TeamSummary } + | { status: "unavailable"; team: TeamSummary } + | { status: "conflict"; teams: TeamSummary[] } + | { status: "error"; error: Error }; + +export const TeamsQueryKeys = { + Identity: (teamId: string) => ["teams", teamId, "identity"] as const, +} as const; diff --git a/src/services/teams/useTeamForProjects.test.tsx b/src/services/teams/useTeamForProjects.test.tsx new file mode 100644 index 0000000000..810c2936bd --- /dev/null +++ b/src/services/teams/useTeamForProjects.test.tsx @@ -0,0 +1,183 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/services/projects/projectsService"); +vi.mock("@/services/projects/workspacesService"); +vi.mock("@/utils/user"); +vi.mock("./teamIdentityService"); + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => ({ configured: true, available: true }), +})); + +import { ProjectsApiError } from "@/services/projects/errors"; +import { getProject } from "@/services/projects/projectsService"; +import type { Project, Workspace } from "@/services/projects/types"; +import { getWorkspace } from "@/services/projects/workspacesService"; +import { getUserDetails } from "@/utils/user"; + +import { fetchTeamIdentity } from "./teamIdentityService"; +import { useTeamForProjects } from "./useTeamForProjects"; + +const TEAM_ID = "ml-team@project.iam.gserviceaccount.com"; +const OTHER_TEAM_ID = "data-team@project.iam.gserviceaccount.com"; + +const workspaces: Record = { + research: { + id: "research", + name: "ML Research", + description: null, + isActive: true, + metadata: { teamId: TEAM_ID }, + createdAt: new Date("2026-10-01T00:00:00Z"), + }, + data: { + id: "data", + name: "Data", + description: null, + isActive: true, + metadata: { teamId: OTHER_TEAM_ID }, + createdAt: new Date("2026-10-01T00:00:00Z"), + }, + sandbox: { + id: "sandbox", + name: "Sandbox", + description: null, + isActive: true, + metadata: null, + createdAt: new Date("2026-10-01T00:00:00Z"), + }, +}; + +const projectIn = (id: string, workspaceId: string): Project => ({ + id, + workspaceId, + name: id, + description: null, + createdBy: "alice@example.com", + origin: "user", + createdAt: new Date("2026-10-02T00:00:00Z"), + updatedAt: new Date("2026-10-02T00:00:00Z"), + resourceCounts: {}, + metadata: null, +}); + +const projects: Record = { + "research-project": projectIn("research-project", "research"), + "data-project": projectIn("data-project", "data"), + "sandbox-project": projectIn("sandbox-project", "sandbox"), +}; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +const renderTeam = (projectIds: string[]) => + renderHook(() => useTeamForProjects(projectIds), { + wrapper: createWrapper(), + }); + +describe("useTeamForProjects", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.__TANGLE_TEAM_IMPERSONATION__ = { + headerName: "x-tangle-impersonate-team-id", + }; + vi.mocked(getProject).mockImplementation(async (id) => { + const project = projects[id]; + if (!project) throw new ProjectsApiError("Project not found", 404); + return project; + }); + vi.mocked(getWorkspace).mockImplementation(async (id) => workspaces[id]); + vi.mocked(getUserDetails).mockResolvedValue({ + id: "alice@example.com", + permissions: ["read", "write"], + teams: { [TEAM_ID]: { roles: ["editor"] } }, + } as Awaited>); + vi.mocked(fetchTeamIdentity).mockResolvedValue({ + id: TEAM_ID, + permissions: ["read", "write"], + }); + }); + + afterEach(() => { + delete window.__TANGLE_TEAM_IMPERSONATION__; + }); + + it("is none, and looks nothing up, without the host's header config", () => { + delete window.__TANGLE_TEAM_IMPERSONATION__; + + const { result } = renderTeam(["research-project"]); + + expect(result.current).toEqual({ status: "none" }); + expect(getProject).not.toHaveBeenCalled(); + }); + + it("is active once the backend confirms the user can act as the team", async () => { + const { result } = renderTeam(["research-project"]); + + expect(result.current).toEqual({ status: "resolving" }); + await waitFor(() => + expect(result.current).toEqual({ + status: "active", + team: { id: TEAM_ID, label: "ML Research", canWrite: true }, + }), + ); + expect(fetchTeamIdentity).toHaveBeenCalledWith(TEAM_ID); + }); + + it("is not-member, without asking the backend, for a team the user is not in", async () => { + const { result } = renderTeam(["data-project"]); + + await waitFor(() => + expect(result.current).toEqual({ + status: "not-member", + team: { id: OTHER_TEAM_ID, label: "Data" }, + }), + ); + expect(fetchTeamIdentity).not.toHaveBeenCalled(); + }); + + it("is a conflict for projects whose workspaces map to different teams", async () => { + const { result } = renderTeam(["research-project", "data-project"]); + + await waitFor(() => + expect(result.current).toEqual({ + status: "conflict", + teams: [ + { id: TEAM_ID, label: "ML Research" }, + { id: OTHER_TEAM_ID, label: "Data" }, + ], + }), + ); + expect(fetchTeamIdentity).not.toHaveBeenCalled(); + }); + + it("takes the team of the one project that has one", async () => { + const { result } = renderTeam(["sandbox-project", "research-project"]); + + await waitFor(() => expect(result.current.status).toBe("active")); + }); + + it("is none for a project whose workspace names no team", async () => { + const { result } = renderTeam(["sandbox-project"]); + + await waitFor(() => expect(result.current).toEqual({ status: "none" })); + expect(fetchTeamIdentity).not.toHaveBeenCalled(); + }); + + it("ignores a project that no longer exists", async () => { + const { result } = renderTeam(["deleted-project", "research-project"]); + + await waitFor(() => expect(result.current.status).toBe("active")); + }); +}); diff --git a/src/services/teams/useTeamForProjects.ts b/src/services/teams/useTeamForProjects.ts new file mode 100644 index 0000000000..3c94b99e1b --- /dev/null +++ b/src/services/teams/useTeamForProjects.ts @@ -0,0 +1,91 @@ +import { useQueries } from "@tanstack/react-query"; + +import { getTeamImpersonationHeaderName } from "@/config/teamImpersonation"; +import { useBackend } from "@/providers/BackendProvider"; +import { isProjectGone } from "@/services/projects/errors"; +import { getProject } from "@/services/projects/projectsService"; +import { projectQueryDefaults } from "@/services/projects/queryDefaults"; +import { + ProjectsQueryKeys, + WorkspacesQueryKeys, +} from "@/services/projects/types"; +import { getWorkspace } from "@/services/projects/workspacesService"; + +import { resolveTeam } from "./teamResolution"; +import type { TeamResolution, TeamSummary } from "./types"; +import { useTeamIdentity } from "./useTeamIdentity"; +import { useUserTeams } from "./useUserTeams"; +import { teamOfWorkspace } from "./workspaceTeam"; + +function distinctTeams(teams: TeamSummary[]): TeamSummary[] { + return teams.filter( + (team, index) => + teams.findIndex((candidate) => candidate.id === team.id) === index, + ); +} + +export function useTeamForProjects( + projectIds: readonly string[], +): TeamResolution { + const { configured, available } = useBackend(); + const enabled = + configured && + available && + getTeamImpersonationHeaderName() !== null && + projectIds.length > 0; + + const projects = useQueries({ + queries: projectIds.map((id) => ({ + queryKey: ProjectsQueryKeys.Id(id), + queryFn: () => getProject(id), + enabled, + ...projectQueryDefaults, + })), + }); + const reachableProjects = projects.filter( + (project) => !isProjectGone(project.error), + ); + + const workspaceIds = [ + ...new Set( + reachableProjects.flatMap((project) => + project.data ? [project.data.workspaceId] : [], + ), + ), + ]; + const workspaces = useQueries({ + queries: workspaceIds.map((id) => ({ + queryKey: WorkspacesQueryKeys.Id(id), + queryFn: () => getWorkspace(id), + enabled, + ...projectQueryDefaults, + })), + }); + + const teams = distinctTeams( + workspaces.flatMap((workspace) => { + const team = workspace.data ? teamOfWorkspace(workspace.data) : null; + return team ? [team] : []; + }), + ); + const candidate = teams.length === 1 ? teams[0] : undefined; + + const memberships = useUserTeams({ + enabled: enabled && candidate !== undefined, + }); + const isMember = + candidate !== undefined && + (memberships.data ?? []).some( + (membership) => membership.teamId === candidate.id, + ); + const identity = useTeamIdentity(isMember ? candidate.id : undefined); + + if (!enabled) return { status: "none" }; + + return resolveTeam({ + lookups: [...reachableProjects, ...workspaces], + teams, + memberships, + identity, + }); +} diff --git a/src/services/teams/useTeamIdentity.ts b/src/services/teams/useTeamIdentity.ts new file mode 100644 index 0000000000..7a6e57e0c4 --- /dev/null +++ b/src/services/teams/useTeamIdentity.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useBackend } from "@/providers/BackendProvider"; +import { projectQueryDefaults } from "@/services/projects/queryDefaults"; + +import { fetchTeamIdentity } from "./teamIdentityService"; +import { TeamsQueryKeys } from "./types"; + +export function useTeamIdentity(teamId: string | undefined) { + const { configured, available } = useBackend(); + + return useQuery({ + queryKey: TeamsQueryKeys.Identity(teamId ?? ""), + queryFn: () => { + if (!teamId) { + throw new Error("Team id is required"); + } + return fetchTeamIdentity(teamId); + }, + enabled: configured && available && Boolean(teamId), + ...projectQueryDefaults, + }); +} diff --git a/src/services/teams/useUserTeams.ts b/src/services/teams/useUserTeams.ts new file mode 100644 index 0000000000..d45e5eeacf --- /dev/null +++ b/src/services/teams/useUserTeams.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; + +import { userQueryOptions } from "@/hooks/useUserDetails"; + +import { readTeamMemberships } from "./teamMemberships"; + +export function useUserTeams({ enabled = true }: { enabled?: boolean } = {}) { + return useQuery({ + ...userQueryOptions, + select: readTeamMemberships, + enabled, + }); +} diff --git a/src/services/teams/workspaceTeam.test.ts b/src/services/teams/workspaceTeam.test.ts new file mode 100644 index 0000000000..d6c1c69cee --- /dev/null +++ b/src/services/teams/workspaceTeam.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTeamId, teamOfWorkspace } from "./workspaceTeam"; + +const TEAM_ID = "ml-team@project.iam.gserviceaccount.com"; + +describe("resolveTeamId", () => { + it("reads the team from the workspace's data", () => { + expect(resolveTeamId({ metadata: { teamId: TEAM_ID } })).toBe(TEAM_ID); + }); + + it("trims the team id", () => { + expect(resolveTeamId({ metadata: { teamId: ` ${TEAM_ID} ` } })).toBe( + TEAM_ID, + ); + }); + + it("is null for a workspace without a team", () => { + expect(resolveTeamId({ metadata: null })).toBeNull(); + expect(resolveTeamId({ metadata: { tangentBaseUrl: "x" } })).toBeNull(); + }); + + it("is null for a team id that is not a non-empty string", () => { + expect(resolveTeamId({ metadata: { teamId: " " } })).toBeNull(); + expect(resolveTeamId({ metadata: { teamId: 42 } })).toBeNull(); + }); +}); + +describe("teamOfWorkspace", () => { + it("labels the team with the workspace's name", () => { + expect( + teamOfWorkspace({ name: "ML Research", metadata: { teamId: TEAM_ID } }), + ).toEqual({ id: TEAM_ID, label: "ML Research" }); + }); + + it("is null for a workspace without a team", () => { + expect(teamOfWorkspace({ name: "Sandbox", metadata: null })).toBeNull(); + }); +}); diff --git a/src/services/teams/workspaceTeam.ts b/src/services/teams/workspaceTeam.ts new file mode 100644 index 0000000000..a6815d582c --- /dev/null +++ b/src/services/teams/workspaceTeam.ts @@ -0,0 +1,20 @@ +import type { Workspace } from "@/services/projects/types"; + +import type { TeamSummary } from "./types"; + +const WORKSPACE_TEAM_ID_KEY = "teamId"; + +export function resolveTeamId( + workspace: Pick, +): string | null { + const teamId = workspace.metadata?.[WORKSPACE_TEAM_ID_KEY]; + if (typeof teamId !== "string") return null; + return teamId.trim() || null; +} + +export function teamOfWorkspace( + workspace: Pick, +): TeamSummary | null { + const teamId = resolveTeamId(workspace); + return teamId ? { id: teamId, label: workspace.name } : null; +}