mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
e5b1c7b22b
Removes backend/src/entitlements/ (registry, loadProvider, CommunityEntitlementProvider, types, headers, normalize) and the two abstraction-only tests. Relocates headers/normalize/types to services/license-*.ts. Swaps 22 consumer call sites from getEntitlementProvider() to LicenseService.getInstance(). Drops the Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and github_token BuildKit secret in docker-publish.yml. Removes the now stale no-restricted-imports rule in backend/eslint.config.mjs. Net: 37 files changed, ~700 lines removed, no behavior change. Local dev no longer requires GitHub Packages auth to start the backend. Rationale and revisit conditions in docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
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';
|
|
import { parseIntParam } from '../utils/parseIntParam';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
|
|
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:', sanitizeForLog(username), 'role:', sanitizeForLog(role), 'by:', sanitizeForLog(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 = parseIntParam(req, res, 'id', 'user id');
|
|
if (id === null) 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', sanitizeForLog(role), 'on', sanitizeForLog(resource_type), sanitizeForLog(resource_id), 'to user', userId, 'by:', sanitizeForLog(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' });
|
|
}
|
|
});
|