439 lines
15 KiB
TypeScript
439 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:widget_events': ['kalender:view'],
|
|
'kalender:widget_quick_add': ['kalender:view', 'kalender:create'],
|
|
'fahrzeugbuchungen:create': ['fahrzeugbuchungen:view'],
|
|
'fahrzeugbuchungen:manage': ['fahrzeugbuchungen:view', 'fahrzeugbuchungen:create'],
|
|
'fahrzeugbuchungen:widget': ['fahrzeugbuchungen:view'],
|
|
'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'],
|
|
'buchhaltung:create': ['buchhaltung:view'],
|
|
'buchhaltung:edit': ['buchhaltung:view'],
|
|
'buchhaltung:delete': ['buchhaltung:view', 'buchhaltung:create'],
|
|
'buchhaltung:manage_accounts': ['buchhaltung:view'],
|
|
'buchhaltung:manage_settings': ['buchhaltung:view'],
|
|
'buchhaltung:export': ['buchhaltung:view'],
|
|
'buchhaltung:widget': ['buchhaltung: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);
|
|
}
|
|
|
|
// dashboard_admin always holds every permission in the system
|
|
const allPermsResult = await pool.query('SELECT id FROM permissions');
|
|
newMap.set('dashboard_admin', new Set(allPermsResult.rows.map((r: any) => r.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();
|