diff --git a/bastion/src/labd/src/server.ts b/bastion/src/labd/src/server.ts index 9da02bb..ec55573 100644 --- a/bastion/src/labd/src/server.ts +++ b/bastion/src/labd/src/server.ts @@ -2,6 +2,7 @@ import Fastify from "fastify"; import websocket from "@fastify/websocket"; +import type { PrismaClient } from "@prisma/client"; import type { LabdConfig } from "./config.js"; import { logger } from "./services/logger.js"; import { registerHealthRoutes } from "./routes/health.js"; @@ -9,8 +10,16 @@ import { registerServerRoutes } from "./routes/servers.js"; import { registerAuthRoutes } from "./routes/auth.js"; import { registerAgentRoutes } from "./routes/agents.js"; import { registerBastionRoutes } from "./routes/bastions.js"; +import { registerV2AuthRoutes } from "./routes/v2-auth.js"; +import { registerEnvironmentRoutes } from "./routes/environments.js"; +import { registerResourceRoutes } from "./routes/resources.js"; import { setupRateLimiting } from "./middleware/rate-limit.js"; +import { createBearerAuthMiddleware } from "./middleware/bearer-auth.js"; import { bastionRegistry } from "./services/bastion-registry.js"; +import { AuthService } from "./services/auth.js"; +import { RbacService } from "./services/rbac.js"; +import { ResourceStore } from "./services/resource-store.js"; +import { AuditService } from "./services/audit.js"; import { isBastionMessage } from "@lab/shared"; export interface DbClient { @@ -37,6 +46,7 @@ export interface DbClient { export async function createApp(_config: LabdConfig, db: DbClient): Promise<{ app: ReturnType; + auditService: AuditService; }> { const app = Fastify({ logger: false, // We use winston instead @@ -48,13 +58,39 @@ export async function createApp(_config: LabdConfig, db: DbClient): Promise<{ // Register WebSocket support void app.register(websocket); - // Register route handlers + // v2 services. The structural DbClient is a subset of the real PrismaClient; + // at runtime db IS the PrismaClient instance, so the cast is safe. Tests that + // exercise v2 routes provide a PrismaClient-shaped mock (see auth-bootstrap, + // rbac-deny, audit-correlation tests). + const prisma = db as unknown as PrismaClient; + const authService = new AuthService(prisma); + const rbacService = new RbacService(prisma); + const resourceStore = new ResourceStore(prisma); + const auditService = new AuditService(prisma); + auditService.start(); + + // Register v1 (legacy) route handlers registerHealthRoutes(app, db); registerServerRoutes(app, db); registerAuthRoutes(app, db); registerAgentRoutes(app); registerBastionRoutes(app, db); + // v2 routes live in a scope with bearer-auth as preHandler. Public paths + // (login, /health, websockets) are skipped inside the middleware itself. + // v1 routes above are unaffected — they're registered on the root scope. + await app.register(async (scope) => { + scope.addHook("preHandler", createBearerAuthMiddleware(authService)); + registerV2AuthRoutes(scope, authService, auditService); + registerEnvironmentRoutes(scope, prisma, rbacService, auditService); + registerResourceRoutes(scope, resourceStore, rbacService, auditService); + }); + + // Flush pending audit events on shutdown so we never lose the last batch. + app.addHook("onClose", async () => { + auditService.stop(); + }); + // WebSocket handler for agent connections app.register(async (fastify) => { fastify.get("/ws/agent", { websocket: true }, (socket, _request) => { @@ -267,5 +303,5 @@ export async function createApp(_config: LabdConfig, db: DbClient): Promise<{ logger.info(`HTTP: ${request.ip} ${request.method} ${request.url}`); }); - return { app }; + return { app, auditService }; } diff --git a/bastion/src/labd/src/services/audit.ts b/bastion/src/labd/src/services/audit.ts index d2ab3ad..096cac9 100644 --- a/bastion/src/labd/src/services/audit.ts +++ b/bastion/src/labd/src/services/audit.ts @@ -66,6 +66,12 @@ export class AuditService { return `corr_${randomBytes(8).toString("hex")}`; } + /** Flush all pending events synchronously. Tests await this; production + * relies on the interval timer or stop() during shutdown. */ + async flushPending(): Promise { + await this.flush(); + } + private async flush(): Promise { if (this.batch.length === 0) return; diff --git a/bastion/src/labd/tests/v2-smoke.test.ts b/bastion/src/labd/tests/v2-smoke.test.ts new file mode 100644 index 0000000..4d0ae96 --- /dev/null +++ b/bastion/src/labd/tests/v2-smoke.test.ts @@ -0,0 +1,425 @@ +// End-to-end smoke tests for the v2.0 Phase 1 surface (auth bootstrap, RBAC, +// audit correlation). These exercise the wiring in createApp(): the bearer +// auth middleware, the v2 routes scope, and the AuditService lifecycle. +// +// We don't spin up CockroachDB. Instead we provide a PrismaClient-shaped +// in-memory mock that matches the surface the v2 services actually touch. +// Tests follow the project convention of using mock DBs + Fastify.inject(). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import bcrypt from "bcryptjs"; +import { createApp } from "../src/server.js"; +import type { DbClient } from "../src/server.js"; +import type { AuditService } from "../src/services/audit.js"; + +const TEST_CONFIG = { port: 0, host: "127.0.0.1", databaseUrl: "", caDir: "/tmp", logLevel: "silent" }; + +interface UserRow { id: string; email: string; password: string; role: string; name: string | null; } +interface SessionRow { id: string; userId: string; token: string; expiresAt: Date; user?: UserRow; } +interface RbacDefRow { id: string; name: string; subjects: unknown; roleBindings: unknown; } +interface AuditEventRow { + id: string; + eventKind: string; + source: string; + verified: boolean; + userId: string | null; + userName: string | null; + environmentName: string | null; + resourceKind: string | null; + correlationId: string | null; + parentEventId: string | null; + details: unknown; + result: string; + error: string | null; + durationMs: number | null; + timestamp: Date; +} + +interface Stores { + users: Map; + sessions: Map; + groupMembers: Array<{ userId: string; group: { name: string } }>; + rbacDefs: RbacDefRow[]; + auditEvents: AuditEventRow[]; + resources: Array>; +} + +function makeStores(): Stores { + return { + users: new Map(), + sessions: new Map(), + groupMembers: [], + rbacDefs: [], + auditEvents: [], + resources: [], + }; +} + +function makeMockDb(s: Stores): DbClient { + let idCounter = 0; + const newId = (prefix: string): string => `${prefix}-${++idCounter}`; + + return { + $queryRaw: vi.fn(async () => [{ "?column?": 1 }]), + server: { findMany: vi.fn(async () => []), findUnique: vi.fn(), upsert: vi.fn() }, + joinToken: { findUnique: vi.fn(), findMany: vi.fn(), create: vi.fn(), update: vi.fn() }, + bastion: { upsert: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), update: vi.fn() }, + + user: { + count: vi.fn(async () => s.users.size), + findUnique: vi.fn(async (args: { where: { email?: string; id?: string } }) => { + if (args.where.email) { + for (const u of s.users.values()) if (u.email === args.where.email) return u; + } + if (args.where.id) return s.users.get(args.where.id) ?? null; + return null; + }), + create: vi.fn(async (args: { data: Omit }) => { + const id = newId("user"); + const row: UserRow = { id, ...args.data }; + s.users.set(id, row); + return row; + }), + }, + session: { + findUnique: vi.fn(async (args: { where: { token?: string; id?: string }; include?: { user?: boolean } }) => { + let session: SessionRow | undefined; + if (args.where.token) { + for (const sess of s.sessions.values()) if (sess.token === args.where.token) { session = sess; break; } + } else if (args.where.id) { + session = s.sessions.get(args.where.id); + } + if (!session) return null; + if (args.include?.user) { + return { ...session, user: s.users.get(session.userId)! }; + } + return session; + }), + create: vi.fn(async (args: { data: { userId: string; token: string; expiresAt: Date } }) => { + const id = newId("sess"); + const row: SessionRow = { id, ...args.data }; + s.sessions.set(id, row); + return row; + }), + delete: vi.fn(async (args: { where: { id: string } }) => { + s.sessions.delete(args.where.id); + return null; + }), + }, + groupMember: { + findMany: vi.fn(async (args: { where: { userId: string } }) => + s.groupMembers.filter((m) => m.userId === args.where.userId), + ), + }, + rbacDefinition: { + findMany: vi.fn(async () => s.rbacDefs), + }, + auditEvent: { + createMany: vi.fn(async (args: { data: Array> }) => { + const ts = new Date(); + for (const e of args.data) { + s.auditEvents.push({ id: newId("evt"), timestamp: ts, ...e }); + } + return { count: args.data.length }; + }), + findMany: vi.fn(async (args: { where?: Record; orderBy?: unknown; take?: number }) => { + const where = args.where ?? {}; + const filtered = s.auditEvents.filter((e) => { + if (where["eventKind"] && e.eventKind !== where["eventKind"]) return false; + if (where["correlationId"] && e.correlationId !== where["correlationId"]) return false; + if (where["environmentName"] && e.environmentName !== where["environmentName"]) return false; + return true; + }); + return filtered.slice(0, args.take ?? 100); + }), + }, + resource: { + findMany: vi.fn(async () => s.resources), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + environment: { findMany: vi.fn(async () => []), findUnique: vi.fn(), create: vi.fn() }, + account: { findMany: vi.fn(async () => []), findUnique: vi.fn(), create: vi.fn() }, + binding: { findMany: vi.fn(async () => []), create: vi.fn() }, + } as unknown as DbClient; +} + +async function buildApp(s: Stores) { + const db = makeMockDb(s); + const result = await createApp(TEST_CONFIG, db); + await result.app.ready(); + return result; +} + +describe("v2 auth: bootstrap flow", () => { + let stores: Stores; + let app: Awaited>["app"]; + let auditService: AuditService; + + beforeEach(async () => { + stores = makeStores(); + const built = await buildApp(stores); + app = built.app; + auditService = built.auditService; + }); + + afterEach(async () => { + await app.close(); // triggers auditService.stop() + }); + + it("first login with no users seeds the admin and returns a session token", async () => { + expect(stores.users.size).toBe(0); + + const resp = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, + }); + + expect(resp.statusCode).toBe(200); + const body = resp.json(); + expect(body.isBootstrap).toBe(true); + expect(body.token).toMatch(/^[a-f0-9]{64}$/); + expect(typeof body.expiresAt).toBe("string"); + + expect(stores.users.size).toBe(1); + const created = [...stores.users.values()][0]!; + expect(created.email).toBe("admin@itaz.eu"); + expect(created.role).toBe("ADMIN"); + // Password is hashed, not stored plaintext. + expect(created.password).not.toBe("s3cret-pw"); + expect(await bcrypt.compare("s3cret-pw", created.password)).toBe(true); + + // Bootstrap emits an audit event. + await auditService.flushPending(); + const bootstrapEvents = stores.auditEvents.filter((e) => e.eventKind === "auth_bootstrap"); + expect(bootstrapEvents).toHaveLength(1); + expect(bootstrapEvents[0]!.result).toBe("success"); + expect(bootstrapEvents[0]!.userName).toBe("admin@itaz.eu"); + }); + + it("returns 400 for missing credentials", async () => { + const resp = await app.inject({ method: "POST", url: "/api/auth/login", payload: {} }); + expect(resp.statusCode).toBe(400); + }); + + it("second login uses normal flow (no isBootstrap)", async () => { + // Bootstrap once + await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, + }); + expect(stores.users.size).toBe(1); + + // Login again + const resp = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, + }); + + expect(resp.statusCode).toBe(200); + expect(resp.json().isBootstrap).toBe(false); + expect(stores.users.size).toBe(1); // no new user + }); + + it("rejects wrong password with 401", async () => { + // Seed admin + await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, + }); + + const resp = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { email: "admin@itaz.eu", password: "wrong" }, + }); + expect(resp.statusCode).toBe(401); + + // Failed login is also audited. + await auditService.flushPending(); + const fails = stores.auditEvents.filter((e) => e.eventKind === "auth_login" && e.result === "failure"); + expect(fails).toHaveLength(1); + }); +}); + +describe("v2 RBAC: env-scoped denial", () => { + let stores: Stores; + let app: Awaited>["app"]; + + async function seedSession(role: string): Promise { + stores.users.set("u-1", { + id: "u-1", + email: `${role.toLowerCase()}@itaz.eu`, + password: "x", + role, + name: null, + }); + const token = "test-token-" + role; + stores.sessions.set("s-1", { + id: "s-1", + userId: "u-1", + token, + expiresAt: new Date(Date.now() + 86_400_000), + }); + return token; + } + + beforeEach(async () => { + stores = makeStores(); + app = (await buildApp(stores)).app; + }); + + afterEach(async () => { + await app.close(); + }); + + it("non-admin user with no role bindings gets 403 on /api/resources", async () => { + const token = await seedSession("EDITOR"); // not admin, no bindings + + const resp = await app.inject({ + method: "GET", + url: "/api/resources", + headers: { authorization: `Bearer ${token}` }, + }); + + expect(resp.statusCode).toBe(403); + expect(resp.json().error).toMatch(/no matching role binding/); + }); + + it("missing/empty bearer token gets 401 (auth, not RBAC)", async () => { + const r1 = await app.inject({ method: "GET", url: "/api/resources" }); + expect(r1.statusCode).toBe(401); + + const r2 = await app.inject({ + method: "GET", + url: "/api/resources", + headers: { authorization: "Bearer " }, + }); + expect(r2.statusCode).toBe(401); + }); + + it("invalid bearer token gets 401", async () => { + const resp = await app.inject({ + method: "GET", + url: "/api/resources", + headers: { authorization: "Bearer not-a-real-token" }, + }); + expect(resp.statusCode).toBe(401); + }); + + it("admin role bypasses RBAC", async () => { + const token = await seedSession("ADMIN"); + + const resp = await app.inject({ + method: "GET", + url: "/api/resources", + headers: { authorization: `Bearer ${token}` }, + }); + + expect(resp.statusCode).toBe(200); + expect(resp.json()).toEqual([]); + }); + + it("user with binding for env A is denied for resources in env B", async () => { + const token = await seedSession("EDITOR"); + stores.groupMembers.push({ userId: "u-1", group: { name: "team-a" } }); + stores.rbacDefs.push({ + id: "rbac-1", + name: "team-a-edit-on-env-a", + subjects: [{ kind: "Group", name: "team-a" }], + roleBindings: [{ role: "edit", environment: "env-a" }], + }); + + // List in env-a → should pass RBAC (no env query so it's global view, but + // the binding scope is environment-specific → for global list the binding + // doesn't apply when an environment scope is set on the binding). + // Smoke test the targeted denial: trying to create in env-b is rejected. + const respB = await app.inject({ + method: "POST", + url: "/api/resources", + headers: { authorization: `Bearer ${token}` }, + payload: { kind: "database", name: "x", environmentId: "env-b", accountId: "acc-1" }, + }); + + expect(respB.statusCode).toBe(403); + expect(respB.json().error).toMatch(/no matching role binding/); + }); +}); + +describe("v2 audit: correlation chain visible via /api/events", () => { + let stores: Stores; + let app: Awaited>["app"]; + let auditService: AuditService; + + beforeEach(async () => { + stores = makeStores(); + const built = await buildApp(stores); + app = built.app; + auditService = built.auditService; + }); + + afterEach(async () => { + await app.close(); + }); + + it("emitted audit events are queryable by correlation id", async () => { + // Seed admin so /api/events is accessible (it sits behind bearer auth) + const loginResp = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { email: "admin@itaz.eu", password: "pw" }, + }); + const token = loginResp.json().token; + + // Force flush so the bootstrap event is in the DB + await auditService.flushPending(); + + expect(stores.auditEvents.length).toBeGreaterThan(0); + const bootstrap = stores.auditEvents.find((e) => e.eventKind === "auth_bootstrap")!; + expect(bootstrap.correlationId).toMatch(/^corr_[a-f0-9]{16}$/); + + // Query /api/events filtered by correlation id + const queryResp = await app.inject({ + method: "GET", + url: `/api/events?correlation=${bootstrap.correlationId}`, + headers: { authorization: `Bearer ${token}` }, + }); + + expect(queryResp.statusCode).toBe(200); + const events = queryResp.json() as Array<{ correlationId: string; eventKind: string }>; + expect(events.length).toBe(1); + expect(events[0]!.eventKind).toBe("auth_bootstrap"); + expect(events[0]!.correlationId).toBe(bootstrap.correlationId); + }); + + it("explicit parent/child correlation chain is preserved across emits", async () => { + const correlationId = auditService.createCorrelation(); + + auditService.emit({ + eventKind: "test_parent", + source: "test", + result: "success", + correlationId, + }); + auditService.emit({ + eventKind: "test_child", + source: "test", + result: "success", + correlationId, + parentEventId: "evt-1", + }); + + await auditService.flushPending(); + + const chain = stores.auditEvents.filter((e) => e.correlationId === correlationId); + expect(chain).toHaveLength(2); + expect(chain.map((e) => e.eventKind).sort()).toEqual(["test_child", "test_parent"]); + expect(chain.find((e) => e.eventKind === "test_child")!.parentEventId).toBe("evt-1"); + }); +});