Skip to content
Closed
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
67 changes: 61 additions & 6 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2618,35 +2618,54 @@ export function createApp() {
});
});

app.get("/v1/installations", async (c) =>
c.json({
installations: await listInstallations(c.env),
health: (await listInstallationHealth(c.env)).map(enrichInstallationHealth),
}),
);
// #7661: tenant self-service. A non-operator browser session sees only its own installations (and their
// health); an operator or a server-to-server token keeps the unscoped fleet view (scope === null).
app.get("/v1/installations", async (c) => {
const gate = await resolveInstallationSelfServiceScope(c);
if (!gate.ok) return c.json({ error: gate.error }, gate.status);
const scope = gate.scope;
const [installations, health] = await Promise.all([listInstallations(c.env), listInstallationHealth(c.env)]);
return c.json({
installations: scope ? installations.filter((installation) => isInstallationInScope(scope, installation.id)) : installations,
health: (scope ? health.filter((record) => isInstallationInScope(scope, record.installationId)) : health).map(enrichInstallationHealth),
});
});

app.get("/v1/installations/:id/health", async (c) => {
const gate = await resolveInstallationSelfServiceScope(c);
if (!gate.ok) return c.json({ error: gate.error }, gate.status);
const installationId = Number(c.req.param("id"));
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
const health = await getInstallationHealth(c.env, installationId);
if (!health) return c.json({ error: "installation_health_not_found" }, 404);
// A scoped tenant may only read its own installation; another tenant's id returns the SAME not-found shape
// so cross-tenant existence never leaks.
if (gate.scope && !isInstallationInScope(gate.scope, installationId)) return c.json({ error: "installation_health_not_found" }, 404);
return c.json(enrichInstallationHealth(health));
});

app.get("/v1/installations/:id/repair", async (c) => {
const gate = await resolveInstallationSelfServiceScope(c);
if (!gate.ok) return c.json({ error: gate.error }, gate.status);
const installationId = Number(c.req.param("id"));
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
const health = await getInstallationHealth(c.env, installationId);
if (!health) return c.json({ error: "installation_health_not_found" }, 404);
if (gate.scope && !isInstallationInScope(gate.scope, installationId)) return c.json({ error: "installation_health_not_found" }, 404);
return c.json(await buildInstallationRepairDiagnostics(c.env, health));
});

app.post("/v1/installations/:id/repair/refresh", async (c) => {
const gate = await resolveInstallationSelfServiceScope(c);
if (!gate.ok) return c.json({ error: gate.error }, gate.status);
const installationId = Number(c.req.param("id"));
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
// Scope-check BEFORE the refresh mutation so a tenant can never trigger a refresh on another tenant's installation.
if (gate.scope && !isInstallationInScope(gate.scope, installationId)) return c.json({ error: "installation_not_found" }, 404);
const refreshed = await refreshInstallationHealthForInstallation(c.env, installationId);
if (!refreshed) return c.json({ error: "installation_not_found" }, 404);
const health = await getInstallationHealth(c.env, installationId);
/* v8 ignore next -- refreshInstallationHealthForInstallation upserts a health record whenever it returns a truthy installation, so this is an unreachable defensive guard after a successful refresh. */
if (!health) return c.json({ error: "installation_health_not_found" }, 404);
return c.json({ ...(await buildInstallationRepairDiagnostics(c.env, health)), refreshed: true });
});
Expand Down Expand Up @@ -6547,6 +6566,7 @@ function issueQualityMap(repoFullName: string, report: IssueQualityReport | unde
function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind: "session" }>, path: string): boolean {
if (isAuthorizedGitHubSessionLogin(env, identity.actor)) return true;
if (path.startsWith("/v1/app/")) return true;
if (isInstallationSelfServicePath(path)) return true; // #7661: the route's own resolveInstallationSelfServiceScope scopes a non-operator session to its own installations (others → filtered / 404)
if (isIssueQualityPath(path)) return true;
if (isRepoSettingsPath(path)) return true;
if (isRepoActivationPath(path)) return true;
Expand Down Expand Up @@ -6577,6 +6597,41 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
return false;
}

// #7661: the installation health/repair self-service routes a hosted tenant may reach for THEIR OWN
// installation. The coarse allowlist only lets a session past the middleware; per-installation ownership is
// enforced in the handler by resolveInstallationSelfServiceScope below.
function isInstallationSelfServicePath(path: string): boolean {
return /^\/v1\/installations(?:\/[^/]+\/(?:health|repair|repair\/refresh))?$/.test(path);
}

