diff --git a/src/api/routes.ts b/src/api/routes.ts index ccc0b3821a..672bd69003 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -207,6 +207,7 @@ import { buildStaticControlPanelRoleSummary, canLoginAccessRepo, canWatchRepo, + type ControlPanelAccessScope, loadControlPanelAccessScope, loadControlPanelRoleSummary, } from "../services/control-panel-roles"; @@ -2650,6 +2651,61 @@ export function createApp() { return c.json({ ...(await buildInstallationRepairDiagnostics(c.env, health)), refreshed: true }); }); + // Tenant self-service for installation health/repair (#7661). The operator-only `/v1/installations*` routes + // above expose the ENTIRE fleet, so a hosted tenant currently depends on the fleet operator to see or repair + // even their own installation. These `/v1/app/installations*` siblings reuse `/v1/app/maintainer-dashboard`'s + // exact scoping (`loadControlPanelAccessScope`, via `resolveAppInstallationScope`): an operator (or static + // service identity) still sees everything (scope === null), while a non-operator session is limited to + // installations under their own account or maintained repos — tenant A can never read or repair tenant B's. + app.get("/v1/app/installations", async (c) => { + const resolved = await resolveAppInstallationScope(c); + if (resolved instanceof Response) return resolved; + const { scope } = resolved; + const [allInstallations, allHealth] = await Promise.all([listInstallations(c.env), listInstallationHealth(c.env)]); + const installations = allInstallations.filter((installation) => + installationRecordInScope(scope, { installationId: installation.id, accountLogin: installation.accountLogin }), + ); + const health = allHealth.filter((record) => installationRecordInScope(scope, record)); + return c.json({ installations, health: health.map(enrichInstallationHealth) }); + }); + + app.get("/v1/app/installations/:id/health", async (c) => { + const resolved = await resolveAppInstallationScope(c); + if (resolved instanceof Response) return resolved; + 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 (!installationRecordInScope(resolved.scope, health)) return c.json({ error: "forbidden_installation" }, 403); + return c.json(enrichInstallationHealth(health)); + }); + + app.get("/v1/app/installations/:id/repair", async (c) => { + const resolved = await resolveAppInstallationScope(c); + if (resolved instanceof Response) return resolved; + 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 (!installationRecordInScope(resolved.scope, health)) return c.json({ error: "forbidden_installation" }, 403); + return c.json(await buildInstallationRepairDiagnostics(c.env, health)); + }); + + app.post("/v1/app/installations/:id/repair/refresh", async (c) => { + const resolved = await resolveAppInstallationScope(c); + if (resolved instanceof Response) return resolved; + const installationId = Number(c.req.param("id")); + if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400); + // Ownership is enforced BEFORE the refresh side effect so a tenant can never trigger repair on an + // installation they don't own; the existing health record supplies the account the scope is checked against. + const existing = await getInstallationHealth(c.env, installationId); + if (!existing) return c.json({ error: "installation_health_not_found" }, 404); + if (!installationRecordInScope(resolved.scope, existing)) return c.json({ error: "forbidden_installation" }, 403); + const refreshed = await refreshInstallationHealthForInstallation(c.env, installationId); + if (!refreshed) return c.json({ error: "installation_not_found" }, 404); + return c.json({ ...(await buildInstallationRepairDiagnostics(c.env, refreshed)), refreshed: true }); + }); + app.get("/v1/repos", async (c) => c.json(await listRepositories(c.env))); app.get("/v1/repos/:owner/:repo", async (c) => { @@ -6632,6 +6688,40 @@ async function requireAppRole(c: ProtectedRouteContext, allowedRoles: ControlPan return summary.roles.some((role) => allowedRoles.includes(role)) ? null : c.json({ error: "insufficient_role" }, 403); } +/** Tenant-scoped gate for the `/v1/app/installations*` self-service routes (#7661), mirroring + * `/v1/app/maintainer-dashboard`: requires a maintainer/owner/operator role and, for a non-operator session, + * resolves the caller's installation access scope. Returns `{ identity, scope }` (scope === null means the + * caller — an operator or static service identity — sees the whole fleet), or a Response to short-circuit. */ +async function resolveAppInstallationScope( + c: ProtectedRouteContext, +): Promise { + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- Protected middleware rejects unauthenticated private routes before reaching the handler. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + const summary = await getRoleSummaryForIdentity(c.env, identity); + if (!summary.roles.some((role) => ["maintainer", "owner", "operator"].includes(role))) { + return c.json({ error: "insufficient_role" }, 403); + } + const scope = + identity.kind === "session" && !summary.roles.includes("operator") + ? await loadControlPanelAccessScope(c.env, identity.actor) + : null; + return { identity, scope }; +} + +/** Whether an installation/health record is visible under a resolved installation scope, using the exact + * installation-id / account-login match `/v1/app/maintainer-dashboard` applies. A null scope is the operator + * (whole-fleet) case and matches everything. */ +function installationRecordInScope( + scope: ControlPanelAccessScope | null, + record: { installationId: number; accountLogin: string }, +): boolean { + if (!scope) return true; + const scopedInstallationIds = new Set(scope.installationIds); + const scopedAccountLogins = new Set(scope.accountLogins.map((accountLogin) => accountLogin.toLowerCase())); + return scopedInstallationIds.has(record.installationId) || scopedAccountLogins.has(record.accountLogin.toLowerCase()); +} + async function requireStaticProtectedApiToken(c: ProtectedRouteContext): Promise { const identity = await authenticateRequestIdentity(c); /* v8 ignore next -- Protected middleware rejects unauthenticated private routes before static-token-only route guards. */ diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index 96b2fd6c89..c20b5e055d 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -146,6 +146,7 @@ export function routeClassForPath(path: string): RateLimitClass { // upsert per request. /\/(?:ai-(?:key|review)|linear-key)$/.test(path) || /^\/v1\/installations\/[^/]+\/repair\/refresh$/.test(path) || + /^\/v1\/app\/installations\/[^/]+\/repair\/refresh$/.test(path) || path.includes("/upstream/") || path.includes("/internal/jobs/generate-signal-snapshots") || path.includes("/internal/jobs/build-contributor-decision-packs") || diff --git a/test/integration/app-installations-selfservice.test.ts b/test/integration/app-installations-selfservice.test.ts new file mode 100644 index 0000000000..2daeee7765 --- /dev/null +++ b/test/integration/app-installations-selfservice.test.ts @@ -0,0 +1,269 @@ +import { generateKeyPairSync } from "node:crypto"; +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 type { InstallationHealthRecord } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +// Tenant self-service for installation health/repair (#7661). These `/v1/app/installations*` routes are the +// tenant-scoped siblings of the operator-only `/v1/installations*` routes, reusing `/v1/app/maintainer-dashboard`'s +// exact `loadControlPanelAccessScope` scoping: an operator (or static api token) sees the whole fleet, while a +// non-operator session is limited to installations under their own account. These tests prove the scoping holds +// (tenant A can neither read nor repair tenant B's installation) and exercise every added branch/error path. + +describe("tenant self-service installation health/repair (#7661)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function seedInstallation(env: Env, id: number, login: string): Promise { + await upsertInstallation(env, { + installation: { + id, + account: { login, id, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + }, + }); + } + + async function seedHealth(env: Env, installationId: number, accountLogin: string): Promise { + const record: InstallationHealthRecord = { + installationId, + accountLogin, + repositorySelection: "selected", + installedReposCount: 1, + registeredInstalledCount: 0, + status: "needs_attention", + missingPermissions: ["issues"], + missingEvents: ["issue_comment"], + permissions: { metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + checkedAt: "2026-05-28T00:00:00.000Z", + authMode: "local", + }; + await upsertInstallationHealth(env, record); + } + + // tenant-a owns installation 500 (health 500); health 501 is a second telemetry row under tenant-a's account + // with no installation row (a pruned/never-registered install) -- it exercises the account-login scope arm and + // the refresh "installation_not_found" path. tenant-b owns installation 600. + async function seedFleet(env: Env): Promise { + await seedInstallation(env, 500, "tenant-a"); + await seedInstallation(env, 600, "tenant-b"); + await seedHealth(env, 500, "tenant-a"); + await seedHealth(env, 501, "tenant-a"); + await seedHealth(env, 600, "tenant-b"); + } + + function cookie(token: string): Record { + return { cookie: `loopover_session=${token}` }; + } + + function apiHeaders(env: Env): Record { + return { authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }; + } + + it("rejects unauthenticated callers on every route", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedFleet(env); + + expect((await app.request("/v1/app/installations", {}, env)).status).toBe(401); + expect((await app.request("/v1/app/installations/500/health", {}, env)).status).toBe(401); + expect((await app.request("/v1/app/installations/500/repair", {}, env)).status).toBe(401); + expect((await app.request("/v1/app/installations/500/repair/refresh", { method: "POST" }, env)).status).toBe(401); + }); + + it("rejects a session with no maintainer/owner/operator role", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await seedFleet(env); + const { token } = await createSessionForGitHubUser(env, { login: "nobody", id: 9 }); + + // Every route short-circuits on the shared role gate before doing any installation work. + const list = await app.request("/v1/app/installations", { headers: cookie(token) }, env); + expect(list.status).toBe(403); + await expect(list.json()).resolves.toMatchObject({ error: "insufficient_role" }); + expect((await app.request("/v1/app/installations/500/health", { headers: cookie(token) }, env)).status).toBe(403); + expect((await app.request("/v1/app/installations/500/repair", { headers: cookie(token) }, env)).status).toBe(403); + expect( + (await app.request("/v1/app/installations/500/repair/refresh", { method: "POST", headers: cookie(token) }, env)) + .status, + ).toBe(403); + }); + + it("lets an operator (static api token) see and read the whole fleet", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await seedFleet(env); + + const list = await app.request("/v1/app/installations", { headers: apiHeaders(env) }, env); + expect(list.status).toBe(200); + const listBody = (await list.json()) as { + installations: Array<{ id: number }>; + health: Array<{ installationId: number }>; + }; + expect(listBody.installations.map((installation) => installation.id).sort((a, b) => a - b)).toEqual([500, 600]); + expect(listBody.health.map((record) => record.installationId).sort((a, b) => a - b)).toEqual([500, 501, 600]); + + // Operator (scope === null) can read another account's installation health/repair directly. + expect((await app.request("/v1/app/installations/600/health", { headers: apiHeaders(env) }, env)).status).toBe(200); + expect((await app.request("/v1/app/installations/600/repair", { headers: apiHeaders(env) }, env)).status).toBe(200); + }); + + it("scopes the list to the tenant's own installations only", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await seedFleet(env); + const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5001 }); + + const list = await app.request("/v1/app/installations", { headers: cookie(tokenA) }, env); + expect(list.status).toBe(200); + const body = (await list.json()) as { + installations: Array<{ id: number; accountLogin: string }>; + health: Array<{ installationId: number }>; + }; + // tenant-a sees only their own installation 500 -- never tenant-b's 600. + expect(body.installations.map((installation) => installation.id)).toEqual([500]); + // Both health rows under tenant-a's account are in scope (500 via installation id, 501 via account login); + // tenant-b's 600 is filtered out. + expect(body.health.map((record) => record.installationId).sort((a, b) => a - b)).toEqual([500, 501]); + expect(JSON.stringify(body)).not.toContain("tenant-b"); + }); + + it("scopes per-installation health reads and their error branches", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await seedFleet(env); + const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5001 }); + + // Own installation (installation-id scope arm) -> 200. + const own = await app.request("/v1/app/installations/500/health", { headers: cookie(tokenA) }, env); + expect(own.status).toBe(200); + await expect(own.json()).resolves.toMatchObject({ installationId: 500, accountLogin: "tenant-a" }); + + // Own telemetry row with no installation record (account-login scope arm) -> 200. + const orphan = await app.request("/v1/app/installations/501/health", { headers: cookie(tokenA) }, env); + expect(orphan.status).toBe(200); + await expect(orphan.json()).resolves.toMatchObject({ installationId: 501, accountLogin: "tenant-a" }); + + // Another tenant's installation -> 403, never leaking it. + const foreign = await app.request("/v1/app/installations/600/health", { headers: cookie(tokenA) }, env); + expect(foreign.status).toBe(403); + await expect(foreign.json()).resolves.toMatchObject({ error: "forbidden_installation" }); + + // Non-numeric id -> 400. + const invalid = await app.request("/v1/app/installations/not-a-number/health", { headers: cookie(tokenA) }, env); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toMatchObject({ error: "invalid_installation_id" }); + + // No health record at all -> 404. + const missing = await app.request("/v1/app/installations/999/health", { headers: cookie(tokenA) }, env); + expect(missing.status).toBe(404); + await expect(missing.json()).resolves.toMatchObject({ error: "installation_health_not_found" }); + }); + + it("scopes per-installation repair diagnostics and their error branches", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await seedFleet(env); + const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5001 }); + + const own = await app.request("/v1/app/installations/500/repair", { headers: cookie(tokenA) }, env); + expect(own.status).toBe(200); + await expect(own.json()).resolves.toMatchObject({ installation: { status: expect.any(String) } }); + + const foreign = await app.request("/v1/app/installations/600/repair", { headers: cookie(tokenA) }, env); + expect(foreign.status).toBe(403); + await expect(foreign.json()).resolves.toMatchObject({ error: "forbidden_installation" }); + + const invalid = await app.request("/v1/app/installations/not-a-number/repair", { headers: cookie(tokenA) }, env); + expect(invalid.status).toBe(400); + + const missing = await app.request("/v1/app/installations/999/repair", { headers: cookie(tokenA) }, env); + expect(missing.status).toBe(404); + await expect(missing.json()).resolves.toMatchObject({ error: "installation_health_not_found" }); + }); + + it("scopes repair/refresh and its error branches, and refreshes the tenant's own installation", async () => { + const app = createApp(); + // Use node:crypto export so the test SOURCE never contains a PEM header literal — that literal is what + // closed #8006 as a false-positive secret scan on an otherwise CI-green PR. + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "", GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/app/installations/500")) { + return Response.json({ + id: 500, + account: { login: "tenant-a", id: 500, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], + }); + } + return new Response("not found", { status: 404 }); + }); + await seedFleet(env); + const { token: tokenA } = await createSessionForGitHubUser(env, { login: "tenant-a", id: 5001 }); + + // Another tenant's installation cannot be repaired -> 403 (enforced BEFORE any refresh side effect). + const foreign = await app.request( + "/v1/app/installations/600/repair/refresh", + { method: "POST", headers: cookie(tokenA) }, + env, + ); + expect(foreign.status).toBe(403); + await expect(foreign.json()).resolves.toMatchObject({ error: "forbidden_installation" }); + + // Non-numeric id -> 400. + const invalid = await app.request( + "/v1/app/installations/not-a-number/repair/refresh", + { method: "POST", headers: cookie(tokenA) }, + env, + ); + expect(invalid.status).toBe(400); + + // No health record at all -> 404. + const missing = await app.request( + "/v1/app/installations/999/repair/refresh", + { method: "POST", headers: cookie(tokenA) }, + env, + ); + expect(missing.status).toBe(404); + await expect(missing.json()).resolves.toMatchObject({ error: "installation_health_not_found" }); + + // In-scope telemetry row whose installation record no longer exists -> refresh yields installation_not_found. + const gone = await app.request( + "/v1/app/installations/501/repair/refresh", + { method: "POST", headers: cookie(tokenA) }, + env, + ); + expect(gone.status).toBe(404); + await expect(gone.json()).resolves.toMatchObject({ error: "installation_not_found" }); + + // The tenant's own installation refreshes successfully. + const refreshed = await app.request( + "/v1/app/installations/500/repair/refresh", + { method: "POST", headers: cookie(tokenA) }, + env, + ); + expect(refreshed.status).toBe(200); + await expect(refreshed.json()).resolves.toMatchObject({ + refreshed: true, + installation: { status: expect.any(String) }, + }); + }); +}); + +function generateRsaPrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); +} diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 4917dd6032..c303491d04 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -129,6 +129,7 @@ describe("private-beta auth and rate limiting", () => { expect(routeClassForPath("/v1/opportunities/find")).toBe("expensive"); expect(routeClassForPath("/v1/issue-rag/retrieve")).toBe("expensive"); expect(routeClassForPath("/v1/installations/999/repair/refresh")).toBe("expensive"); + expect(routeClassForPath("/v1/app/installations/999/repair/refresh")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/generate-signal-snapshots")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/build-contributor-decision-packs")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/refresh-upstream-drift")).toBe("expensive");