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:
Anso
2026-06-01 19:47:06 -04:00
committed by GitHub
parent 53be6a258e
commit 9fb4ccccff
7 changed files with 227 additions and 9 deletions
+7
View File
@@ -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 };
}
}