Files
Anso 63213c0960 feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
2026-07-19 02:42:29 -04:00

63 lines
2.5 KiB
TypeScript

import type { Request, Response, NextFunction, RequestHandler } from 'express';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import type { ApiTokenScope } from '../services/DatabaseService';
/**
* 403 the request if it is authenticated via an API token. Many admin and
* account-scoped endpoints reject API tokens outright; this helper
* centralises the message and `code: 'SCOPE_DENIED'` envelope. Returns true
* and writes the response when rejected, false otherwise; callers should
* early-return on true.
*/
export function rejectApiTokenScope(req: Request, res: Response, message: string): boolean {
if (req.apiTokenScope) {
res.status(403).json({ error: message, code: 'SCOPE_DENIED' });
return true;
}
return false;
}
// Scope enforcement for API tokens: restricts which endpoints a token can reach.
const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
/^\/api\/stacks\/[^/]+\/deploy$/,
/^\/api\/stacks\/[^/]+\/down$/,
/^\/api\/stacks\/[^/]+\/restart$/,
/^\/api\/stacks\/[^/]+\/stop$/,
/^\/api\/stacks\/[^/]+\/start$/,
/^\/api\/stacks\/[^/]+\/update$/,
/^\/api\/stacks\/[^/]+\/services\/[^/]+\/update$/,
/^\/api\/stacks\/[^/]+\/services\/[^/]+\/restore$/,
];
const deny = (res: Response, req: Request, error: string, scope: ApiTokenScope | 'unknown'): void => {
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', sanitizeForLog(req.method), sanitizeForLog(req.path), 'scope:', scope);
res.status(403).json({ error, code: 'SCOPE_DENIED' });
};
export const enforceApiTokenScope: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
const scope = req.apiTokenScope;
if (!scope) { next(); return; } // Not an API token request
if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', sanitizeForLog(req.method), sanitizeForLog(req.path), 'scope:', scope);
if (scope === 'full-admin') { next(); return; }
if (scope === 'read-only') {
if (req.method === 'GET') { next(); return; }
deny(res, req, 'API token scope "read-only" only allows GET requests.', scope);
return;
}
if (scope === 'deploy-only') {
if (req.method === 'GET') { next(); return; }
const fullPath = `/api${req.path}`;
if (req.method === 'POST' && DEPLOY_ALLOWED_PATTERNS.some(p => p.test(fullPath))) {
next();
return;
}
deny(res, req, 'API token scope "deploy-only" does not allow this action.', scope);
return;
}
deny(res, req, 'Unknown API token scope.', 'unknown');
};