Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions src/config/teamImpersonation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 23 additions & 0 deletions src/config/teamImpersonation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions src/services/teams/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
69 changes: 69 additions & 0 deletions src/services/teams/teamIdentityService.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
33 changes: 33 additions & 0 deletions src/services/teams/teamIdentityService.ts
Original file line number Diff line number Diff line change
@@ -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<TeamIdentity> {
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,
);
}
49 changes: 49 additions & 0 deletions src/services/teams/teamMemberships.test.ts
Original file line number Diff line number Diff line change
@@ -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"] },
]);
});
});
21 changes: 21 additions & 0 deletions src/services/teams/teamMemberships.ts
Original file line number Diff line number Diff line change
@@ -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),
}));
}
33 changes: 33 additions & 0 deletions src/services/teams/teamRequestHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
9 changes: 9 additions & 0 deletions src/services/teams/teamRequestHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { getTeamImpersonationHeaderName } from "@/config/teamImpersonation";

export function teamRequestHeaders(
teamId: string | undefined,
): Record<string, string> {
const headerName = getTeamImpersonationHeaderName();
if (!headerName || !teamId) return {};
return { [headerName]: teamId };
}
Loading
Loading