Compare commits
5 Commits
feat/v2-ph
...
4f9a6f64e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f9a6f64e4 | ||
|
|
12fa954a05 | ||
| 7181a61cec | |||
|
|
cdf3b5c045 | ||
| f3c50f71ef |
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import type { LabdConfig } from "./config.js";
|
import type { LabdConfig } from "./config.js";
|
||||||
import { logger } from "./services/logger.js";
|
import { logger } from "./services/logger.js";
|
||||||
import { registerHealthRoutes } from "./routes/health.js";
|
import { registerHealthRoutes } from "./routes/health.js";
|
||||||
@@ -9,8 +10,16 @@ import { registerServerRoutes } from "./routes/servers.js";
|
|||||||
import { registerAuthRoutes } from "./routes/auth.js";
|
import { registerAuthRoutes } from "./routes/auth.js";
|
||||||
import { registerAgentRoutes } from "./routes/agents.js";
|
import { registerAgentRoutes } from "./routes/agents.js";
|
||||||
import { registerBastionRoutes } from "./routes/bastions.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 { setupRateLimiting } from "./middleware/rate-limit.js";
|
||||||
|
import { createBearerAuthMiddleware } from "./middleware/bearer-auth.js";
|
||||||
import { bastionRegistry } from "./services/bastion-registry.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";
|
import { isBastionMessage } from "@lab/shared";
|
||||||
|
|
||||||
export interface DbClient {
|
export interface DbClient {
|
||||||
@@ -37,6 +46,7 @@ export interface DbClient {
|
|||||||
|
|
||||||
export async function createApp(_config: LabdConfig, db: DbClient): Promise<{
|
export async function createApp(_config: LabdConfig, db: DbClient): Promise<{
|
||||||
app: ReturnType<typeof Fastify>;
|
app: ReturnType<typeof Fastify>;
|
||||||
|
auditService: AuditService;
|
||||||
}> {
|
}> {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: false, // We use winston instead
|
logger: false, // We use winston instead
|
||||||
@@ -48,13 +58,39 @@ export async function createApp(_config: LabdConfig, db: DbClient): Promise<{
|
|||||||
// Register WebSocket support
|
// Register WebSocket support
|
||||||
void app.register(websocket);
|
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);
|
registerHealthRoutes(app, db);
|
||||||
registerServerRoutes(app, db);
|
registerServerRoutes(app, db);
|
||||||
registerAuthRoutes(app, db);
|
registerAuthRoutes(app, db);
|
||||||
registerAgentRoutes(app);
|
registerAgentRoutes(app);
|
||||||
registerBastionRoutes(app, db);
|
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
|
// WebSocket handler for agent connections
|
||||||
app.register(async (fastify) => {
|
app.register(async (fastify) => {
|
||||||
fastify.get("/ws/agent", { websocket: true }, (socket, _request) => {
|
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}`);
|
logger.info(`HTTP: ${request.ip} ${request.method} ${request.url}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { app };
|
return { app, auditService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export class AuditService {
|
|||||||
return `corr_${randomBytes(8).toString("hex")}`;
|
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<void> {
|
||||||
|
await this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
private async flush(): Promise<void> {
|
private async flush(): Promise<void> {
|
||||||
if (this.batch.length === 0) return;
|
if (this.batch.length === 0) return;
|
||||||
|
|
||||||
|
|||||||
425
bastion/src/labd/tests/v2-smoke.test.ts
Normal file
425
bastion/src/labd/tests/v2-smoke.test.ts
Normal file
@@ -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<string, UserRow>;
|
||||||
|
sessions: Map<string, SessionRow>;
|
||||||
|
groupMembers: Array<{ userId: string; group: { name: string } }>;
|
||||||
|
rbacDefs: RbacDefRow[];
|
||||||
|
auditEvents: AuditEventRow[];
|
||||||
|
resources: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<UserRow, "id"> }) => {
|
||||||
|
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<Omit<AuditEventRow, "id" | "timestamp">> }) => {
|
||||||
|
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<string, unknown>; 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<ReturnType<typeof buildApp>>["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<ReturnType<typeof buildApp>>["app"];
|
||||||
|
|
||||||
|
async function seedSession(role: string): Promise<string> {
|
||||||
|
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<ReturnType<typeof buildApp>>["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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import type { OperationContext, OperationResult, OperationGroup } from "../types.js";
|
import type { OperationContext, OperationResult, OperationGroup } from "../types.js";
|
||||||
import { runSequential } from "../utils.js";
|
import { runSequential } from "../utils.js";
|
||||||
import { installCilium } from "../operations/cilium.js";
|
import { installCilium } from "../operations/cilium.js";
|
||||||
|
import { installMultus } from "../operations/multus.js";
|
||||||
|
import { installVlanSetup } from "../operations/vlan-setup.js";
|
||||||
import { fixCoreDnsUpstream } from "../operations/dns-fix.js";
|
import { fixCoreDnsUpstream } from "../operations/dns-fix.js";
|
||||||
import { applyDefaultNetworkPolicies } from "../operations/network-policy.js";
|
import { applyDefaultNetworkPolicies } from "../operations/network-policy.js";
|
||||||
|
|
||||||
@@ -11,6 +13,11 @@ export const networkingGroup: OperationGroup = {
|
|||||||
description: "Install Cilium CNI, fix DNS, apply network policies",
|
description: "Install Cilium CNI, fix DNS, apply network policies",
|
||||||
operations: [
|
operations: [
|
||||||
{ name: "Install Cilium CNI", fn: installCilium },
|
{ name: "Install Cilium CNI", fn: installCilium },
|
||||||
|
// Multus + vlan-setup: give pods a second interface on VLAN 10 (macvlan)
|
||||||
|
// for LAN device discovery (Matter/HomeKit mDNS). Must follow Cilium
|
||||||
|
// (needs cni.exclusive=false + bpf.vlanBypass={10} from installCilium).
|
||||||
|
{ name: "Install Multus CNI", fn: installMultus },
|
||||||
|
{ name: "Install vlan-setup (lan10 + CNI plugins)", fn: installVlanSetup },
|
||||||
{ name: "Fix CoreDNS upstream", fn: fixCoreDnsUpstream },
|
{ name: "Fix CoreDNS upstream", fn: fixCoreDnsUpstream },
|
||||||
{ name: "Apply network policies", fn: applyDefaultNetworkPolicies },
|
{ name: "Apply network policies", fn: applyDefaultNetworkPolicies },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -38,12 +38,20 @@ export const installCilium: Operation = async (ctx): Promise<OperationResult> =>
|
|||||||
// Install Cilium
|
// Install Cilium
|
||||||
// - No hardcoded devices: Cilium auto-detects per node (heterogeneous NICs like eno1 vs enP7s7)
|
// - No hardcoded devices: Cilium auto-detects per node (heterogeneous NICs like eno1 vs enP7s7)
|
||||||
// - k8sServiceHost/Port: k3s agents proxy the API on 127.0.0.1:6444 (not 6443)
|
// - k8sServiceHost/Port: k3s agents proxy the API on 127.0.0.1:6444 (not 6443)
|
||||||
|
// - cni.exclusive=false: required so Multus can install its CNI config alongside
|
||||||
|
// Cilium (Cilium otherwise deletes any non-Cilium CNI conf).
|
||||||
|
// - bpf.vlanBypass={10}: allow VLAN 10 (LoT) tagged traffic through the eBPF
|
||||||
|
// host VLAN filter, so pods on a macvlan/VLAN-10 interface receive multicast
|
||||||
|
// (Matter/mDNS ff02::fb + 224.0.0.251). Without this Cilium drops it
|
||||||
|
// ("VLAN traffic disallowed by VLAN filter", bpf_host.c).
|
||||||
const installResult = await ctx.ssh.exec(
|
const installResult = await ctx.ssh.exec(
|
||||||
`KUBECONFIG=/etc/rancher/k3s/k3s.yaml cilium install \
|
`KUBECONFIG=/etc/rancher/k3s/k3s.yaml cilium install \
|
||||||
--set kubeProxyReplacement=true \
|
--set kubeProxyReplacement=true \
|
||||||
--set ipam.mode=kubernetes \
|
--set ipam.mode=kubernetes \
|
||||||
--set k8sServiceHost=127.0.0.1 \
|
--set k8sServiceHost=127.0.0.1 \
|
||||||
--set k8sServicePort=6444`,
|
--set k8sServicePort=6444 \
|
||||||
|
--set cni.exclusive=false \
|
||||||
|
--set bpf.vlanBypass="{10}"`,
|
||||||
{ timeoutMs: 300_000 },
|
{ timeoutMs: 300_000 },
|
||||||
);
|
);
|
||||||
if (installResult.exitCode !== 0) {
|
if (installResult.exitCode !== 0) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export { writeAuditPolicy } from "./audit-policy.js";
|
|||||||
export { cleanupStaleCni } from "./cni-cleanup.js";
|
export { cleanupStaleCni } from "./cni-cleanup.js";
|
||||||
export { installK3sBinary } from "./k3s-install.js";
|
export { installK3sBinary } from "./k3s-install.js";
|
||||||
export { installCilium } from "./cilium.js";
|
export { installCilium } from "./cilium.js";
|
||||||
|
export { installMultus } from "./multus.js";
|
||||||
|
export { installVlanSetup } from "./vlan-setup.js";
|
||||||
export { fixCoreDnsUpstream } from "./dns-fix.js";
|
export { fixCoreDnsUpstream } from "./dns-fix.js";
|
||||||
export { configureLogRotation } from "./log-rotation.js";
|
export { configureLogRotation } from "./log-rotation.js";
|
||||||
export { configureJournaldLimits } from "./journald-limits.js";
|
export { configureJournaldLimits } from "./journald-limits.js";
|
||||||
|
|||||||
34
bastion/src/modules/modules/k3s/src/operations/multus.ts
Normal file
34
bastion/src/modules/modules/k3s/src/operations/multus.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Install Multus CNI (thick plugin) — the meta-CNI that lets pods attach an
|
||||||
|
// extra interface (macvlan on VLAN 10) alongside Cilium, via a
|
||||||
|
// NetworkAttachmentDefinition. Required for Home Assistant's LAN presence
|
||||||
|
// (Matter/HomeKit mDNS discovery). Cilium must be installed with
|
||||||
|
// cni.exclusive=false first (see cilium.ts) or it deletes Multus's CNI conf.
|
||||||
|
|
||||||
|
import type { Operation, OperationResult } from "../types.js";
|
||||||
|
import { sshOpts } from "../utils.js";
|
||||||
|
|
||||||
|
const MULTUS_VERSION = "v4.1.4";
|
||||||
|
const MULTUS_MANIFEST = `https://raw.githubusercontent.com/k8snetworkplumbingwg/multus-cni/${MULTUS_VERSION}/deployments/multus-daemonset-thick.yml`;
|
||||||
|
|
||||||
|
export const installMultus: Operation = async (ctx): Promise<OperationResult> => {
|
||||||
|
const K = "KUBECONFIG=/etc/rancher/k3s/k3s.yaml";
|
||||||
|
|
||||||
|
// Idempotent: skip if the Multus DaemonSet is already present.
|
||||||
|
const check = await ctx.ssh.exec(
|
||||||
|
`${K} kubectl -n kube-system get ds kube-multus-ds -o name 2>/dev/null`,
|
||||||
|
sshOpts(ctx),
|
||||||
|
);
|
||||||
|
if (check.exitCode === 0 && check.stdout.includes("kube-multus-ds")) {
|
||||||
|
return { success: true, changed: false, message: `Multus already installed (${MULTUS_VERSION})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = await ctx.ssh.exec(
|
||||||
|
`${K} kubectl apply -f ${MULTUS_MANIFEST}`,
|
||||||
|
{ ...sshOpts(ctx), timeoutMs: 120_000 },
|
||||||
|
);
|
||||||
|
if (apply.exitCode !== 0) {
|
||||||
|
return { success: false, changed: false, message: "Failed to apply Multus manifest", error: apply.stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, changed: true, message: `Installed Multus ${MULTUS_VERSION} (thick)` };
|
||||||
|
};
|
||||||
96
bastion/src/modules/modules/k3s/src/operations/vlan-setup.ts
Normal file
96
bastion/src/modules/modules/k3s/src/operations/vlan-setup.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// vlan-setup DaemonSet — the node-level half of the macvlan/VLAN-10 story.
|
||||||
|
// On every node it (1) installs the reference CNI plugins (macvlan/ipvlan/
|
||||||
|
// static/host-local/vlan/tuning) into /opt/cni/bin if missing, and (2) creates
|
||||||
|
// a `lan10` VLAN-10 sub-interface on the primary NIC that macvlan
|
||||||
|
// NetworkAttachmentDefinitions use as their master. Idempotent + self-healing
|
||||||
|
// (re-creates lan10 if it disappears). Paired with Multus (multus.ts) + Cilium
|
||||||
|
// bpf.vlanBypass={10} (cilium.ts).
|
||||||
|
|
||||||
|
import type { Operation, OperationResult } from "../types.js";
|
||||||
|
import { sshOpts } from "../utils.js";
|
||||||
|
|
||||||
|
const MANIFEST = `apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: macvlan-sys
|
||||||
|
labels:
|
||||||
|
pod-security.kubernetes.io/enforce: privileged
|
||||||
|
pod-security.kubernetes.io/audit: privileged
|
||||||
|
pod-security.kubernetes.io/warn: privileged
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: vlan-setup
|
||||||
|
namespace: macvlan-sys
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels: { app: vlan-setup }
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: { app: vlan-setup }
|
||||||
|
spec:
|
||||||
|
hostNetwork: true
|
||||||
|
tolerations:
|
||||||
|
- operator: Exists
|
||||||
|
containers:
|
||||||
|
- name: vlan
|
||||||
|
image: nicolaka/netshoot
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -x
|
||||||
|
# install reference CNI plugins (macvlan/ipvlan/static/host-local) if missing
|
||||||
|
if [ ! -f /host/opt/cni/bin/macvlan ] || [ ! -f /host/opt/cni/bin/ipvlan ]; then
|
||||||
|
case "$(uname -m)" in x86_64) A=amd64;; aarch64) A=arm64;; *) A=amd64;; esac
|
||||||
|
curl -sSL "https://github.com/containernetworking/plugins/releases/download/v1.5.1/cni-plugins-linux-$A-v1.5.1.tgz" -o /tmp/cni.tgz
|
||||||
|
tar -xzf /tmp/cni.tgz -C /host/opt/cni/bin ./macvlan ./ipvlan ./static ./host-local ./vlan ./tuning
|
||||||
|
fi
|
||||||
|
# detect the primary NIC (default route dev, else the one holding 192.168.8.x)
|
||||||
|
NIC="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
|
||||||
|
[ -z "$NIC" ] && NIC="$(ip -o -4 addr show 2>/dev/null | awk '/192\\.168\\.8\\./{print $2; exit}')"
|
||||||
|
echo "primary NIC = $NIC"
|
||||||
|
while true; do
|
||||||
|
if [ -n "$NIC" ]; then
|
||||||
|
ip link show lan10 >/dev/null 2>&1 || ip link add link "$NIC" name lan10 type vlan id 10
|
||||||
|
ip link set lan10 up
|
||||||
|
# NIC-driver workarounds for VLAN multicast RX
|
||||||
|
ip link set "$NIC" allmulticast on 2>/dev/null
|
||||||
|
ethtool -K "$NIC" rxvlan off rx-vlan-filter off 2>/dev/null
|
||||||
|
fi
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
volumeMounts:
|
||||||
|
- name: cnibin
|
||||||
|
mountPath: /host/opt/cni/bin
|
||||||
|
volumes:
|
||||||
|
- name: cnibin
|
||||||
|
hostPath:
|
||||||
|
path: /opt/cni/bin
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const installVlanSetup: Operation = async (ctx): Promise<OperationResult> => {
|
||||||
|
const K = "KUBECONFIG=/etc/rancher/k3s/k3s.yaml";
|
||||||
|
|
||||||
|
const check = await ctx.ssh.exec(
|
||||||
|
`${K} kubectl -n macvlan-sys get ds vlan-setup -o name 2>/dev/null`,
|
||||||
|
sshOpts(ctx),
|
||||||
|
);
|
||||||
|
if (check.exitCode === 0 && check.stdout.includes("vlan-setup")) {
|
||||||
|
return { success: true, changed: false, message: "vlan-setup DaemonSet already installed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64 = Buffer.from(MANIFEST).toString("base64");
|
||||||
|
const apply = await ctx.ssh.exec(
|
||||||
|
`echo ${b64} | base64 -d | ${K} kubectl apply -f -`,
|
||||||
|
{ ...sshOpts(ctx), timeoutMs: 60_000 },
|
||||||
|
);
|
||||||
|
if (apply.exitCode !== 0) {
|
||||||
|
return { success: false, changed: false, message: "Failed to apply vlan-setup DaemonSet", error: apply.stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, changed: true, message: "Installed vlan-setup DaemonSet (lan10 + CNI plugins)" };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user