fix(fleet-snapshots): gate reads on admin role and encrypt content at rest (#1273)

* fix(fleet-snapshots): gate reads on admin role and encrypt content at rest

Fleet snapshots capture every node's compose.yaml and .env, so the data is
as sensitive as the live stacks. This hardens access and reliability across
the snapshot pipeline.

- Restrict snapshot reads to administrators. GET /api/fleet/snapshots and
  /:id now require the admin role, matching create, restore, and delete; the
  Fleet "Snapshots" tab and its panel render only for admins. Previously any
  authenticated user could enumerate snapshots and read every node's .env.

- Encrypt snapshot file contents at rest with the instance key. Restore and
  cloud-archive paths decrypt on read, so cloud archives stay portable and a
  database copy no longer exposes stack secrets in plaintext. Rows written
  before this change still read back as plaintext.

- Surface partial captures. A stack whose compose file cannot be read or
  fetched, or a file over the 1 MB capture cap, is recorded as a warning and
  shown on the snapshot instead of being silently dropped, so a snapshot is
  never mistaken for complete. Remote .env read errors are now distinguished
  from a genuinely absent .env.

Adds route-authz, capture-warning, and encryption round-trip tests; updates
the Fleet-Wide Backups feature docs.

* fix(fleet-snapshots): gate cloud snapshot reads on admin role

The cloud snapshot read routes were guarded by provider/license only, not by
role, while their write counterparts (upload, delete) already required admin
and the Cloud Backup settings surface is admin-only. Because a downloaded
archive contains plaintext compose and .env files, a non-admin could list and
download cloud snapshots and read every node's secrets, the same exposure the
local snapshot reads were just closed against.

- Require admin on GET /api/cloud-backup/snapshots, /status/:id, and
  /object/:keyB64/download, matching the local snapshot reads and the
  admin-only Cloud Backup settings section.
- When capturing a remote node, treat a 200 response carrying X-Env-Exists:
  false as a stack with no .env (matching the local ENOENT path) instead of
  storing an empty .env that restore would later write back.

Adds non-admin authorization tests for the cloud read routes and a remote
absent-.env capture test.
This commit is contained in:
Anso
2026-06-01 17:27:59 -04:00
committed by GitHub
parent 0953025036
commit c11a550b6a
15 changed files with 630 additions and 81 deletions
+23 -7
View File
@@ -232,7 +232,8 @@ export interface FleetSnapshot {
created_by: string;
node_count: number;
stack_count: number;
skipped_nodes: string;
skipped_nodes: string; // JSON: Array<{ nodeId; nodeName; reason }>
skipped_stacks: string; // JSON: Array<{ nodeId; nodeName; stackName; reason }>
created_at: number;
}
@@ -821,6 +822,7 @@ export class DatabaseService {
node_count INTEGER NOT NULL,
stack_count INTEGER NOT NULL,
skipped_nodes TEXT NOT NULL DEFAULT '[]',
skipped_stacks TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL
);
@@ -1196,6 +1198,9 @@ export class DatabaseService {
maybeAddCol('nodes', 'pilot_last_seen', 'INTEGER');
maybeAddCol('nodes', 'pilot_agent_version', 'TEXT');
// Fleet snapshot per-stack capture warnings (partial-capture surfacing)
maybeAddCol('fleet_snapshots', 'skipped_stacks', "TEXT NOT NULL DEFAULT '[]'");
// Scheduled operations migrations
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
@@ -2882,20 +2887,25 @@ export class DatabaseService {
// --- Fleet Snapshots ---
public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string): number {
public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string, skippedStacks = '[]'): number {
const result = this.db.prepare(
'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(description, createdBy, nodeCount, stackCount, skippedNodes, Date.now());
'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(description, createdBy, nodeCount, stackCount, skippedNodes, skippedStacks, Date.now());
return result.lastInsertRowid as number;
}
public insertSnapshotFiles(snapshotId: number, files: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>): void {
// Snapshot file bodies are compose.yaml and .env captures, so they carry
// the same secrets as the live stack. Encrypt content at rest with the
// instance key; getSnapshotFiles/getSnapshotStackFiles decrypt on read,
// so restore and cloud-archive paths see plaintext and stay portable.
const crypto = CryptoService.getInstance();
const insert = this.db.prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)'
);
const insertMany = this.db.transaction((rows: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>) => {
for (const row of rows) {
insert.run(snapshotId, row.nodeId, row.nodeName, row.stackName, row.filename, row.content);
insert.run(snapshotId, row.nodeId, row.nodeName, row.stackName, row.filename, crypto.encrypt(row.content));
}
});
insertMany(files);
@@ -2912,15 +2922,21 @@ export class DatabaseService {
}
public getSnapshotFiles(snapshotId: number): FleetSnapshotFile[] {
return this.db.prepare(
const crypto = CryptoService.getInstance();
const rows = this.db.prepare(
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? ORDER BY node_name, stack_name'
).all(snapshotId) as FleetSnapshotFile[];
// decrypt() returns non-ciphertext input unchanged, so rows written
// before content-at-rest encryption still read back as plaintext.
return rows.map(row => ({ ...row, content: crypto.decrypt(row.content) }));
}
public getSnapshotStackFiles(snapshotId: number, nodeId: number, stackName: string): FleetSnapshotFile[] {
return this.db.prepare(
const crypto = CryptoService.getInstance();
const rows = this.db.prepare(
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? AND node_id = ? AND stack_name = ?'
).all(snapshotId, nodeId, stackName) as FleetSnapshotFile[];
return rows.map(row => ({ ...row, content: crypto.decrypt(row.content) }));
}
public deleteSnapshot(id: number): void {
+21 -4
View File
@@ -11,7 +11,7 @@ import type { ImageCheckResult } from './ImageUpdateService';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot-capture';
import { captureLocalNodeFiles, captureRemoteNodeFiles, type SnapshotNodeData } from '../utils/snapshot-capture';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService';
@@ -562,7 +562,7 @@ export class SchedulerService {
})
);
const capturedNodes: Array<{ nodeId: number; nodeName: string; stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> }> = [];
const capturedNodes: SnapshotNodeData[] = [];
const skippedNodes: Array<{ nodeId: number; nodeName: string; reason: string }> = [];
results.forEach((result, i) => {
@@ -579,6 +579,7 @@ export class SchedulerService {
let totalStacks = 0;
const allFiles: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }> = [];
const skippedStacks: Array<{ nodeId: number; nodeName: string; stackName: string; reason: string }> = [];
for (const nodeData of capturedNodes) {
totalStacks += nodeData.stacks.length;
@@ -593,6 +594,14 @@ export class SchedulerService {
});
}
}
for (const warning of nodeData.warnings) {
skippedStacks.push({
nodeId: nodeData.nodeId,
nodeName: nodeData.nodeName,
stackName: warning.stackName,
reason: warning.reason,
});
}
}
const description = `Scheduled snapshot: ${task.name}`;
@@ -602,6 +611,7 @@ export class SchedulerService {
capturedNodes.length,
totalStacks,
JSON.stringify(skippedNodes),
JSON.stringify(skippedStacks),
);
if (allFiles.length > 0) {
@@ -622,11 +632,18 @@ export class SchedulerService {
}
}
if (skippedNodes.length > 0 || skippedStacks.length > 0) {
console.warn(`[SchedulerService] Snapshot task ${task.id} partial: skipped ${skippedNodes.length} node(s), ${skippedStacks.length} stack(s)`);
}
if (isDebugEnabled()) {
console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}${cloudUploadNote}`);
console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length} node(s)/${skippedStacks.length} stack(s)${cloudUploadNote}`);
}
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''}${cloudUploadNote})`;
const skippedNote = [
skippedNodes.length > 0 ? `${skippedNodes.length} node(s)` : '',
skippedStacks.length > 0 ? `${skippedStacks.length} stack(s)` : '',
].filter(Boolean).join(', ');
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNote ? `, skipped ${skippedNote}` : ''}${cloudUploadNote})`;
}
private async executePrune(task: ScheduledTask): Promise<string> {