feat: implement enterprise RBAC, profile identity, system management, and audit stabilization

This commit is contained in:
Austin A
2026-04-17 23:36:07 +01:00
parent 5def26e0df
commit 6279347e4b
28 changed files with 4521 additions and 326 deletions

View File

@@ -18,6 +18,9 @@ import backupRoutes from "./routes/backup.routes";
import networkRoutes from "./routes/network.routes";
import monitoringRoutes from "./routes/monitoring.routes";
import clientRoutes from "./routes/client.routes";
import profileRoutes from "./routes/profile.routes";
import adminUsersRoutes from "./routes/admin-users.routes";
import systemRoutes from "./routes/system.routes";
import { errorHandler, notFoundHandler } from "./middleware/error-handler";
import { createRateLimit } from "./middleware/rate-limit";
@@ -82,6 +85,9 @@ export function createApp() {
app.use("/api/network", networkRoutes);
app.use("/api/monitoring", monitoringRoutes);
app.use("/api/client", clientRoutes);
app.use("/api/profile", profileRoutes);
app.use("/api/admin", adminUsersRoutes);
app.use("/api/system", systemRoutes);
app.use(notFoundHandler);
app.use(errorHandler);

View File

@@ -8,6 +8,7 @@ const envSchema = z.object({
PORT: z.coerce.number().default(8080),
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
JWT_SECRET: z.string().min(16, "JWT_SECRET must be at least 16 characters"),
SETTINGS_ENCRYPTION_KEY: z.string().min(16).optional(),
JWT_EXPIRES_IN: z.string().default("7d"),
JWT_REFRESH_SECRET: z.string().min(16, "JWT_REFRESH_SECRET must be at least 16 characters").optional(),
JWT_REFRESH_EXPIRES_IN: z.string().default("30d"),

105
backend/src/lib/security.ts Normal file
View File

@@ -0,0 +1,105 @@
import crypto from "crypto";
import { env } from "../config/env";
const PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
type EncryptedEnvelope = {
__enc: "v1";
iv: string;
tag: string;
data: string;
};
function getEncryptionKey() {
const seed = env.SETTINGS_ENCRYPTION_KEY ?? env.JWT_SECRET;
return crypto.createHash("sha256").update(seed).digest();
}
function normalizeSecret(value: string) {
return crypto.createHash("sha256").update(value).digest("hex");
}
export function generateSecurePassword(length = 20) {
const bytes = crypto.randomBytes(length * 2);
let output = "";
for (let i = 0; i < bytes.length && output.length < length; i += 1) {
output += PASSWORD_ALPHABET[bytes[i] % PASSWORD_ALPHABET.length];
}
return output;
}
export function hashToken(token: string) {
return normalizeSecret(token);
}
export function timingSafeEqualHash(candidate: string, storedHash: string) {
const candidateHash = Buffer.from(normalizeSecret(candidate), "utf8");
const knownHash = Buffer.from(storedHash, "utf8");
if (candidateHash.length !== knownHash.length) return false;
return crypto.timingSafeEqual(candidateHash, knownHash);
}
export function generateRecoveryCodes(count = 8) {
const codes: string[] = [];
for (let i = 0; i < count; i += 1) {
const raw = crypto.randomBytes(5).toString("hex").toUpperCase();
codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`);
}
return codes;
}
export function hashRecoveryCodes(codes: string[]) {
return codes.map((code) => normalizeSecret(code.trim().toUpperCase()));
}
export function consumeRecoveryCode(input: string, hashes: string[]) {
const normalized = normalizeSecret(input.trim().toUpperCase());
const matchIndex = hashes.findIndex((hash) => hash === normalized);
if (matchIndex < 0) {
return { matched: false, remainingHashes: hashes };
}
const remainingHashes = [...hashes.slice(0, matchIndex), ...hashes.slice(matchIndex + 1)];
return { matched: true, remainingHashes };
}
function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope {
return (
typeof value === "object" &&
value !== null &&
(value as Record<string, unknown>).__enc === "v1" &&
typeof (value as Record<string, unknown>).iv === "string" &&
typeof (value as Record<string, unknown>).tag === "string" &&
typeof (value as Record<string, unknown>).data === "string"
);
}
export function encryptJson(value: unknown): EncryptedEnvelope {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const payload = Buffer.from(JSON.stringify(value), "utf8");
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
const tag = cipher.getAuthTag();
return {
__enc: "v1",
iv: iv.toString("base64"),
tag: tag.toString("base64"),
data: encrypted.toString("base64")
};
}
export function decryptJson<T = unknown>(value: unknown): T {
if (!isEncryptedEnvelope(value)) {
return value as T;
}
const key = getEncryptionKey();
const iv = Buffer.from(value.iv, "base64");
const tag = Buffer.from(value.tag, "base64");
const encrypted = Buffer.from(value.data, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
return JSON.parse(decrypted) as T;
}

100
backend/src/lib/totp.ts Normal file
View File

@@ -0,0 +1,100 @@
import crypto from "crypto";
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32Encode(buffer: Buffer) {
let bits = 0;
let value = 0;
let output = "";
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
function base32Decode(input: string) {
const normalized = input.toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, "");
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index < 0) continue;
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
function hotp(secret: string, counter: number, digits = 6) {
const key = base32Decode(secret);
const counterBuffer = Buffer.alloc(8);
const high = Math.floor(counter / 0x100000000);
const low = counter % 0x100000000;
counterBuffer.writeUInt32BE(high >>> 0, 0);
counterBuffer.writeUInt32BE(low >>> 0, 4);
const hmac = crypto.createHmac("sha1", key).update(counterBuffer).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const codeInt =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return String(codeInt % 10 ** digits).padStart(digits, "0");
}
export function generateTotpSecret(bytes = 20) {
return base32Encode(crypto.randomBytes(bytes));
}
export function generateTotpCode(secret: string, timestampMs = Date.now(), stepSeconds = 30, digits = 6) {
const counter = Math.floor(timestampMs / 1000 / stepSeconds);
return hotp(secret, counter, digits);
}
export function verifyTotpCode(
token: string,
secret: string,
options?: { window?: number; timestampMs?: number; stepSeconds?: number; digits?: number }
) {
const window = options?.window ?? 1;
const timestampMs = options?.timestampMs ?? Date.now();
const stepSeconds = options?.stepSeconds ?? 30;
const digits = options?.digits ?? 6;
const normalizedToken = token.replace(/\s+/g, "");
if (!/^\d{6,8}$/.test(normalizedToken)) return false;
const baseCounter = Math.floor(timestampMs / 1000 / stepSeconds);
for (let i = -window; i <= window; i += 1) {
if (hotp(secret, baseCounter + i, digits) === normalizedToken) {
return true;
}
}
return false;
}
export function buildTotpUri(issuer: string, accountLabel: string, secret: string) {
const safeIssuer = encodeURIComponent(issuer);
const safeAccount = encodeURIComponent(accountLabel);
return `otpauth://totp/${safeIssuer}:${safeAccount}?secret=${secret}&issuer=${safeIssuer}&algorithm=SHA1&digits=6&period=30`;
}

View File

@@ -26,7 +26,10 @@ type Permission =
| "security:manage"
| "security:read"
| "user:manage"
| "user:read";
| "user:read"
| "profile:read"
| "profile:manage"
| "session:manage";
const rolePermissions: Record<Role, Set<Permission>> = {
SUPER_ADMIN: new Set<Permission>([
@@ -51,7 +54,10 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"security:manage",
"security:read",
"user:manage",
"user:read"
"user:read",
"profile:read",
"profile:manage",
"session:manage"
]),
TENANT_ADMIN: new Set<Permission>([
"vm:create",
@@ -68,7 +74,10 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"settings:read",
"audit:read",
"security:read",
"user:read"
"user:read",
"profile:read",
"profile:manage",
"session:manage"
]),
OPERATOR: new Set<Permission>([
"vm:read",
@@ -81,7 +90,9 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"backup:read",
"audit:read",
"security:manage",
"security:read"
"security:read",
"profile:read",
"profile:manage"
]),
VIEWER: new Set<Permission>([
"vm:read",
@@ -92,7 +103,9 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"audit:read",
"security:read",
"settings:read",
"user:read"
"user:read",
"profile:read",
"profile:manage"
])
};
@@ -120,7 +133,8 @@ export function verifyRefreshToken(token: string): Express.UserToken | null {
id: decoded.id,
email: decoded.email,
role: decoded.role,
tenant_id: decoded.tenant_id
tenant_id: decoded.tenant_id,
sid: decoded.sid
};
} catch {
return null;

View File

@@ -0,0 +1,272 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { Role } from "@prisma/client";
import { z } from "zod";
import { authorize, requireAuth } from "../middleware/auth";
import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error";
import { generateSecurePassword } from "../lib/security";
import { logAudit } from "../services/audit.service";
import { toPrismaJsonValue } from "../lib/prisma-json";
const router = Router();
const createUserSchema = z.object({
email: z.string().email(),
full_name: z.string().min(1).max(120),
role: z.nativeEnum(Role),
tenant_id: z.string().optional(),
password: z.string().min(10).max(120).optional(),
generate_password: z.boolean().default(true),
is_active: z.boolean().default(true)
});
const updateUserSchema = z.object({
full_name: z.string().min(1).max(120).optional(),
role: z.nativeEnum(Role).optional(),
tenant_id: z.string().nullable().optional(),
is_active: z.boolean().optional()
});
function rolesCatalog() {
return [
{
role: "SUPER_ADMIN",
label: "Super Admin",
scope: "Global",
description: "Full platform control including billing, security, and system configuration."
},
{
role: "TENANT_ADMIN",
label: "Tenant Admin",
scope: "Tenant",
description: "Owns a tenant environment, users, workloads, and tenant-level billing views."
},
{
role: "OPERATOR",
label: "Operator",
scope: "Ops",
description: "Runs day-2 operations for compute, backup, and node workflows."
},
{
role: "VIEWER",
label: "Viewer",
scope: "Read-only",
description: "Read-only access for auditors and stakeholders."
}
];
}
router.get("/roles", requireAuth, authorize("rbac:manage"), async (_req, res) => {
res.json(rolesCatalog());
});
router.get("/users", requireAuth, authorize("user:read"), async (req, res, next) => {
try {
const tenantId = typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined;
const role = typeof req.query.role === "string" ? req.query.role.toUpperCase() : undefined;
const where: Record<string, unknown> = {};
if (tenantId) where.tenant_id = tenantId;
if (role && Object.values(Role).includes(role as Role)) where.role = role;
const users = await prisma.user.findMany({
where,
orderBy: { created_at: "desc" },
select: {
id: true,
email: true,
full_name: true,
role: true,
tenant_id: true,
is_active: true,
mfa_enabled: true,
must_change_password: true,
created_at: true,
updated_at: true,
last_login_at: true,
tenant: {
select: {
id: true,
name: true,
slug: true
}
}
}
});
res.json(users);
} catch (error) {
next(error);
}
});
router.post("/users", requireAuth, authorize("user:manage"), async (req, res, next) => {
try {
const payload = createUserSchema.parse(req.body ?? {});
if (req.user?.role !== "SUPER_ADMIN") {
throw new HttpError(403, "Only SUPER_ADMIN can create administrative users", "FORBIDDEN");
}
if (payload.role === "TENANT_ADMIN" && !payload.tenant_id) {
throw new HttpError(400, "tenant_id is required for TENANT_ADMIN users", "VALIDATION_ERROR");
}
const existing = await prisma.user.findUnique({ where: { email: payload.email.toLowerCase().trim() } });
if (existing) {
throw new HttpError(409, "A user with this email already exists", "USER_EXISTS");
}
const tempPassword = payload.generate_password || !payload.password ? generateSecurePassword(20) : payload.password;
const passwordHash = await bcrypt.hash(tempPassword, 12);
const user = await prisma.user.create({
data: {
email: payload.email.toLowerCase().trim(),
full_name: payload.full_name,
role: payload.role,
tenant_id: payload.tenant_id ?? null,
is_active: payload.is_active,
password_hash: passwordHash,
must_change_password: true,
password_changed_at: new Date()
},
select: {
id: true,
email: true,
full_name: true,
role: true,
tenant_id: true,
is_active: true,
must_change_password: true,
created_at: true
}
});
await logAudit({
action: "rbac.user.create",
resource_type: "USER",
resource_id: user.id,
resource_name: user.email,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({
role: user.role,
tenant_id: user.tenant_id
}),
ip_address: req.ip
});
res.status(201).json({
user,
temporary_password: tempPassword
});
} catch (error) {
next(error);
}
});
router.patch("/users/:id", requireAuth, authorize("user:manage"), async (req, res, next) => {
try {
const payload = updateUserSchema.parse(req.body ?? {});
const existing = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!existing) {
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
}
if (req.user?.role !== "SUPER_ADMIN" && existing.role === "SUPER_ADMIN") {
throw new HttpError(403, "Only SUPER_ADMIN can modify this user", "FORBIDDEN");
}
if (payload.role === "SUPER_ADMIN" && req.user?.role !== "SUPER_ADMIN") {
throw new HttpError(403, "Only SUPER_ADMIN can assign SUPER_ADMIN role", "FORBIDDEN");
}
const updated = await prisma.user.update({
where: { id: req.params.id },
data: payload,
select: {
id: true,
email: true,
full_name: true,
role: true,
tenant_id: true,
is_active: true,
must_change_password: true,
created_at: true,
updated_at: true,
last_login_at: true
}
});
await logAudit({
action: "rbac.user.update",
resource_type: "USER",
resource_id: updated.id,
resource_name: updated.email,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({ changes: payload }),
ip_address: req.ip
});
res.json(updated);
} catch (error) {
next(error);
}
});
router.post("/users/:id/reset-password", requireAuth, authorize("user:manage"), async (req, res, next) => {
try {
const existing = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!existing) {
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
}
if (existing.role === "SUPER_ADMIN" && req.user?.role !== "SUPER_ADMIN") {
throw new HttpError(403, "Only SUPER_ADMIN can reset this account", "FORBIDDEN");
}
const tempPassword = generateSecurePassword(20);
const passwordHash = await bcrypt.hash(tempPassword, 12);
await prisma.user.update({
where: { id: existing.id },
data: {
password_hash: passwordHash,
must_change_password: true,
password_changed_at: new Date(),
mfa_enabled: false,
mfa_secret: null,
mfa_recovery_codes: []
}
});
await prisma.authSession.updateMany({
where: {
user_id: existing.id,
revoked_at: null
},
data: {
revoked_at: new Date()
}
});
await logAudit({
action: "rbac.user.reset_password",
resource_type: "USER",
resource_id: existing.id,
resource_name: existing.email,
actor_email: req.user!.email,
actor_role: req.user!.role,
ip_address: req.ip
});
res.json({
success: true,
temporary_password: tempPassword
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,25 +1,80 @@
import { Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { z } from "zod";
import jwt from "jsonwebtoken";
import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error";
import { createJwtToken, createRefreshToken, requireAuth, verifyRefreshToken } from "../middleware/auth";
import { consumeRecoveryCode, hashToken } from "../lib/security";
import { verifyTotpCode } from "../lib/totp";
const router = Router();
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
password: z.string().min(1),
mfa_code: z.string().optional(),
recovery_code: z.string().optional()
});
const refreshSchema = z.object({
refresh_token: z.string().min(1)
});
const logoutSchema = z.object({
refresh_token: z.string().min(1).optional()
});
function tokenExpiryDate(refreshToken: string) {
const decoded = jwt.decode(refreshToken) as { exp?: number } | null;
const exp = decoded?.exp ? new Date(decoded.exp * 1000) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
return exp;
}
async function createAuthSession(input: {
user: {
id: string;
email: string;
role: Express.UserToken["role"];
tenant_id?: string | null;
};
ipAddress?: string;
userAgent?: string;
}) {
const sessionId = crypto.randomUUID();
const basePayload = {
id: input.user.id,
email: input.user.email,
role: input.user.role,
tenant_id: input.user.tenant_id,
sid: sessionId
};
const accessToken = createJwtToken(basePayload);
const refreshToken = createRefreshToken(basePayload);
await prisma.authSession.create({
data: {
id: sessionId,
user_id: input.user.id,
refresh_token_hash: hashToken(refreshToken),
ip_address: input.ipAddress,
user_agent: input.userAgent,
expires_at: tokenExpiryDate(refreshToken),
last_used_at: new Date()
}
});
return {
token: accessToken,
refresh_token: refreshToken
};
}
router.post("/login", async (req, res, next) => {
try {
const payload = loginSchema.parse(req.body);
const user = await prisma.user.findUnique({ where: { email: payload.email } });
const user = await prisma.user.findUnique({ where: { email: payload.email.toLowerCase().trim() } });
if (!user || !user.is_active) {
throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS");
}
@@ -28,6 +83,35 @@ router.post("/login", async (req, res, next) => {
throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS");
}
if (user.mfa_enabled) {
const mfaCode = payload.mfa_code?.trim();
const recoveryCode = payload.recovery_code?.trim();
if (!mfaCode && !recoveryCode) {
throw new HttpError(401, "MFA code is required", "MFA_REQUIRED");
}
let mfaPassed = false;
if (mfaCode && user.mfa_secret) {
mfaPassed = verifyTotpCode(mfaCode, user.mfa_secret, { window: 1 });
}
if (!mfaPassed && recoveryCode) {
const existingHashes = Array.isArray(user.mfa_recovery_codes) ? (user.mfa_recovery_codes as string[]) : [];
const result = consumeRecoveryCode(recoveryCode, existingHashes);
if (result.matched) {
mfaPassed = true;
await prisma.user.update({
where: { id: user.id },
data: { mfa_recovery_codes: result.remainingHashes }
});
}
}
if (!mfaPassed) {
throw new HttpError(401, "Invalid MFA code", "MFA_INVALID");
}
}
await prisma.user.update({
where: { id: user.id },
data: { last_login_at: new Date() }
@@ -39,18 +123,24 @@ router.post("/login", async (req, res, next) => {
role: user.role,
tenant_id: user.tenant_id
};
const token = createJwtToken(userPayload);
const refreshToken = createRefreshToken(userPayload);
const tokens = await createAuthSession({
user: userPayload,
ipAddress: req.ip,
userAgent: req.get("user-agent") ?? undefined
});
res.json({
token,
refresh_token: refreshToken,
token: tokens.token,
refresh_token: tokens.refresh_token,
must_change_password: user.must_change_password,
user: {
id: user.id,
email: user.email,
full_name: user.full_name,
role: user.role,
tenant_id: user.tenant_id
tenant_id: user.tenant_id,
avatar_url: user.avatar_url,
mfa_enabled: user.mfa_enabled
}
});
} catch (error) {
@@ -62,10 +152,27 @@ router.post("/refresh", async (req, res, next) => {
try {
const payload = refreshSchema.parse(req.body ?? {});
const decoded = verifyRefreshToken(payload.refresh_token);
if (!decoded) {
if (!decoded || !decoded.sid) {
throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN");
}
const session = await prisma.authSession.findFirst({
where: {
id: decoded.sid,
user_id: decoded.id,
revoked_at: null
}
});
if (!session) {
throw new HttpError(401, "Refresh session not found", "INVALID_REFRESH_TOKEN");
}
if (session.expires_at.getTime() < Date.now()) {
throw new HttpError(401, "Refresh session expired", "INVALID_REFRESH_TOKEN");
}
if (session.refresh_token_hash !== hashToken(payload.refresh_token)) {
throw new HttpError(401, "Refresh token mismatch", "INVALID_REFRESH_TOKEN");
}
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: {
@@ -84,11 +191,23 @@ router.post("/refresh", async (req, res, next) => {
id: user.id,
email: user.email,
role: user.role,
tenant_id: user.tenant_id
tenant_id: user.tenant_id,
sid: decoded.sid
};
const token = createJwtToken(userPayload);
const refreshToken = createRefreshToken(userPayload);
await prisma.authSession.update({
where: { id: decoded.sid },
data: {
refresh_token_hash: hashToken(refreshToken),
expires_at: tokenExpiryDate(refreshToken),
last_used_at: new Date(),
ip_address: req.ip,
user_agent: req.get("user-agent") ?? session.user_agent
}
});
res.json({
token,
refresh_token: refreshToken
@@ -98,6 +217,46 @@ router.post("/refresh", async (req, res, next) => {
}
});
router.post("/logout", requireAuth, async (req, res, next) => {
try {
const payload = logoutSchema.parse(req.body ?? {});
const refreshToken = payload.refresh_token;
if (!refreshToken) {
await prisma.authSession.updateMany({
where: {
user_id: req.user!.id,
revoked_at: null
},
data: {
revoked_at: new Date()
}
});
return res.json({ success: true, revoked: "all" });
}
const decoded = verifyRefreshToken(refreshToken);
if (!decoded?.sid || decoded.id !== req.user!.id) {
throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN");
}
await prisma.authSession.updateMany({
where: {
id: decoded.sid,
user_id: req.user!.id,
refresh_token_hash: hashToken(refreshToken),
revoked_at: null
},
data: {
revoked_at: new Date()
}
});
return res.json({ success: true, revoked: decoded.sid });
} catch (error) {
return next(error);
}
});
router.get("/me", requireAuth, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
@@ -109,7 +268,12 @@ router.get("/me", requireAuth, async (req, res, next) => {
role: true,
tenant_id: true,
is_active: true,
created_at: true
created_at: true,
avatar_url: true,
profile_metadata: true,
mfa_enabled: true,
must_change_password: true,
last_login_at: true
}
});
if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND");

View File

@@ -0,0 +1,381 @@
import { Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error";
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from "../lib/totp";
import { generateRecoveryCodes, hashRecoveryCodes, hashToken } from "../lib/security";
import { logAudit } from "../services/audit.service";
import { toPrismaJsonValue } from "../lib/prisma-json";
const router = Router();
const updateProfileSchema = z.object({
full_name: z.string().min(1).max(120).optional(),
avatar_url: z.string().url().max(500).optional(),
profile_metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional()
});
const changePasswordSchema = z.object({
current_password: z.string().min(1),
new_password: z.string().min(10).max(120)
});
const mfaSetupSchema = z.object({
password: z.string().min(1)
});
const mfaEnableSchema = z.object({
code: z.string().min(6).max(8)
});
const mfaDisableSchema = z.object({
password: z.string().min(1),
code: z.string().min(6).max(8).optional()
});
router.get("/", requireAuth, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
email: true,
full_name: true,
avatar_url: true,
profile_metadata: true,
role: true,
tenant_id: true,
mfa_enabled: true,
must_change_password: true,
created_at: true,
updated_at: true,
last_login_at: true
}
});
if (!user) {
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
}
return res.json(user);
} catch (error) {
return next(error);
}
});
router.patch("/", requireAuth, async (req, res, next) => {
try {
const payload = updateProfileSchema.parse(req.body ?? {});
if (Object.keys(payload).length === 0) {
throw new HttpError(400, "No profile fields were provided", "VALIDATION_ERROR");
}
const user = await prisma.user.update({
where: { id: req.user!.id },
data: payload,
select: {
id: true,
email: true,
full_name: true,
avatar_url: true,
profile_metadata: true,
role: true,
tenant_id: true,
mfa_enabled: true,
must_change_password: true,
created_at: true,
updated_at: true,
last_login_at: true
}
});
await logAudit({
action: "profile.update",
resource_type: "USER",
resource_id: user.id,
resource_name: user.email,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({ updated_fields: Object.keys(payload) }),
ip_address: req.ip
});
return res.json(user);
} catch (error) {
return next(error);
}
});
router.post("/change-password", requireAuth, async (req, res, next) => {
try {
const payload = changePasswordSchema.parse(req.body ?? {});
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, email: true, password_hash: true }
});
if (!user) {
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
}
const matched = await bcrypt.compare(payload.current_password, user.password_hash);
if (!matched) {
throw new HttpError(401, "Current password is incorrect", "INVALID_CREDENTIALS");
}
const newHash = await bcrypt.hash(payload.new_password, 12);
await prisma.user.update({
where: { id: user.id },
data: {
password_hash: newHash,
must_change_password: false,
password_changed_at: new Date()
}
});
await prisma.authSession.updateMany({
where: {
user_id: user.id,
revoked_at: null
},
data: {
revoked_at: new Date()
}
});
await logAudit({
action: "profile.password.change",
resource_type: "USER",
resource_id: user.id,
resource_name: user.email,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({ revoked_sessions: true }),
ip_address: req.ip
});
return res.json({ success: true, message: "Password changed. All active sessions were revoked." });
} catch (error) {
return next(error);
}
});
router.post("/mfa/setup", requireAuth, async (req, res, next) => {
try {
const payload = mfaSetupSchema.parse(req.body ?? {});
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
email: true,
password_hash: true
}
});
if (!user) {
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
}
const matched = await bcrypt.compare(payload.password, user.password_hash);
if (!matched) {
throw new HttpError(401, "Password is incorrect", "INVALID_CREDENTIALS");
}
const secret = generateTotpSecret();
const recoveryCodes = generateRecoveryCodes();
await prisma.user.update({
where: { id: user.id },
data: {
mfa_secret: secret,
mfa_enabled: false,
mfa_recovery_codes: hashRecoveryCodes(recoveryCodes)
}
});
return res.json({
secret,
otpauth_uri: buildTotpUri("ProxPanel", user.email, secret),
recovery_codes: recoveryCodes
});
} catch (error) {
return next(error);
}
});
router.post("/mfa/enable", requireAuth, async (req, res, next) => {
try {
const payload = mfaEnableSchema.parse(req.body ?? {});
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
email: true,
mfa_secret: true
}
});
if (!user || !user.mfa_secret) {
throw new HttpError(400, "MFA setup is not initialized", "MFA_NOT_CONFIGURED");
}
if (!verifyTotpCode(payload.code, user.mfa_secret, { window: 1 })) {
throw new HttpError(401, "Invalid MFA code", "MFA_INVALID");
}
await prisma.user.update({
where: { id: user.id },
data: {
mfa_enabled: true
}
});
await logAudit({
action: "profile.mfa.enable",
resource_type: "USER",
resource_id: user.id,
resource_name: user.email,
actor_email: req.user!.email,
actor_role: req.user!.role,
ip_address: req.ip
});
return res.json({ success: true, mfa_enabled: true });
} catch (error) {
return next(error);
}
});
router.post("/mfa/disable", requireAuth, async (req, res, next) => {
try {
const payload = mfaDisableSchema.parse(req.body ?? {});
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
email: true,
password_hash: true,
mfa_enabled: true,
mfa_secret: true
}
});
if (!user) {
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
}
const matched = await bcrypt.compare(payload.password, user.password_hash);
if (!matched) {
throw new HttpError(401, "Password is incorrect", "INVALID_CREDENTIALS");
}
if (user.mfa_enabled && user.mfa_secret && payload.code && !verifyTotpCode(payload.code, user.mfa_secret, { window: 1 })) {
throw new HttpError(401, "Invalid MFA code", "MFA_INVALID");
}
await prisma.user.update({
where: { id: user.id },
data: {
mfa_enabled: false,
mfa_secret: null,
mfa_recovery_codes: []
}
});
await logAudit({
action: "profile.mfa.disable",
resource_type: "USER",
resource_id: user.id,
resource_name: user.email,
actor_email: req.user!.email,
actor_role: req.user!.role,
ip_address: req.ip
});
return res.json({ success: true, mfa_enabled: false });
} catch (error) {
return next(error);
}
});
router.get("/sessions", requireAuth, async (req, res, next) => {
try {
const sessions = await prisma.authSession.findMany({
where: { user_id: req.user!.id },
orderBy: { issued_at: "desc" },
select: {
id: true,
ip_address: true,
user_agent: true,
issued_at: true,
last_used_at: true,
expires_at: true,
revoked_at: true
}
});
return res.json(sessions);
} catch (error) {
return next(error);
}
});
router.post("/sessions/:id/revoke", requireAuth, async (req, res, next) => {
try {
const updated = await prisma.authSession.updateMany({
where: {
id: req.params.id,
user_id: req.user!.id,
revoked_at: null
},
data: {
revoked_at: new Date()
}
});
return res.json({ success: true, revoked: updated.count });
} catch (error) {
return next(error);
}
});
router.post("/sessions/revoke-all", requireAuth, async (req, res, next) => {
try {
const result = await prisma.authSession.updateMany({
where: {
user_id: req.user!.id,
revoked_at: null
},
data: { revoked_at: new Date() }
});
return res.json({ success: true, revoked: result.count });
} catch (error) {
return next(error);
}
});
router.post("/password-reset/request", requireAuth, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true }
});
if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND");
const token = crypto.randomUUID().replace(/-/g, "");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + 30 * 60 * 1000);
await prisma.passwordResetToken.create({
data: {
user_id: user.id,
token_hash: tokenHash,
expires_at: expiresAt
}
});
return res.json({
success: true,
token,
expires_at: expiresAt
});
} catch (error) {
return next(error);
}
});
export default router;

