Files
vendoo/lib/security.mjs
T
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

336 lines
16 KiB
JavaScript

import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { db } from './db.mjs';
export const PERMISSION_CATALOG = Object.freeze([
{ key: 'dashboard.view', label: 'Dashboard ansehen', group: 'Allgemein' },
{ key: 'listings.view', label: 'Artikel ansehen', group: 'Artikel' },
{ key: 'listings.edit', label: 'Artikel erstellen und bearbeiten', group: 'Artikel' },
{ key: 'listings.delete', label: 'Artikel in den Papierkorb verschieben', group: 'Artikel' },
{ key: 'listings.permanent_delete', label: 'Artikel endgültig löschen', group: 'Artikel' },
{ key: 'media.view', label: 'Bilder ansehen und herunterladen', group: 'Bilder' },
{ key: 'media.edit', label: 'Bilder hochladen und bearbeiten', group: 'Bilder' },
{ key: 'media.delete', label: 'Bilder löschen', group: 'Bilder' },
{ key: 'generation.execute', label: 'AI- und FLUX-Generierung starten', group: 'AI & FLUX' },
{ key: 'batch.execute', label: 'Bildproduktion ausführen', group: 'AI & FLUX' },
{ key: 'quality.execute', label: 'Qualitätsanalysen und AI-Verbesserungen', group: 'Qualität' },
{ key: 'publishing.view', label: 'Publishing ansehen', group: 'Publishing' },
{ key: 'publishing.execute', label: 'Veröffentlichungen starten und wiederholen', group: 'Publishing' },
{ key: 'publishing.manage', label: 'Veröffentlichungen beenden und endgültig löschen', group: 'Publishing' },
{ key: 'inventory.edit', label: 'Lagerorte und Bestände ändern', group: 'Organisation' },
{ key: 'templates.edit', label: 'Vorlagen verwalten', group: 'Organisation' },
{ key: 'settings.manage', label: 'Globale Einstellungen ändern', group: 'Administration' },
{ key: 'users.manage', label: 'Benutzer und Rollen verwalten', group: 'Administration' },
{ key: 'sessions.manage', label: 'Fremde Sitzungen beenden', group: 'Administration' },
{ key: 'audit.view', label: 'Audit-Protokoll ansehen und exportieren', group: 'Administration' },
{ key: 'operations.manage', label: 'Backups, Restore und lokale Updates verwalten', group: 'Administration' },
{ key: 'updates.manage', label: 'GitHub-/Coolify-Deployments auslösen', group: 'Administration' },
{ key: 'security.manage', label: 'Server-Sicherheit konfigurieren', group: 'Administration' },
{ key: 'modules.manage', label: 'Module installieren, aktivieren und entfernen', group: 'Administration' },
]);
const ALL_PERMISSIONS = PERMISSION_CATALOG.map(item => item.key);
export const ROLE_DEFINITIONS = Object.freeze({
admin: {
key: 'admin',
label: 'Administrator',
description: 'Vollzugriff auf Vendoo, Benutzer, Backups, Updates und Server-Sicherheit.',
permissions: ALL_PERMISSIONS,
},
manager: {
key: 'manager',
label: 'Manager',
description: 'Operative Leitung mit vollständiger Artikel-, Medien- und Publishing-Verwaltung, ohne System- und Benutzeradministration.',
permissions: ALL_PERMISSIONS.filter(key => ![
'users.manage', 'sessions.manage', 'operations.manage', 'updates.manage', 'security.manage', 'settings.manage', 'modules.manage',
].includes(key)),
},
editor: {
key: 'editor',
label: 'Editor',
description: 'Erstellt und bearbeitet Artikel, Bilder und AI-Inhalte. Keine endgültigen Löschungen oder Systemverwaltung.',
permissions: [
'dashboard.view', 'listings.view', 'listings.edit', 'listings.delete',
'media.view', 'media.edit', 'generation.execute', 'batch.execute', 'quality.execute',
'publishing.view', 'publishing.execute', 'inventory.edit', 'templates.edit',
],
},
publisher: {
key: 'publisher',
label: 'Publisher',
description: 'Prüft Artikel und führt Veröffentlichungen aus, ohne Artikelstammdaten oder Systemeinstellungen zu ändern.',
permissions: [
'dashboard.view', 'listings.view', 'media.view', 'quality.execute',
'publishing.view', 'publishing.execute',
],
},
viewer: {
key: 'viewer',
label: 'Leser',
description: 'Reiner Lesezugriff auf Artikel, Bilder, Qualität und Publishing-Status.',
permissions: ['dashboard.view', 'listings.view', 'media.view', 'publishing.view'],
},
});
const MUTATION_DEFAULT_PERMISSION = 'listings.edit';
export function normalizeRole(role) {
return ROLE_DEFINITIONS[role] ? role : 'viewer';
}
export function getRoleDefinitions() {
return Object.values(ROLE_DEFINITIONS).map(role => ({ ...role, permissions: [...role.permissions] }));
}
export function getPermissionCatalog() {
return PERMISSION_CATALOG.map(item => ({ ...item }));
}
export function permissionsForRole(role) {
return new Set(ROLE_DEFINITIONS[normalizeRole(role)].permissions);
}
export function hasPermission(role, permission) {
if (!permission) return true;
return permissionsForRole(role).has(permission);
}
export function requiredPermissionForApi(method, path) {
const verb = String(method || 'GET').toUpperCase();
const route = String(path || '').split('?')[0];
if (route === '/api/me' || route.startsWith('/api/my/')) return null;
if (route === '/api/extensions/heartbeat' || route === '/api/extensions/prepare') return null;
if (route === '/api/backup' || route === '/api/restore') return 'operations.manage';
if (route.startsWith('/api/admin/users')) return 'users.manage';
if (route.startsWith('/api/admin/sessions')) return 'sessions.manage';
if (route.startsWith('/api/admin/login-history')) return 'users.manage';
if (route.startsWith('/api/admin/smtp')) return 'settings.manage';
if (route.startsWith('/api/admin/modules')) return 'modules.manage';
if (route.startsWith('/api/admin/deployment')) return 'updates.manage';
if (route.startsWith('/api/admin/audit')) return 'audit.view';
if (route.startsWith('/api/admin/') || route.startsWith('/api/operations/')) return 'operations.manage';
if (route.startsWith('/api/settings')) return verb === 'GET' ? null : 'settings.manage';
if (route.startsWith('/api/themes')) return null; // sichere Detail-Policies übernimmt der Platform Kernel
if (route.startsWith('/api/extensions/diagnostics')) return 'security.manage';
if (route.startsWith('/api/security/')) return verb === 'GET' ? 'security.manage' : 'security.manage';
if (route.startsWith('/api/locks/admin/')) return 'sessions.manage';
if (route.startsWith('/api/trash')) {
if (verb === 'GET') return 'listings.view';
if (verb === 'DELETE') return 'listings.permanent_delete';
return 'listings.edit';
}
if (route.startsWith('/api/listings')) {
if (verb === 'GET') return 'listings.view';
if (verb === 'DELETE') return 'listings.delete';
return 'listings.edit';
}
if (route.startsWith('/api/templates')) return verb === 'GET' ? null : 'templates.edit';
if (route.startsWith('/api/storage') || route.startsWith('/api/inventory')) return verb === 'GET' ? null : 'inventory.edit';
if (route.startsWith('/api/media') || route.startsWith('/api/gallery')) {
if (verb === 'GET') return 'media.view';
if (verb === 'DELETE') return 'media.delete';
return 'media.edit';
}
if (route.startsWith('/api/upload') || route.startsWith('/api/images') || route.startsWith('/api/image-editor') || route.startsWith('/api/mobile-upload-sessions')) {
if (verb === 'GET') return 'media.view';
if (verb === 'DELETE') return 'media.delete';
return 'media.edit';
}
if (route === '/api/html-render' || route === '/api/html-wrap') return 'listings.view';
if (route === '/api/photos/archive') return 'publishing.execute';
if (route.startsWith('/api/local-image/install') || route.startsWith('/api/local-image/update') || route.startsWith('/api/local-image/uninstall') || route.startsWith('/api/local-image/regenerate-token')) return 'settings.manage';
if (route.startsWith('/api/ebay')) {
if (verb === 'GET') return 'publishing.view';
if (verb === 'DELETE') return 'publishing.manage';
return 'publishing.execute';
}
if (route.startsWith('/api/flux') || route.startsWith('/api/local-image') || route.startsWith('/api/ai-model') || route === '/api/generate' || route.startsWith('/api/prompt-lab')) {
return verb === 'GET' ? 'media.view' : 'generation.execute';
}
if (route.startsWith('/api/image-batch')) return verb === 'GET' ? 'media.view' : 'batch.execute';
if (route.startsWith('/api/quality')) return verb === 'GET' ? 'listings.view' : 'quality.execute';
if (route.startsWith('/api/publishing') || route.startsWith('/api/extension/publishing') || route.startsWith('/api/publish') || route.startsWith('/api/schedule')) {
if (verb === 'GET') return 'publishing.view';
if (verb === 'DELETE' || route.includes('/end') || route.includes('/delete')) return 'publishing.manage';
return 'publishing.execute';
}
if (route.startsWith('/api/extension/queue') || route.startsWith('/api/extension/listing')) return 'publishing.view';
if (['GET', 'HEAD', 'OPTIONS'].includes(verb)) return null;
return MUTATION_DEFAULT_PERMISSION;
}
// --- Resource locks -------------------------------------------------------
db.exec(`
CREATE TABLE IF NOT EXISTS resource_locks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
renewed_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
client_label TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(resource_type, resource_id)
);
CREATE INDEX IF NOT EXISTS idx_resource_locks_expiry ON resource_locks(expires_at);
CREATE INDEX IF NOT EXISTS idx_resource_locks_user ON resource_locks(user_id, expires_at);
`);
try {
db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description)
VALUES ('multiuser-security-1.31.0', '1.31.0', 'Rollen, sichere Sitzungen, Bearbeitungssperren und Server-Härtung')`).run();
} catch {}
function hashLockToken(token) {
return createHash('sha256').update(String(token || '')).digest('hex');
}
function safeEqualHex(a, b) {
try {
const aa = Buffer.from(String(a || ''), 'hex');
const bb = Buffer.from(String(b || ''), 'hex');
return aa.length > 0 && aa.length === bb.length && timingSafeEqual(aa, bb);
} catch { return false; }
}
export function cleanExpiredResourceLocks() {
return db.prepare("DELETE FROM resource_locks WHERE expires_at <= datetime('now')").run().changes;
}
function hydrateLock(row) {
if (!row) return null;
return {
id: row.id,
resource_type: row.resource_type,
resource_id: row.resource_id,
user_id: row.user_id,
user_name: row.user_name || '',
user_email: row.user_email || '',
acquired_at: row.acquired_at,
renewed_at: row.renewed_at,
expires_at: row.expires_at,
client_label: row.client_label || '',
};
}
export function getResourceLock(resourceType, resourceId) {
cleanExpiredResourceLocks();
const row = db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email
FROM resource_locks l JOIN users u ON u.id = l.user_id
WHERE l.resource_type = ? AND l.resource_id = ? AND l.expires_at > datetime('now')`).get(String(resourceType), String(resourceId));
return hydrateLock(row);
}
export function listResourceLocks() {
cleanExpiredResourceLocks();
return db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email
FROM resource_locks l JOIN users u ON u.id = l.user_id
WHERE l.expires_at > datetime('now') ORDER BY l.renewed_at DESC`).all().map(hydrateLock);
}
export function acquireResourceLock({ resourceType, resourceId, userId, clientLabel = '', ttlSeconds = 120 }) {
cleanExpiredResourceLocks();
const type = String(resourceType || '').trim().slice(0, 40);
const id = String(resourceId || '').trim().slice(0, 120);
if (!type || !id) throw new Error('Ressourcentyp und ID sind erforderlich');
const existing = getResourceLock(type, id);
if (existing && Number(existing.user_id) !== Number(userId)) {
const error = new Error(`${existing.user_name || existing.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Datensatz bereits.`);
error.code = 'LOCKED';
error.lock = existing;
throw error;
}
const token = randomBytes(32).toString('hex');
const tokenHash = hashLockToken(token);
const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120));
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
const transaction = db.transaction(() => {
db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id);
db.prepare(`INSERT INTO resource_locks
(resource_type, resource_id, user_id, token_hash, expires_at, client_label)
VALUES (?, ?, ?, ?, ?, ?)`).run(type, id, userId, tokenHash, expiresAt, String(clientLabel || '').slice(0, 160));
});
transaction();
return { token, lock: getResourceLock(type, id) };
}
export function validateResourceLock({ resourceType, resourceId, userId, token }) {
const type = String(resourceType || '');
const id = String(resourceId || '');
const row = db.prepare(`SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND expires_at > datetime('now')`).get(type, id);
if (!row) return { valid: true, lock: null };
const providedHash = hashLockToken(token);
const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, providedHash);
return { valid, lock: getResourceLock(type, id) };
}
export function renewResourceLock({ resourceType, resourceId, userId, token, ttlSeconds = 120 }) {
const validation = validateResourceLock({ resourceType, resourceId, userId, token });
if (!validation.valid || !validation.lock) {
const error = new Error('Bearbeitungssperre ist abgelaufen oder gehört zu einer anderen Sitzung.');
error.code = 'LOCK_INVALID';
error.lock = validation.lock;
throw error;
}
const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120));
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
db.prepare(`UPDATE resource_locks SET renewed_at = datetime('now'), expires_at = ?
WHERE resource_type = ? AND resource_id = ?`).run(expiresAt, String(resourceType), String(resourceId));
return getResourceLock(resourceType, resourceId);
}
export function releaseResourceLock({ resourceType, resourceId, userId, token, force = false }) {
const type = String(resourceType || '');
const id = String(resourceId || '');
if (force) return db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id).changes;
const row = db.prepare('SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ?').get(type, id);
if (!row) return 0;
const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, hashLockToken(token));
if (!valid) {
const error = new Error('Bearbeitungssperre kann nicht durch diese Sitzung aufgehoben werden.');
error.code = 'LOCK_INVALID';
throw error;
}
return db.prepare('DELETE FROM resource_locks WHERE id = ?').run(row.id).changes;
}
// --- Rate limiter ---------------------------------------------------------
const rateBuckets = new Map();
const rateStats = { allowed: 0, blocked: 0, buckets: 0, last_blocked_at: null };
export function consumeRateLimit(key, { limit = 120, windowMs = 60_000 } = {}) {
const now = Date.now();
const normalizedKey = String(key || 'anonymous');
let bucket = rateBuckets.get(normalizedKey);
if (!bucket || now >= bucket.resetAt) bucket = { count: 0, resetAt: now + windowMs };
bucket.count += 1;
rateBuckets.set(normalizedKey, bucket);
rateStats.buckets = rateBuckets.size;
const remaining = Math.max(0, limit - bucket.count);
const allowed = bucket.count <= limit;
if (allowed) rateStats.allowed += 1;
else {
rateStats.blocked += 1;
rateStats.last_blocked_at = new Date().toISOString();
}
if (rateBuckets.size > 5000) {
for (const [entryKey, entry] of rateBuckets) if (entry.resetAt <= now) rateBuckets.delete(entryKey);
}
return { allowed, remaining, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), resetAt: bucket.resetAt };
}
export function getRateLimitStats() {
return { ...rateStats, buckets: rateBuckets.size };
}
setInterval(cleanExpiredResourceLocks, 60_000).unref?.();