mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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,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);
|
||||
};
|
||||
Reference in New Issue
Block a user