mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
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:
@@ -29,6 +29,7 @@ interface FleetSnapshot {
|
||||
node_count: number;
|
||||
stack_count: number;
|
||||
skipped_nodes: string; // JSON string
|
||||
skipped_stacks: string; // JSON string
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
@@ -58,6 +59,13 @@ interface SkippedNode {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface SkippedStack {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// --- Main Component ---
|
||||
@@ -294,12 +302,12 @@ export default function FleetSnapshots() {
|
||||
});
|
||||
};
|
||||
|
||||
// --- Parse skipped nodes safely ---
|
||||
// --- Parse JSON-array warning columns safely ---
|
||||
|
||||
function parseSkippedNodes(raw: string): SkippedNode[] {
|
||||
function parseJsonArray<T>(raw: string): T[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed as SkippedNode[];
|
||||
if (Array.isArray(parsed)) return parsed as T[];
|
||||
} catch { /* invalid JSON */ }
|
||||
return [];
|
||||
}
|
||||
@@ -353,7 +361,7 @@ export default function FleetSnapshots() {
|
||||
|
||||
{/* Skipped nodes warning */}
|
||||
{(() => {
|
||||
const skipped = parseSkippedNodes(selectedSnapshot.skipped_nodes);
|
||||
const skipped = parseJsonArray<SkippedNode>(selectedSnapshot.skipped_nodes);
|
||||
if (skipped.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-warning/30 bg-warning/5 p-4">
|
||||
@@ -376,6 +384,33 @@ export default function FleetSnapshots() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Partially captured stacks warning */}
|
||||
{(() => {
|
||||
const skipped = parseJsonArray<SkippedStack>(selectedSnapshot.skipped_stacks);
|
||||
if (skipped.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-warning/30 bg-warning/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
|
||||
<span className="text-sm font-medium text-warning">
|
||||
Some stacks were not fully captured:
|
||||
</span>
|
||||
</div>
|
||||
<ul className="ml-6 space-y-1">
|
||||
{skipped.map((stack, i) => (
|
||||
<li key={`${stack.nodeId}:${stack.stackName}:${i}`} className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">{stack.nodeName}</span>
|
||||
{' / '}
|
||||
<span className="font-mono">{stack.stackName}</span>
|
||||
{' - '}
|
||||
{stack.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Node / Stack / File tree */}
|
||||
<div className="space-y-2">
|
||||
{selectedSnapshot.nodes.map(node => {
|
||||
@@ -580,8 +615,13 @@ export default function FleetSnapshots() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedSnapshots.map(snapshot => {
|
||||
const skipped = parseSkippedNodes(snapshot.skipped_nodes);
|
||||
const skippedNames = skipped.map(s => s.nodeName).join(', ');
|
||||
const skippedNodes = parseJsonArray<SkippedNode>(snapshot.skipped_nodes);
|
||||
const skippedStacks = parseJsonArray<SkippedStack>(snapshot.skipped_stacks);
|
||||
const warningCount = skippedNodes.length + skippedStacks.length;
|
||||
const warningTitle = [
|
||||
skippedNodes.length > 0 ? `Nodes: ${skippedNodes.map(s => s.nodeName).join(', ')}` : '',
|
||||
skippedStacks.length > 0 ? `Stacks: ${skippedStacks.map(s => `${s.nodeName}/${s.stackName}`).join(', ')}` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<TableRow key={snapshot.id}>
|
||||
<TableCell className="text-xs font-mono tabular-nums whitespace-nowrap">
|
||||
@@ -609,13 +649,13 @@ export default function FleetSnapshots() {
|
||||
{snapshot.stack_count} stack{snapshot.stack_count !== 1 ? 's' : ''}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skipped.length > 0 ? (
|
||||
{warningCount > 0 ? (
|
||||
<span
|
||||
className="flex items-center gap-1 text-warning"
|
||||
title={`Skipped: ${skippedNames}`}
|
||||
title={warningTitle}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-mono tabular-nums">{skipped.length}</span>
|
||||
<span className="text-xs font-mono tabular-nums">{warningCount}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">None</span>
|
||||
|
||||
@@ -78,11 +78,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="snapshots">
|
||||
<TabsTrigger value="snapshots">
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isAdmin && (
|
||||
<TabsHighlightItem value="snapshots">
|
||||
<TabsTrigger value="snapshots">
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="configuration">
|
||||
<TabsTrigger value="configuration">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
|
||||
@@ -192,9 +194,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
{isAdmin && (
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="configuration">
|
||||
<FleetConfiguration />
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user