From 480e119e44008d08a3e6badef8a08a4deba76856 Mon Sep 17 00:00:00 2001 From: Hardik Bhatia Date: Sat, 26 Sep 2026 21:06:30 +0530 Subject: [PATCH] feat(access): add individual accounts OIDC app roles and audit history --- .env.example | 7 ++ apps/control-plane/package.json | 4 +- apps/control-plane/src/access.ts | 51 ++++++++ apps/control-plane/src/app.ts | 102 ++++++++-------- apps/control-plane/src/auth.ts | 26 +--- apps/control-plane/src/config.ts | 2 + apps/control-plane/src/oidc.ts | 58 +++++++++ apps/control-plane/src/team.ts | 70 +++++++++++ apps/control-plane/test/oidc.test.ts | 52 ++++++++ apps/control-plane/test/team.test.ts | 38 ++++++ apps/dashboard/src/App.tsx | 17 ++- apps/dashboard/src/pages/LoginPage.tsx | 16 ++- apps/dashboard/src/pages/TeamPage.tsx | 31 +++++ docker-compose.yml | 5 + docs/control-plane.openapi.yaml | 161 +++++++++++++++++++++++++ docs/deployment.md | 36 ++++++ packages/cli/src/cli.ts | 2 +- packages/cli/src/input.ts | 2 +- packages/cli/test/contract.test.ts | 2 +- packages/contracts/src/index.ts | 8 +- packages/storage/src/index.ts | 33 +++-- pnpm-lock.yaml | 24 ++++ 22 files changed, 644 insertions(+), 103 deletions(-) create mode 100644 apps/control-plane/src/access.ts create mode 100644 apps/control-plane/src/oidc.ts create mode 100644 apps/control-plane/src/team.ts create mode 100644 apps/control-plane/test/oidc.test.ts create mode 100644 apps/control-plane/test/team.test.ts create mode 100644 apps/dashboard/src/pages/TeamPage.tsx diff --git a/.env.example b/.env.example index d6c13e7..b856bfe 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,10 @@ TYPESAFE_MODEL=jev-latest QUEUE_CONCURRENCY=16 QUEUE_MAX_DEPTH=1000 CLASSIFICATION_TIMEOUT_MS=8000 + +# Optional OIDC SSO (HTTPS issuer and exact callback required) +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI= +EVENT_RETENTION_DAYS=30 diff --git a/apps/control-plane/package.json b/apps/control-plane/package.json index 7a0de92..6fbe4c0 100644 --- a/apps/control-plane/package.json +++ b/apps/control-plane/package.json @@ -15,11 +15,13 @@ "@fastify/cookie": "^11.0.2", "@fastify/websocket": "^11.2.0", "@pyro/contracts": "workspace:*", + "@pyro/integrations": "workspace:*", "@pyro/storage": "workspace:*", "fastify": "^5.6.1", + "openid-client": "^6.8.8", "ws": "^8.18.3", "yaml": "^2.9.1", - "@pyro/integrations": "workspace:*" + "zod": "^4.6.5" }, "devDependencies": { "@types/ws": "^8.18.1", diff --git a/apps/control-plane/src/access.ts b/apps/control-plane/src/access.ts new file mode 100644 index 0000000..b94075a --- /dev/null +++ b/apps/control-plane/src/access.ts @@ -0,0 +1,51 @@ +import { randomUUID } from "node:crypto"; +import type { AuditEntry } from "./team.js"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { AppRecord, ClassificationEvent, UserRecord } from "@pyro/contracts"; +import type { Database } from "@pyro/storage"; +import { sessionUserId } from "./auth.js"; + +export function visibleUser({ passwordHash, ...user }: UserRecord) { return user; } +export const appScope = (user: UserRecord) => user.role === "admin" ? undefined : user.appIds ?? []; +export const canAccessApp = (user: UserRecord, id = "default") => user.role === "admin" || Boolean(user.appIds?.includes(id)); +export function visibleEvent(user: UserRecord, event: ClassificationEvent): ClassificationEvent { + if (user.role === "admin" || user.rawPreviews) return event; + const { inputPreview, metadata, appRulesSnapshot, ...safe } = event; + return safe; +} +export function accessGuard(database: Database) { + const users = database.document("users", () => []); + const sessions = database.document("sessions", () => []); + return async (request: FastifyRequest, reply: FastifyReply) => { + const id = await sessionUserId(sessions, request.cookies.pf_session); + const user = (await users.read()).find((u) => u.id === id && !u.disabled); + if (!user) return reply.code(401).send({ error: "Authentication required." }); + request.user = user; + const grant = async () => { + if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return; + await database.document("audit_log", () => []).update((rows) => [...rows, { id: randomUUID(), actorId: user.id, at: new Date().toISOString(), action: request.method, resource: request.url.split("?")[0]!, status: 0, revision: (request.body as { revision?: number })?.revision }]); + }; + if (user.role === "admin") return grant(); + const path = request.routeOptions.url ?? ""; + const method = request.method; + if (path.startsWith("/api/auth/")) return grant(); + if (method === "GET" && ["/api/overview", "/api/usage", "/api/activity", "/api/activity/:id", "/api/apps", "/api/profiles", "/api/profile-presets", "/api/reviews", "/api/reviews/:id", "/api/evaluations", "/api/evaluations/:id", "/api/datasets"].includes(path)) return grant(); + if (path.startsWith("/api/reviews/") && user.role === "reviewer") return grant(); + if (user.role === "operator") { + if (["/api/evaluations", "/api/evaluations/:id", "/api/datasets", "/api/reviews/:id"].includes(path)) return grant(); + if (path === "/api/keys" && method === "GET") return; + const body = request.body as { appId?: string } | undefined; + const params = request.params as { id?: string }; + let appId: string | undefined; + if (path === "/api/keys" && method === "POST") appId = body?.appId ?? "default"; + if (path === "/api/keys/:id") appId = (await database.document("api_keys", () => []).read()).find((k) => k.id === params.id)?.appId; + if (appId && canAccessApp(user, appId)) return grant(); + } + return reply.code(403).send({ error: "Your role does not permit this action." }); + }; +} + +export async function allowedProfiles(database: Database, user: UserRecord) { + const apps = (await database.document("apps", () => []).read()).filter((a) => canAccessApp(user, a.id)); + return (id: string) => user.role === "admin" || apps.some((a) => !a.allowedProfileIds.length || a.allowedProfileIds.includes(id)); +} diff --git a/apps/control-plane/src/app.ts b/apps/control-plane/src/app.ts index 26e6fcf..92fc5e2 100644 --- a/apps/control-plane/src/app.ts +++ b/apps/control-plane/src/app.ts @@ -18,10 +18,13 @@ import { type StoredSecret, type UserRecord, } from "@pyro/contracts"; -import { encryptText, openDatabase, PolicyStore, type PolicyRecord } from "@pyro/storage"; +import { encryptText, openDatabase, PolicyStore, type PolicyRecord, consumeQuota } from "@pyro/storage"; import { createSession, ensureAdmin, sessionUserId, sha256, verifyAdminPassword } from "./auth.js"; import type { ControlPlaneConfig } from "./config.js"; +import { accessGuard, appScope, canAccessApp, visibleEvent, visibleUser, allowedProfiles } from "./access.js"; +import { registerTeam, verifyPassword } from "./team.js"; +import { registerOidc } from "./oidc.js"; import { registerPolicyHistory } from "./policies.js"; import { registerIntegrations } from "./integrations.js"; import { exportProfileYaml, loadPresetProfiles, parseProfileYaml } from "./profile-files.js"; @@ -68,7 +71,6 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise("provider_secrets", () => ({})); const eventsStore = database.events; - const loginState = { failures: 0, windowStartedAt: 0, blockedUntil: 0 }; await ensureAdmin(usersStore); const storedApps = await appsStore.read(); @@ -98,13 +100,11 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise { - const userId = await sessionUserId(sessionsStore, request.cookies.pf_session); - if (!userId) return reply.code(401).send({ error: "Authentication required." }); - request.user = { id: userId }; - }; + const requireSession = accessGuard(database); app.decorateRequest("user", null); + registerTeam(app, database, requireSession, config.oidc?.issuer); + registerOidc(app, database, config); registerIntegrations(app, database, config.controlPlaneSecret, requireSession); app.get("/health", async () => { @@ -112,28 +112,16 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise("/api/auth/login", async (request, reply) => { - const now = Date.now(); - if (loginState.blockedUntil > now) { - reply.header("Retry-After", Math.max(1, Math.ceil((loginState.blockedUntil - now) / 1_000))); - return reply.code(429).send({ error: "Too many failed sign-in attempts. Try again later." }); - } - if (now - loginState.windowStartedAt > 15 * 60_000) { - loginState.failures = 0; - loginState.windowStartedAt = now; - } - const { password } = request.body ?? {}; - const user = (await usersStore.read()).find((item) => item.username === "admin"); - if (!user || typeof password !== "string" || !verifyAdminPassword(password, config.adminPassword)) { - loginState.windowStartedAt ||= now; - loginState.failures += 1; - if (loginState.failures >= 10) loginState.blockedUntil = now + 15 * 60_000; + app.post<{ Body: { username?: string; password?: string } }>("/api/auth/login", async (request, reply) => { + const { password, username = "admin" } = request.body ?? {}; + if (typeof username !== "string" || username.length > 100 || typeof password !== "string" || password.length > 1024) return reply.code(400).send({ error: "Invalid credentials." }); + const quota = await consumeQuota(database, [{ id: `login:${sha256(username)}`, limit: 10 }]); + if (!quota.allowed) return reply.code(429).send({ error: "Too many sign-in attempts. Try again in a minute." }); + const user = (await usersStore.read()).find((item) => item.username === username && !item.disabled); + if (!user || !(username === "admin" ? verifyAdminPassword(password, config.adminPassword) : await verifyPassword(password, user.passwordHash))) { await new Promise((resolve) => setTimeout(resolve, 300)); return reply.code(401).send({ error: "Invalid administrator password." }); } - loginState.failures = 0; - loginState.windowStartedAt = now; - loginState.blockedUntil = 0; await usersStore.update((users) => users.map((item) => item.id === user.id ? { ...item, lastLoginAt: new Date().toISOString() } : item)); @@ -145,7 +133,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise { @@ -157,15 +146,11 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise { const user = (await usersStore.read()).find((item) => item.id === request.user?.id); - return { user: user ? { - id: user.id, - username: user.username, - role: user.role ?? "admin", - } : null }; + return { user: user ? visibleUser(user) : null }; }); - app.get("/api/overview", { preHandler: requireSession }, async () => { - const aggregate = await eventsStore.overview(); + app.get("/api/overview", { preHandler: requireSession }, async (request) => { + const aggregate = await eventsStore.overview(undefined, appScope(request.user!)); let gateway: unknown = { status: "offline" }; try { const response = await fetch(`${config.gatewayInternalUrl}/v1/health`, { signal: AbortSignal.timeout(1_500) }); @@ -175,7 +160,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise visibleEvent(request.user!, event))); } if (query.format === "csv") { const result = await eventsStore.query(filters); @@ -229,13 +217,13 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise row.map(escape).join(",")).join("\n")); } const result = await eventsStore.query({ ...filters, limit, offset }); - return { events: result.events, total: result.total, hasMore: offset + limit < result.total, labelKeys: result.labelKeys }; + return { events: result.events.map((event) => visibleEvent(request.user!, event)), total: result.total, hasMore: offset + limit < result.total, labelKeys: result.labelKeys }; }); app.get<{ Params: { id: string } }>("/api/activity/:id", { preHandler: requireSession }, async (request, reply) => { const event = await eventsStore.findById(request.params.id); - if (!event) return reply.code(404).send({ error: "Trace not found." }); - return { event }; + if (!event || !canAccessApp(request.user!, event.appId)) return reply.code(404).send({ error: "Trace not found." }); + return { event: visibleEvent(request.user!, event) }; }); app.post("/api/classify", { preHandler: requireSession }, async (request, reply) => { @@ -286,7 +274,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise ({ profiles: await profilesStore.read() })); + app.get("/api/profiles", { preHandler: requireSession }, async (request) => { const allows = await allowedProfiles(database, request.user!); return { profiles: (await profilesStore.read()).filter((p) => allows(p.id)) }; }); app.post("/api/profiles", { preHandler: requireSession }, async (request, reply) => { const now = new Date().toISOString(); @@ -336,10 +324,10 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise { + app.get("/api/apps", { preHandler: requireSession }, async (request) => { const keys = await keysStore.read(); return { - apps: (await appsStore.read()).map((record) => ({ + apps: (await appsStore.read()).filter((record) => canAccessApp(request.user!, record.id)).map((record) => ({ ...record, activeKeyCount: keys.filter((key) => (key.appId ?? "default") === record.id && !key.revokedAt).length, })), @@ -419,8 +407,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise ({ - keys: (await keysStore.read()).map(({ hash: _hash, ...key }) => key), + app.get("/api/keys", { preHandler: requireSession }, async (request) => ({ + keys: (await keysStore.read()).filter((key) => canAccessApp(request.user!, key.appId)).map(({ hash: _hash, ...key }) => key), })); app.post<{ Body: { name?: string; appId?: string; defaultProfileId?: string; allowedProfileIds?: string[]; rateLimitPerMinute?: number } }>("/api/keys", { preHandler: requireSession }, async (request, reply) => { @@ -512,14 +500,15 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise(); + const sockets = new Map(); app.get("/ws", { websocket: true }, async (socket: WebSocket, request) => { const userId = await sessionUserId(sessionsStore, request.cookies.pf_session); - if (!userId) { + const user = (await usersStore.read()).find((u) => u.id === userId && !u.disabled); + if (!user) { socket.close(1008, "Authentication required"); return; } - sockets.add(socket); + sockets.set(socket, request.cookies.pf_session!); socket.send(JSON.stringify({ type: "connected", data: { userId } })); socket.on("close", () => sockets.delete(socket)); }); @@ -531,18 +520,21 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise(); + const users = await usersStore.read(); + for (const [socket, token] of sockets) { + const userId = await sessionUserId(sessionsStore, token); + const user = users.find((u) => u.id === userId && !u.disabled); + if (!user) { socket.close(1008, "Session revoked or expired"); sockets.delete(socket); } + else connected.set(socket, user); + } while (true) { const unseen = await eventsStore.readAfter(eventCursor, 500); if (unseen.length === 0) break; for (const event of unseen) { const profile = profiles.find((item) => item.id === event.profileId); - const message = JSON.stringify({ - type: "decision", - data: event, - notify: profile?.notifyOn.includes(event.action) ?? event.action !== "allow", - }); - for (const socket of sockets) { - if (socket.readyState === socket.OPEN) socket.send(message); + for (const [socket, user] of connected) { + if (socket.readyState === socket.OPEN && canAccessApp(user, event.appId)) socket.send(JSON.stringify({ type: "decision", data: visibleEvent(user, event), notify: profile?.notifyOn.includes(event.action) ?? event.action !== "allow" })); } } const last = unseen.at(-1)!; @@ -567,6 +559,6 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise): Promise { - const current = await users.read(); - const existing = current.find((user) => user.username === "admin"); - if (existing) { - const admin: UserRecord = { - id: existing.id, - username: "admin", - role: "admin", - lastLoginAt: existing.lastLoginAt, - createdAt: existing.createdAt, - }; - await users.write(current.map((user) => user.id === existing.id ? admin : user)); - return admin; - } - const admin: UserRecord = { - id: randomUUID(), - username: "admin", - role: "admin", - createdAt: new Date().toISOString(), - }; - await users.write([...current, admin]); + let admin!: UserRecord; + await users.update((current) => { + const existing = current.find((user) => user.username === "admin"); + admin = existing ? { ...existing, role: "admin" } : { id: randomUUID(), username: "admin", role: "admin", createdAt: new Date().toISOString() }; + return existing ? current.map((u) => u.id === admin.id ? admin : u) : [...current, admin]; + }); return admin; } diff --git a/apps/control-plane/src/config.ts b/apps/control-plane/src/config.ts index 07e5600..f9b31ce 100644 --- a/apps/control-plane/src/config.ts +++ b/apps/control-plane/src/config.ts @@ -1,4 +1,5 @@ export interface ControlPlaneConfig { + oidc?: { issuer: string; clientId: string; clientSecret: string; redirectUri: string }; host: string; port: number; databaseUrl: string; @@ -21,6 +22,7 @@ function required(name: string, minimumLength = 1): string { export function loadConfig(): ControlPlaneConfig { return { + oidc: process.env.OIDC_ISSUER ? { issuer: required("OIDC_ISSUER"), clientId: required("OIDC_CLIENT_ID"), clientSecret: required("OIDC_CLIENT_SECRET"), redirectUri: required("OIDC_REDIRECT_URI") } : undefined, host: process.env.HOST ?? "0.0.0.0", port: Number.parseInt(process.env.PORT ?? "8081", 10), databaseUrl: required("DATABASE_URL"), diff --git a/apps/control-plane/src/oidc.ts b/apps/control-plane/src/oidc.ts new file mode 100644 index 0000000..028a4f0 --- /dev/null +++ b/apps/control-plane/src/oidc.ts @@ -0,0 +1,58 @@ +import * as oidc from "openid-client"; +import { randomBytes } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import type { Database } from "@pyro/storage"; +import { encryptText, decryptText } from "@pyro/storage"; +import type { SessionRecord, StoredSecret, UserRecord } from "@pyro/contracts"; +import type { ControlPlaneConfig } from "./config.js"; +import { createSession, sha256 } from "./auth.js"; + +export function registerOidc(app: FastifyInstance, database: Database, config: ControlPlaneConfig) { + const settings = config.oidc; + const attempts = database.document>("oidc_attempts", () => []); + let discovered: Promise | undefined; + const provider = () => { + if (!settings) throw new Error("OIDC is not configured."); + if (new URL(settings.issuer).protocol !== "https:" || new URL(settings.redirectUri).protocol !== "https:") throw new Error("OIDC issuer and callback must use HTTPS."); + return discovered ??= oidc.discovery(new URL(settings.issuer), settings.clientId, settings.clientSecret, undefined, { execute: [oidc.enableNonRepudiationChecks] }).catch((error) => { discovered = undefined; throw error; }); + }; + app.get("/api/auth/options", async () => ({ oidc: Boolean(settings) })); + app.get("/api/auth/oidc/start", async (_request, reply) => { + if (!settings) return reply.code(404).send({ error: "OIDC is not configured." }); + const client = await provider(); + const state = oidc.randomState(), nonce = oidc.randomNonce(), verifier = oidc.randomPKCECodeVerifier(); + const browser = randomBytes(32).toString("base64url"); + await attempts.update((rows) => [...rows.filter((r) => r.expires > Date.now()).slice(-999), { stateHash: sha256(state), browserHash: sha256(browser), expires: Date.now() + 5 * 60_000, secret: encryptText(JSON.stringify({ nonce, verifier }), config.controlPlaneSecret) }]); + reply.setCookie("pf_oidc", browser, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 300 }); + return reply.redirect(oidc.buildAuthorizationUrl(client, { redirect_uri: settings.redirectUri, scope: "openid profile", state, nonce, code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: "S256" }).href); + }); + app.get("/api/auth/oidc/callback", async (request, reply) => { + if (!settings) return reply.code(404).send({ error: "OIDC is not configured." }); + const callback = new URL(settings.redirectUri); + callback.search = new URL(request.url, callback.origin).search; + const state = callback.searchParams.get("state"); + const browser = request.cookies.pf_oidc; + if (!state || !browser) return reply.code(400).send({ error: "SSO session is missing or expired. Sign in again." }); + let match: { secret: StoredSecret } | undefined; + await attempts.update((rows) => rows.filter((row) => { + if (row.stateHash === sha256(state) && row.browserHash === sha256(browser) && row.expires > Date.now()) { match = row; return false; } + return row.expires > Date.now(); + })); + reply.clearCookie("pf_oidc", { path: "/" }); + if (!match) return reply.code(400).send({ error: "Invalid or already-used SSO state." }); + try { + const { nonce, verifier } = JSON.parse(decryptText(match.secret, config.controlPlaneSecret)); + const tokens = await oidc.authorizationCodeGrant(await provider(), callback, { expectedState: state, expectedNonce: nonce, pkceCodeVerifier: verifier, idTokenExpected: true }); + const claims = tokens.claims(); + const users = database.document("users", () => []); + const user = (await users.read()).find((u) => !u.disabled && u.oidcIssuer === settings.issuer && u.oidcSubject === claims?.sub); + if (!user) return reply.code(403).send({ error: "An administrator must provision this SSO subject before sign-in." }); + const session = await createSession(database.document("sessions", () => []), user.id); + request.user = user; + reply.setCookie("pf_session", session.token, { path: "/", httpOnly: true, secure: true, sameSite: "strict", maxAge: 86_400 }); + return reply.redirect(new URL("/", settings.redirectUri).href); + } catch { + return reply.code(401).send({ error: "SSO verification failed. Sign in again or contact the administrator." }); + } + }); +} diff --git a/apps/control-plane/src/team.ts b/apps/control-plane/src/team.ts new file mode 100644 index 0000000..8d658ef --- /dev/null +++ b/apps/control-plane/src/team.ts @@ -0,0 +1,70 @@ +import { randomBytes, randomUUID, scrypt as scryptCallback, timingSafeEqual } from "node:crypto"; +import { promisify } from "node:util"; +import { z } from "zod"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { SessionRecord, UserRecord, AppRecord } from "@pyro/contracts"; +import type { Database } from "@pyro/storage"; +import { visibleUser } from "./access.js"; +const scrypt = promisify(scryptCallback); +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString("hex"); + return `${salt}:${(await scrypt(password, salt, 64) as Buffer).toString("hex")}`; +} +export async function verifyPassword(password: string, stored?: string): Promise { + if (!stored || password.length > 1024) return false; + const [salt, expected] = stored.split(":"); + const actual = await scrypt(password, salt!, 64) as Buffer; + const hash = Buffer.from(expected!, "hex"); + return hash.length === actual.length && timingSafeEqual(hash, actual); +} +const input = z.object({ username: z.string().trim().min(2).max(100), role: z.enum(["admin", "operator", "reviewer", "viewer"]), appIds: z.array(z.string()).max(100).default([]), rawPreviews: z.boolean().default(false), disabled: z.boolean().default(false), password: z.string().min(12).max(1024).optional(), oidcSubject: z.string().max(500).optional() }); +export interface AuditEntry { id: string; actorId: string; at: string; action: string; resource: string; status: number; revision?: number } +export function registerTeam(app: FastifyInstance, database: Database, requireSession: (r: FastifyRequest, p: FastifyReply) => Promise, oidcIssuer?: string) { + const users = database.document("users", () => []); + const sessions = database.document("sessions", () => []); + const audits = database.document("audit_log", () => []); + app.addHook("onSend", async (request, reply, payload) => { + if (request.user && !request.auditLogged && !["GET", "HEAD", "OPTIONS"].includes(request.method)) { + request.auditLogged = true; + // Only identifiers are audited. Never record bodies, passwords or cookies. + let revision: number | undefined; + try { revision = typeof payload === "string" ? JSON.parse(payload)?.profile?.revision : undefined; } catch {} + await audits.update((rows) => [...rows, { id: randomUUID(), actorId: request.user!.id, at: new Date().toISOString(), action: request.method, resource: request.url.split("?")[0]!, status: reply.statusCode, revision }]); + } + return payload; + }); + app.get("/api/team", { preHandler: requireSession }, async () => ({ users: (await users.read()).map(visibleUser), oidcConfigured: Boolean(oidcIssuer) })); + app.post("/api/team", { preHandler: requireSession }, async (request, reply) => { + const parsed = input.safeParse(request.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message }); + const body = parsed.data; + const appIds = new Set((await database.document("apps", () => []).read()).map((a) => a.id)); + if (body.appIds.some((id) => !appIds.has(id))) return reply.code(400).send({ error: "Application grant does not exist." }); + if (body.oidcSubject && !oidcIssuer) return reply.code(400).send({ error: "Configure OIDC before provisioning an SSO account." }); + const password = body.oidcSubject ? undefined : body.password ?? randomBytes(24).toString("base64url"); + const user: UserRecord = { id: randomUUID(), username: body.username, role: body.role, appIds: body.appIds, rawPreviews: body.rawPreviews, disabled: body.disabled, createdAt: new Date().toISOString(), passwordHash: password ? await hashPassword(password) : undefined, oidcIssuer: body.oidcSubject ? oidcIssuer : undefined, oidcSubject: body.oidcSubject }; + let conflict = false; + await users.update((rows) => { conflict = rows.some((u) => u.username === user.username || user.oidcSubject && u.oidcSubject === user.oidcSubject); return conflict ? rows : [...rows, user]; }); + if (conflict) return reply.code(409).send({ error: "Username or SSO subject already exists." }); + return reply.code(201).send({ user: visibleUser(user), password, warning: password ? "Store this password securely; it is shown only once." : undefined }); + }); + app.put<{ Params: { id: string } }>("/api/team/:id", { preHandler: requireSession }, async (request, reply) => { + const parsed = input.safeParse(request.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message }); + const prior = (await users.read()).find((u) => u.id === request.params.id); + if (!prior) return reply.code(404).send({ error: "User not found." }); + if (prior.username === "admin" || prior.id === request.user!.id) return reply.code(400).send({ error: "The bootstrap administrator and your own account cannot be changed here." }); + const body = parsed.data; + const apps = await database.document("apps", () => []).read(); + if (body.appIds.some((id) => !apps.some((a) => a.id === id))) return reply.code(400).send({ error: "Application grant does not exist." }); + if (body.username !== prior.username || body.oidcSubject !== prior.oidcSubject) return reply.code(400).send({ error: "Account identity is immutable. Provision a new account to change identity." }); + const passwordHash = body.password ? await hashPassword(body.password) : prior.passwordHash; + await users.update((rows) => rows.map((u) => u.id === prior.id ? { ...u, role: body.role, appIds: body.appIds, rawPreviews: body.rawPreviews, disabled: body.disabled, passwordHash } : u)); + await sessions.update((rows) => rows.filter((s) => s.userId !== prior.id)); + return { user: visibleUser((await users.read()).find((u) => u.id === prior.id)!) }; + }); + app.delete<{ Params: { id: string } }>("/api/team/:id/sessions", { preHandler: requireSession }, async (request) => { await sessions.update((rows) => rows.filter((s) => s.userId !== request.params.id)); return { ok: true }; }); + app.get("/api/audit", { preHandler: requireSession }, async () => ({ entries: (await audits.read()).slice(-1000).reverse() })); +} + +declare module "fastify" { interface FastifyRequest { auditLogged?: boolean } } diff --git a/apps/control-plane/test/oidc.test.ts b/apps/control-plane/test/oidc.test.ts new file mode 100644 index 0000000..f9e4263 --- /dev/null +++ b/apps/control-plane/test/oidc.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import { buildControlPlane } from "../src/app.js"; + +test("OIDC verifies signed identity, browser-bound state, nonce and provisioned subject", async (t) => { + const issuer = "https://idp.example.test"; + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = { ...publicKey.export({ format: "jwk" }), kid: "test", alg: "RS256", use: "sig" }; + let nonce = "", subject = "alice-subject", wrongNonce = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) return Response.json({ issuer, authorization_endpoint: issuer + "/authorize", token_endpoint: issuer + "/token", jwks_uri: issuer + "/jwks", response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], token_endpoint_auth_methods_supported: ["client_secret_post"], code_challenge_methods_supported: ["S256"] }); + if (url.endsWith("/jwks")) return Response.json({ keys: [jwk] }); + if (url.endsWith("/token")) { + const encode = (v: unknown) => Buffer.from(JSON.stringify(v)).toString("base64url"); + const data = `${encode({ alg: "RS256", kid: "test" })}.${encode({ iss: issuer, sub: subject, aud: "pyro-test", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60, nonce: wrongNonce ? "wrong" : nonce })}`; + return Response.json({ access_token: "test-access-token", token_type: "Bearer", id_token: `${data}.${sign("RSA-SHA256", Buffer.from(data), privateKey).toString("base64url")}` }); + } + throw new Error(`Unexpected provider URL: ${url}`); + }; + t.after(() => { globalThis.fetch = originalFetch; }); + const config = { host: "127.0.0.1", port: 0, databaseUrl: `memory://oidc-${randomUUID()}`, adminPassword: "correct-horse-battery-staple", controlPlaneSecret: "control-plane-test-secret", gatewayInternalUrl: "http://127.0.0.1:1", gatewayApiKey: "test-key", typesafeEndpoint: "https://api.typesafe.ai/v1/systemone", typesafeModel: "jev-latest", oidc: { issuer, clientId: "pyro-test", clientSecret: "test-client-secret", redirectUri: "https://pyro.example.test/control/api/auth/oidc/callback" } }; + const app = await buildControlPlane(config); t.after(() => app.close()); + const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { password: config.adminPassword } }); + const admin = login.headers["set-cookie"]!.split(";")[0]!; + const create = await app.inject({ method: "POST", url: "/api/team", headers: { cookie: admin }, payload: { username: "alice", role: "viewer", appIds: ["default"], oidcSubject: subject } }); + assert.equal(create.statusCode, 201); assert.equal(create.json().password, undefined); + const start = async () => { + const response = await app.inject({ method: "GET", url: "/api/auth/oidc/start" }); + assert.equal(response.statusCode, 302, response.body); + const location = new URL(response.headers.location!); nonce = location.searchParams.get("nonce")!; + assert.equal(location.searchParams.get("code_challenge_method"), "S256"); + return { cookie: response.headers["set-cookie"]!.split(";")[0]!, url: `/api/auth/oidc/callback?code=test&state=${location.searchParams.get("state")}` }; + }; + const first = await start(); + assert.equal((await app.inject({ method: "GET", url: first.url })).statusCode, 400); + const callback = await app.inject({ method: "GET", url: first.url, headers: { cookie: first.cookie } }); + assert.equal(callback.statusCode, 302, callback.body); + const cookies = callback.headers["set-cookie"] as unknown as string[]; + const sessionCookie = (Array.isArray(cookies) ? cookies : [cookies]).find((c) => c.startsWith("pf_session="))!.split(";")[0]!; + const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: sessionCookie } }); + assert.equal(me.json().user.username, "alice"); assert.equal(me.json().user.role, "viewer"); + assert.equal((await app.inject({ method: "GET", url: first.url, headers: { cookie: first.cookie } })).statusCode, 400); + wrongNonce = true; + const invalid = await start(); + assert.equal((await app.inject({ method: "GET", url: invalid.url, headers: { cookie: invalid.cookie } })).statusCode, 401); + wrongNonce = false; subject = "unprovisioned"; + const unknown = await start(); + assert.equal((await app.inject({ method: "GET", url: unknown.url, headers: { cookie: unknown.cookie } })).statusCode, 403); +}); diff --git a/apps/control-plane/test/team.test.ts b/apps/control-plane/test/team.test.ts new file mode 100644 index 0000000..aee904b --- /dev/null +++ b/apps/control-plane/test/team.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomUUID } from "node:crypto"; +import { createDefaultApp, type ClassificationEvent } from "@pyro/contracts"; +import { openDatabase } from "@pyro/storage"; +import { buildControlPlane } from "../src/app.js"; +const configuration = () => ({ host: "127.0.0.1", port: 0, databaseUrl: `memory://team-${randomUUID()}`, adminPassword: "correct-horse-battery-staple", controlPlaneSecret: "control-plane-test-secret", gatewayInternalUrl: "http://127.0.0.1:1", gatewayApiKey: "test-key", typesafeEndpoint: "https://api.typesafe.ai/v1/systemone", typesafeModel: "jev-latest" }); + +test("roles scope lists, aggregates, exports, previews and mutations; revocation stops sessions", async (t) => { + const config = configuration(), server = await buildControlPlane(config); t.after(() => server.close()); + const db = await openDatabase(config.databaseUrl); + await db.document("apps", () => []).write([{ ...createDefaultApp(), id: "alpha" }, { ...createDefaultApp(), id: "beta" }]); + const login = await server.inject({ method: "POST", url: "/api/auth/login", payload: { password: config.adminPassword } }); + const admin = login.headers["set-cookie"]!.split(";")[0]!; + const create = await server.inject({ method: "POST", url: "/api/team", headers: { cookie: admin }, payload: { username: "alice", role: "viewer", appIds: ["alpha"] } }); + assert.equal(create.statusCode, 201); + const { user, password } = create.json(); + assert.ok(password); assert.equal(user.passwordHash, undefined); + const session = await server.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password } }); + assert.equal(session.statusCode, 200); + const cookie = session.headers["set-cookie"]!.split(";")[0]!; + for (const appId of ["alpha", "beta"]) await db.events.append({ id: appId, appId, createdAt: new Date().toISOString(), profileId: "default", action: "review", verdict: "suspicious", risk: 0.5, confidence: 0.5, reason: "review", detectors: [], model: "local", provider: "local-rules", latencyMs: 0, queueMs: 0, inputHash: "hash", inputPreview: "sensitive-preview", metadata: { raw: "private" }, labels: { [appId]: "label" } } satisfies ClassificationEvent); + const read = (url: string) => server.inject({ method: "GET", url, headers: { cookie } }); + const activity = (await read("/api/activity")).json(); + assert.equal(activity.total, 1); assert.equal(activity.events[0].appId, "alpha"); assert.equal(activity.events[0].inputPreview, undefined); assert.equal(activity.events[0].metadata, undefined); assert.deepEqual(activity.labelKeys, ["alpha"]); + assert.equal((await read("/api/activity?search=sensitive-preview")).json().total, 0); + assert.equal((await read("/api/activity/beta")).statusCode, 404); + assert.equal((await read("/api/activity?appId=beta&format=json")).json().length, 0); + assert.equal((await read("/api/overview")).json().totals.requests, 1); + assert.equal((await read("/api/usage")).json().totals.requests, 1); + assert.equal((await read("/api/apps")).json().apps.length, 1); + for (const url of ["/api/settings/provider", "/api/team", "/api/audit", "/api/keys"]) assert.equal((await read(url)).statusCode, 403, url); + for (const url of ["/api/keys", "/api/profiles", "/api/classify", "/api/team"]) assert.equal((await server.inject({ method: "POST", url, headers: { cookie }, payload: {} })).statusCode, 403, url); + const log = (await server.inject({ method: "GET", url: "/api/audit", headers: { cookie: admin } })).json(); + assert.ok(log.entries.some((e: { resource: string }) => e.resource === "/api/team")); assert.ok(!JSON.stringify(log).includes(password)); + await server.inject({ method: "DELETE", url: `/api/team/${user.id}/sessions`, headers: { cookie: admin } }); + assert.equal((await read("/api/auth/me")).statusCode, 401); +}); diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 12e380b..574c82b 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Activity, BookOpenCheck, Boxes, ChartColumn, KeyRound, LogOut, Menu, Settings, SlidersHorizontal, TerminalSquare, X } from "lucide-react"; -import type { ClassificationEvent } from "@pyro/contracts"; +import type { ClassificationEvent, UserRecord } from "@pyro/contracts"; import { PyroMark } from "@/components/PyroMark"; import { PageErrorBoundary } from "@/components/PageErrorBoundary"; import { BranchedMenu, type BranchedMenuItem } from "@/components/react-bits/BranchedMenu"; @@ -16,11 +16,12 @@ import { PlaygroundPage } from "@/pages/PlaygroundPage"; import { PolicyHistoryPage } from "@/pages/PolicyHistoryPage"; import { ProfilesPage } from "@/pages/ProfilesPage"; import { IntegrationsPage } from "@/pages/IntegrationsPage"; +import { TeamPage } from "@/pages/TeamPage"; import { SettingsPage } from "@/pages/SettingsPage"; import { UsagePage } from "@/pages/UsagePage"; -type Page = "history" | "overview" | "apps" | "usage" | "playground" | "profiles" | "activity" | "keys" | "settings" | "integrations"; -interface User { id: string; username: string; role?: "admin" | "viewer" } +type Page = "team" | "history" | "overview" | "apps" | "usage" | "playground" | "profiles" | "activity" | "keys" | "settings" | "integrations"; +type User = UserRecord; const NAV: BranchedMenuItem[] = [ { label: "Observe", children: [ @@ -30,6 +31,7 @@ const NAV: BranchedMenuItem[] = [ { value: "playground", label: "Playground", icon: }, ] }, { label: "Configure", children: [ + { value: "team", label: "Team & audit", icon: }, { value: "apps", label: "Applications", icon: }, { value: "history", label: "Policy history", icon: }, { value: "profiles", label: "Protection Profiles", icon: }, @@ -96,6 +98,8 @@ export default function App() { if (checking) return
Starting Pyro…
; if (!user) return ; + const visiblePages = user.role === "admin" ? undefined : ["overview", "usage", "activity", "reviews", "evaluations", ...(user.role === "operator" ? ["keys"] : [])]; + const navigation = NAV.map((group) => ({ ...group, children: group.children?.filter((item) => !visiblePages || visiblePages.includes(String(item.value))) })).filter((group) => group.children?.length); const content = { overview: , apps: , @@ -106,8 +110,9 @@ export default function App() { activity: , keys: , settings: , + team: , integrations: , - }[page]; + }[visiblePages && !visiblePages.includes(page) ? "overview" : page]; return (
@@ -117,8 +122,8 @@ export default function App() {
-
navigate(value as Page)} />
-
+
navigate(value as Page)} />
+
{content}
diff --git a/apps/dashboard/src/pages/LoginPage.tsx b/apps/dashboard/src/pages/LoginPage.tsx index 5bb5d08..7f080e6 100644 --- a/apps/dashboard/src/pages/LoginPage.tsx +++ b/apps/dashboard/src/pages/LoginPage.tsx @@ -1,4 +1,5 @@ -import { useState, type FormEvent } from "react"; +import { useState, useEffect, type FormEvent } from "react"; +import type { UserRecord } from "@pyro/contracts"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -7,14 +8,17 @@ import { Label } from "@/components/ui/label"; import { api } from "@/lib/api"; import { PyroMark } from "@/components/PyroMark"; -export function LoginPage({ onLogin }: { onLogin: (user: { id: string; username: string }) => void }) { +export function LoginPage({ onLogin }: { onLogin: (user: UserRecord) => void }) { + const [username, setUsername] = useState("admin"); + const [oidc, setOidc] = useState(false); + useEffect(() => { void api.get<{ oidc: boolean }>("/api/auth/options").then((r) => setOidc(r.oidc)).catch(() => {}); }, []); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const submit = async (event: FormEvent) => { event.preventDefault(); setLoading(true); setError(undefined); try { - const result = await api.post<{ user: { id: string; username: string } }>("/api/auth/login", { password }); + const result = await api.post<{ user: UserRecord }>("/api/auth/login", { username, password }); onLogin(result.user); } catch (reason) { setError(reason instanceof Error ? reason.message : "Sign in failed."); } finally { setLoading(false); } @@ -25,9 +29,11 @@ export function LoginPage({ onLogin }: { onLogin: (user: { id: string; username:
Pyro

Sign in

-

Enter the administrator password configured for this instance.

+

Use your account credentials or your organization’s single sign-on.

+ {oidc && Continue with SSO}
-
setPassword(event.target.value)} />
+
setUsername(e.target.value)} />
+
setPassword(event.target.value)} />
{error &&
{error}
}
diff --git a/apps/dashboard/src/pages/TeamPage.tsx b/apps/dashboard/src/pages/TeamPage.tsx new file mode 100644 index 0000000..474ba5c --- /dev/null +++ b/apps/dashboard/src/pages/TeamPage.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import type { UserRecord, AppRecord } from "@pyro/contracts"; +import { api } from "@/lib/api"; +import { PageHeader } from "@/components/shared"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +const roles = ["admin", "operator", "reviewer", "viewer"] as const; +export function TeamPage() { + const [users, setUsers] = useState([]), [apps, setApps] = useState([]); + const [draft, setDraft] = useState>({ username: "", role: "viewer", appIds: [] }); + const [message, setMessage] = useState(""); const [password, setPassword] = useState(""); + const [oidc, setOidc] = useState(false); const [busy, setBusy] = useState(false); + const [audit, setAudit] = useState>([]); + const load = async () => { const [team, applications, log] = await Promise.all([api.get<{ users: UserRecord[]; oidcConfigured: boolean }>("/api/team"), api.get<{ apps: AppRecord[] }>("/api/apps"), api.get<{ entries: typeof audit }>("/api/audit")]); setUsers(team.users); setOidc(team.oidcConfigured); setApps(applications.apps); setAudit(log.entries); }; + useEffect(() => { void load().catch((e) => setMessage(e.message)); }, []); + const run = async (work: () => Promise) => { setBusy(true); setMessage(""); setPassword(""); try { await work(); await load(); } catch (e) { setMessage(e instanceof Error ? e.message : "Save failed."); } finally { setBusy(false); } }; + return
+ {message &&

{message}

} + {password &&

Copy this password now and share it securely. It will not be shown again.

{password}
} +

{draft.id ? "Edit account" : "Create account"}

Operators manage application keys, evaluations and reviews. Reviewers triage decisions. Viewers can inspect activity and aggregate usage. Only administrators change shared configuration.

+ {draft.role !== "admin" &&
Applications{apps.map((a) => )}
} + + {oidc && } + {draft.id && } +
+
+ {users.map((u) => )}
UserRoleApplicationsAccessSessions
{u.role}{u.role === "admin" ? "All" : u.appIds?.join(", ") || "None"}{u.disabled ? "Disabled" : u.oidcSubject ? "SSO" : "Password"}
+

Audit log (latest 1,000 entries)

{audit.map((a) => )}
TimeActorActionResourceResult
{new Date(a.at).toLocaleString()}{users.find((u) => u.id === a.actorId)?.username ?? a.actorId}{a.action}{a.resource}{a.revision ? ` → revision ${a.revision}` : ""}{a.status}
+
; +} diff --git a/docker-compose.yml b/docker-compose.yml index beb4e11..f89a1ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: DATABASE_URL: postgresql://pyro:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@postgres:5432/pyro CONTROL_PLANE_SECRET: ${CONTROL_PLANE_SECRET:?Set CONTROL_PLANE_SECRET in .env} TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-} + EVENT_RETENTION_DAYS: ${EVENT_RETENTION_DAYS:-30} QUEUE_CONCURRENCY: ${QUEUE_CONCURRENCY:-16} QUEUE_MAX_DEPTH: ${QUEUE_MAX_DEPTH:-1000} CLASSIFICATION_TIMEOUT_MS: ${CLASSIFICATION_TIMEOUT_MS:-8000} @@ -58,6 +59,10 @@ services: GATEWAY_API_KEY: ${GATEWAY_API_KEY:?Set GATEWAY_API_KEY in .env} CONTROL_PLANE_SECRET: ${CONTROL_PLANE_SECRET:?Set CONTROL_PLANE_SECRET in .env} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-} TYPESAFE_ENDPOINT: ${TYPESAFE_ENDPOINT:-https://api.typesafe.ai/v1/systemone} TYPESAFE_MODEL: ${TYPESAFE_MODEL:-jev-latest} diff --git a/docs/control-plane.openapi.yaml b/docs/control-plane.openapi.yaml index ff34241..7ccda18 100644 --- a/docs/control-plane.openapi.yaml +++ b/docs/control-plane.openapi.yaml @@ -504,6 +504,9 @@ paths: properties: password: type: string + username: + type: string + description: Individual username; defaults to admin. required: - password responses: @@ -1217,6 +1220,164 @@ paths: type: integer expectedRevision: type: integer + /api/team: + get: + operationId: team_list + x-cli-command: team list + summary: team list + responses: + "200": + description: Success + "403": + description: Insufficient permissions + post: + operationId: team_create + x-cli-command: team create + summary: team create + responses: + "200": + description: Success + "403": + description: Insufficient permissions + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - username + - role + properties: + username: + type: string + role: + type: string + enum: + - admin + - operator + - reviewer + - viewer + appIds: + type: array + items: + type: string + rawPreviews: + type: boolean + disabled: + type: boolean + password: + type: string + minLength: 12 + oidcSubject: + type: string + /api/team/{id}: + put: + operationId: team_update + x-cli-command: team update + summary: team update + responses: + "200": + description: Success + "403": + description: Insufficient permissions + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - username + - role + properties: + username: + type: string + role: + type: string + enum: + - admin + - operator + - reviewer + - viewer + appIds: + type: array + items: + type: string + rawPreviews: + type: boolean + disabled: + type: boolean + password: + type: string + minLength: 12 + oidcSubject: + type: string + /api/team/{id}/sessions: + delete: + operationId: team_revoke-sessions + x-cli-command: team revoke-sessions + summary: team revoke-sessions + responses: + "200": + description: Success + "403": + description: Insufficient permissions + parameters: + - name: id + in: path + required: true + schema: + type: string + /api/audit: + get: + operationId: audit_list + x-cli-command: audit list + summary: audit list + responses: + "200": + description: Success + "403": + description: Insufficient permissions + /api/auth/options: + get: + operationId: auth_options + x-cli-command: auth options + summary: auth options + responses: + "200": + description: Success + "403": + description: Insufficient permissions + security: [] + /api/auth/oidc/start: + get: + operationId: auth_sso-start + x-cli-command: auth sso-start + summary: auth sso-start + responses: + "200": + description: Success + "403": + description: Insufficient permissions + security: [] + /api/auth/oidc/callback: + get: + operationId: auth_sso-callback + x-cli-command: auth sso-callback + summary: auth sso-callback + responses: + "200": + description: Success + "403": + description: Insufficient permissions + security: [] components: securitySchemes: session: diff --git a/docs/deployment.md b/docs/deployment.md index c8f87fc..ecb0ff4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -28,3 +28,39 @@ shorter deadlines above, independent of `persistInputs`. Labels, caller metadata and opt-in previews are event data: never put credentials in them. Review and evaluation retention are documented with those features. Audit and policy history are retained until an administrator removes the deployment database. + +## Team access and SSO + +Keep the bootstrap `admin` password for recovery; use individual accounts for +normal work. Administrators can provision users in **Team & audit**, grant +applications, disable accounts, and revoke sessions. Passwords use salted scrypt. +Sessions expire after 24 hours; grants and disabled state are checked on each +request and every live-notification poll. Changing access revokes existing +sessions. Raw input previews and caller metadata need a separate grant. + +Admins manage global policies, provider secrets, integrations and team settings. +Operators manage keys, evaluations and reviews within their granted applications; +reviewers triage reviews; viewers inspect scoped activity and usage. Gateway API +keys are application-scoped service credentials, shown once and revocable. Rotate +by creating a replacement key, deploying it, then revoking the old key. + +For OIDC, set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and +`OIDC_REDIRECT_URI=https://YOUR_HOST/control/api/auth/oidc/callback`, then restart +the control plane. Both issuer and callback require HTTPS. Register that exact +callback with your provider. Provision each user's **exact issuer/subject pair** +in Team & audit; email addresses and domain membership never grant access. +No just-in-time accounts or implicit administrator grants are created. + +The [openid-client library](https://github.com/panva/openid-client) verifies the +code flow with PKCE, nonce, browser-bound single-use state and signed ID tokens. +Tests cover valid signed tokens, nonce mismatch, state replay and unprovisioned +subjects. A deployment still needs a smoke test against its actual identity +provider and reverse proxy before enabling SSO for a team. + +Audit records contain actors, timestamps, operation paths, status and policy +revision identifiers, without request bodies or secrets. Status 0 records an +intent persisted before a privileged mutation; the following HTTP status records +its outcome. An intent without an outcome means an interrupted operation that +requires reconciliation. The API provides no edit/delete operation for audit +history. PostgreSQL administrators remain trusted and can alter the database; +use external backups or log shipping when tamper resistance is required. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6500a0b..d721f1e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -37,7 +37,7 @@ async function buildBody(endpoint: Endpoint, options: Options, args: string[]): } if (kind === "login") { const password = options.passwordStdin ? (await readStdin()).replace(/\r?\n$/, "") : await passwordPrompt(); - return { body: JSON.stringify({ password }), contentType }; + return { body: JSON.stringify({ password, ...(options.username ? { username: options.username } : {}) }), contentType }; } if (contentType !== "application/json") throw new Error("Use --data with this content type; for a text file use --data @input.txt."); let body: Record = {}; diff --git a/packages/cli/src/input.ts b/packages/cli/src/input.ts index 9153954..4cee9fa 100644 --- a/packages/cli/src/input.ts +++ b/packages/cli/src/input.ts @@ -39,7 +39,7 @@ export async function parseValue(value: string, schema: Schema, flag: string): P export async function passwordPrompt(): Promise { if (!process.stdin.isTTY) throw new Error("Use --password-stdin to sign in from a pipe."); - process.stderr.write("Administrator password: "); + process.stderr.write("Password: "); process.stdin.setRawMode(true); process.stdin.resume(); return new Promise((resolve, reject) => { diff --git a/packages/cli/test/contract.test.ts b/packages/cli/test/contract.test.ts index d239fd9..233556e 100644 --- a/packages/cli/test/contract.test.ts +++ b/packages/cli/test/contract.test.ts @@ -30,7 +30,7 @@ test("bundled contracts match source OpenAPI and every operation has a unique re }); test("every gateway and dashboard route is documented (including webhooks and WebSockets)", async () => { - for (const [service, files] of Object.entries({ gateway: ["apps/gateway/src/app.ts"], control: ["apps/control-plane/src/app.ts", "apps/control-plane/src/integrations.ts", "apps/control-plane/src/policies.ts"] })) { + for (const [service, files] of Object.entries({ gateway: ["apps/gateway/src/app.ts"], control: ["apps/control-plane/src/app.ts", "apps/control-plane/src/integrations.ts", "apps/control-plane/src/policies.ts", "apps/control-plane/src/team.ts", "apps/control-plane/src/oidc.ts"] })) { const documented = new Set(endpoints().filter(endpoint => endpoint.service === service).map(endpoint => `${endpoint.method} ${endpoint.path}`)); for (const file of files) { const code = await readFile(new URL(`../../../${file}`, import.meta.url), "utf8"); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3736c19..733221c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -226,7 +226,13 @@ export interface ApiKeyRecord { export interface UserRecord { id: string; username: string; - role?: "admin" | "viewer"; + role?: "admin" | "operator" | "reviewer" | "viewer"; + appIds?: string[]; + rawPreviews?: boolean; + disabled?: boolean; + passwordHash?: string; + oidcIssuer?: string; + oidcSubject?: string; lastLoginAt?: string; createdAt: string; } diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index d44cb74..97c682d 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -15,7 +15,7 @@ export interface EventStore { query(options: EventQuery): Promise; latestCursor(): Promise; readAfter(cursor: EventCursor, limit?: number): Promise; - overview(now?: Date): Promise; + overview(now?: Date, appIds?: string[]): Promise; usage(options: EventUsageOptions): Promise; } @@ -31,12 +31,14 @@ export interface EventQuery { provider?: string; apiKey?: string; appId?: string; + appIds?: string[]; from?: string; to?: string; minimumRisk?: number; labelKey?: string; labelValue?: string; search?: string; + excludeRawSearch?: boolean; } export interface EventQueryResult { @@ -72,6 +74,7 @@ export interface EventUsageOptions { bucketMs: number; buckets: number; appId?: string; + appIds?: string[]; } export interface EventUsage { @@ -283,6 +286,7 @@ class PostgresEvents implements EventStore { if (options.profile) add((p) => `profile_id = ${p}`, options.profile); if (options.provider) add((p) => `provider = ${p}`, options.provider); if (options.apiKey) add((p) => `api_key_id = ${p}`, options.apiKey); + if (options.appIds) add((p) => `COALESCE(app_id, 'default') = ANY(${p}::text[])`, options.appIds); if (options.appId) add((p) => `COALESCE(app_id, 'default') = ${p}`, options.appId); if (options.from) add((p) => `created_at >= ${p}::timestamptz`, options.from); if (options.to) add((p) => `created_at <= ${p}::timestamptz`, options.to); @@ -298,7 +302,7 @@ class PostgresEvents implements EventStore { } } if (options.search) { - add((p) => `concat_ws(' ', id, payload->>'requestId', payload->>'inputHash', payload->>'apiKeyName', payload->>'inputPreview', payload->>'reason', labels::text) ILIKE ${p}`, `%${options.search}%`); + add((p) => `concat_ws(' ', id, payload->>'requestId', payload->>'inputHash', payload->>'apiKeyName', ${options.excludeRawSearch ? "NULL" : "payload->>'inputPreview'"}, payload->>'reason', labels::text) ILIKE ${p}`, `%${options.search}%`); } return { sql: clauses.length ? `WHERE ${clauses.join(" AND ")}` : "", values }; } @@ -348,7 +352,7 @@ class PostgresEvents implements EventStore { const [rows, count, labels] = await Promise.all([ this.pool.query<{ payload: ClassificationEvent }>(`SELECT payload FROM pyro_events ${where.sql} ORDER BY created_at DESC, id DESC${pageSql}`, pageValues), this.pool.query<{ total: number }>(`SELECT count(*)::int AS total FROM pyro_events ${where.sql}`, where.values), - this.pool.query<{ key: string }>("SELECT DISTINCT key FROM pyro_events CROSS JOIN LATERAL jsonb_object_keys(labels) AS key ORDER BY key"), + this.pool.query<{ key: string }>(`SELECT DISTINCT key FROM pyro_events CROSS JOIN LATERAL jsonb_object_keys(labels) AS key ${where.sql} ORDER BY key`, where.values), ]); return { events: rows.rows.map((row) => row.payload), @@ -372,14 +376,14 @@ class PostgresEvents implements EventStore { return result.rows.map((row) => row.payload); } - async overview(now = new Date()): Promise { + async overview(now = new Date(), appIds?: string[]): Promise { const bucketMs = 2 * 60 * 60_000; const toMs = Math.ceil(now.getTime() / bucketMs) * bucketMs; const fromMs = toMs - 12 * bucketMs; const from = new Date(fromMs).toISOString(); const to = new Date(toMs).toISOString(); - const values = [from, to]; - const windowSql = "WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz"; + const values: unknown[] = [from, to, appIds ?? null]; + const windowSql = "WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz AND ($3::text[] IS NULL OR COALESCE(app_id, 'default') = ANY($3))"; const [totalsResult, actionsResult, detectorsResult, timelineResult] = await Promise.all([ this.pool.query<{ requests: number; blocked: number; reviewed: number; failed: number; @@ -410,7 +414,7 @@ class PostgresEvents implements EventStore { GROUP BY detector->>'id' ORDER BY signals DESC`, values), this.pool.query<{ bucket: number; total: number; blocked: number; reviewed: number }>(` - SELECT floor((extract(epoch FROM created_at) * 1000 - $3) / $4)::int AS bucket, + SELECT floor((extract(epoch FROM created_at) * 1000 - $4) / $5)::int AS bucket, count(*)::int AS total, count(*) FILTER (WHERE action = 'block')::int AS blocked, count(*) FILTER (WHERE action = 'review')::int AS reviewed @@ -448,6 +452,10 @@ class PostgresEvents implements EventStore { const fromMs = new Date(options.from).getTime(); const whereValues: unknown[] = [options.from, options.to]; let whereSql = "WHERE created_at >= $1::timestamptz AND created_at <= $2::timestamptz"; + if (options.appIds) { + whereValues.push(options.appIds); + whereSql += ` AND COALESCE(app_id, 'default') = ANY($${whereValues.length}::text[])`; + } if (options.appId) { whereValues.push(options.appId); whereSql += ` AND COALESCE(app_id, 'default') = $${whereValues.length}`; @@ -627,6 +635,7 @@ function quantile(values: number[], fraction: number): number { } function eventMatches(event: ClassificationEvent, options: EventQuery): boolean { + if (options.appIds && !options.appIds.includes(event.appId ?? "default")) return false; if (options.action && event.action !== options.action) return false; if (options.verdict && event.verdict !== options.verdict) return false; if (options.status === "flagged" && !["suspicious", "unsafe"].includes(event.verdict)) return false; @@ -646,7 +655,7 @@ function eventMatches(event: ClassificationEvent, options: EventQuery): boolean if (!values.some((value) => value?.toLowerCase().includes(options.labelValue!.toLowerCase()))) return false; } if (options.search) { - const haystack = `${event.id} ${event.requestId ?? ""} ${event.inputHash} ${event.apiKeyName ?? ""} ${event.inputPreview ?? ""} ${event.reason} ${JSON.stringify(event.labels ?? {})}`.toLowerCase(); + const haystack = `${event.id} ${event.requestId ?? ""} ${event.inputHash} ${event.apiKeyName ?? ""} ${options.excludeRawSearch ? "" : event.inputPreview ?? ""} ${event.reason} ${JSON.stringify(event.labels ?? {})}`.toLowerCase(); if (!haystack.includes(options.search.toLowerCase())) return false; } return true; @@ -695,7 +704,7 @@ class MemoryDatabase implements Database { return { events: structuredClone(filtered.slice(offset, offset + limit)), total: filtered.length, - labelKeys: [...new Set(this.eventRows.flatMap((event) => Object.keys(event.labels ?? {})))].sort(), + labelKeys: [...new Set(filtered.flatMap((event) => Object.keys(event.labels ?? {})))].sort(), }; }, latestCursor: async () => { @@ -706,11 +715,11 @@ class MemoryDatabase implements Database { .filter((event) => event.createdAt > cursor.createdAt || (event.createdAt === cursor.createdAt && event.id > cursor.id)) .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)) .slice(0, limit)), - overview: async (now = new Date()) => { + overview: async (now = new Date(), appIds?: string[]) => { const bucketMs = 2 * 60 * 60_000; const toMs = Math.ceil(now.getTime() / bucketMs) * bucketMs; const fromMs = toMs - 12 * bucketMs; - const events = this.eventRows.filter((event) => { const time = new Date(event.createdAt).getTime(); return time >= fromMs && time < toMs; }); + const events = this.eventRows.filter((event) => { const time = new Date(event.createdAt).getTime(); return time >= fromMs && time < toMs && (!appIds || appIds.includes(event.appId ?? "default")); }); const detectors = new Map(); for (const event of events) for (const detector of event.detectors) { const item = detectors.get(detector.id) ?? { id: detector.id, name: detector.name, signals: 0, probabilities: [] }; @@ -738,7 +747,7 @@ class MemoryDatabase implements Database { }, usage: async (options) => { const fromMs = new Date(options.from).getTime(); const toMs = new Date(options.to).getTime(); - const events = this.eventRows.filter((event) => { const time = new Date(event.createdAt).getTime(); return time >= fromMs && time <= toMs && (!options.appId || (event.appId ?? "default") === options.appId); }); + const events = this.eventRows.filter((event) => { const time = new Date(event.createdAt).getTime(); return time >= fromMs && time <= toMs && (!options.appId || (event.appId ?? "default") === options.appId) && (!options.appIds || options.appIds.includes(event.appId ?? "default")); }); const group = (key: (event: ClassificationEvent) => string) => { const values = new Map(); for (const event of events) values.set(key(event), (values.get(key(event)) ?? 0) + 1); return [...values].map(([id, requests]) => ({ id, requests })).sort((left, right) => right.requests - left.requests); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f34fdbc..ac4a0fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,12 +39,18 @@ importers: fastify: specifier: ^5.6.1 version: 5.12.5 + openid-client: + specifier: ^6.8.8 + version: 6.8.8 ws: specifier: ^8.18.3 version: 8.21.3 yaml: specifier: ^2.9.1 version: 2.9.1 + zod: + specifier: ^4.6.5 + version: 4.6.5 devDependencies: '@types/ws': specifier: ^8.18.1 @@ -1503,6 +1509,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} @@ -1690,6 +1699,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + oauth4webapi@3.8.8: + resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -1697,6 +1709,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openid-client@6.8.8: + resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -3132,6 +3147,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 @@ -3266,12 +3283,19 @@ snapshots: nanoid@3.3.19: {} + oauth4webapi@3.8.8: {} + on-exit-leak-free@2.1.2: {} once@1.4.0: dependencies: wrappy: 1.0.2 + openid-client@6.8.8: + dependencies: + jose: 6.2.12 + oauth4webapi: 3.8.8 + pg-cloudflare@1.4.0: optional: true