Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion apps/control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions apps/control-plane/src/access.ts
Original file line number Diff line number Diff line change
@@ -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<UserRecord[]>("users", () => []);
const sessions = database.document<import("@pyro/contracts").SessionRecord[]>("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<AuditEntry[]>("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<import("@pyro/contracts").ApiKeyRecord[]>("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<AppRecord[]>("apps", () => []).read()).filter((a) => canAccessApp(user, a.id));
return (id: string) => user.role === "admin" || apps.some((a) => !a.allowedProfileIds.length || a.allowedProfileIds.includes(id));
}
102 changes: 47 additions & 55 deletions apps/control-plane/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,7 +71,6 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
}));
const secretsStore = database.document<SecretFile>("provider_secrets", () => ({}));
const eventsStore = database.events;
const loginState = { failures: 0, windowStartedAt: 0, blockedUntil: 0 };

await ensureAdmin(usersStore);
const storedApps = await appsStore.read();
Expand Down Expand Up @@ -98,42 +100,28 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
await settingsStore.write({ ...createDefaultProviderSettings(), endpoint: config.typesafeEndpoint, model: config.typesafeModel });
}

const requireSession = async (request: FastifyRequest, reply: FastifyReply) => {
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 () => {
await database.ping();
return { status: "ok", database: database.kind };
});

app.post<{ Body: { password?: string } }>("/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));
Expand All @@ -145,7 +133,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
secure: request.protocol === "https",
maxAge: 24 * 60 * 60,
});
return { user: { id: user.id, username: user.username } };
request.user = user;
return { user: visibleUser(user) };
});

app.post("/api/auth/logout", { preHandler: requireSession }, async (request, reply) => {
Expand All @@ -157,15 +146,11 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas

app.get("/api/auth/me", { preHandler: requireSession }, async (request) => {
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) });
Expand All @@ -175,7 +160,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
}
return {
...aggregate,
gateway,
gateway: request.user!.role === "admin" ? gateway : undefined,
};
});

Expand All @@ -190,6 +175,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
bucketMs: window.bucketMs,
buckets: window.buckets,
appId: query.appId,
appIds: appScope(request.user!),
});
return {
range: window.name,
Expand All @@ -204,6 +190,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
const limit = Math.min(500, Math.max(1, Number(query.limit ?? 100)));
const offset = Math.max(0, Number(query.offset ?? 0));
const filters = {
appIds: appScope(request.user!),
excludeRawSearch: request.user!.role !== "admin" && !request.user!.rawPreviews,
action: query.action, verdict: query.verdict, status: query.status, profile: query.profile, provider: query.provider,
apiKey: query.apiKey, appId: query.appId, from: query.from, to: query.to,
minimumRisk: query.minimumRisk === undefined ? undefined : Number(query.minimumRisk),
Expand All @@ -212,7 +200,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
if (query.format === "json") {
const result = await eventsStore.query(filters);
reply.header("Content-Disposition", `attachment; filename="pyro-events-${Date.now()}.json"`);
return reply.type("application/json").send(result.events);
return reply.type("application/json").send(result.events.map((event) => visibleEvent(request.user!, event)));
}
if (query.format === "csv") {
const result = await eventsStore.query(filters);
Expand All @@ -229,13 +217,13 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
return reply.type("text/csv").send(rows.map((row) => 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) => {
Expand Down Expand Up @@ -286,7 +274,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
return reply.header("Content-Disposition", `attachment; filename="${profile.id}.yaml"`).type("application/yaml").send(exportProfileYaml(profile));
});

app.get("/api/profiles", { preHandler: requireSession }, async () => ({ 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();
Expand Down Expand Up @@ -336,10 +324,10 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
return reply.code(204).send();
});

app.get("/api/apps", { preHandler: requireSession }, async () => {
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,
})),
Expand Down Expand Up @@ -419,8 +407,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
return reply.code(204).send();
});

app.get("/api/keys", { preHandler: requireSession }, async () => ({
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) => {
Expand Down Expand Up @@ -512,14 +500,15 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
};
});

const sockets = new Set<WebSocket>();
const sockets = new Map<WebSocket, string>();
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));
});
Expand All @@ -531,18 +520,21 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
polling = true;
try {
const profiles = await profilesStore.read();
const connected = new Map<WebSocket, UserRecord>();
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)!;
Expand All @@ -567,6 +559,6 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas

declare module "fastify" {
interface FastifyRequest {
user: { id: string } | null;
user: UserRecord | null;
}
}
26 changes: 6 additions & 20 deletions apps/control-plane/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,12 @@ export function verifyAdminPassword(candidate: string, configuredPassword: strin
}

export async function ensureAdmin(users: DocumentStore<UserRecord[]>): Promise<UserRecord> {
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;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/control-plane/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface ControlPlaneConfig {
oidc?: { issuer: string; clientId: string; clientSecret: string; redirectUri: string };
host: string;
port: number;
databaseUrl: string;
Expand All @@ -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"),
Expand Down
Loading
Loading