From b34887d6ad1685da3aad075d2cbc8bb8a65cd3e9 Mon Sep 17 00:00:00 2001 From: Hardik Bhatia Date: Sat, 26 Sep 2026 20:58:28 +0530 Subject: [PATCH] feat(policies): preserve revisions and support staged application rollouts --- apps/control-plane/src/app.ts | 30 +++-- apps/control-plane/src/policies.ts | 24 ++++ apps/control-plane/src/profile-files.ts | 3 +- apps/dashboard/src/App.tsx | 5 +- .../dashboard/src/pages/PolicyHistoryPage.tsx | 59 ++++++++++ apps/gateway/src/app.ts | 24 ++-- docs/control-plane.openapi.yaml | 103 ++++++++++++++++++ packages/cli/test/contract.test.ts | 2 +- packages/contracts/src/index.ts | 11 ++ packages/storage/src/index.ts | 18 ++- packages/storage/src/policies.test.ts | 28 +++++ packages/storage/src/policies.ts | 82 ++++++++++++++ 12 files changed, 369 insertions(+), 20 deletions(-) create mode 100644 apps/control-plane/src/policies.ts create mode 100644 apps/dashboard/src/pages/PolicyHistoryPage.tsx create mode 100644 packages/storage/src/policies.test.ts create mode 100644 packages/storage/src/policies.ts diff --git a/apps/control-plane/src/app.ts b/apps/control-plane/src/app.ts index d285024..26e6fcf 100644 --- a/apps/control-plane/src/app.ts +++ b/apps/control-plane/src/app.ts @@ -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"; @@ -58,7 +59,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise("sessions", () => []); const keysStore = database.document("api_keys", () => []); const appsStore = database.document("apps", () => [createDefaultApp()]); - const profilesStore = database.document("profiles", () => [createDefaultProfile()]); + const profilesStore = new PolicyStore(database.document("profiles", () => [createDefaultProfile()])); + await profilesStore.initialize(); const settingsStore = database.document("provider_settings", () => ({ ...createDefaultProviderSettings(), endpoint: config.typesafeEndpoint, @@ -255,6 +257,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise ({ presets })); app.post<{ Body: { yaml?: string } }>("/api/profiles/preview", { preHandler: requireSession }, async (request, reply) => { @@ -273,7 +277,7 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise 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) => { @@ -297,8 +301,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise 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) => { @@ -313,8 +317,8 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise 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) => { @@ -365,6 +369,12 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise 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." }); @@ -384,6 +394,12 @@ export async function buildControlPlane(config: ControlPlaneConfig): Promise 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." }); diff --git a/apps/control-plane/src/policies.ts b/apps/control-plane/src/policies.ts new file mode 100644 index 0000000..8230f2e --- /dev/null +++ b/apps/control-plane/src/policies.ts @@ -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) { + 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) }; + }); +} diff --git a/apps/control-plane/src/profile-files.ts b/apps/control-plane/src/profile-files.ts index fc7cfe5..eee340f 100644 --- a/apps/control-plane/src/profile-files.ts +++ b/apps/control-plane/src/profile-files.ts @@ -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 { @@ -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> { diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 9669f4b..12e380b 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -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[] = [ @@ -30,6 +31,7 @@ const NAV: BranchedMenuItem[] = [ ] }, { label: "Configure", children: [ { value: "apps", label: "Applications", icon: }, + { value: "history", label: "Policy history", icon: }, { value: "profiles", label: "Protection Profiles", icon: }, { value: "keys", label: "API keys", icon: }, { value: "integrations", label: "Webhooks", icon: }, @@ -100,6 +102,7 @@ export default function App() { usage: , playground: setRefreshKey((value) => value + 1)} />, profiles: , + history: , activity: , keys: , settings: , diff --git a/apps/dashboard/src/pages/PolicyHistoryPage.tsx b/apps/dashboard/src/pages/PolicyHistoryPage.tsx new file mode 100644 index 0000000..0e15587 --- /dev/null +++ b/apps/dashboard/src/pages/PolicyHistoryPage.tsx @@ -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([]); + const [apps, setApps] = useState([]); + const [id, setId] = useState(""); + const [revisions, setRevisions] = useState([]); + 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) => { + 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
+ + + {message &&

{message}

} + {[...revisions].reverse().map((r) => )}
RevisionStateSaved byDateHash
{r.state}{r.actorId}{new Date(r.createdAt).toLocaleString()}{r.contentHash.slice(0, 12)}
+ {selection && +

Revision {selected}

Changed from active: {differences.join(", ") || "no configuration changes"}. Publishing an older revision records a new revision; history remains intact.

+