Files
dashboard/backend/src/services/permission.service.ts
Matthias Hochmeister 97c9af7f14 new features
2026-03-23 17:54:19 +01:00

433 lines
15 KiB
TypeScript

import pool from '../config/database';
import logger from '../utils/logger';
// Default configs — used when no DB config exists yet
const DEFAULT_GROUP_HIERARCHY: Record<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]>;
maintenance: Record<string, boolean>;
}
class PermissionService {
private groupPermissions: Map<string, Set<string>> = new Map();
private maintenanceFlags: Map<string, boolean> = new Map();
async loadCache(): Promise<void> {
try {
// Load group permissions
const gpResult = await pool.query('SELECT authentik_group, permission_id FROM group_permissions');
const newMap = new Map<string, Set<string>>();
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<string, boolean>();
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<string>();
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<string, boolean> {
const result: Record<string, boolean> = {};
for (const [k, v] of this.maintenanceFlags) {
result[k] = v;
}
return result;
}
// ── Admin methods ──
async getMatrix(): Promise<MatrixData> {
const [fgResult, pResult, gpResult, userGroupsResult] = 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'),
// Also include all dashboard_ groups from users table
pool.query(`SELECT DISTINCT g AS group_name FROM users, unnest(authentik_groups) AS g WHERE g LIKE 'dashboard_%' AND g != 'dashboard_admin'`),
]);
const grants: Record<string, string[]> = {};
const groupSet = new Set<string>();
// Add groups from group_permissions
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);
}
// Also add groups from users table (they may have no permissions yet)
for (const row of userGroupsResult.rows) {
groupSet.add(row.group_name);
if (!grants[row.group_name]) grants[row.group_name] = [];
}
const maintenance: Record<string, boolean> = {};
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<string[]> {
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<string[]> {
// Groups from users table that have zero permissions assigned
// (they appear in the matrix but admin should be notified)
const result = await pool.query(`
SELECT DISTINCT g AS group_name
FROM users, unnest(authentik_groups) AS g
WHERE g LIKE 'dashboard_%'
AND g != 'dashboard_admin'
AND g NOT IN (SELECT DISTINCT authentik_group FROM group_permissions)
ORDER BY group_name
`);
return result.rows.map((r: any) => r.group_name);
}
async setGroupPermissions(group: string, permIds: string[], grantedBy: string): Promise<void> {
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<void> {
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<{ droppedPermissions: string[] }> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Collect all referenced permission IDs to validate in one query
const allPermIds = new Set<string>();
for (const u of updates) {
for (const p of u.permissions) allPermIds.add(p);
}
let validSet = new Set<string>();
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));
}
const allDropped: string[] = [];
for (const { group, permissions } of updates) {
const validPermIds = permissions.filter(p => validSet.has(p));
const droppedPermIds = permissions.filter(p => !validSet.has(p));
if (droppedPermIds.length > 0) {
logger.warn('Permissions dropped during save — not found in permissions table', {
group,
droppedPermIds,
});
allDropped.push(...droppedPermIds);
}
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,
});
return { droppedPermissions: [...new Set(allDropped)] };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async setMaintenanceFlag(featureGroup: string, active: boolean): Promise<void> {
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<Record<string, string[]>> {
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<string, string[]>, userId: string): Promise<void> {
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<Record<string, string[]>> {
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<string, string[]>, userId: string): Promise<void> {
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<string, string[]>;
permissionDeps: Record<string, string[]>;
}> {
const [groupHierarchy, permissionDeps] = await Promise.all([
this.getGroupHierarchy(),
this.getPermissionDeps(),
]);
return { groupHierarchy, permissionDeps };
}
/**
* Returns users whose Authentik groups grant a specific permission,
* or who are dashboard_admin (always have all permissions).
*/
async getUsersWithPermission(permissionId: string): Promise<Array<{ id: string; name: string }>> {
// Find all groups that have this permission
const groupsWithPerm: string[] = [];
for (const [group, perms] of this.groupPermissions.entries()) {
if (perms.has(permissionId)) {
groupsWithPerm.push(group);
}
}
// Always include dashboard_admin
groupsWithPerm.push('dashboard_admin');
const result = await pool.query(
`SELECT DISTINCT u.id, COALESCE(u.name, u.email) AS name
FROM users u
WHERE u.authentik_groups && $1::text[]
ORDER BY name ASC`,
[groupsWithPerm]
);
return result.rows;
}
}
export const permissionService = new PermissionService();