mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
fix(fleet-secrets): restrict bundle management to admin hub sessions (#1274)
* fix(fleet-secrets): restrict bundle management to admin hub sessions Fleet Secrets exposes decrypted environment-variable values and writes credentials across the fleet, so every route now requires an admin role, runs only on the instance you are signed into, and rejects long-lived API tokens: - Add an admin-role check to all secrets routes; the frontend Secrets tab renders only for admin users so the affordance matches the backend gate. - Add /api/secrets/ to the hub-only path list so a request carrying a remote node id cannot be proxied to read another node's decrypted values. - Reject API tokens on every secrets route (browser admin sessions only), matching how registry credentials are handled. Also adds lifecycle and developer-mode diagnostic logging (never the secret values) and tests covering the admin boundary on every endpoint, API-token rejection, hub-only enforcement, and diagnostic gating. * fix(fleet-secrets): require a signed-in user session for all secrets routes The earlier API-token rejection only blocked opaque API tokens. node_proxy and pilot_tunnel JWTs are mapped to an admin role by the auth middleware without an API-token scope, so they still passed the admin gate and could read decrypted bundles via GET /api/secrets/:id. Replace the API-token check with requireUserSession, which rejects API tokens and node_proxy / pilot_tunnel machine credentials (userId 0) on every secrets route, returning SESSION_REQUIRED. The admin role is still enforced after. Tests now assert SESSION_REQUIRED for a full-admin API token across all nine routes and for node_proxy and pilot_tunnel JWTs. * test(fleet-secrets): mint the rejection-test token via the real endpoint The machine-credential test reconstructed an API token by sha256-hashing a raw key inline. That duplicated a hashing sink that CodeQL's js/insufficient-password-hash query flags (a false positive for a 256-bit random token, but a new occurrence in the diff). Create the token through POST /api/api-tokens instead, so the hashing stays in the production path and the test carries none of its own. Behavior and coverage are unchanged.
This commit is contained in:
@@ -8,6 +8,7 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
import { resolveAllEnvFilePaths } from '../routes/stacks';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { formatNoTargetError } from '../utils/remoteTarget';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
export type SecretKv = Record<string, string>;
|
||||
export type DiffStatus = 'added' | 'changed' | 'removed' | 'unchanged';
|
||||
@@ -427,6 +428,7 @@ export class SecretsService {
|
||||
const db = DatabaseService.getInstance();
|
||||
const overlay = this.getDecryptedKv(id);
|
||||
const matched = NodeLabelService.getInstance().matchSelector(selector, db.getNodes());
|
||||
if (isDebugEnabled()) console.log(`[Secrets:diag] preview id=${id} targets=${matched.length}`);
|
||||
|
||||
// Preview is read-only and per-node; fan out in parallel for snappier wizard UX.
|
||||
return Promise.all(matched.map(async (node): Promise<SecretPushPlanEntry> => {
|
||||
@@ -472,6 +474,7 @@ export class SecretsService {
|
||||
const overlay = decryptKv(versionRow.encrypted_payload);
|
||||
const matched = NodeLabelService.getInstance().matchSelector(selector, db.getNodes());
|
||||
const pushId = crypto.randomUUID();
|
||||
if (isDebugEnabled()) console.log(`[Secrets:diag] push id=${id} v${versionRow.version} targets=${matched.length}`);
|
||||
const results: SecretPushResultEntry[] = [];
|
||||
const rows: Array<Parameters<typeof db.insertSecretPushes>[0][number]> = [];
|
||||
const pushedAt = Date.now();
|
||||
@@ -526,6 +529,10 @@ export class SecretsService {
|
||||
} finally {
|
||||
activePushes.delete(id);
|
||||
}
|
||||
if (isDebugEnabled()) {
|
||||
const okCount = results.filter(r => r.status === 'ok').length;
|
||||
console.log(`[Secrets:diag] push id=${id} done in ${Date.now() - pushedAt}ms ok=${okCount} failed=${results.length - okCount}`);
|
||||
}
|
||||
return { pushId, results };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user