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
30 changes: 23 additions & 7 deletions apps/control-plane/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import {
type StoredSecret,
type UserRecord,
} from "@pyro/contracts";
import { encryptText, openDatabase } from "@pyro/storage";
import { encryptText, openDatabase, PolicyStore, type PolicyRecord } from "@pyro/storage";
import { createSession, ensureAdmin, sessionUserId, sha256, verifyAdminPassword } from "./auth.js";
import type { ControlPlaneConfig } from "./config.js";

import { registerPolicyHistory } from "./policies.js";
import { registerIntegrations } from "./integrations.js";
import { exportProfileYaml, loadPresetProfiles, parseProfileYaml } from "./profile-files.js";

Expand Down Expand Up @@ -58,7 +59,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
const sessionsStore = database.document<SessionRecord[]>("sessions", () => []);
const keysStore = database.document<ApiKeyRecord[]>("api_keys", () => []);
const appsStore = database.document<AppRecord[]>("apps", () => [createDefaultApp()]);
const profilesStore = database.document<Profile[]>("profiles", () => [createDefaultProfile()]);
const profilesStore = new PolicyStore(database.document<PolicyRecord[]>("profiles", () => [createDefaultProfile()]));
await profilesStore.initialize();
const settingsStore = database.document<ProviderSettings>("provider_settings", () => ({
...createDefaultProviderSettings(),
endpoint: config.typesafeEndpoint,
Expand Down Expand Up @@ -255,6 +257,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
}
});

registerPolicyHistory(app, profilesStore, requireSession);

const presets = await loadPresetProfiles();
app.get("/api/profile-presets", { preHandler: requireSession }, async () => ({ presets }));
app.post<{ Body: { yaml?: string } }>("/api/profiles/preview", { preHandler: requireSession }, async (request, reply) => {
Expand All @@ -273,7 +277,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
return conflict ? current : [...current, profile];
});
if (conflict) return reply.code(409).send({ error: "A profile with this ID or name already exists. Change it before importing." });
return reply.code(201).send({ profile });
return reply.code(201).send({ profile: (await profilesStore.read()).find((p) => p.id === profile.id) });
} catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : "Invalid YAML." }); }
});
app.get<{ Params: { id: string } }>("/api/profiles/:id/export", { preHandler: requireSession }, async (request, reply) => {
Expand All @@ -297,8 +301,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
for (let suffix = 2; profiles.some((profile) => profile.id === id); suffix += 1) id = `${baseId}-${suffix}`;
const parsed = ProfileSchema.safeParse({ ...body, id, name, createdAt: now, updatedAt: now });
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid profile." });
await profilesStore.update((current) => [...current, parsed.data]);
return reply.code(201).send({ profile: parsed.data });
const saved = await profilesStore.update((current) => [...current, parsed.data], request.user!.id);
return reply.code(201).send({ profile: saved.find((p) => p.id === parsed.data.id) });
});

app.put<{ Params: { id: string } }>("/api/profiles/:id", { preHandler: requireSession }, async (request, reply) => {
Expand All @@ -313,8 +317,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid profile." });
const duplicateName = (await profilesStore.read()).some((profile) => profile.id !== request.params.id && profile.name.trim().toLocaleLowerCase() === parsed.data.name.trim().toLocaleLowerCase());
if (duplicateName) return reply.code(409).send({ error: "A policy with this name already exists." });
await profilesStore.update((profiles) => profiles.map((item) => item.id === request.params.id ? parsed.data : item));
return { profile: parsed.data };
const saved = await profilesStore.update((profiles) => profiles.map((item) => item.id === request.params.id ? parsed.data : item), request.user!.id);
return { profile: saved.find((p) => p.id === request.params.id) };
});