type InstallationSelfServiceGate =
| { ok: true; scope: ControlPanelAccessScope | null }
| { ok: false; status: 401 | 403; error: string };

// Resolve tenant self-service access for the installation routes (#7661), reusing the maintainer-dashboard's
// exact identity → role-gate → `loadControlPanelAccessScope` pattern. A browser-session non-operator is scoped
// to its own installations (`scope` non-null); an operator or a server-to-server token gets the unscoped view
// (`scope: null`), matching how every other /v1/app-scoped surface treats operators and service tokens.
async function resolveInstallationSelfServiceScope(c: ProtectedRouteContext): Promise<InstallationSelfServiceGate> {
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- requiresApiToken("/v1/installations…") means the global middleware already 401'd a missing identity before this handler runs; kept as a type-safe defensive guard. */
if (!identity) return { ok: false, status: 401, error: "unauthorized" };
const summary = await getRoleSummaryForIdentity(c.env, identity);
if (!summary.roles.some((role) => role === "maintainer" || role === "owner" || role === "operator")) {
return { ok: false, status: 403, error: "insufficient_role" };
}
const scope =
identity.kind === "session" && !summary.roles.includes("operator") ? await loadControlPanelAccessScope(c.env, identity.actor) : null;
return { ok: true, scope };
}

// Whether a scoped (non-operator) tenant owns `installationId`. `scope.installationIds` is the authoritative set
// of the tenant's own account installations (plus any they maintain), computed by the same
// `loadControlPanelAccessScope` call the maintainer-dashboard uses — so this never grants cross-tenant access.
function isInstallationInScope(scope: ControlPanelAccessScope, installationId: number): boolean {
return scope.installationIds.includes(installationId);
}

function isRepoSettingsPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/settings$/.test(path);
}
Expand Down
173 changes: 173 additions & 0 deletions test/unit/routes-installation-self-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { createApp } from "../../src/api/routes";
import { createSessionForGitHubUser } from "../../src/auth/security";
import { upsertInstallation, upsertInstallationHealth } from "../../src/db/repositories";
import { createTestEnv } from "../helpers/d1";

// #7661: tenant self-service for installation health/repair. A hosted tenant (a non-operator browser session)
// may see and repair ONLY their own installation; an operator or a server-to-server token keeps the unscoped
// fleet view. Mirrors the maintainer-dashboard's identity → role-gate → loadControlPanelAccessScope scoping.

