feat(fleet): Fleet Secrets tab with env-var bundles (v1 MVP) (#965)

* feat(fleet): add Fleet Secrets tab with versioned env-var bundles (Skipper+)

Centralized, encrypted-at-rest secret bundles that can be pushed to labeled
nodes' stacks. Each save bumps a monotonic version; each push records a
per-node-per-version row in `secret_pushes` plus an entry in `audit_log`.
Conflict detection shows added/changed/unchanged/removed (informational)
diffs before write. Overlay merge preserves keys missing from the bundle.

- Adds `secrets`, `secret_versions`, `secret_pushes` tables.
- New `SecretsService` reuses CryptoService for AES-256-GCM, NodeLabelService
  for selectors, and direct fetch + Bearer for outbound calls to remote nodes.
- New `secretsRouter` with 9 endpoints under `/api/secrets`, gated by
  `requirePaid`. Mounted after the auth gate.
- Audit summary patterns added for the new routes.
- New Fleet › Secrets tab with bundle list, editor sheet (key=value rows,
  versions tab), and push wizard (selector, target stack, env file picker,
  per-node diff preview, results pills).
- Documentation: docs/features/fleet-secrets.mdx + docs.json nav entry.
- 26 Vitest cases cover parser, encryption, versioning, push aggregation,
  tier gating.

* fix(fleet): use const for rawValue in env parser

ESLint prefer-const flagged the let declaration as a CI-blocking error;
the variable is never reassigned.
This commit is contained in:
Anso
2026-05-07 06:03:39 -04:00
committed by GitHub
parent 52b46753af
commit 0f0b22c51a
14 changed files with 2550 additions and 21 deletions
+282
View File
@@ -0,0 +1,282 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireBody } from '../middleware/tierGates';
import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService';
import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
export const secretsRouter = Router();
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,62}[a-zA-Z0-9]$/;
function isValidName(name: unknown): name is string {
return typeof name === 'string' && NAME_PATTERN.test(name);
}
function isValidEnvFileBasename(name: unknown): name is string {
if (typeof name !== 'string' || name.length === 0 || name.length > 64) return false;
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') return false;
return /^[A-Za-z0-9._-]+$/.test(name);
}
function isValidKv(value: unknown): value is SecretKv {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (typeof k !== 'string' || typeof v !== 'string') return false;
}
return true;
}
function isValidSelector(value: unknown): value is BlueprintSelector {
if (!value || typeof value !== 'object') return false;
const sel = value as { type?: unknown; ids?: unknown; any?: unknown; all?: unknown };
if (sel.type === 'nodes') {
return Array.isArray(sel.ids) && sel.ids.every(id => typeof id === 'number');
}
if (sel.type === 'labels') {
const isStringArr = (a: unknown) => Array.isArray(a) && a.every(s => typeof s === 'string');
return isStringArr(sel.any) && isStringArr(sel.all);
}
return false;
}
function getActor(req: Request): string {
return req.user?.username || 'unknown';
}
interface PushBody { selector: BlueprintSelector; stackName: string; envFileBasename: string }
function parsePushBody(body: unknown): PushBody | { error: string } {
if (!body || typeof body !== 'object') return { error: 'Request body is required' };
const { selector, stackName, envFileBasename } = body as { selector?: unknown; stackName?: unknown; envFileBasename?: unknown };
if (!isValidSelector(selector)) return { error: 'selector is invalid' };
if (typeof stackName !== 'string' || !isValidStackName(stackName)) return { error: 'stackName is invalid' };
const basename = envFileBasename === undefined ? '.env' : envFileBasename;
if (!isValidEnvFileBasename(basename)) return { error: 'envFileBasename is invalid' };
return { selector, stackName, envFileBasename: basename };
}
secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const items = SecretsService.getInstance().list();
res.json(items);
} catch (err) {
console.error('[Secrets] List error:', err);
res.status(500).json({ error: 'Failed to list secrets' });
}
});
secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const { name, description, kv, note } = req.body as { name?: unknown; description?: unknown; kv?: unknown; note?: unknown };
if (!isValidName(name)) {
res.status(400).json({ error: 'name is required (letters, digits, spaces, dot, dash, underscore; 2-64 chars)' });
return;
}
if (description !== undefined && typeof description !== 'string') {
res.status(400).json({ error: 'description must be a string' });
return;
}
if (!isValidKv(kv)) {
res.status(400).json({ error: 'kv must be an object of string values' });
return;
}
if (note !== undefined && typeof note !== 'string') {
res.status(400).json({ error: 'note must be a string' });
return;
}
const result = SecretsService.getInstance().create({
name,
description: typeof description === 'string' ? description : undefined,
kv,
user: getActor(req),
note: typeof note === 'string' ? note : undefined,
});
res.status(201).json(result);
} catch (err) {
if (isSqliteUniqueViolation(err)) {
res.status(409).json({ error: 'A secret with that name already exists' });
return;
}
console.error('[Secrets] Create error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to create secret') });
}
});
secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const summary = SecretsService.getInstance().getCurrent(id);
if (!summary) {
res.status(404).json({ error: 'Secret not found' });
return;
}
const kv = SecretsService.getInstance().getDecryptedKv(id);
res.json({ ...summary, kv });
} catch (err) {
console.error('[Secrets] Get error:', err);
res.status(500).json({ error: 'Failed to read secret' });
}
});
secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const { description, kv, note } = req.body as { description?: unknown; kv?: unknown; note?: unknown };
if (description !== undefined && typeof description !== 'string') {
res.status(400).json({ error: 'description must be a string' });
return;
}
if (!isValidKv(kv)) {
res.status(400).json({ error: 'kv must be an object of string values' });
return;
}
if (note !== undefined && typeof note !== 'string') {
res.status(400).json({ error: 'note must be a string' });
return;
}
const existing = DatabaseService.getInstance().getSecret(id);
if (!existing) {
res.status(404).json({ error: 'Secret not found' });
return;
}
const result = SecretsService.getInstance().update(id, {
description: typeof description === 'string' ? description : undefined,
kv,
user: getActor(req),
note: typeof note === 'string' ? note : undefined,
});
res.json(result);
} catch (err) {
console.error('[Secrets] Update error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to update secret') });
}
});
secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const ok = SecretsService.getInstance().delete(id);
if (!ok) {
res.status(404).json({ error: 'Secret not found' });
return;
}
res.json({ ok: true });
} catch (err) {
console.error('[Secrets] Delete error:', err);
res.status(500).json({ error: 'Failed to delete secret' });
}
});
secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
if (!DatabaseService.getInstance().getSecret(id)) {
res.status(404).json({ error: 'Secret not found' });
return;
}
res.json(SecretsService.getInstance().listVersions(id));
} catch (err) {
console.error('[Secrets] Versions error:', err);
res.status(500).json({ error: 'Failed to list versions' });
}
});
secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const { nodeId, stackName, envFileBasename } = req.body as { nodeId?: unknown; stackName?: unknown; envFileBasename?: unknown };
if (typeof nodeId !== 'number' || !Number.isFinite(nodeId)) {
res.status(400).json({ error: 'nodeId must be a number' });
return;
}
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
res.status(400).json({ error: 'stackName is invalid' });
return;
}
const basename = envFileBasename === undefined ? '.env' : envFileBasename;
if (!isValidEnvFileBasename(basename)) {
res.status(400).json({ error: 'envFileBasename is invalid' });
return;
}
const kv = await SecretsService.getInstance().importFromStack(nodeId, stackName, basename);
res.json({ kv });
} catch (err) {
console.error('[Secrets] Import error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to import env from stack') });
}
});
secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const parsed = parsePushBody(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
if (!DatabaseService.getInstance().getSecret(id)) {
res.status(404).json({ error: 'Secret not found' });
return;
}
const plan = await SecretsService.getInstance().previewPushDiff(id, parsed.selector, parsed.stackName, parsed.envFileBasename);
res.json(plan);
} catch (err) {
console.error('[Secrets] Preview error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to preview push') });
}
});
secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const parsed = parsePushBody(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const secret = DatabaseService.getInstance().getSecret(id);
if (!secret) {
res.status(404).json({ error: 'Secret not found' });
return;
}
try {
const result = await SecretsService.getInstance().executePush(id, parsed.selector, parsed.stackName, parsed.envFileBasename, getActor(req));
console.log(`[Secrets] Push ${sanitizeForLog(secret.name)} v${secret.current_version}: ${result.results.length} nodes`);
res.json(result);
} catch (err) {
if (err instanceof PushBusyError) {
res.status(409).json({ error: 'A push for this secret is already running' });
return;
}
throw err;
}
} catch (err) {
console.error('[Secrets] Push error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to push secret') });
}
});
+1 -1
View File
@@ -37,7 +37,7 @@ function notifyActionSuccess(category: NotificationCategory, message: string, st
.catch(err => console.error('[Stacks] Failed to dispatch activity for %s:', sanitizeForLog(stackName), err));
}
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
const fsService = FileSystemService.getInstance(nodeId);
const stackDir = path.join(fsService.getBaseDir(), stackName);
const defaultEnvPath = path.join(stackDir, '.env');