Files
OPNexus/backend/app/routers/policy_hierarchy.py
T
Julian Boldt 1fc2d2c87c
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release v1.11.1
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:

- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
  endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
  `/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
  Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
  gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
  es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
  `cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
  Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
  `/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
  Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
  (VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
  kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
  er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
  unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
  `SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
  Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
  („instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
  Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
  Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
  („Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
  einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
  und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
  Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
  sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
  Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
  „Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
  zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
  noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
  der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
  abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
  Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
  ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
  die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
  Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
  auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
  Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
  Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
  Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
  WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
  allen Seiten, hell und dunkel, nichts mehr.

- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
  blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
  bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
  Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
  jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
  (Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
  sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
  Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
  das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
2026-10-09 18:35:53 +02:00

380 lines
17 KiB
Python

import uuid
from fastapi import APIRouter, Body, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.errors import ErrorCode, api_error
from app.database import get_db
from app.models.policy import PolicyHierarchyRuleRevision
from app.models.policy_hierarchy_group import PolicyHierarchyGroup
from app.models.policy_hierarchy_rule import PolicyHierarchyRule
from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment
from app.schemas.rule import DeploymentResult
from app.schemas.policy_hierarchy import (
PolicyHierarchyDeployApproval,
PolicyHierarchyMoveDirection,
PolicyHierarchyReorder,
PolicyHierarchyRuleCreate,
PolicyHierarchyRuleEdit,
PolicyHierarchyRuleRead,
)
from app.services.alias_service import check_alias_consistency, describe_alias_contents, ensure_aliases_settled
from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_hierarchy_deploy_service import (
confirm_hierarchy_rule,
deploy_hierarchy_rule,
reassert_group_ordering,
revert_hierarchy_deployment,
undeploy_hierarchy_rule,
)
from app.services.policy_hierarchy_service import (
HIERARCHY_RULE_FIELDS,
effective_hosts,
ensure_editable,
policy_lock,
preview,
revision,
workflow,
)
router = APIRouter(prefix="/policy-hierarchy", tags=["policy-hierarchy"])
def _rule_load_options():
return [selectinload(PolicyHierarchyRule.deployments).selectinload(PolicyHierarchyRuleDeployment.host)]
async def _load_all_groups(db: AsyncSession) -> list[PolicyHierarchyGroup]:
return list((await db.scalars(
select(PolicyHierarchyGroup).options(selectinload(PolicyHierarchyGroup.hosts))
)).all())
async def _load_rule(db: AsyncSession, rule_id: uuid.UUID) -> PolicyHierarchyRule:
await policy_lock(db)
# populate_existing=True: without it, a rule already in this request's
# identity map (e.g. right after create_rule's db.add()+commit(), or a
# second _load_rule call later in the same request) is returned as-is
# by db.get() and the selectinload(deployments) option is silently
# skipped — leaving `.deployments` unloaded and triggering a
# synchronous lazy-load attempt outside any greenlet context later.
rule = await db.get(PolicyHierarchyRule, rule_id, options=_rule_load_options(), populate_existing=True)
if not rule:
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN)
return rule
def _to_read(rule: PolicyHierarchyRule, all_groups: list[PolicyHierarchyGroup]) -> PolicyHierarchyRuleRead:
hosts_by_id = {h.id: h for h in effective_hosts(rule, all_groups)}
hosts_by_id.update({d.host_id: d.host for d in rule.deployments if d.host is not None})
state, counts = workflow(rule, rule.deployments, all_groups)
return PolicyHierarchyRuleRead(
workflow_state=state,
workflow_counts=counts,
id=rule.id,
hierarchy_group_id=rule.hierarchy_group_id,
rulebase=rule.rulebase,
sequence=rule.sequence,
description=rule.description,
interface=rule.interface,
direction=rule.direction,
ipprotocol=rule.ipprotocol,
protocol=rule.protocol,
action=rule.action,
source_net=rule.source_net,
source_port=rule.source_port,
destination_net=rule.destination_net,
destination_port=rule.destination_port,
enabled=rule.enabled,
created_at=rule.created_at,
updated_at=rule.updated_at,
effective_host_ids=[h.id for h in effective_hosts(rule, all_groups)],
deployments=[
DeploymentResult(
host_id=d.host_id,
host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?",
status=d.status,
message=d.message,
revert_deadline=d.revert_deadline,
)
for d in rule.deployments
],
)
async def _get_group_or_400(db: AsyncSession, group_id: uuid.UUID) -> PolicyHierarchyGroup:
group = await db.get(PolicyHierarchyGroup, group_id)
if not group:
raise api_error(400, ErrorCode.POLICY_HIERARCHIE_GRUPPE_NICHT_GEFUNDEN)
return group
@router.get("/rules", response_model=list[PolicyHierarchyRuleRead])
async def list_rules(db: AsyncSession = Depends(get_db)):
all_groups = await _load_all_groups(db)
rules = (await db.scalars(select(PolicyHierarchyRule).options(*_rule_load_options()))).all()
return [_to_read(rule, all_groups) for rule in rules]
@router.post("/rules", response_model=PolicyHierarchyRuleRead, status_code=201)
async def create_rule(payload: PolicyHierarchyRuleCreate, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
await _get_group_or_400(db, payload.hierarchy_group_id)
# New rules are appended to the end of their (group, rulebase) list;
# atomic drag-and-drop and up/down controls handle reordering later.
max_sequence = await db.scalar(
select(PolicyHierarchyRule.sequence).where(
PolicyHierarchyRule.hierarchy_group_id == payload.hierarchy_group_id,
PolicyHierarchyRule.rulebase == payload.rulebase,
).order_by(PolicyHierarchyRule.sequence.desc()).limit(1)
)
rule = PolicyHierarchyRule(
hierarchy_group_id=payload.hierarchy_group_id,
rulebase=payload.rulebase,
sequence=(max_sequence + 1) if max_sequence is not None else 0,
description=payload.description,
interface=payload.interface,
direction=payload.direction,
ipprotocol=payload.ipprotocol,
protocol=payload.protocol,
action=payload.action,
source_net=payload.source_net,
source_port=payload.source_port,
destination_net=payload.destination_net,
destination_port=payload.destination_port,
enabled=payload.enabled,
)
db.add(rule)
await db.flush()
revision(db, rule, "Regel angelegt")
await db.commit()
return _to_read(await _load_rule(db, rule.id), await _load_all_groups(db))
@router.put("/rules/{rule_id}", response_model=PolicyHierarchyRuleRead)
async def edit_rule(rule_id: uuid.UUID, payload: PolicyHierarchyRuleEdit, db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
ensure_editable(rule)
if rule.updated_at != payload.expected_updated_at:
raise api_error(409, ErrorCode.REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0)
await _get_group_or_400(db, payload.hierarchy_group_id)
if not await db.scalar(select(PolicyHierarchyRuleRevision.id).where(PolicyHierarchyRuleRevision.policy_hierarchy_id == rule.id).limit(1)):
revision(db, rule, "Vor erster Bearbeitung")
for field in HIERARCHY_RULE_FIELDS:
setattr(rule, field, getattr(payload, field))
rule.hierarchy_group_id = payload.hierarchy_group_id
rule.rulebase = payload.rulebase
from datetime import datetime, timezone
rule.updated_at = datetime.now(timezone.utc)
revision(db, rule, "Entwurf gespeichert")
await db.commit()
return _to_read(rule, await _load_all_groups(db))
@router.put("/groups/{group_id}/rules/{rulebase}/order", response_model=list[PolicyHierarchyRuleRead])
async def reorder_rules(
group_id: uuid.UUID,
rulebase: str,
payload: PolicyHierarchyReorder,
db: AsyncSession = Depends(get_db),
):
"""Atomically persist a complete drag-and-drop order.
The complete sibling set and every optimistic timestamp must still
match. This prevents two administrators from silently overwriting each
other's order and avoids the partial state produced by several
sequential up/down requests.
"""
if rulebase not in ("pre", "post"):
raise api_error(404, ErrorCode.RULEBASE_NICHT_GEFUNDEN)
await policy_lock(db)
await _get_group_or_400(db, group_id)
siblings = list((await db.scalars(
select(PolicyHierarchyRule).where(
PolicyHierarchyRule.hierarchy_group_id == group_id,
PolicyHierarchyRule.rulebase == rulebase,
).options(*_rule_load_options()).order_by(PolicyHierarchyRule.sequence, PolicyHierarchyRule.id)
)).all())
if len({item.id for item in payload.items}) != len(payload.items):
raise api_error(422, ErrorCode.EINE_REGEL_DARF_IN_DER_REIHENFOLGE_NUR_EINMAL_F05DF47C)
if {rule.id for rule in siblings} != {item.id for item in payload.items}:
raise api_error(409, ErrorCode.REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203)
expected = {item.id: item.expected_updated_at for item in payload.items}
for rule in siblings:
ensure_editable(rule)
if any(rule.updated_at != expected[rule.id] for rule in siblings):
raise api_error(409, ErrorCode.MINDESTENS_EINE_REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_932925BD)
by_id = {rule.id: rule for rule in siblings}
from datetime import datetime, timezone
changed_at = datetime.now(timezone.utc)
for sequence, item in enumerate(payload.items):
rule = by_id[item.id]
if rule.sequence != sequence:
rule.sequence = sequence
rule.updated_at = changed_at
revision(db, rule, "Reihenfolge per Drag-and-drop geändert")
await db.commit()
all_groups = await _load_all_groups(db)
return [_to_read(await _load_rule(db, item.id), all_groups) for item in payload.items]
@router.post("/rules/{rule_id}/move", response_model=PolicyHierarchyRuleRead)
async def move_rule(rule_id: uuid.UUID, payload: PolicyHierarchyMoveDirection, db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
ensure_editable(rule)
siblings = list((await db.scalars(
select(PolicyHierarchyRule).where(
PolicyHierarchyRule.hierarchy_group_id == rule.hierarchy_group_id,
PolicyHierarchyRule.rulebase == rule.rulebase,
).order_by(PolicyHierarchyRule.sequence, PolicyHierarchyRule.id)
)).all())
index = next(i for i, r in enumerate(siblings) if r.id == rule.id)
neighbor_index = index - 1 if payload.direction == "up" else index + 1
if 0 <= neighbor_index < len(siblings):
neighbor = siblings[neighbor_index]
rule.sequence, neighbor.sequence = neighbor.sequence, rule.sequence
revision(db, rule, "Reihenfolge geändert")
await db.commit()
return _to_read(await _load_rule(db, rule_id), await _load_all_groups(db))
@router.post("/rules/{rule_id}/deploy", response_model=PolicyHierarchyRuleRead)
async def deploy(rule_id: uuid.UUID, approval: PolicyHierarchyDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
all_groups = await _load_all_groups(db)
plan = preview(rule, all_groups)
if approval and approval.preview_token != plan["token"]:
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
if any(d.status in ("pending", "revert_failed") for d in rule.deployments):
raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN)
hosts = [h for h in effective_hosts(rule, all_groups) if h.enabled]
if not hosts:
raise api_error(400, ErrorCode.REGEL_HAT_KEINE_ZIEL_HOSTS)
if len(hosts) > 1:
labels = await check_interface_consistency(hosts, rule.interface)
distinct = set(labels.values())
if len(distinct) > 1:
detail = ", ".join(f"{name}={label}" for name, label in sorted(labels.items()))
raise api_error(409, ErrorCode.INTERFACE_MEANING_DIFFERS, interface=rule.interface, detail=detail)
for field_label, value in (
("Source", rule.source_net),
("Destination", rule.destination_net),
("Source-Port", rule.source_port),
("Destination-Port", rule.destination_port),
):
if not value:
continue
contents = await check_alias_consistency(hosts, value)
if len(set(contents.values())) > 1:
detail = describe_alias_contents(contents)
raise api_error(409, ErrorCode.ALIAS_CONTENT_DIFFERS, field_label=field_label, alias=value, detail=detail)
await ensure_aliases_settled(db, hosts, [rule.source_net, rule.destination_net, rule.source_port, rule.destination_port])
await deploy_hierarchy_rule(db, rule, hosts)
await db.refresh(rule, attribute_names=["deployments"])
for d in rule.deployments:
await db.refresh(d, attribute_names=["host"])
return _to_read(rule, await _load_all_groups(db))
@router.post("/rules/{rule_id}/confirm", response_model=PolicyHierarchyRuleRead)
async def confirm(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
await confirm_hierarchy_rule(db, rule)
return _to_read(rule, await _load_all_groups(db))
@router.delete("/rules/{rule_id}", status_code=204)
async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
if any(d.status in ("pending", "revert_failed") for d in rule.deployments):
raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_KLAEREN)
try:
await undeploy_hierarchy_rule(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@router.get("/rules/{rule_id}/preview")
async def preview_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
return preview(rule, await _load_all_groups(db))
@router.get("/rules/{rule_id}/revisions")
async def list_revisions(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _load_rule(db, rule_id)
rows = (await db.scalars(
select(PolicyHierarchyRuleRevision).where(PolicyHierarchyRuleRevision.policy_hierarchy_id == rule_id)
.order_by(PolicyHierarchyRuleRevision.created_at.desc(), PolicyHierarchyRuleRevision.id)
)).all()
return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows]
@router.post("/rules/{rule_id}/deployments/{host_id}/remove", status_code=204)
async def remove_previous_target(rule_id: uuid.UUID, host_id: uuid.UUID, approval: PolicyHierarchyDeployApproval, db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
all_groups = await _load_all_groups(db)
plan = preview(rule, all_groups)
if approval.preview_token != plan["token"]:
raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN)
entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None)
if entry is None:
raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN)
if not entry["can_remove"]:
raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB)
deployment = next(d for d in rule.deployments if d.host_id == host_id)
from app.services import opnsense_client
try:
await opnsense_client.del_rule(deployment.host, deployment.opnsense_policy_hierarchy_uuid)
await opnsense_client.apply(deployment.host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
await db.commit()
raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc
await db.delete(deployment)
await db.commit()
@router.post("/rules/{rule_id}/deployments/{host_id}/retry-rollback", status_code=204)
async def retry_rollback(rule_id: uuid.UUID, host_id: uuid.UUID, approval: PolicyHierarchyDeployApproval, db: AsyncSession = Depends(get_db)):
rule = await _load_rule(db, rule_id)
all_groups = await _load_all_groups(db)
plan = preview(rule, all_groups)
if approval.preview_token != plan["token"]:
raise api_error(409, ErrorCode.DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN)
entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None)
if entry is None:
raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN)
if not entry["can_retry_rollback"]:
raise api_error(409, ErrorCode.FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR)
deployment = next(d for d in rule.deployments if d.host_id == host_id)
reverted = await revert_hierarchy_deployment(db, deployment)
await db.commit()
if not reverted:
raise api_error(502, ErrorCode.ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL)
@router.post("/groups/{group_id}/reassert-ordering")
async def reassert_ordering(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Explicit ordering repair for a group subtree.
Normal OPNexus rule/alias/NAT deploys also reassert automatically; this
action remains useful after changing the desired order or after manual
edits made directly on a firewall.
"""
await policy_lock(db)
group = await db.get(PolicyHierarchyGroup, group_id)
if not group:
raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN)
errors = await reassert_group_ordering(db, group)
if errors:
raise api_error(502, ErrorCode.ORDERING_FAILED, reason='; '.join(errors))
return {"status": "ok"}