From 929e2fa6b1a30fb824769d96ef61154a81580a7f Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 23 Apr 2026 17:58:04 -0400 Subject: [PATCH] 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. --- backend/src/helpers/constants.ts | 29 +++ backend/src/helpers/cookies.ts | 15 ++ backend/src/helpers/policyGate.ts | 101 ++++++++ backend/src/helpers/proxyExemptPaths.ts | 24 ++ backend/src/index.ts | 329 +++--------------------- backend/src/middleware/permissions.ts | 75 ++++++ backend/src/middleware/tierGates.ts | 65 +++++ backend/src/types/express.ts | 26 ++ 8 files changed, 370 insertions(+), 294 deletions(-) create mode 100644 backend/src/helpers/constants.ts create mode 100644 backend/src/helpers/cookies.ts create mode 100644 backend/src/helpers/policyGate.ts create mode 100644 backend/src/helpers/proxyExemptPaths.ts create mode 100644 backend/src/middleware/permissions.ts create mode 100644 backend/src/middleware/tierGates.ts create mode 100644 backend/src/types/express.ts diff --git a/backend/src/helpers/constants.ts b/backend/src/helpers/constants.ts new file mode 100644 index 00000000..1c8f18e4 --- /dev/null +++ b/backend/src/helpers/constants.ts @@ -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:", "system-stats:", "stack-statuses:". +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; diff --git a/backend/src/helpers/cookies.ts b/backend/src/helpers/cookies.ts new file mode 100644 index 00000000..b040ac5f --- /dev/null +++ b/backend/src/helpers/cookies.ts @@ -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, +}); diff --git a/backend/src/helpers/policyGate.ts b/backend/src/helpers/policyGate.ts new file mode 100644 index 00000000..0aef8ec0 --- /dev/null +++ b/backend/src/helpers/policyGate.ts @@ -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 { + 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 { + 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(); + 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')); + } +} diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts new file mode 100644 index 00000000..589aed86 --- /dev/null +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -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; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 18f9a811..46131f73 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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:", "system-stats:", "stack-statuses:". -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 { - 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 { - 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(); - 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 = { - 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$/, diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts new file mode 100644 index 00000000..077631a9 --- /dev/null +++ b/backend/src/middleware/permissions.ts @@ -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 = { + 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; +} diff --git a/backend/src/middleware/tierGates.ts b/backend/src/middleware/tierGates.ts new file mode 100644 index 00000000..c9685032 --- /dev/null +++ b/backend/src/middleware/tierGates.ts @@ -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); +}; diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts new file mode 100644 index 00000000..0efe577e --- /dev/null +++ b/backend/src/types/express.ts @@ -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 {};