mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
refactor(backend): extract webhooks, users, git-sources, and fleet routers (phase 4a-3) (#736)
Final slice of Phase 4 Round A. Pulls the four remaining well-tested route
groups out of index.ts. index.ts drops from ~5,930 to ~4,206 lines.
New route files:
- routes/webhooks.ts: /api/webhooks CRUD + HMAC-authenticated trigger.
Uses shared webhookTriggerLimiter. Trigger preserves the raw-body path
established by the conditional JSON parser for HMAC validation.
- routes/users.ts: /api/users CRUD + /:id/mfa/reset + /:id/roles
scoped-assignment surface. Uses rejectApiTokenScope across every
handler, validateUsername helper, BCRYPT_SALT_ROUNDS, and
isSqliteUniqueViolation for the role-assignment UNIQUE guard.
- routes/gitSources.ts: /api/git-sources + /api/stacks/:name/git-source/*.
Exports two routers (gitSourcesRouter + stackGitSourceRouter) because
the per-stack paths need to mount at /api/stacks alongside the label
routes extracted in phase 4a-1. String length limits are now named
constants so the 400 responses stay truthful if the bounds change.
- routes/fleet.ts: /api/fleet role, sync, overview, node drill-down,
update-status + trigger (single + fleet-wide), and snapshot CRUD +
restore. Local parseIdParam helper collapses seven copies of the
parseInt/isNaN route-param pattern.
Bugs fixed during review:
- users.ts :id/roles POST — replace the fragile
(err as Error).message?.includes('UNIQUE constraint') check with
isSqliteUniqueViolation from utils/errors.ts.
index.ts carries forward three symbols (updateTracker alias,
CVE_ID_RE, parseScannersInput) until the corresponding security /
nodes / scan routes get extracted in a later slice.
This commit is contained in:
+16
-1740
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import path from 'path';
|
||||
import { GitSourceService } from '../services/GitSourceService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { checkPermission, requirePermission } from '../middleware/permissions';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { triggerPostDeployScan } from '../helpers/policyGate';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { sendGitSourceError } from '../utils/gitSourceHttp';
|
||||
|
||||
// Reasonable upper bounds so a caller cannot flood the service with huge
|
||||
// payloads. Generous compared to anything a real Git provider emits.
|
||||
const MAX_REPO_URL_LENGTH = 2048;
|
||||
const MAX_BRANCH_LENGTH = 256;
|
||||
const MAX_COMPOSE_PATH_LENGTH = 1024;
|
||||
const MAX_ENV_PATH_LENGTH = 1024;
|
||||
const MAX_TOKEN_LENGTH = 8192;
|
||||
|
||||
/** Router for listing git-source configuration: `GET /api/git-sources`. */
|
||||
export const gitSourcesRouter = Router();
|
||||
|
||||
gitSourcesRouter.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const all = GitSourceService.getInstance().list();
|
||||
// Filter to the subset of stacks the caller can read. Keeps scoped
|
||||
// Admiral roles from discovering git config for stacks outside their grant.
|
||||
const visible = all.filter(src => checkPermission(req, 'stack:read', 'stack', src.stack_name));
|
||||
res.json(visible);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Router for per-stack git-source endpoints. Mount at `/api/stacks` so the
|
||||
* `/:stackName/git-source*` paths work alongside other stack-scoped routes
|
||||
* (such as the label-assignments router extracted in Phase 4A-1).
|
||||
*/
|
||||
export const stackGitSourceRouter = Router();
|
||||
|
||||
stackGitSourceRouter.get('/:stackName/git-source', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
try {
|
||||
const source = GitSourceService.getInstance().get(stackName);
|
||||
if (!source) {
|
||||
res.status(404).json({ error: 'No Git source configured for this stack' });
|
||||
return;
|
||||
}
|
||||
res.json(source);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const {
|
||||
repo_url,
|
||||
branch,
|
||||
compose_path,
|
||||
sync_env,
|
||||
env_path,
|
||||
auth_type,
|
||||
token,
|
||||
auto_apply_on_webhook,
|
||||
auto_deploy_on_apply,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (typeof repo_url !== 'string' || !repo_url.trim()) {
|
||||
res.status(400).json({ error: 'repo_url is required' });
|
||||
return;
|
||||
}
|
||||
if (typeof branch !== 'string' || !branch.trim()) {
|
||||
res.status(400).json({ error: 'branch is required' });
|
||||
return;
|
||||
}
|
||||
if (typeof compose_path !== 'string' || !compose_path.trim()) {
|
||||
res.status(400).json({ error: 'compose_path is required' });
|
||||
return;
|
||||
}
|
||||
if (auth_type !== 'none' && auth_type !== 'token') {
|
||||
res.status(400).json({ error: 'auth_type must be "none" or "token"' });
|
||||
return;
|
||||
}
|
||||
if (!/^https:\/\//i.test(repo_url)) {
|
||||
res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
|
||||
return;
|
||||
}
|
||||
if (repo_url.length > MAX_REPO_URL_LENGTH) {
|
||||
res.status(400).json({ error: 'repo_url is too long' });
|
||||
return;
|
||||
}
|
||||
if (branch.length > MAX_BRANCH_LENGTH) {
|
||||
res.status(400).json({ error: 'branch is too long' });
|
||||
return;
|
||||
}
|
||||
if (compose_path.length > MAX_COMPOSE_PATH_LENGTH) {
|
||||
res.status(400).json({ error: 'compose_path is too long' });
|
||||
return;
|
||||
}
|
||||
if (typeof env_path === 'string' && env_path.length > MAX_ENV_PATH_LENGTH) {
|
||||
res.status(400).json({ error: 'env_path is too long' });
|
||||
return;
|
||||
}
|
||||
if (typeof token === 'string' && token.length > MAX_TOKEN_LENGTH) {
|
||||
res.status(400).json({ error: 'token is too long' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm the stack actually exists on the active node. Without this guard
|
||||
// a caller could stash a git-source row for a name that does not exist
|
||||
// yet and have it auto-link when a stack with that name is later created.
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (!stacks.includes(stackName)) {
|
||||
res.status(404).json({ error: 'Stack not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const syncEnv = Boolean(sync_env);
|
||||
const resolvedEnvPath = syncEnv
|
||||
? (typeof env_path === 'string' && env_path.trim()
|
||||
? env_path
|
||||
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
|
||||
: null;
|
||||
|
||||
const source = await GitSourceService.getInstance().upsert({
|
||||
stackName,
|
||||
repoUrl: repo_url.trim(),
|
||||
branch: branch.trim(),
|
||||
composePath: compose_path.trim(),
|
||||
syncEnv,
|
||||
envPath: resolvedEnvPath,
|
||||
authType: auth_type,
|
||||
token: typeof token === 'string' ? token : undefined,
|
||||
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
|
||||
autoDeployOnApply: Boolean(auto_deploy_on_apply),
|
||||
});
|
||||
|
||||
console.log(`[GitSource] Configured git source for ${stackName}`);
|
||||
res.json(source);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.delete('/:stackName/git-source', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
GitSourceService.getInstance().delete(stackName);
|
||||
console.log(`[GitSource] Removed git source for ${stackName}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/pull', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const result = await GitSourceService.getInstance().pull(stackName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const { commitSha, deploy } = req.body ?? {};
|
||||
if (typeof commitSha !== 'string' || !commitSha.trim()) {
|
||||
res.status(400).json({ error: 'commitSha is required' });
|
||||
return;
|
||||
}
|
||||
const result = await GitSourceService.getInstance().apply(
|
||||
stackName,
|
||||
commitSha.trim(),
|
||||
{ deploy: typeof deploy === 'boolean' ? deploy : undefined },
|
||||
);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
const shortSha = commitSha.trim().slice(0, 7);
|
||||
if (result.deployed) {
|
||||
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName} (deployed)`);
|
||||
} else if (result.deployError) {
|
||||
console.warn(`[GitSource] Applied commit ${shortSha} to ${stackName}, deploy failed: ${result.deployError}`);
|
||||
} else {
|
||||
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName}`);
|
||||
}
|
||||
res.json(result);
|
||||
if (result.deployed) {
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/dismiss-pending', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
GitSourceService.getInstance().dismissPending(stackName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { BCRYPT_SALT_ROUNDS, MIN_PASSWORD_LENGTH } from '../helpers/constants';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
|
||||
|
||||
const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.';
|
||||
const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
|
||||
const VALID_ASSIGNMENT_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
||||
const VALID_RESOURCE_TYPES: ResourceType[] = ['stack', 'node'];
|
||||
|
||||
// Roles that require an Admiral license. Viewer and admin are available on
|
||||
// all paid tiers; the rest need variant=admiral for per-resource scoping to
|
||||
// be meaningful.
|
||||
function roleRequiresAdmiral(role: UserRole): boolean {
|
||||
return role === 'deployer' || role === 'node-admin' || role === 'auditor';
|
||||
}
|
||||
|
||||
function validateUsername(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
return 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const users = db.getUsers();
|
||||
const mfaUserIds = db.getUsersWithMfaEnabled();
|
||||
const enriched = users.map((u) => ({
|
||||
...u,
|
||||
mfaEnabled: mfaUserIds.has(u.id),
|
||||
}));
|
||||
res.json(enriched);
|
||||
} catch (error) {
|
||||
console.error('[Users] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
}
|
||||
});
|
||||
|
||||
usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
res.status(400).json({ error: 'Username, password, and role are required' });
|
||||
return;
|
||||
}
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) {
|
||||
res.status(400).json({ error: usernameError });
|
||||
return;
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
if (!VALID_USER_ROLES.includes(role)) {
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
|
||||
return;
|
||||
}
|
||||
if (roleRequiresAdmiral(role) && !requireAdmiral(req, res)) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getUserByUsername(username);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'A user with this username already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce seat limits based on license variant.
|
||||
const seatLimits = LicenseService.getInstance().getSeatLimits();
|
||||
if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) {
|
||||
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.` });
|
||||
return;
|
||||
}
|
||||
if (role !== 'admin' && seatLimits.maxViewers !== null && db.getNonAdminCount() >= seatLimits.maxViewers) {
|
||||
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
const id = db.addUser({ username, password_hash: passwordHash, role });
|
||||
console.log('[Users] Created:', username, 'role:', role, 'by:', req.user!.username);
|
||||
res.status(201).json({ id, username, role });
|
||||
} catch (error) {
|
||||
console.error('[Users] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT/DELETE intentionally do NOT enforce requirePaid. Admins must be able
|
||||
// to manage existing users even if their license lapses.
|
||||
usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUser(id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, role } = req.body;
|
||||
const updates: Partial<{ username: string; password_hash: string; role: string }> = {};
|
||||
|
||||
if (username !== undefined) {
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) {
|
||||
res.status(400).json({ error: usernameError });
|
||||
return;
|
||||
}
|
||||
const existing = db.getUserByUsername(username);
|
||||
if (existing && existing.id !== id) {
|
||||
res.status(409).json({ error: 'A user with this username already exists' });
|
||||
return;
|
||||
}
|
||||
updates.username = username;
|
||||
}
|
||||
|
||||
if (role !== undefined) {
|
||||
if (!VALID_USER_ROLES.includes(role)) {
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
|
||||
return;
|
||||
}
|
||||
if (roleRequiresAdmiral(role) && !requireAdmiral(req, res)) return;
|
||||
if (user.username === req.user!.username && role !== user.role) {
|
||||
res.status(400).json({ error: 'Cannot change your own role' });
|
||||
return;
|
||||
}
|
||||
if (user.role === 'admin' && role !== 'admin' && db.getAdminCount() <= 1) {
|
||||
res.status(400).json({ error: 'Cannot demote the only admin user' });
|
||||
return;
|
||||
}
|
||||
updates.role = role;
|
||||
}
|
||||
|
||||
if (password !== undefined) {
|
||||
// Prevent setting passwords on SSO-provisioned users (would enable a
|
||||
// local-login bypass).
|
||||
if (user.auth_provider !== 'local') {
|
||||
res.status(400).json({ error: 'Cannot set a password on an SSO-provisioned user.' });
|
||||
return;
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
updates.password_hash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
}
|
||||
|
||||
db.updateUser(id, updates);
|
||||
// Invalidate the user's active sessions when their role or password changes.
|
||||
if (updates.role || updates.password_hash) {
|
||||
db.bumpTokenVersion(id);
|
||||
}
|
||||
console.log('[Users] Updated user', id, 'fields:', Object.keys(updates).join(', '), 'by:', req.user!.username);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Users] Update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
}
|
||||
});
|
||||
|
||||
usersRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUser(id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.username === req.user!.username) {
|
||||
res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role === 'admin' && db.getAdminCount() <= 1) {
|
||||
res.status(400).json({ error: 'Cannot delete the only admin user' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.deleteUser(id);
|
||||
console.log('[Users] Deleted:', user.username, '(id:', id, ') by:', req.user!.username);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Users] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin reset: clear a target user's MFA enrolment and force re-auth. Used
|
||||
* when a user has lost their authenticator AND exhausted their backup codes,
|
||||
* and another admin is available. For total lockout (including sole admin),
|
||||
* see the CLI `reset-mfa` command.
|
||||
*/
|
||||
usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid user id' });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const target = db.getUser(id);
|
||||
if (!target) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
db.deleteUserMfa(id);
|
||||
db.bumpTokenVersion(id);
|
||||
try {
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: req.user!.username,
|
||||
method: 'POST',
|
||||
path: req.originalUrl,
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: req.ip || 'unknown',
|
||||
summary: `Admin reset two-factor authentication for ${target.username}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[MFA] Admin reset audit log write failed:', getErrorMessage(err, 'unknown'));
|
||||
}
|
||||
console.log('[MFA] Admin reset: target=', target.username, 'by=', req.user!.username);
|
||||
if (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] admin-reset target=', target.username, 'actor=', req.user!.username);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[MFA] Admin reset error:', getErrorMessage(error, 'unknown'));
|
||||
res.status(500).json({ error: 'Failed to reset two-factor authentication' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scoped Role Assignments (Admiral) ---
|
||||
|
||||
usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getUser(userId)) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
const assignments = db.getAllRoleAssignments(userId);
|
||||
res.json(assignments);
|
||||
} catch (error) {
|
||||
console.error('[Roles] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role assignments' });
|
||||
}
|
||||
});
|
||||
|
||||
usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const { role, resource_type, resource_id } = req.body;
|
||||
|
||||
if (!VALID_ASSIGNMENT_ROLES.includes(role)) {
|
||||
res.status(400).json({ error: 'Invalid role' });
|
||||
return;
|
||||
}
|
||||
if (!VALID_RESOURCE_TYPES.includes(resource_type)) {
|
||||
res.status(400).json({ error: 'Invalid resource type' });
|
||||
return;
|
||||
}
|
||||
if (!resource_id || typeof resource_id !== 'string') {
|
||||
res.status(400).json({ error: 'resource_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getUser(userId)) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id });
|
||||
console.log('[Roles] Assigned', role, 'on', resource_type, resource_id, 'to user', userId, 'by:', req.user!.username);
|
||||
res.status(201).json({ id, user_id: userId, role, resource_type, resource_id });
|
||||
} catch (err: unknown) {
|
||||
if (isSqliteUniqueViolation(err)) {
|
||||
res.status(409).json({ error: 'This role assignment already exists' });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Roles] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to add role assignment' });
|
||||
}
|
||||
});
|
||||
|
||||
usersRouter.delete('/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const assignId = parseInt(req.params.assignId as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const assignment = db.getRoleAssignmentById(assignId);
|
||||
if (!assignment || assignment.user_id !== userId) {
|
||||
res.status(404).json({ error: 'Role assignment not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.deleteRoleAssignment(assignId);
|
||||
console.log('[Roles] Removed assignment', assignId, 'from user', userId, 'by:', req.user!.username);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Roles] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete role assignment' });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { WebhookService } from '../services/WebhookService';
|
||||
import { GitSourceService } from '../services/GitSourceService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { webhookTriggerLimiter } from '../middleware/rateLimiters';
|
||||
|
||||
const VALID_WEBHOOK_ACTIONS = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
|
||||
|
||||
export const webhooksRouter = Router();
|
||||
|
||||
webhooksRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const webhooks = DatabaseService.getInstance().getWebhooks();
|
||||
const svc = WebhookService.getInstance();
|
||||
res.json(webhooks.map(w => ({ ...w, secret: svc.maskSecret(w.secret) })));
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to list webhooks' });
|
||||
}
|
||||
});
|
||||
|
||||
webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
if (!name || !stack_name || !action) {
|
||||
res.status(400).json({ error: 'name, stack_name, and action are required' });
|
||||
return;
|
||||
}
|
||||
if (!VALID_WEBHOOK_ACTIONS.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${VALID_WEBHOOK_ACTIONS.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (action === 'git-pull' && !GitSourceService.getInstance().get(stack_name)) {
|
||||
res.status(400).json({ error: 'Configure a Git source for this stack before creating a git-pull webhook' });
|
||||
return;
|
||||
}
|
||||
|
||||
const svc = WebhookService.getInstance();
|
||||
const secret = svc.generateSecret();
|
||||
const id = DatabaseService.getInstance().addWebhook({
|
||||
name, stack_name, action, secret, enabled: enabled !== false,
|
||||
});
|
||||
|
||||
// Return the full secret only on creation.
|
||||
res.status(201).json({ id, secret });
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to create webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const webhook = DatabaseService.getInstance().getWebhook(id);
|
||||
if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; }
|
||||
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
if (action && !VALID_WEBHOOK_ACTIONS.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${VALID_WEBHOOK_ACTIONS.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (action === 'git-pull') {
|
||||
const targetStack = stack_name || webhook.stack_name;
|
||||
if (!GitSourceService.getInstance().get(targetStack)) {
|
||||
res.status(400).json({ error: 'Configure a Git source for this stack before enabling a git-pull webhook' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
webhooksRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
DatabaseService.getInstance().deleteWebhook(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
webhooksRouter.get('/:id/history', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const executions = DatabaseService.getInstance().getWebhookExecutions(id);
|
||||
res.json(executions);
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] History error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch webhook history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: authenticated via HMAC signature, not session cookie.
|
||||
webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const webhook = db.getWebhook(id);
|
||||
|
||||
if (!webhook || !webhook.enabled) {
|
||||
res.status(404).json({ error: 'Webhook not found or disabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger only works with an active Skipper or Admiral license.
|
||||
if (LicenseService.getInstance().getTier() !== 'paid') {
|
||||
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = req.headers['x-webhook-signature'] as string;
|
||||
if (!signature) {
|
||||
res.status(401).json({ error: 'Missing X-Webhook-Signature header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = req.rawBody?.toString('utf-8') ?? JSON.stringify(req.body ?? {});
|
||||
const svc = WebhookService.getInstance();
|
||||
if (!svc.validateSignature(rawBody, webhook.secret, signature)) {
|
||||
res.status(401).json({ error: 'Invalid signature' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use action from body if provided, otherwise use webhook default.
|
||||
const action = req.body?.action || webhook.action;
|
||||
const triggerSource = req.headers['user-agent'] || req.ip || null;
|
||||
|
||||
// Execute asynchronously; return 202 immediately.
|
||||
res.status(202).json({ message: 'Webhook accepted', action });
|
||||
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
svc.execute(id, action, triggerSource, atomic).catch(err => {
|
||||
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Trigger error:', error);
|
||||
res.status(500).json({ error: 'Failed to process webhook' });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user