// A server-to-server token (kind "api") -> scope null -> unscoped operator-equivalent view, exactly like today.
function apiHeaders(env: Env): Record<string, string> {
return { authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" };
}

function sessionCookie(token: string): Record<string, string> {
return { cookie: `loopover_session=${token}`, "content-type": "application/json" };
}

async function seedInstallation(env: Env, installationId: number, login: string): Promise<void> {
await upsertInstallation(env, {
installation: {
id: installationId,
account: { login, id: installationId, type: "User" },
repository_selection: "selected",
permissions: { metadata: "read", contents: "read", pull_requests: "write", issues: "write" },
events: ["pull_request", "issues", "issue_comment", "repository"],
},
});
await upsertInstallationHealth(env, {
installationId,
accountLogin: login,
installedReposCount: 1,
registeredInstalledCount: 1,
status: "healthy",
missingPermissions: [],
missingEvents: [],
permissions: { metadata: "read" },
events: ["pull_request"],
checkedAt: "2026-07-20T00:00:00.000Z",
authMode: "local",
});
}

// alice (installation 111) and bob (installation 222) are two separate hosted tenants.
async function seedTwoTenants(env: Env): Promise<{ aliceToken: string; bobToken: string }> {
await seedInstallation(env, 111, "alice");
await seedInstallation(env, 222, "bob");
const alice = await createSessionForGitHubUser(env, { login: "alice", id: 501 });
const bob = await createSessionForGitHubUser(env, { login: "bob", id: 502 });
return { aliceToken: alice.token, bobToken: bob.token };
}

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe("installation self-service scoping (#7661)", () => {
it("an unauthenticated request is rejected (middleware)", async () => {
const app = createApp();
const env = createTestEnv();
expect((await app.request("/v1/installations", {}, env)).status).toBe(401);
});

it("a session with no installation/role is denied on every installation route with insufficient_role", async () => {
const app = createApp();
const env = createTestEnv();
const { token } = await createSessionForGitHubUser(env, { login: "nobody", id: 999 });
const cookie = sessionCookie(token);
const requests = [
app.request("/v1/installations", { headers: cookie }, env),
app.request("/v1/installations/111/health", { headers: cookie }, env),
app.request("/v1/installations/111/repair", { headers: cookie }, env),
app.request("/v1/installations/111/repair/refresh", { method: "POST", headers: cookie }, env),
];
for (const request of requests) {
const res = await request;
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({ error: "insufficient_role" });
}
});

it("an operator/service token sees every installation and health record (unscoped)", async () => {
const app = createApp();
const env = createTestEnv();
await seedTwoTenants(env);
const res = await app.request("/v1/installations", { headers: apiHeaders(env) }, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { installations: Array<{ id: number }>; health: Array<{ installationId: number }> };
expect(body.installations.map((i) => i.id).sort()).toEqual([111, 222]);
expect(body.health.map((h) => h.installationId).sort()).toEqual([111, 222]);
});

it("a tenant's list is scoped to only their own installation and health", async () => {
const app = createApp();
const env = createTestEnv();
const { aliceToken } = await seedTwoTenants(env);
const res = await app.request("/v1/installations", { headers: sessionCookie(aliceToken) }, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { installations: Array<{ id: number }>; health: Array<{ installationId: number }> };
expect(body.installations.map((i) => i.id)).toEqual([111]);
expect(body.health.map((h) => h.installationId)).toEqual([111]);
});

it("a tenant can read their own installation health, but another tenant's returns not-found", async () => {
const app = createApp();
const env = createTestEnv();
const { aliceToken } = await seedTwoTenants(env);

const own = await app.request("/v1/installations/111/health", { headers: sessionCookie(aliceToken) }, env);
expect(own.status).toBe(200);
await expect(own.json()).resolves.toMatchObject({ installationId: 111, accountLogin: "alice" });

const other = await app.request("/v1/installations/222/health", { headers: sessionCookie(aliceToken) }, env);
expect(other.status).toBe(404); // same not-found shape as a missing id — cross-tenant existence never leaks
await expect(other.json()).resolves.toMatchObject({ error: "installation_health_not_found" });
});

it("a tenant can read their own repair diagnostics, but another tenant's returns not-found", async () => {
const app = createApp();
const env = createTestEnv();
const { aliceToken } = await seedTwoTenants(env);

const own = await app.request("/v1/installations/111/repair", { headers: sessionCookie(aliceToken) }, env);
expect(own.status).toBe(200);

const other = await app.request("/v1/installations/222/repair", { headers: sessionCookie(aliceToken) }, env);
expect(other.status).toBe(404);
await expect(other.json()).resolves.toMatchObject({ error: "installation_health_not_found" });
});

it("a tenant cannot trigger a refresh on another tenant's installation (denied before any mutation)", async () => {
const app = createApp();
const env = createTestEnv();
const { aliceToken } = await seedTwoTenants(env);
const fetchSpy = vi.fn(async () => new Response("nope", { status: 500 }));
vi.stubGlobal("fetch", fetchSpy);

const res = await app.request("/v1/installations/222/repair/refresh", { method: "POST", headers: sessionCookie(aliceToken) }, env);
expect(res.status).toBe(404);
await expect(res.json()).resolves.toMatchObject({ error: "installation_not_found" });
expect(fetchSpy).not.toHaveBeenCalled(); // scope-checked BEFORE the mutation, so no refresh work happens
});

it("a tenant can trigger a refresh on their own installation", async () => {
const app = createApp();
const env = createTestEnv();
const { aliceToken } = await seedTwoTenants(env);
// The refresh recomputes health via GitHub; a failed fetch degrades to an error summary (not a throw), so
// the scoped owner still gets a 200 refreshed result — this test proves ownership passes the scope gate.
vi.stubGlobal("fetch", vi.fn(async () => new Response("unavailable", { status: 500 })));

const res = await app.request("/v1/installations/111/repair/refresh", { method: "POST", headers: sessionCookie(aliceToken) }, env);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({ refreshed: true });
});

it("an operator can refresh any installation (unscoped), and an invalid id is a 400", async () => {
const app = createApp();
const env = createTestEnv();
await seedTwoTenants(env);
vi.stubGlobal("fetch", vi.fn(async () => new Response("unavailable", { status: 500 })));

const refreshed = await app.request("/v1/installations/222/repair/refresh", { method: "POST", headers: apiHeaders(env) }, env);
expect(refreshed.status).toBe(200);
await expect(refreshed.json()).resolves.toMatchObject({ refreshed: true });

const badId = await app.request("/v1/installations/not-a-number/health", { headers: apiHeaders(env) }, env);
expect(badId.status).toBe(400);
});
});