app.delete<{ Params: { id: string } }>("/api/profiles/:id", { preHandler: requireSession }, async (request, reply) => {
Expand Down Expand Up @@ -365,6 +369,12 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
updatedAt: now,
});
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid application." });
const records = await profilesStore.records.read();
for (const [id, revision] of Object.entries(parsed.data.profileRevisions ?? {})) {
if (!records.find((p) => p.id === id && !p.archived)?.revisions?.some((r) => r.revision === revision && r.state === "published")) return reply.code(400).send({ error: "Pinned policy revision does not exist or is not published." });
}
const canary = parsed.data.canary;
if (canary && !records.find((p) => p.id === canary.profileId && !p.archived)?.revisions?.some((r) => r.revision === canary.revision && r.state === "published")) return reply.code(400).send({ error: "Canary revision must be published." });
const profiles = new Set((await profilesStore.read()).map((profile) => profile.id));
if (!profiles.has(parsed.data.defaultProfileId)) return reply.code(400).send({ error: "The default policy does not exist." });
if (parsed.data.allowedProfileIds.some((profileId) => !profiles.has(profileId))) return reply.code(400).send({ error: "An allowed policy does not exist." });
Expand All @@ -384,6 +394,12 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise<Fas
updatedAt: new Date().toISOString(),
});
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid application." });
const records = await profilesStore.records.read();
for (const [id, revision] of Object.entries(parsed.data.profileRevisions ?? {})) {
if (!records.find((p) => p.id === id && !p.archived)?.revisions?.some((r) => r.revision === revision && r.state === "published")) return reply.code(400).send({ error: "Pinned policy revision does not exist or is not published." });
}
const canary = parsed.data.canary;
if (canary && !records.find((p) => p.id === canary.profileId && !p.archived)?.revisions?.some((r) => r.revision === canary.revision && r.state === "published")) return reply.code(400).send({ error: "Canary revision must be published." });
const profiles = new Set((await profilesStore.read()).map((profile) => profile.id));
if (!profiles.has(parsed.data.defaultProfileId)) return reply.code(400).send({ error: "The default policy does not exist." });
if (parsed.data.allowedProfileIds.some((profileId) => !profiles.has(profileId))) return reply.code(400).send({ error: "An allowed policy does not exist." });
Expand Down
24 changes: 24 additions & 0 deletions apps/control-plane/src/policies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { PolicyStore } from "@pyro/storage";

export function registerPolicyHistory(app: FastifyInstance, store: PolicyStore, requireSession: (request: FastifyRequest, reply: FastifyReply) => Promise<unknown>) {
app.get<{ Params: { id: string } }>("/api/profiles/:id/revisions", { preHandler: requireSession }, async (request, reply) => {
const record = (await store.records.read()).find((p) => p.id === request.params.id);
if (!record) return reply.code(404).send({ error: "Policy not found." });
return { activeRevision: record.revision, revisions: record.revisions };
});
app.post<{ Params: { id: string }; Body: { profile: unknown; expectedRevision: number } }>("/api/profiles/:id/revisions", { preHandler: requireSession }, async (request, reply) => {
if (!Number.isInteger(request.body?.expectedRevision)) return reply.code(400).send({ error: "expectedRevision is required." });
try { return reply.code(201).send({ revision: await store.draft(request.params.id, request.body.profile, request.body.expectedRevision, request.user!.id) }); }
catch (error) { return reply.code(error instanceof Error && "statusCode" in error ? 409 : 400).send({ error: error instanceof Error ? error.message : "Invalid draft." }); }
});
app.post<{ Params: { id: string }; Body: { revision: number; expectedRevision: number } }>("/api/profiles/:id/publish", { preHandler: requireSession }, async (request, reply) => {
const record = (await store.records.read()).find((p) => p.id === request.params.id && !p.archived);
const revision = record?.revisions?.find((r) => r.revision === request.body?.revision);
if (!record || !revision) return reply.code(404).send({ error: "Revision not found." });
if (record.revision !== request.body.expectedRevision) return reply.code(409).send({ error: "Policy changed. Reload before publishing." });
const profiles = await store.update((current) => current.map((p) => p.id === record.id
? { ...revision.profile, revision: request.body.expectedRevision, updatedAt: new Date().toISOString() } : p), request.user!.id);
return { profile: profiles.find((p) => p.id === record.id) };
});
}
3 changes: 2 additions & 1 deletion apps/control-plane/src/profile-files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readdir, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { parseDocument, stringify } from "yaml";
import { policyHash } from "@pyro/storage";
import { ProfileSchema, type Profile } from "@pyro/contracts";

