mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
865d792874
* feat(pricing): collapse to two tiers (Community + Admiral) Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral) to two: a generous free Community tier and a single paid Admiral tier. The Skipper tier is removed. Now free in Community: auto-heal, auto-update, scheduled operations, webhooks, notification routing, Fleet Actions and bulk operations, SSO preset providers (Google / GitHub / Okta), unlimited users with admin and viewer roles, and deploy safety (atomic deploys, auto-rollback, and one-click rollback). Admiral (paid) is focused on running and governing a fleet: blueprints, Fleet Secrets, deploy enforcement, vulnerability report export, audit log, host console, private registries, mesh networking, node cordon, managed cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles (deployer, node-admin, auditor) with per-resource scoped assignments. Internally the license variant distinction is removed so tier is binary (community / paid). License validation still verifies the Lemon Squeezy store and product before granting paid status. Docs and the contributor guide are updated to the two-tier model. * docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording The licensing docs page kept the old Admiral pricing plus a Founder Lifetime column and an Enterprise paragraph after the two-tier collapse. Update it to $12/month or $99/year, drop the lifetime and Enterprise content, and link to the pricing page for current pricing. Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title, and three test comments. Historical CHANGELOG entries and the retired-Skipper license-guard test are intentionally left as-is. * docs: align licensing and SSO pages with the two-tier model Correct the SSO overview so the Google, GitHub, and Okta presets read as available on every tier, matching the provider table; only LDAP and Active Directory require Sencho Admiral. Remove the lifetime-plan references from the licensing, settings, and troubleshooting pages so they reflect subscription-only Admiral pricing. * fix(rbac): omit scoped permissions from /me on the Community tier Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case. * docs: use custom-pricing wording on the contact page The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import bcrypt from 'bcrypt';
|
|
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { requirePaid, requireAdmin } 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';
|
|
import { validateUsername } from '../helpers/validateUsername';
|
|
|
|
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 a paid license. Viewer and admin are available on the
|
|
// free tier; the advanced roles unlock per-resource scoping that is only
|
|
// meaningful on paid.
|
|
function roleRequiresPaid(role: UserRole): boolean {
|
|
return role === 'deployer' || role === 'node-admin' || role === 'auditor';
|
|
}
|
|
|
|
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;
|
|
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 (roleRequiresPaid(role) && !requirePaid(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;
|
|
}
|
|
|
|
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 (roleRequiresPaid(role) && !requirePaid(req, res)) return;
|
|
if (user.username === req.user!.username && role !== user.role) {
|
|
res.status(400).json({ error: 'Cannot change your own role' });
|
|
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);
|
|
}
|
|
|
|
// updateUserIfNotLastAdmin returns false only when this update would demote
|
|
// the last remaining admin; map that single case to the guard message.
|
|
const applied = db.updateUserIfNotLastAdmin(id, updates);
|
|
if (!applied) {
|
|
res.status(400).json({ error: 'Cannot demote the only admin user' });
|
|
return;
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
const deleted = db.deleteUserIfNotLastAdmin(id);
|
|
if (!deleted) {
|
|
res.status(400).json({ error: 'Cannot delete the only admin user' });
|
|
return;
|
|
}
|
|
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);
|
|
// The audit-log middleware records this POST automatically (summary
|
|
// "Reset two-factor authentication: <id>", keyed on the target user id);
|
|
// no explicit write is needed here.
|
|
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 (paid) ---
|
|
|
|
usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
|
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requirePaid(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 (!requirePaid(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 (!requirePaid(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' });
|
|
}
|
|
});
|