diff --git a/apps/web/e2e/dev-system/helpers.ts b/apps/web/e2e/dev-system/helpers.ts index c8babeb..ed675e1 100644 --- a/apps/web/e2e/dev-system/helpers.ts +++ b/apps/web/e2e/dev-system/helpers.ts @@ -16,13 +16,26 @@ export async function signIn(page: Page, email: string, password: string) { } export async function signOut(page: Page) { - await page.goto("/auth/signout"); - // Auth.js v5 renders a confirmation page at /auth/signout before signing out. - // Click the submit button if a form is present. - const confirmBtn = page.locator('button[type="submit"]').first(); - if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) { - await confirmBtn.click(); - } + // next-auth/react signOut() POSTs to /auth/signout with a CSRF token. + // There is no GET-accessible signout page in this app (/auth/signout returns 404). + // Replicate what the client-side signOut() function does: + // 1. Fetch the CSRF token from /auth/csrf + // 2. POST to /auth/signout with that token + // 3. Follow the redirect to /auth/signin + await page.goto("/dashboard"); // land on any authenticated page for cookie context + await page.evaluate(async () => { + const csrfRes = await fetch("/api/auth/csrf"); + const { csrfToken } = await csrfRes.json() as { csrfToken: string }; + await fetch("/api/auth/signout", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ csrfToken, callbackUrl: "/auth/signin", json: "true" }), + redirect: "follow", + }); + }); + // After the POST clears the session cookie, navigating to a protected route + // should redirect to sign-in. + await page.goto("/dashboard"); await page.waitForURL(/\/auth\/signin/, { timeout: 10000 }); } diff --git a/apps/web/e2e/dev-system/rbac-data-access.spec.ts b/apps/web/e2e/dev-system/rbac-data-access.spec.ts new file mode 100644 index 0000000..ffcbaac --- /dev/null +++ b/apps/web/e2e/dev-system/rbac-data-access.spec.ts @@ -0,0 +1,239 @@ +/** + * RBAC data-access matrix — dev system + * + * Verifies that role-based access control is enforced at the network level + * (tRPC response payload) against the running dev server with real seed data. + * + * Unlike rbac-permissions.spec.ts (which checks UI visibility), these tests + * call tRPC procedures directly via fetch() inside the browser context and + * assert on HTTP status + tRPC error codes in the response body. + * + * All tests use pre-authenticated storage states — no signIn() calls, no + * auth rate limiter pressure. 3 logins total per suite run (from globalSetup). + * + * Tested procedures and their audience classes (docs/route-access-matrix.md): + * + * user.list → admin-only (adminProcedure) + * allocation.listView → planning-read (planningReadProcedure → VIEW_PLANNING) + * resource.listSummaries → resource-overview (resourceOverviewProcedure) + * user.listAssignable → manager-write (managerProcedure → ADMIN or MANAGER) + * + * Expected access matrix: + * + * Procedure ADMIN MANAGER VIEWER + * user.list ✓ FORBIDDEN FORBIDDEN + * allocation.listView ✓ ✓ FORBIDDEN + * resource.listSummaries ✓ ✓ FORBIDDEN + * user.listAssignable ✓ ✓ FORBIDDEN + */ +import { expect, test, type Page } from "@playwright/test"; +import { STORAGE_STATE } from "../../playwright.dev.config.js"; + +// --------------------------------------------------------------------------- +// Helper — call a tRPC query procedure directly from within the browser context +// --------------------------------------------------------------------------- + +type TrpcQueryResult = { + httpStatus: number; + trpcCode: string | null; + hasData: boolean; +}; + +/** + * Runs a tRPC GET query inside the browser context (inherits the session cookie). + * Returns the HTTP status, the tRPC error code (null on success), and whether + * a non-null `result.data` was returned. + * + * tRPC v11 batch GET format: + * /api/trpc/?batch=1&input={"0":{"json":}} + * Success response: [{"result":{"data":{"json": ...}}}] + * Error response: [{"error":{"json":{"data":{"code":"FORBIDDEN","httpStatus":403}}}}] + */ +async function trpcQuery( + page: Page, + procedure: string, + input: unknown = null, +): Promise { + return page.evaluate( + async ({ procedure, input }) => { + const encodedInput = encodeURIComponent( + JSON.stringify({ "0": { json: input } }), + ); + const url = `/api/trpc/${procedure}?batch=1&input=${encodedInput}`; + const res = await fetch(url, { credentials: "include" }); + const httpStatus = res.status; + + // tRPC v11 with no transformer: no extra .json wrapper around the payload. + // Error format: [{"error":{"message":"...","code":-32603,"data":{"code":"FORBIDDEN","httpStatus":403}}}] + // Success format: [{"result":{"data": }}] + type TrpcBatchItem = { + result?: { data?: unknown }; + error?: { data?: { code?: string }; message?: string }; + }; + const body = (await res.json()) as TrpcBatchItem[]; + const item = body[0]; + const trpcCode = item?.error?.data?.code ?? null; + const hasData = + item?.result?.data !== undefined && item.result.data !== null; + + return { httpStatus, trpcCode, hasData } satisfies { + httpStatus: number; + trpcCode: string | null; + hasData: boolean; + }; + }, + { procedure, input }, + ); +} + +// --------------------------------------------------------------------------- +// Admin — should have access to all procedures +// --------------------------------------------------------------------------- + +test.describe("RBAC data-access — admin", () => { + test.use({ storageState: STORAGE_STATE.admin }); + + test("admin: user.list returns data (admin-only procedure)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "user.list"); + expect(result.trpcCode).toBeNull(); + expect(result.httpStatus).toBe(200); + expect(result.hasData).toBe(true); + }); + + test("admin: allocation.listView returns data (planning-read)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "allocation.listView", {}); + expect(result.trpcCode).toBeNull(); + expect(result.httpStatus).toBe(200); + }); + + test("admin: resource.listSummaries returns data (resource-overview)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "resource.listSummaries"); + expect(result.trpcCode).toBeNull(); + expect(result.httpStatus).toBe(200); + }); + + test("admin: user.listAssignable returns data (manager-write procedure)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "user.listAssignable"); + expect(result.trpcCode).toBeNull(); + expect(result.httpStatus).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// Manager — FORBIDDEN on admin-only, allowed on planning-read/resource-overview/manager +// --------------------------------------------------------------------------- + +test.describe("RBAC data-access — manager", () => { + test.use({ storageState: STORAGE_STATE.manager }); + + test("manager: user.list is FORBIDDEN (admin-only procedure)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "user.list"); + expect(result.trpcCode).toBe("FORBIDDEN"); + }); + + test("manager: allocation.listView returns data (planning-read)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "allocation.listView", {}); + expect(result.trpcCode).toBeNull(); + expect(result.httpStatus).toBe(200); + }); + + test("manager: resource.listSummaries returns data (resource-overview)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "resource.listSummaries"); + expect(result.trpcCode).toBeNull(); + expect(result.httpStatus).toBe(200); + }); + + test("manager: user.listAssignable returns data (manager-write procedure)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "user.listAssignable"); + expect(result.trpcCode).toBeNull(); + expect(result.httpStatus).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// Viewer — FORBIDDEN on all sensitive procedures +// --------------------------------------------------------------------------- + +test.describe("RBAC data-access — viewer", () => { + test.use({ storageState: STORAGE_STATE.viewer }); + + test("viewer: user.list is FORBIDDEN (admin-only procedure)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "user.list"); + expect(result.trpcCode).toBe("FORBIDDEN"); + }); + + test("viewer: allocation.listView is FORBIDDEN (planning-read — no VIEW_PLANNING)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "allocation.listView", {}); + expect(result.trpcCode).toBe("FORBIDDEN"); + }); + + test("viewer: resource.listSummaries is FORBIDDEN (resource-overview)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "resource.listSummaries"); + expect(result.trpcCode).toBe("FORBIDDEN"); + }); + + test("viewer: user.listAssignable is FORBIDDEN (manager-write procedure)", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const result = await trpcQuery(page, "user.listAssignable"); + expect(result.trpcCode).toBe("FORBIDDEN"); + }); +}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index b4341ab..61d3b59 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -28,14 +28,8 @@ const nextConfig: NextConfig = { { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }, - { - key: "Content-Security-Policy", - value: process.env.NODE_ENV === "production" - // Production: no unsafe-eval, no unsafe-inline in script-src - ? "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" - // Development: allow unsafe-eval and unsafe-inline for HMR / dev tooling - : "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", - }, + // Content-Security-Policy is set per-request by middleware.ts (nonce-based). + // Static CSP here would conflict and cannot carry per-request nonces. { key: "X-XSS-Protection", value: "0" }, ], }, diff --git a/apps/web/src/app/api/perf/route.test.ts b/apps/web/src/app/api/perf/route.test.ts new file mode 100644 index 0000000..1910312 --- /dev/null +++ b/apps/web/src/app/api/perf/route.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/api/sse", () => ({ + eventBus: { subscriberCount: 0 }, +})); + +// Lazy import so we can stub env before the module-level code runs. +const importRoute = () => import("./route.js"); + +describe("GET /api/perf — security hardening", () => { + const ORIGINAL_SECRET = process.env["CRON_SECRET"]; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + if (ORIGINAL_SECRET === undefined) { + delete process.env["CRON_SECRET"]; + } else { + process.env["CRON_SECRET"] = ORIGINAL_SECRET; + } + }); + + it("returns 200 with metrics for an authorised request via Authorization header", async () => { + process.env["CRON_SECRET"] = "test-secret-abc"; + const { GET } = await importRoute(); + + const request = new Request("http://localhost/api/perf", { + headers: { Authorization: "Bearer test-secret-abc" }, + }); + + const response = await GET(request); + expect(response.status).toBe(200); + + const body = await response.json() as { timestamp: string; uptime: unknown; memory: unknown }; + expect(typeof body.timestamp).toBe("string"); + expect(body.uptime).toBeDefined(); + expect(body.memory).toBeDefined(); + }); + + it("returns 401 when no Authorization header is provided", async () => { + process.env["CRON_SECRET"] = "test-secret-abc"; + const { GET } = await importRoute(); + + const request = new Request("http://localhost/api/perf"); + const response = await GET(request); + expect(response.status).toBe(401); + }); + + it("returns 401 when the Authorization header contains a wrong secret", async () => { + process.env["CRON_SECRET"] = "test-secret-abc"; + const { GET } = await importRoute(); + + const request = new Request("http://localhost/api/perf", { + headers: { Authorization: "Bearer wrong-secret" }, + }); + const response = await GET(request); + expect(response.status).toBe(401); + }); + + it("returns 401 for a query-param token — query-string auth is not supported", async () => { + process.env["CRON_SECRET"] = "test-secret-abc"; + const { GET } = await importRoute(); + + // Pass secret as query param only — no Authorization header + const request = new Request("http://localhost/api/perf?token=test-secret-abc"); + const response = await GET(request); + // The endpoint ignores query params entirely; without a valid header it must reject. + expect(response.status).toBe(401); + }); + + it("returns 401 and leaks no metrics when CRON_SECRET is not configured (fail-closed)", async () => { + delete process.env["CRON_SECRET"]; + const { GET } = await importRoute(); + + // Even a request that would otherwise be valid must be rejected. + const request = new Request("http://localhost/api/perf", { + headers: { Authorization: "Bearer anything" }, + }); + const response = await GET(request); + expect(response.status).toBe(401); + + const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown }; + expect(body.timestamp).toBeUndefined(); + expect(body.memory).toBeUndefined(); + }); +}); diff --git a/apps/web/src/app/api/perf/route.ts b/apps/web/src/app/api/perf/route.ts index 7dc993d..9833f48 100644 --- a/apps/web/src/app/api/perf/route.ts +++ b/apps/web/src/app/api/perf/route.ts @@ -7,7 +7,9 @@ export const runtime = "nodejs"; /** * GET /api/perf — Runtime performance metrics. * - * Protected by CRON_SECRET header or query param. + * Protected by CRON_SECRET via `Authorization: Bearer ` header only. + * Query-string authentication is not supported (secrets must not appear in URLs). + * Fails closed (401) when CRON_SECRET is not configured in the environment. * Returns Node.js memory usage, process uptime, and SSE connection count. */ export function GET(request: Request) { diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index b872f23..d83023f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import { headers } from "next/headers"; import { Manrope, Source_Sans_3 } from "next/font/google"; import { TRPCProvider } from "~/lib/trpc/provider.js"; import { ServiceWorkerRegistration } from "~/components/layout/ServiceWorkerRegistration.js"; @@ -45,11 +46,12 @@ export const viewport: Viewport = { themeColor: "#0284c7", }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const nonce = (await headers()).get("x-nonce") ?? undefined; return ( -