diff --git a/backend/src/controllers/permission.controller.ts b/backend/src/controllers/permission.controller.ts index 5cf5c0e..a0e6fc0 100644 --- a/backend/src/controllers/permission.controller.ts +++ b/backend/src/controllers/permission.controller.ts @@ -76,6 +76,35 @@ class PermissionController { } } + /** + * PUT /api/admin/permissions/bulk + * Bulk-update permissions for multiple groups in one request. + * Body: { updates: [{ group: string, permissions: string[] }] } + */ + async setBulkPermissions(req: Request, res: Response): Promise { + try { + const { updates } = req.body; + + if (!Array.isArray(updates)) { + res.status(400).json({ success: false, message: 'updates must be an array' }); + return; + } + + for (const u of updates) { + if (typeof u.group !== 'string' || !Array.isArray(u.permissions)) { + res.status(400).json({ success: false, message: 'Each update must have group (string) and permissions (array)' }); + return; + } + } + + await permissionService.setMultipleGroupPermissions(updates, req.user!.id); + res.json({ success: true, message: 'Berechtigungen aktualisiert' }); + } catch (error) { + logger.error('Failed to set bulk permissions', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Speichern der Berechtigungen' }); + } + } + /** * GET /api/admin/permissions/groups * Returns all known Authentik groups from the permission table. diff --git a/backend/src/routes/permission.routes.ts b/backend/src/routes/permission.routes.ts index 133420e..2c8918f 100644 --- a/backend/src/routes/permission.routes.ts +++ b/backend/src/routes/permission.routes.ts @@ -13,6 +13,7 @@ router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permi router.get('/admin/groups', authenticate, requirePermission('admin:view'), permissionController.getGroups.bind(permissionController)); router.get('/admin/unknown-groups', authenticate, requirePermission('admin:view'), permissionController.getUnknownGroups.bind(permissionController)); router.put('/admin/group/:groupName', authenticate, requirePermission('admin:write'), permissionController.setGroupPermissions.bind(permissionController)); +router.put('/admin/bulk', authenticate, requirePermission('admin:write'), permissionController.setBulkPermissions.bind(permissionController)); router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController)); export default router; diff --git a/backend/src/services/permission.service.ts b/backend/src/services/permission.service.ts index 2703fb3..d5f5d13 100644 --- a/backend/src/services/permission.service.ts +++ b/backend/src/services/permission.service.ts @@ -148,21 +148,100 @@ class PermissionService { 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 - for (const permId of permIds) { + 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 ($1, $2, $3) ON CONFLICT DO NOTHING', - [group, permId, grantedBy] + `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: permIds.length, grantedBy }); + logger.info('Group permissions updated', { group, permissionCount: validPermIds.length, grantedBy }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * 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; diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 85389a1..4fcb6fa 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -188,10 +188,10 @@ function PermissionMatrixTab() { onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'), }); - // ── Permission save (saves full group permissions) ── + // ── Permission save (bulk — single request for all affected groups) ── const permissionMutation = useMutation({ mutationFn: (updates: { group: string; permissions: string[] }[]) => - Promise.all(updates.map(u => permissionsApi.setGroupPermissions(u.group, u.permissions))), + permissionsApi.setBulkPermissions(updates), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); @@ -576,16 +576,28 @@ function PermissionMatrixTab() { {nonAdminGroups.map(g => { const isGranted = (grants[g] || []).includes(perm.id); + // Check if this perm is required by another granted perm (dependency lock) + const groupGrants = grants[g] || []; + const dependents = REVERSE_DEPS[perm.id] || []; + const isRequiredByOther = isGranted && dependents.some(d => groupGrants.includes(d)); return ( - - handlePermissionToggle(g, perm.id, grants, groups) - } - disabled={permissionMutation.isPending} - size="small" - /> + + + + handlePermissionToggle(g, perm.id, grants, groups) + } + disabled={permissionMutation.isPending} + size="small" + sx={isRequiredByOther ? { color: 'warning.main', '&.Mui-checked': { color: 'warning.main' } } : undefined} + /> + + ); })} diff --git a/frontend/src/services/permissions.ts b/frontend/src/services/permissions.ts index d4c47b5..1f9606b 100644 --- a/frontend/src/services/permissions.ts +++ b/frontend/src/services/permissions.ts @@ -21,6 +21,10 @@ export const permissionsApi = { await api.put(`/api/permissions/admin/group/${encodeURIComponent(group)}`, { permissions }); }, + setBulkPermissions: async (updates: { group: string; permissions: string[] }[]): Promise => { + await api.put('/api/permissions/admin/bulk', { updates }); + }, + setMaintenanceFlag: async (featureGroup: string, active: boolean): Promise => { await api.put(`/api/permissions/admin/maintenance/${encodeURIComponent(featureGroup)}`, { active }); },