From eb104f466581aea6270c98d39217064fc3bd01dd Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:10:30 +0000 Subject: [PATCH 01/12] feat: add ServiceAccount model to Prisma schema Add database migration for service accounts with hashed API key storage, environment scoping, JSON permissions, and expiration support. --- .../migration.sql | 32 +++++++++++++++++++ prisma/schema.prisma | 22 +++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 prisma/migrations/20260308000000_add_service_accounts/migration.sql diff --git a/prisma/migrations/20260308000000_add_service_accounts/migration.sql b/prisma/migrations/20260308000000_add_service_accounts/migration.sql new file mode 100644 index 00000000..7753f274 --- /dev/null +++ b/prisma/migrations/20260308000000_add_service_accounts/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "ServiceAccount" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "hashedKey" TEXT NOT NULL, + "keyPrefix" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "permissions" JSONB NOT NULL, + "createdById" TEXT NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ServiceAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ServiceAccount_hashedKey_key" ON "ServiceAccount"("hashedKey"); + +-- CreateIndex +CREATE INDEX "ServiceAccount_hashedKey_idx" ON "ServiceAccount"("hashedKey"); + +-- CreateIndex +CREATE INDEX "ServiceAccount_environmentId_idx" ON "ServiceAccount"("environmentId"); + +-- AddForeignKey +ALTER TABLE "ServiceAccount" ADD CONSTRAINT "ServiceAccount_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceAccount" ADD CONSTRAINT "ServiceAccount_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7c59e304..a7759503 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ model User { pipelinesCreated Pipeline[] @relation("PipelineCreatedBy") vrlSnippets VrlSnippet[] dashboardViews DashboardView[] + serviceAccounts ServiceAccount[] createdAt DateTime @default(now()) } @@ -87,6 +88,7 @@ model Environment { alertRules AlertRule[] alertWebhooks AlertWebhook[] notificationChannels NotificationChannel[] + serviceAccounts ServiceAccount[] createdAt DateTime @default(now()) } @@ -606,3 +608,23 @@ model AlertRuleChannel { @@unique([alertRuleId, channelId]) } + +model ServiceAccount { + id String @id @default(cuid()) + name String + description String? + hashedKey String @unique + keyPrefix String + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + permissions Json // string[] of permission names + createdById String + createdBy User @relation(fields: [createdById], references: [id]) + lastUsedAt DateTime? + expiresAt DateTime? + enabled Boolean @default(true) + createdAt DateTime @default(now()) + + @@index([hashedKey]) + @@index([environmentId]) +} From 59991bddcaec387feab5da1a218d66284eb4752d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:12:00 +0000 Subject: [PATCH 02/12] feat: add service account management tRPC router Implements list, create, revoke, and delete procedures for service accounts with ADMIN-only access, audit logging, and SHA-256 key hashing. Registers the router in the app router and updates audit/team-access middleware to resolve ServiceAccount entities. --- src/server/middleware/audit.ts | 25 +++++ src/server/routers/service-account.ts | 144 ++++++++++++++++++++++++++ src/trpc/init.ts | 11 ++ src/trpc/router.ts | 2 + 4 files changed, 182 insertions(+) create mode 100644 src/server/routers/service-account.ts diff --git a/src/server/middleware/audit.ts b/src/server/middleware/audit.ts index b279907b..e5a78ef6 100644 --- a/src/server/middleware/audit.ts +++ b/src/server/middleware/audit.ts @@ -116,6 +116,14 @@ async function resolveTeamId( return snippet?.teamId ?? null; } + if (inputData.id && entityType === "ServiceAccount") { + const sa = await prisma.serviceAccount.findUnique({ + where: { id: inputData.id as string }, + select: { environment: { select: { teamId: true } } }, + }); + return sa?.environment.teamId ?? null; + } + return null; } @@ -200,6 +208,14 @@ async function resolveEnvironmentId( return channel?.environmentId ?? null; } + if (inputData.id && entityType === "ServiceAccount") { + const sa = await prisma.serviceAccount.findUnique({ + where: { id: inputData.id as string }, + select: { environmentId: true }, + }); + return sa?.environmentId ?? null; + } + return null; } @@ -295,6 +311,15 @@ const ENTITY_LOADERS: Record Promise | null>, Template: (id) => prisma.template.findUnique({ where: { id } }) as Promise | null>, + ServiceAccount: (id) => + prisma.serviceAccount.findUnique({ + where: { id }, + select: { + id: true, name: true, description: true, keyPrefix: true, + environmentId: true, permissions: true, enabled: true, + expiresAt: true, createdAt: true, + }, + }) as Promise | null>, }; async function loadEntity( diff --git a/src/server/routers/service-account.ts b/src/server/routers/service-account.ts new file mode 100644 index 00000000..1026cc1a --- /dev/null +++ b/src/server/routers/service-account.ts @@ -0,0 +1,144 @@ +import crypto from "crypto"; +import { z } from "zod"; +import { router, protectedProcedure, withTeamAccess } from "@/trpc/init"; +import { prisma } from "@/lib/prisma"; +import { withAudit } from "@/server/middleware/audit"; +import { TRPCError } from "@trpc/server"; + +export const PERMISSIONS = [ + "pipelines.read", + "pipelines.deploy", + "nodes.read", + "nodes.manage", + "secrets.read", + "secrets.manage", + "alerts.read", + "alerts.manage", + "audit.read", +] as const; + +export type Permission = (typeof PERMISSIONS)[number]; + +export const serviceAccountRouter = router({ + list: protectedProcedure + .input(z.object({ environmentId: z.string() })) + .use(withTeamAccess("ADMIN")) + .query(async ({ input }) => { + return prisma.serviceAccount.findMany({ + where: { environmentId: input.environmentId }, + select: { + id: true, + name: true, + description: true, + keyPrefix: true, + environmentId: true, + permissions: true, + lastUsedAt: true, + expiresAt: true, + enabled: true, + createdAt: true, + createdBy: { + select: { name: true, email: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + }), + + create: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + permissions: z.array(z.enum(PERMISSIONS)).min(1), + expiresInDays: z.number().int().min(1).optional(), + }), + ) + .use(withTeamAccess("ADMIN")) + .use(withAudit("serviceAccount.created", "ServiceAccount")) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user?.id; + if (!userId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const rawKey = `vf_live_${crypto.randomBytes(24).toString("hex")}`; + const hashedKey = crypto + .createHash("sha256") + .update(rawKey) + .digest("hex"); + const keyPrefix = rawKey.substring(0, 16); + + const expiresAt = input.expiresInDays + ? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000) + : null; + + const sa = await prisma.serviceAccount.create({ + data: { + name: input.name, + description: input.description, + hashedKey, + keyPrefix, + environmentId: input.environmentId, + permissions: input.permissions, + createdById: userId, + expiresAt, + }, + select: { + id: true, + name: true, + keyPrefix: true, + permissions: true, + expiresAt: true, + createdAt: true, + }, + }); + + return { + ...sa, + rawKey, + }; + }), + + revoke: protectedProcedure + .input(z.object({ id: z.string() })) + .use(withTeamAccess("ADMIN")) + .use(withAudit("serviceAccount.revoked", "ServiceAccount")) + .mutation(async ({ input }) => { + const sa = await prisma.serviceAccount.findUnique({ + where: { id: input.id }, + }); + if (!sa) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Service account not found", + }); + } + + return prisma.serviceAccount.update({ + where: { id: input.id }, + data: { enabled: false }, + select: { id: true, name: true, enabled: true }, + }); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .use(withTeamAccess("ADMIN")) + .use(withAudit("serviceAccount.deleted", "ServiceAccount")) + .mutation(async ({ input }) => { + const sa = await prisma.serviceAccount.findUnique({ + where: { id: input.id }, + }); + if (!sa) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Service account not found", + }); + } + + await prisma.serviceAccount.delete({ where: { id: input.id } }); + return { deleted: true }; + }), +}); diff --git a/src/trpc/init.ts b/src/trpc/init.ts index a0a63ae8..f42959f7 100644 --- a/src/trpc/init.ts +++ b/src/trpc/init.ts @@ -259,6 +259,17 @@ export const withTeamAccess = (minRole: Role) => } } + // Resolve id as ServiceAccount → environment.teamId + if (!teamId && rawInput?.id) { + const sa = await prisma.serviceAccount.findUnique({ + where: { id: rawInput.id as string }, + select: { environment: { select: { teamId: true } } }, + }); + if (sa) { + teamId = sa.environment.teamId ?? undefined; + } + } + // Resolve id as VrlSnippet → teamId (for vrl-snippet update/delete) if (!teamId && rawInput?.id) { const snippet = await prisma.vrlSnippet.findUnique({ diff --git a/src/trpc/router.ts b/src/trpc/router.ts index 591f2d8a..2b3edc5c 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -17,6 +17,7 @@ import { secretRouter } from "@/server/routers/secret"; import { certificateRouter } from "@/server/routers/certificate"; import { vrlSnippetRouter } from "@/server/routers/vrl-snippet"; import { alertRouter } from "@/server/routers/alert"; +import { serviceAccountRouter } from "@/server/routers/service-account"; export const appRouter = router({ team: teamRouter, @@ -37,6 +38,7 @@ export const appRouter = router({ certificate: certificateRouter, vrlSnippet: vrlSnippetRouter, alert: alertRouter, + serviceAccount: serviceAccountRouter, }); export type AppRouter = typeof appRouter; From b398c5a205aee9e0c314f967b85a38345584463d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:16:45 +0000 Subject: [PATCH 03/12] feat: add REST API with service account authentication Implements API key authentication middleware and 12 REST endpoints: - Pipelines: list, get, deploy, undeploy, versions, rollback - Nodes: list (with label filtering), get, toggle maintenance - Secrets: CRUD operations - Alert rules: list and create - Audit: cursor-based polling with action filtering All endpoints authenticate via Bearer token (service account API keys) and enforce per-permission authorization. --- src/app/api/v1/_lib/api-handler.ts | 34 ++++ src/app/api/v1/alerts/rules/route.ts | 102 ++++++++++++ src/app/api/v1/audit/route.ts | 43 +++++ .../api/v1/nodes/[id]/maintenance/route.ts | 61 +++++++ src/app/api/v1/nodes/[id]/route.ts | 28 ++++ src/app/api/v1/nodes/route.ts | 35 ++++ src/app/api/v1/pipelines/[id]/deploy/route.ts | 61 +++++++ .../api/v1/pipelines/[id]/rollback/route.ts | 58 +++++++ src/app/api/v1/pipelines/[id]/route.ts | 54 ++++++ .../api/v1/pipelines/[id]/undeploy/route.ts | 33 ++++ .../api/v1/pipelines/[id]/versions/route.ts | 43 +++++ src/app/api/v1/pipelines/route.ts | 21 +++ src/app/api/v1/secrets/route.ts | 157 ++++++++++++++++++ src/server/middleware/api-auth.ts | 44 +++++ 14 files changed, 774 insertions(+) create mode 100644 src/app/api/v1/_lib/api-handler.ts create mode 100644 src/app/api/v1/alerts/rules/route.ts create mode 100644 src/app/api/v1/audit/route.ts create mode 100644 src/app/api/v1/nodes/[id]/maintenance/route.ts create mode 100644 src/app/api/v1/nodes/[id]/route.ts create mode 100644 src/app/api/v1/nodes/route.ts create mode 100644 src/app/api/v1/pipelines/[id]/deploy/route.ts create mode 100644 src/app/api/v1/pipelines/[id]/rollback/route.ts create mode 100644 src/app/api/v1/pipelines/[id]/route.ts create mode 100644 src/app/api/v1/pipelines/[id]/undeploy/route.ts create mode 100644 src/app/api/v1/pipelines/[id]/versions/route.ts create mode 100644 src/app/api/v1/pipelines/route.ts create mode 100644 src/app/api/v1/secrets/route.ts create mode 100644 src/server/middleware/api-auth.ts diff --git a/src/app/api/v1/_lib/api-handler.ts b/src/app/api/v1/_lib/api-handler.ts new file mode 100644 index 00000000..bf511f7b --- /dev/null +++ b/src/app/api/v1/_lib/api-handler.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + authenticateApiKey, + hasPermission, + type ServiceAccountContext, +} from "@/server/middleware/api-auth"; + +export function apiRoute( + permission: string, + handler: ( + req: NextRequest, + ctx: ServiceAccountContext, + params?: Record, + ) => Promise, +) { + return async ( + req: NextRequest, + { params }: { params?: Promise> }, + ) => { + const auth = req.headers.get("authorization"); + const ctx = await authenticateApiKey(auth); + if (!ctx) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!hasPermission(ctx, permission)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + try { + const resolvedParams = params ? await params : undefined; + return await handler(req, ctx, resolvedParams); + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } + }; +} diff --git a/src/app/api/v1/alerts/rules/route.ts b/src/app/api/v1/alerts/rules/route.ts new file mode 100644 index 00000000..2d60fcdb --- /dev/null +++ b/src/app/api/v1/alerts/rules/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../_lib/api-handler"; + +export const GET = apiRoute("alerts.read", async (_req, ctx) => { + const rules = await prisma.alertRule.findMany({ + where: { environmentId: ctx.environmentId }, + include: { + pipeline: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ rules }); +}); + +export const POST = apiRoute( + "alerts.manage", + async (req: NextRequest, ctx) => { + let body: { + name?: string; + pipelineId?: string; + metric?: string; + condition?: string; + threshold?: number; + durationSeconds?: number; + teamId?: string; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + if (!body.name || !body.metric || !body.condition || body.threshold === undefined || !body.teamId) { + return NextResponse.json( + { + error: + "name, metric, condition, threshold, and teamId are required", + }, + { status: 400 }, + ); + } + + const validMetrics = [ + "node_unreachable", + "cpu_usage", + "memory_usage", + "disk_usage", + "error_rate", + "discarded_rate", + "pipeline_crashed", + ]; + const validConditions = ["gt", "lt", "eq"]; + + if (!validMetrics.includes(body.metric)) { + return NextResponse.json( + { error: `Invalid metric. Must be one of: ${validMetrics.join(", ")}` }, + { status: 400 }, + ); + } + + if (!validConditions.includes(body.condition)) { + return NextResponse.json( + { + error: `Invalid condition. Must be one of: ${validConditions.join(", ")}`, + }, + { status: 400 }, + ); + } + + if (body.pipelineId) { + const pipeline = await prisma.pipeline.findUnique({ + where: { id: body.pipelineId }, + }); + if (!pipeline || pipeline.environmentId !== ctx.environmentId) { + return NextResponse.json( + { error: "Pipeline not found in this environment" }, + { status: 404 }, + ); + } + } + + const rule = await prisma.alertRule.create({ + data: { + name: body.name, + environmentId: ctx.environmentId, + pipelineId: body.pipelineId, + teamId: body.teamId, + metric: body.metric as "cpu_usage", + condition: body.condition as "gt", + threshold: body.threshold, + durationSeconds: body.durationSeconds ?? 60, + }, + }); + + return NextResponse.json({ rule }, { status: 201 }); + }, +); diff --git a/src/app/api/v1/audit/route.ts b/src/app/api/v1/audit/route.ts new file mode 100644 index 00000000..e7a3563a --- /dev/null +++ b/src/app/api/v1/audit/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../_lib/api-handler"; + +export const GET = apiRoute("audit.read", async (req: NextRequest, ctx) => { + const after = req.nextUrl.searchParams.get("after"); + const limitParam = req.nextUrl.searchParams.get("limit"); + const action = req.nextUrl.searchParams.get("action"); + + const limit = Math.min(Math.max(parseInt(limitParam ?? "50", 10) || 50, 1), 200); + + const conditions: Record[] = [ + { environmentId: ctx.environmentId }, + ]; + + if (action) { + conditions.push({ action }); + } + + const where = { AND: conditions }; + + const events = await prisma.auditLog.findMany({ + where, + include: { + user: { + select: { id: true, name: true, email: true }, + }, + }, + orderBy: { createdAt: "desc" }, + take: limit + 1, + ...(after ? { cursor: { id: after }, skip: 1 } : {}), + }); + + let hasMore = false; + if (events.length > limit) { + events.pop(); + hasMore = true; + } + + const cursor = events.length > 0 ? events[events.length - 1].id : null; + + return NextResponse.json({ events, cursor, hasMore }); +}); diff --git a/src/app/api/v1/nodes/[id]/maintenance/route.ts b/src/app/api/v1/nodes/[id]/maintenance/route.ts new file mode 100644 index 00000000..5a7a6ae9 --- /dev/null +++ b/src/app/api/v1/nodes/[id]/maintenance/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../../_lib/api-handler"; + +export const POST = apiRoute( + "nodes.manage", + async (req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json( + { error: "Missing node id" }, + { status: 400 }, + ); + } + + const node = await prisma.vectorNode.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + + if (!node) { + return NextResponse.json( + { error: "Node not found" }, + { status: 404 }, + ); + } + + let body: { enabled?: boolean }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + if (typeof body.enabled !== "boolean") { + return NextResponse.json( + { error: "enabled (boolean) is required" }, + { status: 400 }, + ); + } + + const updated = await prisma.vectorNode.update({ + where: { id }, + data: { + maintenanceMode: body.enabled, + maintenanceModeAt: body.enabled ? new Date() : null, + }, + select: { + id: true, + name: true, + maintenanceMode: true, + maintenanceModeAt: true, + }, + }); + + return NextResponse.json({ node: updated }); + }, +); diff --git a/src/app/api/v1/nodes/[id]/route.ts b/src/app/api/v1/nodes/[id]/route.ts new file mode 100644 index 00000000..2fae6d39 --- /dev/null +++ b/src/app/api/v1/nodes/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../_lib/api-handler"; + +export const GET = apiRoute("nodes.read", async (_req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing node id" }, { status: 400 }); + } + + const node = await prisma.vectorNode.findUnique({ + where: { id, environmentId: ctx.environmentId }, + include: { + environment: { select: { id: true, name: true } }, + pipelineStatuses: { + include: { + pipeline: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!node) { + return NextResponse.json({ error: "Node not found" }, { status: 404 }); + } + + return NextResponse.json({ node }); +}); diff --git a/src/app/api/v1/nodes/route.ts b/src/app/api/v1/nodes/route.ts new file mode 100644 index 00000000..bc088b2b --- /dev/null +++ b/src/app/api/v1/nodes/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../_lib/api-handler"; + +export const GET = apiRoute("nodes.read", async (req: NextRequest, ctx) => { + const labelFilter = req.nextUrl.searchParams.get("label"); + + const nodes = await prisma.vectorNode.findMany({ + where: { environmentId: ctx.environmentId }, + include: { + environment: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + // Apply label filtering if requested (labels stored in metadata JSON) + let filtered = nodes; + if (labelFilter) { + const [key, value] = labelFilter.split(":"); + if (key) { + filtered = nodes.filter((node) => { + const metadata = node.metadata as Record | null; + if (!metadata) return false; + const labels = metadata.labels as Record | undefined; + if (!labels) return false; + if (value !== undefined) { + return labels[key] === value; + } + return key in labels; + }); + } + } + + return NextResponse.json({ nodes: filtered }); +}); diff --git a/src/app/api/v1/pipelines/[id]/deploy/route.ts b/src/app/api/v1/pipelines/[id]/deploy/route.ts new file mode 100644 index 00000000..ae1822a0 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/deploy/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../../_lib/api-handler"; +import { deployAgent } from "@/server/services/deploy-agent"; + +export const POST = apiRoute( + "pipelines.deploy", + async (req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json( + { error: "Missing pipeline id" }, + { status: 400 }, + ); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + + if (!pipeline) { + return NextResponse.json( + { error: "Pipeline not found" }, + { status: 404 }, + ); + } + + let changelog = "Deployed via REST API"; + try { + const body = await req.json(); + if (body.changelog && typeof body.changelog === "string") { + changelog = body.changelog; + } + } catch { + // No body or invalid JSON — use default changelog + } + + const result = await deployAgent( + pipeline.id, + ctx.serviceAccountId, + changelog, + ); + + if (!result.success) { + return NextResponse.json( + { + error: "Deployment failed", + validationErrors: result.validationErrors, + }, + { status: 422 }, + ); + } + + return NextResponse.json({ + success: true, + versionId: result.versionId, + versionNumber: result.versionNumber, + }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/rollback/route.ts b/src/app/api/v1/pipelines/[id]/rollback/route.ts new file mode 100644 index 00000000..f9e5abd1 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/rollback/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../../_lib/api-handler"; +import { rollback } from "@/server/services/pipeline-version"; + +export const POST = apiRoute( + "pipelines.deploy", + async (req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json( + { error: "Missing pipeline id" }, + { status: 400 }, + ); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + + if (!pipeline) { + return NextResponse.json( + { error: "Pipeline not found" }, + { status: 404 }, + ); + } + + let body: { targetVersionId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + if (!body.targetVersionId) { + return NextResponse.json( + { error: "targetVersionId is required" }, + { status: 400 }, + ); + } + + const version = await rollback( + pipeline.id, + body.targetVersionId, + ctx.serviceAccountId, + ); + + return NextResponse.json({ + success: true, + versionId: version.id, + versionNumber: version.version, + }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/route.ts b/src/app/api/v1/pipelines/[id]/route.ts new file mode 100644 index 00000000..e7d09537 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../_lib/api-handler"; + +export const GET = apiRoute("pipelines.read", async (_req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + include: { + nodes: { + select: { + id: true, + componentKey: true, + componentType: true, + kind: true, + positionX: true, + positionY: true, + disabled: true, + }, + }, + edges: { + select: { + id: true, + sourceNodeId: true, + targetNodeId: true, + sourcePort: true, + }, + }, + nodeStatuses: { + select: { + nodeId: true, + status: true, + version: true, + eventsIn: true, + eventsOut: true, + errorsTotal: true, + }, + }, + }, + }); + + if (!pipeline) { + return NextResponse.json( + { error: "Pipeline not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ pipeline }); +}); diff --git a/src/app/api/v1/pipelines/[id]/undeploy/route.ts b/src/app/api/v1/pipelines/[id]/undeploy/route.ts new file mode 100644 index 00000000..2f6aefcb --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/undeploy/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../../_lib/api-handler"; +import { undeployAgent } from "@/server/services/deploy-agent"; + +export const POST = apiRoute( + "pipelines.deploy", + async (_req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json( + { error: "Missing pipeline id" }, + { status: 400 }, + ); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + + if (!pipeline) { + return NextResponse.json( + { error: "Pipeline not found" }, + { status: 404 }, + ); + } + + const result = await undeployAgent(pipeline.id); + + return NextResponse.json({ success: result.success }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/versions/route.ts b/src/app/api/v1/pipelines/[id]/versions/route.ts new file mode 100644 index 00000000..55755b1e --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/versions/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../../_lib/api-handler"; + +export const GET = apiRoute( + "pipelines.read", + async (_req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json( + { error: "Missing pipeline id" }, + { status: 400 }, + ); + } + + // Verify pipeline belongs to the service account's environment + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + + if (!pipeline) { + return NextResponse.json( + { error: "Pipeline not found" }, + { status: 404 }, + ); + } + + const versions = await prisma.pipelineVersion.findMany({ + where: { pipelineId: id }, + orderBy: { version: "desc" }, + select: { + id: true, + version: true, + changelog: true, + createdById: true, + createdAt: true, + }, + }); + + return NextResponse.json({ versions }); + }, +); diff --git a/src/app/api/v1/pipelines/route.ts b/src/app/api/v1/pipelines/route.ts new file mode 100644 index 00000000..e55faf6e --- /dev/null +++ b/src/app/api/v1/pipelines/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../_lib/api-handler"; + +export const GET = apiRoute("pipelines.read", async (_req, ctx) => { + const pipelines = await prisma.pipeline.findMany({ + where: { environmentId: ctx.environmentId }, + select: { + id: true, + name: true, + description: true, + isDraft: true, + deployedAt: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + }); + + return NextResponse.json({ pipelines }); +}); diff --git a/src/app/api/v1/secrets/route.ts b/src/app/api/v1/secrets/route.ts new file mode 100644 index 00000000..d840ee3d --- /dev/null +++ b/src/app/api/v1/secrets/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../_lib/api-handler"; +import { encrypt, decrypt } from "@/server/services/crypto"; + +export const GET = apiRoute("secrets.read", async (_req, ctx) => { + const secrets = await prisma.secret.findMany({ + where: { environmentId: ctx.environmentId }, + select: { id: true, name: true, createdAt: true, updatedAt: true }, + orderBy: { name: "asc" }, + }); + + return NextResponse.json({ secrets }); +}); + +export const POST = apiRoute( + "secrets.manage", + async (req: NextRequest, ctx) => { + let body: { name?: string; value?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + if (!body.name || !body.value) { + return NextResponse.json( + { error: "name and value are required" }, + { status: 400 }, + ); + } + + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(body.name)) { + return NextResponse.json( + { + error: + "Name must start with a letter or number and contain only letters, numbers, hyphens, and underscores", + }, + { status: 400 }, + ); + } + + const existing = await prisma.secret.findUnique({ + where: { + environmentId_name: { + environmentId: ctx.environmentId, + name: body.name, + }, + }, + }); + + if (existing) { + return NextResponse.json( + { error: "A secret with this name already exists" }, + { status: 409 }, + ); + } + + const secret = await prisma.secret.create({ + data: { + name: body.name, + encryptedValue: encrypt(body.value), + environmentId: ctx.environmentId, + }, + select: { id: true, name: true, createdAt: true, updatedAt: true }, + }); + + return NextResponse.json({ secret }, { status: 201 }); + }, +); + +export const PUT = apiRoute( + "secrets.manage", + async (req: NextRequest, ctx) => { + let body: { id?: string; name?: string; value?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + if (!body.value) { + return NextResponse.json( + { error: "value is required" }, + { status: 400 }, + ); + } + + // Look up by id or name + let secret; + if (body.id) { + secret = await prisma.secret.findUnique({ where: { id: body.id } }); + } else if (body.name) { + secret = await prisma.secret.findUnique({ + where: { + environmentId_name: { + environmentId: ctx.environmentId, + name: body.name, + }, + }, + }); + } + + if (!secret || secret.environmentId !== ctx.environmentId) { + return NextResponse.json( + { error: "Secret not found" }, + { status: 404 }, + ); + } + + const updated = await prisma.secret.update({ + where: { id: secret.id }, + data: { encryptedValue: encrypt(body.value) }, + select: { id: true, name: true, updatedAt: true }, + }); + + return NextResponse.json({ secret: updated }); + }, +); + +export const DELETE = apiRoute( + "secrets.manage", + async (req: NextRequest, ctx) => { + const id = req.nextUrl.searchParams.get("id"); + const name = req.nextUrl.searchParams.get("name"); + + let secret; + if (id) { + secret = await prisma.secret.findUnique({ where: { id } }); + } else if (name) { + secret = await prisma.secret.findUnique({ + where: { + environmentId_name: { + environmentId: ctx.environmentId, + name, + }, + }, + }); + } + + if (!secret || secret.environmentId !== ctx.environmentId) { + return NextResponse.json( + { error: "Secret not found" }, + { status: 404 }, + ); + } + + await prisma.secret.delete({ where: { id: secret.id } }); + return NextResponse.json({ deleted: true }); + }, +); diff --git a/src/server/middleware/api-auth.ts b/src/server/middleware/api-auth.ts new file mode 100644 index 00000000..1592b7eb --- /dev/null +++ b/src/server/middleware/api-auth.ts @@ -0,0 +1,44 @@ +import crypto from "crypto"; +import { prisma } from "@/lib/prisma"; + +export interface ServiceAccountContext { + serviceAccountId: string; + serviceAccountName: string; + environmentId: string; + permissions: string[]; +} + +export async function authenticateApiKey( + authHeader: string | null, +): Promise { + if (!authHeader?.startsWith("Bearer vf_")) return null; + + const rawKey = authHeader.slice(7); + const hashedKey = crypto.createHash("sha256").update(rawKey).digest("hex"); + + const sa = await prisma.serviceAccount.findUnique({ where: { hashedKey } }); + if (!sa || !sa.enabled) return null; + if (sa.expiresAt && sa.expiresAt < new Date()) return null; + + // Fire-and-forget lastUsedAt update + prisma.serviceAccount + .update({ + where: { id: sa.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + + return { + serviceAccountId: sa.id, + serviceAccountName: sa.name, + environmentId: sa.environmentId, + permissions: sa.permissions as string[], + }; +} + +export function hasPermission( + ctx: ServiceAccountContext, + permission: string, +): boolean { + return ctx.permissions.includes(permission); +} From 91f257110606e2d398c17eb406c7325b0a87010d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:20:40 +0000 Subject: [PATCH 04/12] feat: add service accounts settings page with create/revoke/delete Frontend page for managing service accounts with: - Table listing with status, permissions badges, and last-used time - Create dialog with permission toggles grouped by category - One-time API key display modal with copy-to-clipboard - Revoke and delete confirmation dialogs - Link from main settings page --- src/app/(dashboard)/settings/page.tsx | 10 + .../settings/service-accounts/page.tsx | 648 ++++++++++++++++++ 2 files changed, 658 insertions(+) create mode 100644 src/app/(dashboard)/settings/service-accounts/page.tsx diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index b598942c..26f940d5 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -3032,6 +3032,16 @@ export default function SettingsPage() { return (
+ {isTeamAdmin && ( +
+ + + +
+ )} {isTeamAdmin && ( diff --git a/src/app/(dashboard)/settings/service-accounts/page.tsx b/src/app/(dashboard)/settings/service-accounts/page.tsx new file mode 100644 index 00000000..c9549979 --- /dev/null +++ b/src/app/(dashboard)/settings/service-accounts/page.tsx @@ -0,0 +1,648 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useTeamStore } from "@/stores/team-store"; +import { copyToClipboard } from "@/lib/utils"; +import { toast } from "sonner"; +import { + ArrowLeft, + Plus, + Loader2, + Copy, + Trash2, + Ban, + KeyRound, + ShieldCheck, + Clock, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { Textarea } from "@/components/ui/textarea"; + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +function formatRelativeTime(date: Date | string | null | undefined): string { + if (!date) return "Never"; + const d = typeof date === "string" ? new Date(date) : date; + const now = Date.now(); + const diffMs = now - d.getTime(); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "Just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +function formatExpiresAt(date: Date | string | null | undefined): string { + if (!date) return "Never"; + const d = typeof date === "string" ? new Date(date) : date; + const now = Date.now(); + if (d.getTime() < now) return "Expired"; + const diffMs = d.getTime() - now; + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays <= 1) return "Today"; + return `${diffDays} days`; +} + +// ─── Permission Definitions ───────────────────────────────────────────────────── + +const PERMISSION_GROUPS = [ + { + label: "Pipelines", + permissions: [ + { value: "pipelines.read", label: "Read" }, + { value: "pipelines.deploy", label: "Deploy" }, + ], + }, + { + label: "Nodes", + permissions: [ + { value: "nodes.read", label: "Read" }, + { value: "nodes.manage", label: "Manage" }, + ], + }, + { + label: "Secrets", + permissions: [ + { value: "secrets.read", label: "Read" }, + { value: "secrets.manage", label: "Manage" }, + ], + }, + { + label: "Alerts", + permissions: [ + { value: "alerts.read", label: "Read" }, + { value: "alerts.manage", label: "Manage" }, + ], + }, + { + label: "Audit", + permissions: [{ value: "audit.read", label: "Read" }], + }, +] as const; + +type PermissionValue = (typeof PERMISSION_GROUPS)[number]["permissions"][number]["value"]; + +// ─── Main Page ────────────────────────────────────────────────────────────────── + +export default function ServiceAccountsPage() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { selectedTeamId } = useTeamStore(); + + const [createOpen, setCreateOpen] = useState(false); + const [keyModalOpen, setKeyModalOpen] = useState(false); + const [createdKey, setCreatedKey] = useState(null); + const [revokeTarget, setRevokeTarget] = useState<{ id: string; name: string } | null>(null); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); + + // Form state + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedEnvId, setSelectedEnvId] = useState(""); + const [expiration, setExpiration] = useState("never"); + const [selectedPermissions, setSelectedPermissions] = useState>(new Set()); + + // Queries + const environmentsQuery = useQuery( + trpc.environment.list.queryOptions( + { teamId: selectedTeamId ?? "" }, + { enabled: !!selectedTeamId }, + ), + ); + + const environments = environmentsQuery.data ?? []; + + // If user selected an environment for the list, use it; otherwise use first available + const listEnvId = selectedEnvId || environments[0]?.id || ""; + + const serviceAccountsQuery = useQuery( + trpc.serviceAccount.list.queryOptions( + { environmentId: listEnvId }, + { enabled: !!listEnvId }, + ), + ); + + // Mutations + const createMutation = useMutation( + trpc.serviceAccount.create.mutationOptions({ + onSuccess: (data) => { + setCreatedKey(data.rawKey); + setKeyModalOpen(true); + setCreateOpen(false); + resetForm(); + queryClient.invalidateQueries({ + queryKey: trpc.serviceAccount.list.queryKey(), + }); + toast.success("Service account created"); + }, + onError: (err) => { + toast.error(err.message || "Failed to create service account"); + }, + }), + ); + + const revokeMutation = useMutation( + trpc.serviceAccount.revoke.mutationOptions({ + onSuccess: () => { + setRevokeTarget(null); + queryClient.invalidateQueries({ + queryKey: trpc.serviceAccount.list.queryKey(), + }); + toast.success("Service account revoked"); + }, + onError: (err) => { + toast.error(err.message || "Failed to revoke service account"); + }, + }), + ); + + const deleteMutation = useMutation( + trpc.serviceAccount.delete.mutationOptions({ + onSuccess: () => { + setDeleteTarget(null); + queryClient.invalidateQueries({ + queryKey: trpc.serviceAccount.list.queryKey(), + }); + toast.success("Service account deleted"); + }, + onError: (err) => { + toast.error(err.message || "Failed to delete service account"); + }, + }), + ); + + function resetForm() { + setName(""); + setDescription(""); + setExpiration("never"); + setSelectedPermissions(new Set()); + } + + function togglePermission(perm: string) { + setSelectedPermissions((prev) => { + const next = new Set(prev); + if (next.has(perm)) { + next.delete(perm); + } else { + next.add(perm); + } + return next; + }); + } + + function handleCreate() { + if (!name.trim() || !selectedEnvId || selectedPermissions.size === 0) { + toast.error("Please fill in all required fields and select at least one permission"); + return; + } + + const expiresInDays = + expiration === "never" ? undefined : parseInt(expiration, 10); + + createMutation.mutate({ + environmentId: selectedEnvId, + name: name.trim(), + description: description.trim() || undefined, + permissions: Array.from(selectedPermissions) as PermissionValue[], + expiresInDays, + }); + } + + const serviceAccounts = serviceAccountsQuery.data ?? []; + const isLoading = serviceAccountsQuery.isLoading || environmentsQuery.isLoading; + + return ( +
+ {/* Header */} +
+ + + +
+

Service Accounts

+

+ Manage API keys for programmatic access to the REST API +

+
+ +
+ + {/* Environment Selector */} + {environments.length > 1 && ( +
+ + +
+ )} + + {/* Service Accounts Table */} + + + + + Service Accounts + + + Service accounts provide API keys for the REST API. Keys are shown + once at creation and cannot be retrieved afterwards. + + + + {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : serviceAccounts.length === 0 ? ( +
+ +

No service accounts

+

+ Create a service account to start using the REST API +

+
+ ) : ( + + + + Name + Key Prefix + Permissions + Last Used + Expires + Status + Created By + Actions + + + + {serviceAccounts.map((sa) => { + const permissions = (sa.permissions as string[]) ?? []; + const isExpired = + sa.expiresAt && new Date(sa.expiresAt) < new Date(); + const status = !sa.enabled + ? "Revoked" + : isExpired + ? "Expired" + : "Active"; + const statusVariant = + status === "Active" + ? "default" + : status === "Revoked" + ? "destructive" + : "secondary"; + + return ( + + +
+
{sa.name}
+ {sa.description && ( +
+ {sa.description} +
+ )} +
+
+ + + {sa.keyPrefix}... + + + +
+ {permissions.map((p) => ( + + {p} + + ))} +
+
+ +
+ + {formatRelativeTime(sa.lastUsedAt)} +
+
+ + {formatExpiresAt(sa.expiresAt)} + + + {status} + + + {sa.createdBy?.name || sa.createdBy?.email || "Unknown"} + + +
+ {sa.enabled && ( + + )} + +
+
+
+ ); + })} +
+
+ )} +
+
+ + {/* Create Dialog */} + { + if (!open) resetForm(); + setCreateOpen(open); + }} + > + + + Create Service Account + + Generate an API key for programmatic access. The key will only be + shown once. + + + +
+ {/* Name */} +
+ + setName(e.target.value)} + /> +
+ + {/* Description */} +
+ +