View File

@@ -1,4 +1,5 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth";
import { HttpError } from "../lib/http-error";
import { toPrismaJsonValue } from "../lib/prisma-json";
@@ -274,6 +275,31 @@ const resourceMap: Record<string, ResourceMeta> = {
readPermission: "security:read",
tenantScoped: true,
searchFields: ["destination", "provider_message"]
},
"cms-pages": {
model: "cmsPage",
readPermission: "settings:read",
createPermission: "settings:manage",
updatePermission: "settings:manage",
deletePermission: "settings:manage",
tenantScoped: false,
searchFields: ["slug", "title", "section"]
},
"site-navigation-items": {
model: "siteNavigationItem",
readPermission: "settings:read",
createPermission: "settings:manage",
updatePermission: "settings:manage",
deletePermission: "settings:manage",
tenantScoped: false,
searchFields: ["label", "href", "position"]
},
"auth-sessions": {
model: "authSession",
readPermission: "user:manage",
updatePermission: "user:manage",
deletePermission: "user:manage",
tenantScoped: false
}
};
@@ -624,6 +650,25 @@ router.post("/:resource", requireAuth, async (req, res, next) => {
const model = getModel(meta);
const payload = normalizePayload(resource, req.body ?? {});
if (resource === "users") {
const email = typeof payload.email === "string" ? payload.email.toLowerCase().trim() : "";
if (!email) {
throw new HttpError(400, "email is required for users.create", "VALIDATION_ERROR");
}
payload.email = email;
const plainPassword = typeof payload.password === "string" ? payload.password : undefined;
if (!plainPassword || plainPassword.length < 10) {
throw new HttpError(400, "password (min 10 chars) is required for users.create", "VALIDATION_ERROR");
}
payload.password_hash = await bcrypt.hash(plainPassword, 12);
payload.must_change_password = true;
payload.password_changed_at = new Date();
delete payload.password;
delete payload.password_hash_raw;
}
if (meta.tenantScoped && isTenantScopedUser(req) && req.user?.tenant_id) {
if (
meta.model !== "backupRestoreTask" &&
@@ -666,6 +711,23 @@ router.patch("/:resource/:id", requireAuth, async (req, res, next) => {
await ensureItemTenantScope(req, meta, existing);
const payload = normalizePayload(resource, req.body ?? {});
if (resource === "users") {
if (typeof payload.email === "string") {
payload.email = payload.email.toLowerCase().trim();
}
if ("password_hash" in payload) {
delete payload.password_hash;
}
if (typeof payload.password === "string") {
if (payload.password.length < 10) {
throw new HttpError(400, "password must be at least 10 characters", "VALIDATION_ERROR");
}
payload.password_hash = await bcrypt.hash(payload.password, 12);
payload.must_change_password = false;
payload.password_changed_at = new Date();
}
delete payload.password;
}
const updated = await model.update({
where: { id: req.params.id },
data: payload

View File

@@ -2,6 +2,8 @@ import { Router } from "express";
import { z } from "zod";
import { authorize, requireAuth } from "../middleware/auth";
import { prisma } from "../lib/prisma";
import { toPrismaJsonValue } from "../lib/prisma-json";
import { decryptJson, encryptJson } from "../lib/security";
import { getOperationsPolicy } from "../services/operations.service";
import { getSchedulerRuntimeSnapshot, reconfigureSchedulers, schedulerDefaults } from "../services/scheduler.service";
@@ -79,10 +81,38 @@ const notificationsSchema = z.object({
ops_email: z.string().email().optional()
});
function decodeSettingValue<T>(raw: unknown, fallback: T): T {
const value = decryptJson<T>(raw);
if (value === null || value === undefined) return fallback;
return value;
}
async function loadSetting<T>(key: string, fallback: T): Promise<T> {
const setting = await prisma.setting.findUnique({ where: { key } });
if (!setting) return fallback;
return decodeSettingValue<T>(setting.value, fallback);
}
async function saveSetting<T>(input: {
key: string;
type: "PROXMOX" | "PAYMENT" | "GENERAL" | "EMAIL";
value: T;
encrypted: boolean;
}) {
const payloadValue = input.encrypted ? encryptJson(input.value) : input.value;
const normalizedValue = toPrismaJsonValue(payloadValue);
const setting = await prisma.setting.upsert({
where: { key: input.key },
update: { value: normalizedValue, is_encrypted: input.encrypted },
create: { key: input.key, type: input.type, value: normalizedValue, is_encrypted: input.encrypted }
});
return decodeSettingValue<T>(setting.value, input.value);
}
router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const setting = await prisma.setting.findUnique({ where: { key: "proxmox" } });
res.json(setting?.value ?? {});
const value = await loadSetting("proxmox", {});
res.json(value);
} catch (error) {
next(error);
}
@@ -91,12 +121,13 @@ router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res
router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = proxmoxSchema.parse(req.body);
const setting = await prisma.setting.upsert({
where: { key: "proxmox" },
update: { value: payload },
create: { key: "proxmox", type: "PROXMOX", value: payload, is_encrypted: true }
const value = await saveSetting({
key: "proxmox",
type: "PROXMOX",
value: payload,
encrypted: true
});
res.json(setting.value);
res.json(value);
} catch (error) {
next(error);
}
@@ -104,8 +135,8 @@ router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, re
router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const setting = await prisma.setting.findUnique({ where: { key: "payment" } });
res.json(setting?.value ?? {});
const value = await loadSetting("payment", {});
res.json(value);
} catch (error) {
next(error);
}
@@ -114,12 +145,13 @@ router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res
router.put("/payment", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = paymentSchema.parse(req.body);
const setting = await prisma.setting.upsert({
where: { key: "payment" },
update: { value: payload },
create: { key: "payment", type: "PAYMENT", value: payload, is_encrypted: true }
const value = await saveSetting({
key: "payment",
type: "PAYMENT",
value: payload,
encrypted: true
});
res.json(setting.value);
res.json(value);
} catch (error) {
next(error);
}
@@ -127,8 +159,8 @@ router.put("/payment", requireAuth, authorize("settings:manage"), async (req, re
router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const setting = await prisma.setting.findUnique({ where: { key: "backup" } });
res.json(setting?.value ?? {});
const value = await loadSetting("backup", {});
res.json(value);
} catch (error) {
next(error);
}
@@ -137,12 +169,13 @@ router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res,
router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = backupSchema.parse(req.body);
const setting = await prisma.setting.upsert({
where: { key: "backup" },
update: { value: payload },
create: { key: "backup", type: "GENERAL", value: payload, is_encrypted: false }
const value = await saveSetting({
key: "backup",
type: "GENERAL",
value: payload,
encrypted: false
});
res.json(setting.value);
res.json(value);
} catch (error) {
next(error);
}
@@ -150,13 +183,12 @@ router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res
router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const setting = await prisma.setting.findUnique({ where: { key: "console_proxy" } });
res.json(
setting?.value ?? {
(await loadSetting("console_proxy", {
mode: "cluster",
cluster: {},
nodes: {}
}
}))
);
} catch (error) {
next(error);
@@ -166,12 +198,13 @@ router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_re
router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = consoleProxySchema.parse(req.body);
const setting = await prisma.setting.upsert({
where: { key: "console_proxy" },
update: { value: payload },
create: { key: "console_proxy", type: "PROXMOX", value: payload, is_encrypted: false }
const value = await saveSetting({
key: "console_proxy",
type: "PROXMOX",
value: payload,
encrypted: false
});
res.json(setting.value);
res.json(value);
} catch (error) {
next(error);
}
@@ -179,12 +212,8 @@ router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (r
router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const setting = await prisma.setting.findUnique({ where: { key: "scheduler" } });
const defaults = schedulerDefaults();
const persisted =
setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value)
? (setting.value as Record<string, unknown>)
: {};
const persisted = await loadSetting<Record<string, unknown>>("scheduler", {});
const config = {
...defaults,
...persisted
@@ -201,15 +230,16 @@ router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, r
router.put("/scheduler", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = schedulerSchema.parse(req.body);
const setting = await prisma.setting.upsert({
where: { key: "scheduler" },
update: { value: payload },
create: { key: "scheduler", type: "GENERAL", value: payload, is_encrypted: false }
const config = await saveSetting({
key: "scheduler",
type: "GENERAL",
value: payload,
encrypted: false
});
const runtime = await reconfigureSchedulers(payload);
return res.json({
config: setting.value,
config,
runtime
});
} catch (error) {
@@ -229,10 +259,11 @@ router.get("/operations-policy", requireAuth, authorize("settings:read"), async
router.put("/operations-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = operationsPolicySchema.parse(req.body);
await prisma.setting.upsert({
where: { key: "operations_policy" },
update: { value: payload },
create: { key: "operations_policy", type: "GENERAL", value: payload, is_encrypted: false }
await saveSetting({
key: "operations_policy",
type: "GENERAL",
value: payload,
encrypted: true
});
const policy = await getOperationsPolicy();
@@ -244,9 +275,8 @@ router.put("/operations-policy", requireAuth, authorize("settings:manage"), asyn
router.get("/notifications", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const setting = await prisma.setting.findUnique({ where: { key: "notifications" } });
return res.json(
setting?.value ?? {
await loadSetting("notifications", {
email_alerts: true,
backup_alerts: true,
billing_alerts: true,
@@ -256,7 +286,7 @@ router.get("/notifications", requireAuth, authorize("settings:read"), async (_re
email_gateway_url: "",
notification_email_webhook: "",
ops_email: ""
}
})
);
} catch (error) {
return next(error);
@@ -266,12 +296,13 @@ router.get("/notifications", requireAuth, authorize("settings:read"), async (_re
router.put("/notifications", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = notificationsSchema.parse(req.body);
const setting = await prisma.setting.upsert({
where: { key: "notifications" },
update: { value: payload },
create: { key: "notifications", type: "EMAIL", value: payload, is_encrypted: false }
const value = await saveSetting({
key: "notifications",
type: "EMAIL",
value: payload,
encrypted: true
});
return res.json(setting.value);
return res.json(value);
} catch (error) {
return next(error);
}

View File

@@ -0,0 +1,421 @@
import { Router } from "express";
import { z } from "zod";
import { TenantStatus } from "@prisma/client";
import { authorize, requireAuth } from "../middleware/auth";
import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error";
import { logAudit } from "../services/audit.service";
import { toPrismaJsonValue } from "../lib/prisma-json";
const router = Router();
const brandingSchema = z.object({
app_name: z.string().min(2).max(120),
logo_url: z.string().url().optional(),
primary_color: z.string().optional(),
accent_color: z.string().optional(),
support_email: z.string().email().optional(),
website_url: z.string().url().optional(),
legal_company_name: z.string().optional(),
copyright_notice: z.string().optional()
});
const subscriptionPolicySchema = z.object({
default_trial_days: z.number().int().min(1).max(90).default(14),
default_grace_days: z.number().int().min(0).max(30).default(3),
trial_vm_limit: z.number().int().min(1).max(200).default(2),
auto_suspend_on_expiry: z.boolean().default(true)
});
const startTrialSchema = z.object({
days: z.number().int().min(1).max(90).optional(),
grace_days: z.number().int().min(0).max(30).optional(),
vm_limit: z.number().int().min(1).max(200).optional()
});
const cmsPageSchema = z.object({
slug: z.string().min(2).max(180).regex(/^[a-z0-9-]+$/),
title: z.string().min(2).max(180),
section: z.string().min(2).max(80).default("general"),
content: z.record(z.string(), z.any()).default({}),
is_published: z.boolean().default(false)
});
const navItemSchema = z.object({
label: z.string().min(1).max(120),
href: z.string().min(1).max(260),
position: z.enum(["header", "footer", "legal"]).default("header"),
sort_order: z.number().int().min(0).max(10000).default(100),
is_enabled: z.boolean().default(true),
metadata: z.record(z.string(), z.any()).default({})
});
async function getSetting<T = unknown>(key: string, fallback: T): Promise<T> {
const setting = await prisma.setting.findUnique({ where: { key } });
if (!setting) return fallback;
return (setting.value as T) ?? fallback;
}
async function upsertSetting<T = unknown>(input: { key: string; type?: "GENERAL" | "SECURITY" | "NETWORK" | "PROXMOX" | "PAYMENT" | "EMAIL"; value: T }) {
const normalizedValue = toPrismaJsonValue(input.value);
return prisma.setting.upsert({
where: { key: input.key },
update: {
value: normalizedValue
},
create: {
key: input.key,
type: input.type ?? "GENERAL",
value: normalizedValue,
is_encrypted: false
}
});
}
router.get("/public/site", async (_req, res, next) => {
try {
const [branding, pages, navigation] = await Promise.all([
getSetting("branding", {
app_name: "VotCloud",
legal_company_name: "VotCloud",
copyright_notice: ""
}),
prisma.cmsPage.findMany({
where: { is_published: true },
orderBy: [{ section: "asc" }, { updated_at: "desc" }]
}),
prisma.siteNavigationItem.findMany({
where: { is_enabled: true },
orderBy: [{ position: "asc" }, { sort_order: "asc" }]
})
]);
res.json({
branding,
pages,
navigation
});
} catch (error) {
next(error);
}
});
router.get("/branding", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const branding = await getSetting("branding", {
app_name: "VotCloud",
legal_company_name: "VotCloud",
copyright_notice: ""
});
res.json(branding);
} catch (error) {
next(error);
}
});
router.put("/branding", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = brandingSchema.parse(req.body ?? {});
const setting = await upsertSetting({
key: "branding",
value: payload
});
await logAudit({
action: "system.branding.update",
resource_type: "SYSTEM",
resource_id: setting.id,
resource_name: "branding",
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue(payload),
ip_address: req.ip
});
res.json(setting.value);
} catch (error) {
next(error);
}
});
router.get("/subscription-policy", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const policy = await getSetting("subscription_policy", {
default_trial_days: 14,
default_grace_days: 3,
trial_vm_limit: 2,
auto_suspend_on_expiry: true
});
res.json(policy);
} catch (error) {
next(error);
}
});
router.put("/subscription-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = subscriptionPolicySchema.parse(req.body ?? {});
const setting = await upsertSetting({
key: "subscription_policy",
value: payload
});
await logAudit({
action: "system.subscription_policy.update",
resource_type: "SYSTEM",
resource_id: setting.id,
resource_name: "subscription_policy",
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue(payload),
ip_address: req.ip
});
res.json(setting.value);
} catch (error) {
next(error);
}
});
router.post("/trials/:tenantId/start", requireAuth, authorize("tenant:manage"), async (req, res, next) => {
try {
const payload = startTrialSchema.parse(req.body ?? {});
const policy = await getSetting("subscription_policy", {
default_trial_days: 14,
default_grace_days: 3,
trial_vm_limit: 2
});
const now = new Date();
const days = payload.days ?? Number(policy.default_trial_days ?? 14);
const graceDays = payload.grace_days ?? Number(policy.default_grace_days ?? 3);
const trialEndsAt = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
const trialGraceEndsAt = new Date(trialEndsAt.getTime() + graceDays * 24 * 60 * 60 * 1000);
const tenant = await prisma.tenant.update({
where: { id: req.params.tenantId },
data: {
status: TenantStatus.TRIAL,
trial_days: days,
trial_starts_at: now,
trial_ends_at: trialEndsAt,
trial_grace_ends_at: trialGraceEndsAt,
trial_locked: false,
vm_limit: payload.vm_limit ?? Number(policy.trial_vm_limit ?? 2)
}
});
await logAudit({
action: "tenant.trial.start",
resource_type: "TENANT",
resource_id: tenant.id,
resource_name: tenant.name,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({
days,
trial_ends_at: trialEndsAt.toISOString(),
trial_grace_ends_at: trialGraceEndsAt.toISOString()
}),
ip_address: req.ip
});
res.json(tenant);
} catch (error) {
next(error);
}
});
router.post("/trials/expire", requireAuth, authorize("tenant:manage"), async (req, res, next) => {
try {
const now = new Date();
const policy = await getSetting("subscription_policy", {
auto_suspend_on_expiry: true
});
if (!policy.auto_suspend_on_expiry) {
return res.json({ success: true, expired_count: 0, skipped: true });
}
const expiredTenants = await prisma.tenant.findMany({
where: {
status: TenantStatus.TRIAL,
trial_ends_at: { lt: now }
},
select: {
id: true,
name: true
}
});
if (expiredTenants.length === 0) {
return res.json({ success: true, expired_count: 0 });
}
const ids = expiredTenants.map((tenant) => tenant.id);
await prisma.tenant.updateMany({
where: { id: { in: ids } },
data: {
status: TenantStatus.SUSPENDED,
trial_locked: true
}
});
await logAudit({
action: "tenant.trial.expire.batch",
resource_type: "TENANT",
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({
expired_tenants: expiredTenants
}),
ip_address: req.ip
});
return res.json({
success: true,
expired_count: expiredTenants.length,
tenants: expiredTenants
});
} catch (error) {
return next(error);
}
});
router.get("/cms/pages", requireAuth, authorize("settings:read"), async (req, res, next) => {
try {
const includeDrafts = req.query.include_drafts === "true";
const pages = await prisma.cmsPage.findMany({
where: includeDrafts ? undefined : { is_published: true },
orderBy: [{ section: "asc" }, { updated_at: "desc" }]
});
res.json(pages);
} catch (error) {
next(error);
}
});
router.post("/cms/pages", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = cmsPageSchema.parse(req.body ?? {});
const page = await prisma.cmsPage.create({
data: {
...payload,
created_by: req.user!.email,
updated_by: req.user!.email
}
});
await logAudit({
action: "cms.page.create",
resource_type: "SYSTEM",
resource_id: page.id,
resource_name: page.slug,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({ section: page.section, is_published: page.is_published }),
ip_address: req.ip
});
res.status(201).json(page);
} catch (error) {
next(error);
}
});
router.patch("/cms/pages/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const partial = cmsPageSchema.partial().parse(req.body ?? {});
if (Object.keys(partial).length === 0) {
throw new HttpError(400, "No fields provided", "VALIDATION_ERROR");
}
const page = await prisma.cmsPage.update({
where: { id: req.params.id },
data: {
...partial,
updated_by: req.user!.email
}
});
await logAudit({
action: "cms.page.update",
resource_type: "SYSTEM",
resource_id: page.id,
resource_name: page.slug,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: toPrismaJsonValue({ updated_fields: Object.keys(partial) }),
ip_address: req.ip
});
res.json(page);
} catch (error) {
next(error);
}
});
router.delete("/cms/pages/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const page = await prisma.cmsPage.delete({
where: { id: req.params.id }
});
await logAudit({
action: "cms.page.delete",
resource_type: "SYSTEM",
resource_id: page.id,
resource_name: page.slug,
actor_email: req.user!.email,
actor_role: req.user!.role,
ip_address: req.ip
});
res.status(204).send();
} catch (error) {
next(error);
}
});
router.get("/cms/navigation", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try {
const items = await prisma.siteNavigationItem.findMany({
orderBy: [{ position: "asc" }, { sort_order: "asc" }]
});
res.json(items);
} catch (error) {
next(error);
}
});
router.post("/cms/navigation", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = navItemSchema.parse(req.body ?? {});
const item = await prisma.siteNavigationItem.create({ data: payload });
res.status(201).json(item);
} catch (error) {
next(error);
}
});
router.patch("/cms/navigation/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
const payload = navItemSchema.partial().parse(req.body ?? {});
const item = await prisma.siteNavigationItem.update({
where: { id: req.params.id },
data: payload
});
res.json(item);
} catch (error) {
next(error);
}
});
router.delete("/cms/navigation/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try {
await prisma.siteNavigationItem.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -8,6 +8,7 @@
import axios from "axios";
import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error";
import { decryptJson } from "../lib/security";
import { restartVm, shutdownVm, startVm, stopVm } from "./proxmox.service";
type TaskCreateInput = {
@@ -255,13 +256,16 @@ export async function getOperationsPolicy(): Promise<OperationsPolicy> {
})
]);
const settingValue = decryptJson(setting?.value) as unknown;
const notificationsRaw = decryptJson(notificationsSetting?.value) as unknown;
const value =
setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value)
? (setting.value as Record<string, unknown>)
settingValue && typeof settingValue === "object" && !Array.isArray(settingValue)
? (settingValue as Record<string, unknown>)
: {};
const notificationsValue =
notificationsSetting?.value && typeof notificationsSetting.value === "object" && !Array.isArray(notificationsSetting.value)
? (notificationsSetting.value as Record<string, unknown>)
notificationsRaw && typeof notificationsRaw === "object" && !Array.isArray(notificationsRaw)
? (notificationsRaw as Record<string, unknown>)
: {};
const maxRetryAttemptsRaw = Number(value.max_retry_attempts);

View File

@@ -3,6 +3,7 @@ import crypto from "crypto";
import { PaymentProvider } from "@prisma/client";
import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error";
import { decryptJson } from "../lib/security";
import { markInvoicePaid } from "./billing.service";
type PaymentSettings = {
@@ -19,7 +20,7 @@ async function getPaymentSettings(): Promise<PaymentSettings> {
const setting = await prisma.setting.findUnique({
where: { key: "payment" }
});
return (setting?.value as PaymentSettings) ?? {};
return decryptJson<PaymentSettings>(setting?.value) ?? {};
}
function normalizeProvider(provider: string | undefined, fallback: string): PaymentProvider {

View File

@@ -4,6 +4,7 @@ import { TemplateType, VmType } from "@prisma/client";
import { prisma } from "../lib/prisma";
import { env } from "../config/env";
import { HttpError } from "../lib/http-error";
import { decryptJson } from "../lib/security";
type ProxmoxSettings = {
host: string;
@@ -52,7 +53,7 @@ async function getProxmoxSettings(): Promise<ProxmoxSettings> {
throw new HttpError(400, "Proxmox settings have not been configured", "PROXMOX_NOT_CONFIGURED");
}
const value = setting.value as Partial<ProxmoxSettings>;
const value = decryptJson(setting.value) as Partial<ProxmoxSettings>;
if (!value.host || !value.username || !value.token_id || !value.token_secret) {
throw new HttpError(400, "Proxmox credentials are incomplete", "PROXMOX_INCOMPLETE_CONFIG");
}

View File

@@ -7,6 +7,7 @@ declare global {
email: string;
role: Role;
tenant_id?: string | null;
sid?: string;
}
interface Request {