Files
UNITRONIX a710580b7a Add RBAC permissions UI and server handlers
Implement RBAC Phase 52: add server-side role & permission handlers and wire API routes, plus frontend UI, styles and translations.

Server: new role_handlers.go exposing endpoints to list roles, get effective role permissions, list/set/delete role permission overrides; routes registered in server.go with permission checks (PermUserView / PermServerConfig). Protects super-admin defaults and validates inputs.

Frontend (management & web UI): add permissions page assets (CSS, JS, view), add i18n strings for en/pl/zh, update sidebar to filter items by canView, show user role badge in topbar and empty access-denied state when no permission. Add role badges styling in users.css and small UX/i18n improvements in organizationDetail.js.

Also several route and service adjustments to support the new permissions UI. This change introduces RBAC UI/API plumbing for managing role-based permissions and overrides.
2026-04-11 00:47:08 +02:00

149 lines
8.1 KiB
JavaScript

'use strict';
const express = require('express');
const router = express.Router();
const { requireAuth, requirePermission } = require('../middleware/auth');
let apiClient;
try { apiClient = require('../services/betterdeskApi'); } catch (e) { apiClient = null; }
function goApiProxy(req, res, method, path, body) {
if (!apiClient || !apiClient[method]) {
return res.json({ success: false, error: 'Go API not available' });
}
const fn = method === 'get' || method === 'delete'
? apiClient[method](path)
: apiClient[method](path, body);
fn.then(r => res.json(r.data || r))
.catch(err => {
const status = err.response?.status || 502;
res.status(status).json({ success: false, error: err.message });
});
}
// ── Page ─────────────────────────────────────────────────────
router.get('/security-audit', requireAuth, requirePermission('audit.view'), (req, res) => {
const tab = req.query.tab || 'overview';
res.render('security-audit', {
title: req.t('security_audit.title'),
pageStyles: ['security-audit'],
pageScripts: ['security-audit'],
currentPage: 'security-audit',
breadcrumb: [{ label: req.t('security_audit.title') }],
activeTab: tab
});
});
// ── API: Security overview ───────────────────────────────────
router.get('/api/panel/security-audit/overview', requireAuth, requirePermission('audit.view'), async (req, res) => {
try {
const [healthRes, keysRes, auditRes] = await Promise.allSettled([
apiClient ? apiClient.get('/health') : Promise.reject('no api'),
apiClient ? apiClient.get('/keys') : Promise.reject('no api'),
apiClient ? apiClient.get('/audit/events?limit=100') : Promise.reject('no api')
]);
const health = healthRes.status === 'fulfilled' ? (healthRes.value.data || healthRes.value) : {};
const keys = keysRes.status === 'fulfilled' ? (keysRes.value.data || keysRes.value) : [];
const auditEvents = auditRes.status === 'fulfilled' ? (auditRes.value.data || auditRes.value) : [];
const events = Array.isArray(auditEvents) ? auditEvents : (auditEvents.events || []);
const failedLogins = events.filter(e => e.action === 'login_failed' || e.action === 'auth_failed').length;
const bans = events.filter(e => e.action === 'peer_banned' || e.action === 'ip_banned').length;
const configChanges = events.filter(e => e.action === 'config_changed' || e.action === 'setting_changed').length;
const keyList = Array.isArray(keys) ? keys : (keys.keys || []);
const oldKeys = keyList.filter(k => {
if (!k.created_at) return false;
const age = Date.now() - new Date(k.created_at).getTime();
return age > 30 * 24 * 60 * 60 * 1000;
});
let score = 100;
if (failedLogins > 20) score -= 15;
else if (failedLogins > 5) score -= 5;
if (bans > 0) score -= 10;
if (oldKeys.length > 0) score -= 10;
if (!health.tls_signal) score -= 10;
if (!health.tls_relay) score -= 10;
score = Math.max(0, Math.min(100, score));
const checks = [
{ id: 'tls_signal', name: 'TLS Signal Server', passed: !!health.tls_signal },
{ id: 'tls_relay', name: 'TLS Relay Server', passed: !!health.tls_relay },
{ id: 'csrf', name: 'CSRF Protection', passed: true },
{ id: 'rate_limit', name: 'Rate Limiting', passed: true },
{ id: 'session_fixation', name: 'Session Fixation Prevention', passed: true },
{ id: 'api_key_age', name: 'API Key Rotation (< 30 days)', passed: oldKeys.length === 0 },
{ id: 'failed_logins', name: 'Low Failed Login Rate', passed: failedLogins < 10 },
{ id: 'no_bans', name: 'No Recent Bans', passed: bans === 0 },
{ id: 'totp_available', name: 'TOTP 2FA Available', passed: true },
{ id: 'csp_headers', name: 'CSP Headers', passed: true }
];
res.json({
score,
checks,
stats: { failed_logins: failedLogins, bans, config_changes: configChanges, api_keys: keyList.length, old_keys: oldKeys.length },
tls: { signal: !!health.tls_signal, relay: !!health.tls_relay, api: !!health.tls_api }
});
} catch (err) {
res.json({ score: 0, checks: [], stats: {}, tls: {} });
}
});
// ── API: Audit events ────────────────────────────────────────
router.get('/api/panel/security-audit/events', requireAuth, requirePermission('audit.view'), (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const offset = parseInt(req.query.offset) || 0;
const action = req.query.action || '';
let path = `/audit/events?limit=${limit}&offset=${offset}`;
if (action) path += `&action=${encodeURIComponent(action)}`;
goApiProxy(req, res, 'get', path);
});
// ── API: Hardening checklist ─────────────────────────────────
router.get('/api/panel/security-audit/hardening', requireAuth, requirePermission('audit.view'), async (req, res) => {
try {
const priorities = [
{ id: 'mutual_tls', name: 'Mutual TLS Console ↔ Go Server', category: 'network', severity: 'high', implemented: false, description: 'Replace plain HTTP localhost with mutual TLS authentication between web console and Go server.' },
{ id: 'api_key_rotation', name: 'API Key Auto-Rotation', category: 'auth', severity: 'high', implemented: false, description: 'Automatically rotate API keys every 30 days with a grace period for old keys.' },
{ id: 'audit_tamper', name: 'Audit Log Tamper Detection', category: 'audit', severity: 'medium', implemented: false, description: 'HMAC chain where each audit entry signs the previous — detects log tampering.' },
{ id: 'session_timeout', name: 'Configurable Session Timeout', category: 'auth', severity: 'medium', implemented: true, description: 'Session timeout configurable per organization for compliance requirements.' },
{ id: 'ip_allowlist', name: 'IP Allowlisting', category: 'network', severity: 'medium', implemented: false, description: 'Restrict console and API access to specific IP ranges per organization.' },
{ id: 'cert_pinning', name: 'Certificate Pinning', category: 'network', severity: 'low', implemented: false, description: 'Pin TLS certificates in desktop client to prevent MITM attacks.' },
{ id: 'mem_safe_creds', name: 'Memory-Safe Credential Handling', category: 'crypto', severity: 'medium', implemented: false, description: 'Zeroize secrets in memory after use to prevent memory dump attacks.' },
{ id: 'csp_headers', name: 'CSP Headers Hardening', category: 'web', severity: 'high', implemented: true, description: 'Content Security Policy headers to prevent XSS and code injection.' },
{ id: 'rate_limit_review', name: 'Rate Limiting Review', category: 'network', severity: 'medium', implemented: true, description: 'Review and enforce rate limiting on all public-facing endpoints.' },
{ id: 'docker_scan', name: 'Docker Image Scanning', category: 'infra', severity: 'medium', implemented: false, description: 'Automated vulnerability scanning of Docker images using Trivy in CI/CD pipeline.' }
];
res.json({ priorities });
} catch (err) {
res.json({ priorities: [] });
}
});
// ── API: Vulnerability scan results ──────────────────────────
router.get('/api/panel/security-audit/vulnerabilities', requireAuth, requirePermission('audit.view'), async (req, res) => {
try {
const deps = {
go: { total: 0, vulnerable: 0, items: [] },
nodejs: { total: 0, vulnerable: 0, items: [] }
};
const pkgPath = require('path').join(__dirname, '..', 'package.json');
try {
const pkg = require(pkgPath);
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
deps.nodejs.total = Object.keys(allDeps).length;
} catch (e) { /* ignore */ }
res.json({ dependencies: deps, last_scan: new Date().toISOString() });
} catch (err) {
res.json({ dependencies: {}, last_scan: null });
}
});
module.exports = router;