import pool from '../config/database'; import logger from '../utils/logger'; // Default configs — used when no DB config exists yet const DEFAULT_GROUP_HIERARCHY: Record = { 'dashboard_mitglied': ['dashboard_chargen', 'dashboard_atemschutz', 'dashboard_moderator', 'dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_kommando'], 'dashboard_chargen': ['dashboard_kommando'], 'dashboard_atemschutz': ['dashboard_kommando'], 'dashboard_moderator': ['dashboard_kommando'], 'dashboard_zeugmeister': ['dashboard_kommando'], 'dashboard_fahrmeister': ['dashboard_kommando'], 'dashboard_kommando': [], }; const DEFAULT_PERMISSION_DEPS: Record = { 'kalender:create': ['kalender:view'], 'kalender:cancel': ['kalender:view'], 'kalender:mark_attendance': ['kalender:view'], 'kalender:create_bookings': ['kalender:view'], 'kalender:edit_bookings': ['kalender:view', 'kalender:create_bookings'], 'kalender:cancel_own_bookings': ['kalender:view'], 'kalender:delete_bookings': ['kalender:view', 'kalender:edit_bookings'], 'kalender:manage_categories': ['kalender:view'], 'kalender:view_reports': ['kalender:view'], 'kalender:widget_events': ['kalender:view'], 'kalender:widget_bookings': ['kalender:view'], 'kalender:widget_quick_add': ['kalender:view', 'kalender:create'], 'fahrzeuge:create': ['fahrzeuge:view'], 'fahrzeuge:change_status': ['fahrzeuge:view'], 'fahrzeuge:manage_maintenance': ['fahrzeuge:view'], 'fahrzeuge:delete': ['fahrzeuge:view', 'fahrzeuge:create'], 'fahrzeuge:widget': ['fahrzeuge:view'], 'einsaetze:view_reports': ['einsaetze:view'], 'einsaetze:create': ['einsaetze:view'], 'einsaetze:delete': ['einsaetze:view', 'einsaetze:create'], 'einsaetze:manage_personnel': ['einsaetze:view'], 'ausruestung:create': ['ausruestung:view'], 'ausruestung:manage_maintenance': ['ausruestung:view'], 'ausruestung:delete': ['ausruestung:view', 'ausruestung:create'], 'ausruestung:widget': ['ausruestung:view'], 'mitglieder:view_all': ['mitglieder:view_own'], 'mitglieder:edit': ['mitglieder:view_own', 'mitglieder:view_all'], 'mitglieder:create_profile': ['mitglieder:view_own', 'mitglieder:view_all', 'mitglieder:edit'], 'atemschutz:create': ['atemschutz:view'], 'atemschutz:delete': ['atemschutz:view', 'atemschutz:create'], 'atemschutz:widget': ['atemschutz:view'], 'wissen:widget_recent': ['wissen:view'], 'wissen:widget_search': ['wissen:view'], 'admin:write': ['admin:view'], }; export interface FeatureGroupRow { id: string; label: string; sort_order: number; maintenance: boolean; } export interface PermissionRow { id: string; feature_group_id: string; label: string; description: string | null; sort_order: number; } export interface MatrixData { featureGroups: FeatureGroupRow[]; permissions: PermissionRow[]; groups: string[]; grants: Record; maintenance: Record; } class PermissionService { private groupPermissions: Map> = new Map(); private maintenanceFlags: Map = new Map(); async loadCache(): Promise { try { // Load group permissions const gpResult = await pool.query('SELECT authentik_group, permission_id FROM group_permissions'); const newMap = new Map>(); for (const row of gpResult.rows) { if (!newMap.has(row.authentik_group)) { newMap.set(row.authentik_group, new Set()); } newMap.get(row.authentik_group)!.add(row.permission_id); } this.groupPermissions = newMap; // Load maintenance flags const mResult = await pool.query('SELECT id, maintenance FROM feature_groups'); const newFlags = new Map(); for (const row of mResult.rows) { newFlags.set(row.id, row.maintenance); } this.maintenanceFlags = newFlags; logger.info('Permission cache loaded', { groups: this.groupPermissions.size, featureGroups: this.maintenanceFlags.size, }); } catch (error) { logger.error('Failed to load permission cache', { error }); // Don't throw — service can still function with empty cache // dashboard_admin bypass ensures admins always have access } } getEffectivePermissions(groups: string[]): string[] { const permSet = new Set(); for (const group of groups) { const perms = this.groupPermissions.get(group); if (perms) { for (const p of perms) { permSet.add(p); } } } return Array.from(permSet); } hasPermission(groups: string[], permission: string): boolean { for (const group of groups) { const perms = this.groupPermissions.get(group); if (perms?.has(permission)) return true; } return false; } isFeatureInMaintenance(featureGroup: string): boolean { return this.maintenanceFlags.get(featureGroup) ?? false; } getMaintenanceFlags(): Record { const result: Record = {}; for (const [k, v] of this.maintenanceFlags) { result[k] = v; } return result; } // ── Admin methods ── async getMatrix(): Promise { const [fgResult, pResult, gpResult] = await Promise.all([ pool.query('SELECT id, label, sort_order, maintenance FROM feature_groups ORDER BY sort_order'), pool.query('SELECT id, feature_group_id, label, description, sort_order FROM permissions ORDER BY feature_group_id, sort_order'), pool.query('SELECT authentik_group, permission_id FROM group_permissions'), ]); const grants: Record = {}; const groupSet = new Set(); for (const row of gpResult.rows) { groupSet.add(row.authentik_group); if (!grants[row.authentik_group]) grants[row.authentik_group] = []; grants[row.authentik_group].push(row.permission_id); } const maintenance: Record = {}; for (const row of fgResult.rows) { maintenance[row.id] = row.maintenance; } return { featureGroups: fgResult.rows, permissions: pResult.rows, groups: Array.from(groupSet).sort(), grants, maintenance, }; } async getKnownGroups(): Promise { const result = await pool.query( 'SELECT DISTINCT authentik_group FROM group_permissions ORDER BY authentik_group' ); return result.rows.map((r: any) => r.authentik_group); } async getUnknownGroups(): Promise { // Groups from users table that are not yet in the permission matrix const result = await pool.query(` SELECT DISTINCT g AS group_name FROM users, unnest(authentik_groups) AS g WHERE g LIKE 'dashboard_%' AND g NOT IN (SELECT DISTINCT authentik_group FROM group_permissions) AND g != 'dashboard_admin' ORDER BY group_name `); return result.rows.map((r: any) => r.group_name); } async setGroupPermissions(group: string, permIds: string[], grantedBy: string): Promise { const client = await pool.connect(); try { await client.query('BEGIN'); // Validate permission IDs exist (filter out stale/invalid ones) let validPermIds = permIds; if (permIds.length > 0) { const validResult = await client.query( 'SELECT id FROM permissions WHERE id = ANY($1)', [permIds] ); const validSet = new Set(validResult.rows.map((r: any) => r.id)); validPermIds = permIds.filter(p => validSet.has(p)); } // Remove all existing permissions for this group await client.query('DELETE FROM group_permissions WHERE authentik_group = $1', [group]); // Insert new permissions if (validPermIds.length > 0) { const values = validPermIds.map((_p, i) => `($1, $${i + 2}, $${validPermIds.length + 2})` ).join(', '); await client.query( `INSERT INTO group_permissions (authentik_group, permission_id, granted_by) VALUES ${values} ON CONFLICT DO NOTHING`, [group, ...validPermIds, grantedBy] ); } await client.query('COMMIT'); // Reload cache await this.loadCache(); logger.info('Group permissions updated', { group, permissionCount: validPermIds.length, grantedBy }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } async deleteGroup(group: string): Promise { await pool.query('DELETE FROM group_permissions WHERE authentik_group = $1', [group]); await this.loadCache(); logger.info('Group deleted from permissions', { group }); } /** * Bulk-update permissions for multiple groups in a single transaction. * Reloads cache once at the end. */ async setMultipleGroupPermissions( updates: { group: string; permissions: string[] }[], grantedBy: string, ): Promise { const client = await pool.connect(); try { await client.query('BEGIN'); // Collect all referenced permission IDs to validate in one query const allPermIds = new Set(); for (const u of updates) { for (const p of u.permissions) allPermIds.add(p); } let validSet = new Set(); if (allPermIds.size > 0) { const validResult = await client.query( 'SELECT id FROM permissions WHERE id = ANY($1)', [Array.from(allPermIds)] ); validSet = new Set(validResult.rows.map((r: any) => r.id)); } for (const { group, permissions } of updates) { const validPermIds = permissions.filter(p => validSet.has(p)); await client.query('DELETE FROM group_permissions WHERE authentik_group = $1', [group]); if (validPermIds.length > 0) { const values = validPermIds.map((_p, i) => `($1, $${i + 2}, $${validPermIds.length + 2})` ).join(', '); await client.query( `INSERT INTO group_permissions (authentik_group, permission_id, granted_by) VALUES ${values} ON CONFLICT DO NOTHING`, [group, ...validPermIds, grantedBy] ); } } await client.query('COMMIT'); await this.loadCache(); logger.info('Bulk group permissions updated', { groupCount: updates.length, grantedBy, }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } async setMaintenanceFlag(featureGroup: string, active: boolean): Promise { await pool.query( 'UPDATE feature_groups SET maintenance = $1 WHERE id = $2', [active, featureGroup] ); await this.loadCache(); logger.info('Maintenance flag updated', { featureGroup, active }); } // ── Dependency config (stored in app_settings) ── async getGroupHierarchy(): Promise> { try { const result = await pool.query( "SELECT value FROM app_settings WHERE key = 'permission_group_hierarchy'" ); if (result.rows.length > 0 && result.rows[0].value) { const val = typeof result.rows[0].value === 'string' ? JSON.parse(result.rows[0].value) : result.rows[0].value; return val; } } catch (error) { logger.warn('Failed to load group hierarchy from DB, using defaults', { error }); } return DEFAULT_GROUP_HIERARCHY; } async setGroupHierarchy(hierarchy: Record, userId: string): Promise { await pool.query( `INSERT INTO app_settings (key, value, updated_by, updated_at) VALUES ('permission_group_hierarchy', $1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $1, updated_by = $2, updated_at = NOW()`, [JSON.stringify(hierarchy), userId] ); logger.info('Group hierarchy updated', { userId }); } async getPermissionDeps(): Promise> { try { const result = await pool.query( "SELECT value FROM app_settings WHERE key = 'permission_deps'" ); if (result.rows.length > 0 && result.rows[0].value) { const val = typeof result.rows[0].value === 'string' ? JSON.parse(result.rows[0].value) : result.rows[0].value; return val; } } catch (error) { logger.warn('Failed to load permission deps from DB, using defaults', { error }); } return DEFAULT_PERMISSION_DEPS; } async setPermissionDeps(deps: Record, userId: string): Promise { await pool.query( `INSERT INTO app_settings (key, value, updated_by, updated_at) VALUES ('permission_deps', $1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $1, updated_by = $2, updated_at = NOW()`, [JSON.stringify(deps), userId] ); logger.info('Permission deps updated', { userId }); } async getDependencyConfig(): Promise<{ groupHierarchy: Record; permissionDeps: Record; }> { const [groupHierarchy, permissionDeps] = await Promise.all([ this.getGroupHierarchy(), this.getPermissionDeps(), ]); return { groupHierarchy, permissionDeps }; } } export const permissionService = new PermissionService();