export function parseProfileYaml(source: string): Profile {
Expand All @@ -21,7 +22,7 @@ export function parseProfileYaml(source: string): Profile {

export function exportProfileYaml(profile: Profile): string {
const { createdAt: _created, updatedAt: _updated, ...policy } = profile;
return stringify({ apiVersion: "pyro/v1", kind: "Profile", profile: { ...policy, shadowProfileIds: [] } });
return stringify({ apiVersion: "pyro/v1", kind: "Profile", profile: { ...policy, shadowProfileIds: [], contentHash: policyHash({ ...profile, shadowProfileIds: [] }) } });
}

export async function loadPresetProfiles(): Promise<Array<{ profile: Profile; yaml: string }>> {
Expand Down
5 changes: 4 additions & 1 deletion apps/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import { AppsPage } from "@/pages/AppsPage";
import { LoginPage } from "@/pages/LoginPage";
import { OverviewPage } from "@/pages/OverviewPage";
import { PlaygroundPage } from "@/pages/PlaygroundPage";
import { PolicyHistoryPage } from "@/pages/PolicyHistoryPage";
import { ProfilesPage } from "@/pages/ProfilesPage";
import { IntegrationsPage } from "@/pages/IntegrationsPage";
import { SettingsPage } from "@/pages/SettingsPage";
import { UsagePage } from "@/pages/UsagePage";

type Page = "overview" | "apps" | "usage" | "playground" | "profiles" | "activity" | "keys" | "settings" | "integrations";
type Page = "history" | "overview" | "apps" | "usage" | "playground" | "profiles" | "activity" | "keys" | "settings" | "integrations";
interface User { id: string; username: string; role?: "admin" | "viewer" }

const NAV: BranchedMenuItem[] = [
Expand All @@ -30,6 +31,7 @@ const NAV: BranchedMenuItem[] = [
] },
{ label: "Configure", children: [
{ value: "apps", label: "Applications", icon: <Boxes className="size-3.5" /> },
{ value: "history", label: "Policy history", icon: <BookOpenCheck className="size-3.5" /> },
{ value: "profiles", label: "Protection Profiles", icon: <SlidersHorizontal className="size-3.5" /> },
{ value: "keys", label: "API keys", icon: <KeyRound className="size-3.5" /> },
{ value: "integrations", label: "Webhooks", icon: <Activity className="size-3.5" /> },
Expand Down Expand Up @@ -100,6 +102,7 @@ export default function App() {
usage: <UsagePage refreshKey={refreshKey} />,
playground: <PlaygroundPage onDecision={() => setRefreshKey((value) => value + 1)} />,
profiles: <ProfilesPage />,
history: <PolicyHistoryPage />,
activity: <ActivityPage refreshKey={refreshKey} />,
keys: <ApiKeysPage />,
settings: <SettingsPage />,
Expand Down
59 changes: 59 additions & 0 deletions apps/dashboard/src/pages/PolicyHistoryPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import type { AppRecord, Profile } from "@pyro/contracts";
import { api } from "@/lib/api";
import { PageHeader } from "@/components/shared";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";

interface Revision { revision: number; contentHash: string; state: string; actorId: string; createdAt: string; profile: Profile }
const selectClass = "border border-line bg-surface p-2 text-sm";
export function PolicyHistoryPage() {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [apps, setApps] = useState<AppRecord[]>([]);
const [id, setId] = useState("");
const [revisions, setRevisions] = useState<Revision[]>([]);
const [active, setActive] = useState(0);
const [selected, setSelected] = useState(0);
const [draft, setDraft] = useState("");
const [appId, setAppId] = useState("");
const [percent, setPercent] = useState(10);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const reload = async () => {
const [p, a] = await Promise.all([api.get<{ profiles: Profile[] }>("/api/profiles"), api.get<{ apps: AppRecord[] }>("/api/apps")]);
setProfiles(p.profiles); setApps(a.apps); setId((old) => old || p.profiles[0]?.id || ""); setAppId((old) => old || a.apps[0]?.id || "");
if (id) {
const data = await api.get<{ activeRevision: number; revisions: Revision[] }>(`/api/profiles/${id}/revisions`);
setRevisions(data.revisions); setActive(data.activeRevision);
}
};
useEffect(() => { void reload().catch((e) => setMessage(e.message)); }, [id]);
const selection = revisions.find((r) => r.revision === selected);
const current = revisions.find((r) => r.revision === active);
const run = async (work: () => Promise<unknown>) => {
setBusy(true); setMessage("");
try { await work(); await reload(); setMessage("Saved."); } catch (e) { setMessage(e instanceof Error ? e.message : "Save failed."); } finally { setBusy(false); }
};
const bind = (canary: boolean) => run(async () => {
const application = apps.find((a) => a.id === appId)!;
if (!selection || selection.state !== "published") throw new Error("Choose a published revision.");
return api.put(`/api/apps/${appId}`, canary
? { ...application, canary: { profileId: id, revision: selected, percent } }
: { ...application, profileRevisions: { ...application.profileRevisions, [id]: selected }, canary: undefined });
});
const differences = current && selection ? Object.keys(selection.profile).filter((key) => !["revision", "contentHash", "updatedAt"].includes(key) && JSON.stringify(selection.profile[key as keyof Profile]) !== JSON.stringify(current.profile[key as keyof Profile])) : [];
return <div className="space-y-6">
<PageHeader title="Policy history" description="Inspect immutable revisions, save a draft, and control which revision an application runs." />
<label className="flex items-center gap-3">Policy <select className={selectClass} value={id} onChange={(e) => { setId(e.target.value); setSelected(0); setDraft(""); }}>{profiles.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</select><span className="text-sm text-muted">Active revision {active}</span></label>
{message && <p role="status" className="border border-line p-3 text-sm">{message}</p>}
<Card><CardContent className="overflow-auto p-4"><table className="w-full text-left text-sm"><thead><tr><th>Revision</th><th>State</th><th>Saved by</th><th>Date</th><th>Hash</th></tr></thead><tbody>{[...revisions].reverse().map((r) => <tr key={r.revision} className="border-t border-line"><td className="py-3"><button className="underline" onClick={() => { setSelected(r.revision); setDraft(JSON.stringify(r.profile, null, 2)); }}>{r.revision}{r.revision === active ? " (active)" : ""}</button></td><td>{r.state}</td><td>{r.actorId}</td><td>{new Date(r.createdAt).toLocaleString()}</td><td title={r.contentHash}><code>{r.contentHash.slice(0, 12)}</code></td></tr>)}</tbody></table></CardContent></Card>
{selection && <Card><CardContent className="space-y-4 p-5">
<h2 className="font-semibold">Revision {selected}</h2><p className="text-sm text-muted">Changed from active: {differences.join(", ") || "no configuration changes"}. Publishing an older revision records a new revision; history remains intact.</p>
<label className="block space-y-2"><span>Draft configuration (JSON)</span><Textarea className="min-h-72 font-mono text-xs" value={draft} onChange={(e) => setDraft(e.target.value)} /></label>
<div className="flex flex-wrap gap-3"><Button disabled={busy} onClick={() => void run(() => api.post(`/api/profiles/${id}/revisions`, { profile: JSON.parse(draft), expectedRevision: active }))}>Save as draft</Button><Button variant="outline" disabled={busy} onClick={() => void run(() => api.post(`/api/profiles/${id}/publish`, { revision: selected, expectedRevision: active }))}>{selection.state === "draft" ? "Publish selected draft" : "Publish selected revision"}</Button></div>
{selection.state === "published" && <div className="space-y-3 border-t border-line pt-4"><h3 className="font-semibold">Application rollout</h3><label className="flex gap-3">Application<select className={selectClass} value={appId} onChange={(e) => setAppId(e.target.value)}>{apps.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}</select></label><p className="text-sm text-muted">Pinned revision: {apps.find((a) => a.id === appId)?.profileRevisions?.[id] ?? "latest published"}. Canary selection is stable for a request ID. Start with a pin to the current stable revision.</p><div className="flex flex-wrap items-end gap-3"><Button disabled={busy || !appId} onClick={() => void bind(false)}>Pin selected revision / end canary</Button><label className="text-sm">Canary percentage<Input type="number" min={0} max={100} value={percent} onChange={(e) => setPercent(Number(e.target.value))} /></label><Button variant="outline" disabled={busy || !appId} onClick={() => void bind(true)}>Start canary</Button></div></div>}
</CardContent></Card>}
</div>;
}
Loading
Loading