mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +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:
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireBody } from '../middleware/tierGates';
|
||||
import { requirePaid, requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates';
|
||||
import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService';
|
||||
import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
@@ -10,6 +10,11 @@ import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export const secretsRouter = Router();
|
||||
|
||||
// Fleet Secrets reveals decrypted values and writes credentials fleet-wide, so
|
||||
// every route requires a signed-in admin user. requireUserSession rejects API
|
||||
// tokens and node_proxy / pilot_tunnel machine credentials (authMiddleware maps
|
||||
// the latter to role 'admin'), then requireAdmin enforces the role.
|
||||
|
||||
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,62}[a-zA-Z0-9]$/;
|
||||
|
||||
function isValidName(name: unknown): name is string {
|
||||
@@ -60,7 +65,9 @@ function parsePushBody(body: unknown): PushBody | { error: string } {
|
||||
}
|
||||
|
||||
secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const items = SecretsService.getInstance().list();
|
||||
res.json(items);
|
||||
@@ -71,7 +78,9 @@ secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Prom
|
||||
});
|
||||
|
||||
secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(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 };
|
||||
@@ -98,6 +107,7 @@ secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pro
|
||||
user: getActor(req),
|
||||
note: typeof note === 'string' ? note : undefined,
|
||||
});
|
||||
console.log(`[Secrets] Created bundle ${sanitizeForLog(name)} (v${result.version}) by ${sanitizeForLog(getActor(req))}`);
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
if (isSqliteUniqueViolation(err)) {
|
||||
@@ -110,7 +120,9 @@ secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pro
|
||||
});
|
||||
|
||||
secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||
if (id === null) return;
|
||||
@@ -128,7 +140,9 @@ secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): P
|
||||
});
|
||||
|
||||
secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||
@@ -157,6 +171,7 @@ secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): P
|
||||
user: getActor(req),
|
||||
note: typeof note === 'string' ? note : undefined,
|
||||
});
|
||||
console.log(`[Secrets] Updated bundle ${sanitizeForLog(existing.name)} to v${result.version} by ${sanitizeForLog(getActor(req))}`);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[Secrets] Update error:', err);
|
||||
@@ -165,7 +180,9 @@ secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): P
|
||||
});
|
||||
|
||||
secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||
if (id === null) return;
|
||||
@@ -174,6 +191,7 @@ secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response)
|
||||
res.status(404).json({ error: 'Secret not found' });
|
||||
return;
|
||||
}
|
||||
console.log(`[Secrets] Deleted bundle id=${id} by ${sanitizeForLog(getActor(req))}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Secrets] Delete error:', err);
|
||||
@@ -182,7 +200,9 @@ secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||
if (id === null) return;
|
||||
@@ -198,7 +218,9 @@ secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Res
|
||||
});
|
||||
|
||||
secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||
@@ -226,7 +248,9 @@ secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request
|
||||
});
|
||||
|
||||
secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||
@@ -249,7 +273,9 @@ secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res
|
||||
});
|
||||
|
||||
secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||
@@ -266,7 +292,9 @@ secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Respon
|
||||
}
|
||||
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`);
|
||||
const failedCount = result.results.filter(r => r.status === 'failed').length;
|
||||
console.log(`[Secrets] Push ${sanitizeForLog(secret.name)} v${secret.current_version}: ${result.results.length} node(s), ${failedCount} failed (by ${sanitizeForLog(getActor(req))})`);
|
||||
if (failedCount > 0) console.warn(`[Secrets] Push ${sanitizeForLog(secret.name)} v${secret.current_version}: ${failedCount} node(s) failed`);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof PushBusyError) {
|
||||
|
||||
Reference in New Issue
Block a user