package auth import ( "context" "errors" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // ListRoles liest alle Rollen samt ihrer Berechtigungen. // // Die Anzahl der Rollen ist naturgemäß klein, weshalb hier bewusst nicht // paginiert wird. func (repository *Repository) ListRoles(queryContext context.Context) ([]Role, error) { const selectStatement = ` SELECT r.id, r.name, coalesce(r.description, ''), r.is_system, coalesce(array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id LEFT JOIN permissions p ON p.id = rp.permission_id GROUP BY r.id, r.name, r.description, r.is_system ORDER BY r.name` roleRows, queryError := repository.connectionPool.Query(queryContext, selectStatement) if queryError != nil { return nil, fmt.Errorf("die rollen konnten nicht gelesen werden: %w", queryError) } defer roleRows.Close() loadedRoles := make([]Role, 0) for roleRows.Next() { var loadedRole Role if scanError := roleRows.Scan(&loadedRole.ID, &loadedRole.Name, &loadedRole.Description, &loadedRole.IsSystem, &loadedRole.Permissions); scanError != nil { return nil, fmt.Errorf("eine rolle konnte nicht gelesen werden: %w", scanError) } loadedRoles = append(loadedRoles, loadedRole) } if rowsError := roleRows.Err(); rowsError != nil { return nil, fmt.Errorf("die rollen konnten nicht vollständig gelesen werden: %w", rowsError) } return loadedRoles, nil } // FindRoleByID liest eine Rolle samt ihrer Berechtigungen. func (repository *Repository) FindRoleByID(queryContext context.Context, roleID uuid.UUID) (Role, error) { const selectStatement = ` SELECT r.id, r.name, coalesce(r.description, ''), r.is_system, coalesce(array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id LEFT JOIN permissions p ON p.id = rp.permission_id WHERE r.id = $1 GROUP BY r.id, r.name, r.description, r.is_system` var loadedRole Role scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, roleID).Scan( &loadedRole.ID, &loadedRole.Name, &loadedRole.Description, &loadedRole.IsSystem, &loadedRole.Permissions) if errors.Is(scanError, pgx.ErrNoRows) { return Role{}, ErrRoleNotFound } if scanError != nil { return Role{}, fmt.Errorf("die rolle konnte nicht gelesen werden: %w", scanError) } return loadedRole, nil } // ListPermissions liest alle verfügbaren Berechtigungen. func (repository *Repository) ListPermissions(queryContext context.Context) ([]Permission, error) { permissionRows, queryError := repository.connectionPool.Query(queryContext, "SELECT name, description FROM permissions ORDER BY name") if queryError != nil { return nil, fmt.Errorf("die berechtigungen konnten nicht gelesen werden: %w", queryError) } defer permissionRows.Close() loadedPermissions := make([]Permission, 0) for permissionRows.Next() { var loadedPermission Permission if scanError := permissionRows.Scan(&loadedPermission.Name, &loadedPermission.Description); scanError != nil { return nil, fmt.Errorf("eine berechtigung konnte nicht gelesen werden: %w", scanError) } loadedPermissions = append(loadedPermissions, loadedPermission) } if rowsError := permissionRows.Err(); rowsError != nil { return nil, fmt.Errorf("die berechtigungen konnten nicht vollständig gelesen werden: %w", rowsError) } return loadedPermissions, nil } // CreateRole legt eine Rolle mit den angegebenen Berechtigungen an. func (repository *Repository) CreateRole(createContext context.Context, roleName string, description string, permissionNames []string) (uuid.UUID, error) { databaseTransaction, transactionError := repository.connectionPool.Begin(createContext) if transactionError != nil { return uuid.Nil, fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError) } defer func() { _ = databaseTransaction.Rollback(createContext) }() var createdRoleID uuid.UUID insertError := databaseTransaction.QueryRow(createContext, "INSERT INTO roles (name, description, is_system) VALUES ($1, $2, FALSE) RETURNING id", roleName, nullIfEmpty(description)).Scan(&createdRoleID) if insertError != nil { return uuid.Nil, fmt.Errorf("die rolle konnte nicht angelegt werden: %w", insertError) } if permissionError := setRolePermissionsInTransaction(createContext, databaseTransaction, createdRoleID, permissionNames); permissionError != nil { return uuid.Nil, permissionError } if commitError := databaseTransaction.Commit(createContext); commitError != nil { return uuid.Nil, fmt.Errorf("die rolle konnte nicht gespeichert werden: %w", commitError) } return createdRoleID, nil } // setRolePermissionsInTransaction ersetzt die Berechtigungen einer Rolle. func setRolePermissionsInTransaction(setContext context.Context, databaseTransaction pgx.Tx, roleID uuid.UUID, permissionNames []string) error { if _, deleteError := databaseTransaction.Exec(setContext, "DELETE FROM role_permissions WHERE role_id = $1", roleID); deleteError != nil { return fmt.Errorf("die bisherigen berechtigungen konnten nicht entfernt werden: %w", deleteError) } for _, permissionName := range permissionNames { commandTag, insertError := databaseTransaction.Exec(setContext, ` INSERT INTO role_permissions (role_id, permission_id) SELECT $1, id FROM permissions WHERE name = $2 ON CONFLICT DO NOTHING`, roleID, permissionName) if insertError != nil { return fmt.Errorf("die berechtigung %q konnte nicht zugewiesen werden: %w", permissionName, insertError) } // Eine unbekannte Berechtigung wäre ein stillschweigend wirkungsloser // Eintrag und damit eine falsche Sicherheitsannahme. if commandTag.RowsAffected() == 0 { return fmt.Errorf("die berechtigung %q existiert nicht", permissionName) } } return nil } // UpdateRole ändert Beschreibung und Berechtigungen einer Rolle. // // Mitgelieferte Rollen sind unveränderlich: eine Änderung würde die Bedeutung // bestehender Zuweisungen nachträglich verschieben. func (repository *Repository) UpdateRole(updateContext context.Context, roleID uuid.UUID, newDescription *string, permissionNames []string) error { existingRole, lookupError := repository.FindRoleByID(updateContext, roleID) if lookupError != nil { return lookupError } if existingRole.IsSystem { return ErrSystemRoleImmutable } databaseTransaction, transactionError := repository.connectionPool.Begin(updateContext) if transactionError != nil { return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError) } defer func() { _ = databaseTransaction.Rollback(updateContext) }() if newDescription != nil { if _, updateError := databaseTransaction.Exec(updateContext, "UPDATE roles SET description = $2, updated_at = now() WHERE id = $1", roleID, nullIfEmpty(*newDescription)); updateError != nil { return fmt.Errorf("die rolle konnte nicht geändert werden: %w", updateError) } } if permissionNames != nil { if permissionError := setRolePermissionsInTransaction(updateContext, databaseTransaction, roleID, permissionNames); permissionError != nil { return permissionError } } if commitError := databaseTransaction.Commit(updateContext); commitError != nil { return fmt.Errorf("die änderung konnte nicht gespeichert werden: %w", commitError) } return nil } // DeleteRole löscht eine Rolle. func (repository *Repository) DeleteRole(deleteContext context.Context, roleID uuid.UUID) error { existingRole, lookupError := repository.FindRoleByID(deleteContext, roleID) if lookupError != nil { return lookupError } if existingRole.IsSystem { return ErrSystemRoleImmutable } // Eine noch zugewiesene Rolle zu löschen würde Benutzern unbemerkt Rechte // entziehen; der Fremdschlüssel verhindert das bereits, doch die Meldung // soll verständlich sein. var assignmentCount int if scanError := repository.connectionPool.QueryRow(deleteContext, "SELECT count(*) FROM user_roles WHERE role_id = $1", roleID).Scan(&assignmentCount); scanError != nil { return fmt.Errorf("die zuweisungen konnten nicht geprüft werden: %w", scanError) } if assignmentCount > 0 { return fmt.Errorf("%w (%d Zuweisungen)", ErrRoleInUse, assignmentCount) } commandTag, deleteError := repository.connectionPool.Exec(deleteContext, "DELETE FROM roles WHERE id = $1", roleID) if deleteError != nil { return fmt.Errorf("die rolle konnte nicht gelöscht werden: %w", deleteError) } if commandTag.RowsAffected() == 0 { return ErrRoleNotFound } return nil }