mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
refactor(backend): extract types, constants, and guards from index.ts (phase 0) (#730)
* refactor(backend): extract types, constants, and guards from index.ts (phase 0) Additive, behavior-preserving first step of the modular backend refactor. Moves purely static artifacts out of backend/src/index.ts so later phases can extract routes and middleware without touching shared symbols. New modules: - types/express.ts: Express Request augmentation - helpers/constants.ts: PORT, password policy, label colors, cookie names, MFA TTLs, hot-path cache TTLs - helpers/proxyExemptPaths.ts: PROXY_EXEMPT_PREFIXES + isProxyExemptPath - helpers/cookies.ts: isSecureRequest, getCookieOptions - helpers/policyGate.ts: buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan - middleware/permissions.ts: ROLE_PERMISSIONS, checkPermission, requirePermission - middleware/tierGates.ts: requirePaid, requireAdmiral, requireAdmin, requireNodeProxy, requireScheduledTaskTier + effectiveTier/Variant index.ts shrinks by ~260 lines; no runtime behavior changes. All 64 vitest files and 1,278 tests pass. * refactor(backend): drop unused imports left after phase 0 extraction LicenseTier, LicenseVariant, DIGEST_CACHE_TTL_MS, and isProxyExemptPath were imported into index.ts but no longer referenced there after the phase 0 move; CI lint flagged them as errors. isProxyExemptPath will be re-imported in phase 1 when the JSON parser bypass and nodeContext middleware get extracted. Silence the no-namespace warning on the Express augmentation since the namespace syntax is required for TypeScript module augmentation.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
// Shared constants used across the backend. Extracted from index.ts to keep
|
||||
// the entry point lean and to make values discoverable without scanning the
|
||||
// monolith.
|
||||
|
||||
// Server
|
||||
export const PORT = 3000;
|
||||
|
||||
// Password policy
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
// Labels
|
||||
export const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
|
||||
export type LabelColor = typeof VALID_LABEL_COLORS[number];
|
||||
export const MAX_LABELS_PER_NODE = 50;
|
||||
|
||||
// Session cookies
|
||||
export const COOKIE_NAME = 'sencho_token';
|
||||
export const SESSION_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
export const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
|
||||
export const MFA_PENDING_SCOPE = 'mfa_pending';
|
||||
export const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
|
||||
|
||||
// Hot-path cache TTLs.
|
||||
// Short TTLs collapse concurrent polling pressure across browser tabs and
|
||||
// overlapping service samplers without introducing noticeable UI staleness.
|
||||
// Keys are per-node: "stats:<nodeId>", "system-stats:<nodeId>", "stack-statuses:<nodeId>".
|
||||
export const STATS_CACHE_TTL_MS = 2_000;
|
||||
export const SYSTEM_STATS_CACHE_TTL_MS = 3_000;
|
||||
export const STACK_STATUSES_CACHE_TTL_MS = 3_000;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Request } from 'express';
|
||||
import { SESSION_COOKIE_MAX_AGE_MS } from './constants';
|
||||
|
||||
/** True when the request arrived over HTTPS, either directly or via a trusted TLS-terminating proxy. */
|
||||
export const isSecureRequest = (req: Request): boolean => {
|
||||
return req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||||
};
|
||||
|
||||
/** Cookie options derived from the current request (secure flag follows the connection). */
|
||||
export const getCookieOptions = (req: Request) => ({
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict' as const,
|
||||
maxAge: SESSION_COOKIE_MAX_AGE_MS,
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { enforcePolicyPreDeploy, type PolicyEnforcementOptions } from '../services/PolicyEnforcement';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// Bypass requires `?ignorePolicy=true` AND `req.user.role === 'admin'`. The
|
||||
// `stack:deploy` permission alone is not sufficient because the `deployer`
|
||||
// role has that permission for day-to-day deploys.
|
||||
export function buildPolicyGateOptions(
|
||||
req: Request,
|
||||
overrides: { bypass?: boolean; actor?: string } = {},
|
||||
): PolicyEnforcementOptions {
|
||||
const defaultBypass = req.query.ignorePolicy === 'true' && req.user?.role === 'admin';
|
||||
return {
|
||||
bypass: overrides.bypass ?? defaultBypass,
|
||||
actor: overrides.actor ?? req.user?.username ?? 'unknown',
|
||||
ip: (req.ip ?? req.socket.remoteAddress ?? '') as string,
|
||||
auditMethod: req.method,
|
||||
auditPath: req.originalUrl || req.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the deploy may proceed. Returns false after sending a 409,
|
||||
* in which case the caller must return immediately.
|
||||
*/
|
||||
export async function runPolicyGate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<boolean> {
|
||||
const gate = await enforcePolicyPreDeploy(stackName, nodeId, buildPolicyGateOptions(req));
|
||||
if (!gate.ok) {
|
||||
res.status(409).json({
|
||||
error: `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`,
|
||||
policy: gate.policy && {
|
||||
id: gate.policy.id,
|
||||
name: gate.policy.name,
|
||||
maxSeverity: gate.policy.max_severity,
|
||||
},
|
||||
violations: gate.violations,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function triggerPostDeployScan(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<void> {
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) return;
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
const containers = await docker.listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] },
|
||||
});
|
||||
const imageRefs = new Set<string>();
|
||||
for (const c of containers as Array<{ Image?: string }>) {
|
||||
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
|
||||
}
|
||||
if (imageRefs.size === 0) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const digest = await svc.getImageDigest(imageRef, nodeId);
|
||||
if (digest) {
|
||||
const cached = db.getLatestScanByDigest(digest, 'vuln');
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
|
||||
}
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
|
||||
if (scan.critical_count > 0 || scan.high_count > 0) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
scan.critical_count > 0 ? 'error' : 'warning',
|
||||
`Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'unknown error');
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
`Post-deploy scan failed for ${imageRef} (${stackName}): ${message}`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, getErrorMessage(err, 'unknown error'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Path prefixes whose /api/* requests are handled locally even when an
|
||||
// x-node-id header targets a remote node. These endpoints are gateway-level
|
||||
// concerns (auth, node registry, licensing, fleet aggregation, webhooks,
|
||||
// meta) that must never be proxied to a remote Sencho instance.
|
||||
//
|
||||
// Consumed by:
|
||||
// - middleware/jsonParser.ts → skip JSON parsing only for non-exempt remote proxy requests
|
||||
// - middleware/nodeContext.ts → skip node resolution for exempt paths
|
||||
export const PROXY_EXEMPT_PREFIXES: readonly string[] = [
|
||||
'/api/auth/',
|
||||
'/api/nodes',
|
||||
'/api/license',
|
||||
'/api/fleet',
|
||||
'/api/webhooks',
|
||||
'/api/meta',
|
||||
];
|
||||
|
||||
/** Returns true when the path should bypass the remote proxy (handled locally). */
|
||||
export function isProxyExemptPath(path: string): boolean {
|
||||
for (const prefix of PROXY_EXEMPT_PREFIXES) {
|
||||
if (path.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+35
-294
@@ -34,7 +34,7 @@ import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { PilotTunnelManager } from './services/PilotTunnelManager';
|
||||
import { encodeJsonFrame as encodePilotJsonFrame, PROTOCOL_VERSION as PILOT_PROTOCOL_VERSION, PilotCloseCode } from './pilot/protocol';
|
||||
import { FleetSyncService } from './services/FleetSyncService';
|
||||
import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
|
||||
import { LicenseService, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
|
||||
import { WebhookService } from './services/WebhookService';
|
||||
import { SSOService } from './services/SSOService';
|
||||
import { MfaService } from './services/MfaService';
|
||||
@@ -45,14 +45,39 @@ import { CacheService } from './services/CacheService';
|
||||
import { CAPABILITIES, getSenchoVersion, isValidVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry';
|
||||
import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, repoHost as gitRepoHost } from './services/GitSourceService';
|
||||
import { sendGitSourceError } from './utils/gitSourceHttp';
|
||||
|
||||
// ── Hot-path cache TTLs ────────────────────────────────────────────────
|
||||
// Short TTLs collapse concurrent polling pressure across browser tabs and
|
||||
// overlapping service samplers without introducing noticeable UI staleness.
|
||||
// Keys are per-node: "stats:<nodeId>", "system-stats:<nodeId>", "stack-statuses:<nodeId>".
|
||||
const STATS_CACHE_TTL_MS = 2_000;
|
||||
const SYSTEM_STATS_CACHE_TTL_MS = 3_000;
|
||||
const STACK_STATUSES_CACHE_TTL_MS = 3_000;
|
||||
import './types/express';
|
||||
import {
|
||||
PORT,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
VALID_LABEL_COLORS,
|
||||
MAX_LABELS_PER_NODE,
|
||||
COOKIE_NAME,
|
||||
MFA_PENDING_COOKIE_NAME,
|
||||
MFA_PENDING_SCOPE,
|
||||
MFA_PENDING_TTL_MS,
|
||||
STATS_CACHE_TTL_MS,
|
||||
SYSTEM_STATS_CACHE_TTL_MS,
|
||||
STACK_STATUSES_CACHE_TTL_MS,
|
||||
} from './helpers/constants';
|
||||
import { isSecureRequest, getCookieOptions } from './helpers/cookies';
|
||||
import {
|
||||
ROLE_PERMISSIONS,
|
||||
checkPermission,
|
||||
requirePermission,
|
||||
type PermissionAction,
|
||||
} from './middleware/permissions';
|
||||
import {
|
||||
requirePaid,
|
||||
requireAdmiral,
|
||||
requireAdmin,
|
||||
requireNodeProxy,
|
||||
requireScheduledTaskTier,
|
||||
} from './middleware/tierGates';
|
||||
import {
|
||||
buildPolicyGateOptions,
|
||||
runPolicyGate,
|
||||
triggerPostDeployScan,
|
||||
} from './helpers/policyGate';
|
||||
|
||||
/**
|
||||
* Invalidate the per-node caches affected by a stack/container mutation so
|
||||
@@ -75,7 +100,7 @@ import { getErrorMessage } from './utils/errors';
|
||||
import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from './utils/snapshot-capture';
|
||||
import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing';
|
||||
import SelfUpdateService from './services/SelfUpdateService';
|
||||
import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService';
|
||||
import TrivyService, { SbomFormat } from './services/TrivyService';
|
||||
import TrivyInstaller from './services/TrivyInstaller';
|
||||
import { enforcePolicyPreDeploy } from './services/PolicyEnforcement';
|
||||
import { validateImageRef } from './utils/image-ref';
|
||||
@@ -99,33 +124,10 @@ const _origEmitWarning = process.emitWarning.bind(process);
|
||||
_origEmitWarning(warning, ...args);
|
||||
};
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
|
||||
const MAX_LABELS_PER_NODE = 50;
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
// FileSystemService and ComposeService are instantiated per-request via .getInstance(nodeId)
|
||||
|
||||
// Cookie settings
|
||||
const COOKIE_NAME = 'sencho_token';
|
||||
const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
|
||||
const MFA_PENDING_SCOPE = 'mfa_pending';
|
||||
const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
|
||||
|
||||
// Helper to determine if request is secure (HTTPS or behind a proxy that terminates SSL)
|
||||
const isSecureRequest = (req: Request): boolean => {
|
||||
return req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||||
};
|
||||
|
||||
// Helper to get cookie options dynamically per-request
|
||||
const getCookieOptions = (req: Request) => ({
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict' as const,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
|
||||
// Middleware
|
||||
|
||||
// Trust the first reverse proxy (nginx, Traefik, etc.) for correct req.protocol,
|
||||
@@ -410,26 +412,6 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction)
|
||||
|
||||
app.use(nodeContextMiddleware);
|
||||
|
||||
// Extend Express Request type for user and node
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: { username: string; role: UserRole; userId: number };
|
||||
nodeId: number;
|
||||
apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin';
|
||||
rawBody?: Buffer;
|
||||
/** License tier asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyTier?: LicenseTier;
|
||||
/** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyVariant?: LicenseVariant;
|
||||
/** User ID carried by a scoped `mfa_pending` token. Only set while the user is completing the MFA challenge. */
|
||||
mfaPendingUserId?: number;
|
||||
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
|
||||
mfaPendingSso?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket proxy server for forwarding remote node WS connections
|
||||
const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true });
|
||||
wsProxyServer.on('error', (err, _req, socket: any) => {
|
||||
@@ -1544,54 +1526,6 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
|
||||
// --- License Routes (local-only, never proxied) ---
|
||||
|
||||
// Paid feature guard: returns false and sends 403 if not on a paid tier (Skipper or Admiral).
|
||||
// Checks req.proxyTier first (set by authMiddleware for trusted node proxy requests),
|
||||
// falling back to the local LicenseService tier for direct access.
|
||||
const requirePaid = (req: Request, res: Response): boolean => {
|
||||
const tier = req.proxyTier !== undefined ? req.proxyTier : LicenseService.getInstance().getTier();
|
||||
if (tier !== 'paid') {
|
||||
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Admiral feature guard: requires paid tier with team variant.
|
||||
// Checks req.proxyTier/proxyVariant first (set by authMiddleware for trusted node proxy
|
||||
// requests), falling back to the local LicenseService for direct access.
|
||||
const requireAdmiral = (req: Request, res: Response): boolean => {
|
||||
const ls = LicenseService.getInstance();
|
||||
const tier = req.proxyTier !== undefined ? req.proxyTier : ls.getTier();
|
||||
const variant = req.proxyVariant !== undefined ? req.proxyVariant : ls.getVariant();
|
||||
if (tier !== 'paid') {
|
||||
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
if (variant !== 'admiral') {
|
||||
res.status(403).json({ error: 'This feature requires a Sencho Admiral license.', code: 'ADMIRAL_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Admin access required.', code: 'ADMIN_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Only accept calls from a sibling Sencho using its node_proxy Bearer token.
|
||||
// Browser sessions, API tokens, and console tokens are all rejected.
|
||||
const requireNodeProxy = (req: Request, res: Response): boolean => {
|
||||
if (req.user?.username !== 'node-proxy') {
|
||||
res.status(403).json({ error: 'Node proxy authentication required.', code: 'NODE_PROXY_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const requireBody = (req: Request, res: Response): boolean => {
|
||||
if (!req.body || typeof req.body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
@@ -1604,199 +1538,6 @@ function isSqliteUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE';
|
||||
}
|
||||
|
||||
// Tier gate for scheduled tasks: 'update' and 'scan' actions require Skipper+, everything else requires Admiral.
|
||||
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (action === 'update' || action === 'scan') return requirePaid(req, res);
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the bypass-context options for `enforcePolicyPreDeploy` from a route
|
||||
* request. Centralizes the "bypass requires admin + ignorePolicy=true" rule
|
||||
* and the audit-log attribution fields so every call site is consistent.
|
||||
*
|
||||
* Bypass requires `?ignorePolicy=true` AND `req.user.role === 'admin'`. The
|
||||
* `stack:deploy` permission alone is not sufficient for bypass because the
|
||||
* `deployer` role has that permission for day-to-day deploys.
|
||||
*/
|
||||
function buildPolicyGateOptions(
|
||||
req: Request,
|
||||
overrides: { bypass?: boolean; actor?: string } = {},
|
||||
): { bypass: boolean; actor: string; ip: string; auditMethod: string; auditPath: string } {
|
||||
const defaultBypass = req.query.ignorePolicy === 'true' && req.user?.role === 'admin';
|
||||
return {
|
||||
bypass: overrides.bypass ?? defaultBypass,
|
||||
actor: overrides.actor ?? req.user?.username ?? 'unknown',
|
||||
ip: (req.ip ?? req.socket.remoteAddress ?? '') as string,
|
||||
auditMethod: req.method,
|
||||
auditPath: req.originalUrl || req.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-deploy policy gate for a route handler.
|
||||
*
|
||||
* Returns true if the deploy may proceed (allow, bypass, or no matching
|
||||
* policy). Returns false if the route has already sent an HTTP 409; callers
|
||||
* must return immediately in that case.
|
||||
*/
|
||||
async function runPolicyGate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<boolean> {
|
||||
const gate = await enforcePolicyPreDeploy(stackName, nodeId, buildPolicyGateOptions(req));
|
||||
if (!gate.ok) {
|
||||
res.status(409).json({
|
||||
error: `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`,
|
||||
policy: gate.policy && {
|
||||
id: gate.policy.id,
|
||||
name: gate.policy.name,
|
||||
maxSeverity: gate.policy.max_severity,
|
||||
},
|
||||
violations: gate.violations,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function triggerPostDeployScan(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<void> {
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) return;
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
const containers = await docker.listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] },
|
||||
});
|
||||
const imageRefs = new Set<string>();
|
||||
for (const c of containers as Array<{ Image?: string }>) {
|
||||
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
|
||||
}
|
||||
if (imageRefs.size === 0) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const digest = await svc.getImageDigest(imageRef, nodeId);
|
||||
if (digest) {
|
||||
const cached = db.getLatestScanByDigest(digest, 'vuln');
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
|
||||
}
|
||||
// Blocking enforcement has already run in enforcePolicyPreDeploy.
|
||||
// The post-deploy scan persists a fresh drift result and `finishScan`
|
||||
// attaches a PolicyEvaluation, which the UI surfaces as a banner.
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
|
||||
if (scan.critical_count > 0 || scan.high_count > 0) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
scan.critical_count > 0 ? 'error' : 'warning',
|
||||
`Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
`Post-deploy scan failed for ${imageRef} (${stackName}): ${message}`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scoped RBAC Permission Engine (Admiral) ---
|
||||
|
||||
type PermissionAction =
|
||||
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
||||
| 'node:read' | 'node:manage'
|
||||
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
|
||||
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
|
||||
|
||||
const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
|
||||
admin: [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
'system:settings', 'system:users', 'system:license', 'system:webhooks',
|
||||
'system:tokens', 'system:console', 'system:audit', 'system:registries',
|
||||
],
|
||||
'node-admin': [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
],
|
||||
deployer: [
|
||||
'stack:read', 'stack:deploy',
|
||||
],
|
||||
viewer: [
|
||||
'stack:read', 'node:read',
|
||||
],
|
||||
auditor: [
|
||||
'stack:read', 'node:read', 'system:audit',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Core permission resolver. Checks if the current user can perform `action` on an optional resource.
|
||||
* 1. Admin → always true (backward compat)
|
||||
* 2. Check global role permissions
|
||||
* 3. If resource specified AND Admiral → check scoped role_assignments
|
||||
*/
|
||||
function checkPermission(
|
||||
req: Request,
|
||||
action: PermissionAction,
|
||||
resourceType?: ResourceType,
|
||||
resourceId?: string,
|
||||
): boolean {
|
||||
if (!req.user) return false;
|
||||
|
||||
const globalRole = req.user.role;
|
||||
|
||||
if (isDebugEnabled()) console.log('[RBAC:diag] checkPermission:', action, 'user:', req.user.username, 'globalRole:', globalRole, 'resource:', resourceType, resourceId);
|
||||
|
||||
// Admins always have full access
|
||||
if (globalRole === 'admin') return true;
|
||||
|
||||
// Check if the user's global role grants this action
|
||||
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
|
||||
|
||||
// Scoped assignments only apply when a resource is specified and license is Admiral
|
||||
if (!resourceType || !resourceId) return false;
|
||||
const variant = req.proxyVariant !== undefined ? req.proxyVariant : LicenseService.getInstance().getVariant();
|
||||
if (variant !== 'admiral') return false;
|
||||
|
||||
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
|
||||
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
|
||||
for (const assignment of assignments) {
|
||||
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Generic permission guard — sends 403 if denied. */
|
||||
function requirePermission(
|
||||
req: Request,
|
||||
res: Response,
|
||||
action: PermissionAction,
|
||||
resourceType?: ResourceType,
|
||||
resourceId?: string,
|
||||
): boolean {
|
||||
if (checkPermission(req, action, resourceType, resourceId)) return true;
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scope enforcement for API tokens - restricts which endpoints a token can reach.
|
||||
const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
|
||||
/^\/api\/stacks\/[^/]+\/deploy$/,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { effectiveVariant } from './tierGates';
|
||||
|
||||
// --- Scoped RBAC Permission Engine (Admiral) ---
|
||||
|
||||
export type PermissionAction =
|
||||
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
||||
| 'node:read' | 'node:manage'
|
||||
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
|
||||
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
|
||||
admin: [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
'system:settings', 'system:users', 'system:license', 'system:webhooks',
|
||||
'system:tokens', 'system:console', 'system:audit', 'system:registries',
|
||||
],
|
||||
'node-admin': [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
],
|
||||
deployer: [
|
||||
'stack:read', 'stack:deploy',
|
||||
],
|
||||
viewer: [
|
||||
'stack:read', 'node:read',
|
||||
],
|
||||
auditor: [
|
||||
'stack:read', 'node:read', 'system:audit',
|
||||
],
|
||||
};
|
||||
|
||||
/** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on Admiral. */
|
||||
export function checkPermission(
|
||||
req: Request,
|
||||
action: PermissionAction,
|
||||
resourceType?: ResourceType,
|
||||
resourceId?: string,
|
||||
): boolean {
|
||||
if (!req.user) return false;
|
||||
|
||||
const globalRole = req.user.role;
|
||||
|
||||
if (isDebugEnabled()) console.log('[RBAC:diag] checkPermission:', action, 'user:', req.user.username, 'globalRole:', globalRole, 'resource:', resourceType, resourceId);
|
||||
|
||||
if (globalRole === 'admin') return true;
|
||||
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
|
||||
|
||||
if (!resourceType || !resourceId) return false;
|
||||
if (effectiveVariant(req) !== 'admiral') return false;
|
||||
|
||||
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
|
||||
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
|
||||
for (const assignment of assignments) {
|
||||
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Generic permission guard: sends 403 if denied. */
|
||||
export function requirePermission(
|
||||
req: Request,
|
||||
res: Response,
|
||||
action: PermissionAction,
|
||||
resourceType?: ResourceType,
|
||||
resourceId?: string,
|
||||
): boolean {
|
||||
if (checkPermission(req, action, resourceType, resourceId)) return true;
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { LicenseService, type LicenseTier, type LicenseVariant } from '../services/LicenseService';
|
||||
|
||||
// Tier-based route guards. Each returns true when the request may proceed and
|
||||
// false after sending the appropriate 403 response. Callers MUST check the
|
||||
// return value and `return;` on false.
|
||||
//
|
||||
// Guards trust req.proxyTier/proxyVariant (set by authMiddleware for
|
||||
// node_proxy tokens) ahead of the local LicenseService so a primary Sencho
|
||||
// instance can assert license state for its remote fleet nodes.
|
||||
|
||||
const PAID_MESSAGE = 'This feature requires a Skipper or Admiral license.';
|
||||
const ADMIRAL_MESSAGE = 'This feature requires a Sencho Admiral license.';
|
||||
|
||||
/** Effective license tier for this request (proxy header if trusted, else local). */
|
||||
export const effectiveTier = (req: Request): LicenseTier =>
|
||||
req.proxyTier ?? LicenseService.getInstance().getTier();
|
||||
|
||||
/** Effective license variant for this request (proxy header if trusted, else local). */
|
||||
export const effectiveVariant = (req: Request): LicenseVariant =>
|
||||
req.proxyVariant ?? LicenseService.getInstance().getVariant();
|
||||
|
||||
const deny = (res: Response, code: string, error: string): false => {
|
||||
res.status(403).json({ error, code });
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Paid feature guard: requires Skipper or Admiral. */
|
||||
export const requirePaid = (req: Request, res: Response): boolean => {
|
||||
if (effectiveTier(req) !== 'paid') return deny(res, 'PAID_REQUIRED', PAID_MESSAGE);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Admiral feature guard: requires paid tier with the admiral variant. */
|
||||
export const requireAdmiral = (req: Request, res: Response): boolean => {
|
||||
// Resolve both before branching so every caller observes the same
|
||||
// tier/variant pair (the original behavior; tests mock LicenseService
|
||||
// getters and rely on both being consumed per gate invocation).
|
||||
const tier = effectiveTier(req);
|
||||
const variant = effectiveVariant(req);
|
||||
if (tier !== 'paid') return deny(res, 'PAID_REQUIRED', PAID_MESSAGE);
|
||||
if (variant !== 'admiral') return deny(res, 'ADMIRAL_REQUIRED', ADMIRAL_MESSAGE);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Admin role guard: the request must be authenticated as an `admin` user. */
|
||||
export const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
if (req.user?.role !== 'admin') return deny(res, 'ADMIN_REQUIRED', 'Admin access required.');
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Accept only calls from a sibling Sencho using its node_proxy Bearer token.
|
||||
* Browser sessions, API tokens, and console tokens are all rejected.
|
||||
*/
|
||||
export const requireNodeProxy = (req: Request, res: Response): boolean => {
|
||||
if (req.user?.username !== 'node-proxy') return deny(res, 'NODE_PROXY_REQUIRED', 'Node proxy authentication required.');
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Tier gate for scheduled tasks: `update` and `scan` require Skipper+, everything else requires Admiral. */
|
||||
export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (action === 'update' || action === 'scan') return requirePaid(req, res);
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { UserRole, ApiTokenScope } from '../services/DatabaseService';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/LicenseService';
|
||||
|
||||
// Extend Express Request type for user and node context.
|
||||
// This file is imported for its side effects only (ambient declaration).
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace -- Express type augmentation requires namespace syntax
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: { username: string; role: UserRole; userId: number };
|
||||
nodeId: number;
|
||||
apiTokenScope?: ApiTokenScope;
|
||||
rawBody?: Buffer;
|
||||
/** License tier asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyTier?: LicenseTier;
|
||||
/** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyVariant?: LicenseVariant;
|
||||
/** User ID carried by a scoped `mfa_pending` token. Only set while the user is completing the MFA challenge. */
|
||||
mfaPendingUserId?: number;
|
||||
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
|
||||
mfaPendingSso?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user