Files
OPNexus/backend/app/routers/rule_cleanup.py
T
Julian Boldt b020e38d0a
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 / publish (updater) (push) Canceled after 0s
Release v1.31.0
- **Regel-Bereinigung: Entfernen, Bestätigen, Löschen, Wiederherstellen:** Im Tab **Bereinigung** (Regelwerk › Regel-Analyse) lassen sich Kandidaten jetzt auswählen
  (Checkbox, höchstens so viele Regeln je Lauf wie in den Einstellungen; „vermutlich“ nur mit einer ausdrücklichen Einzelbestätigung je Regel, „unbekannt“ nie).
  **Vorschau** zeigt je Firewall (im Cluster zuerst der Backup) die Regeln mit Befund, Sicherheit und Schutzprüfung; **Ausrollen** deaktiviert die Regeln und wendet an.
  Danach läuft ein Countdown: **Bestätigen** heißt „ich erreiche die Firewall noch“, ohne Bestätigung setzt OPNexus **automatisch zurück**; bis dahin geht auch
  **Jetzt zurücksetzen**. Bestätigte Regeln lassen sich nach der Wartezeit (einstellbar, Standard 7 Tage) **löschen**; vorher sichert OPNexus den Regelinhalt, und
  unter **Verlauf der Bereinigungen** kommt die Regel mit **Wiederherstellen** deaktiviert und möglichst an alter Position zurück (sonst mit Hinweis am Ende der Gruppe);
  **Wieder aktivieren** hat wieder ein Bestätigungsfenster. Wichtig: **Ausrollen, Wiederholen und Wiederherstellen darf nur ein Administrator**, eine Vorschau gehört
  dem, der sie erstellt hat (ein Admin öffnet selbst eine neue); **Bestätigen geht nur in der Browser-Sitzung**, nie per API-Token. **Die Bereinigung bleibt gesperrt,
  bis unter Einstellungen die Verwaltungsquellen** (IP-Adressen oder Netze, von denen aus verwaltet wird) **eingetragen sind**: Regeln, die diesen Zugang treffen
  könnten, deaktiviert OPNexus nie. Dort stehen auch die Obergrenze je Lauf, die Wartezeit bis zum Löschen und **weitere Verwaltungsports** (zusätzlich zu
  API-Port, 443, 80, 22). Tragen Sie die Adresse ein, mit der OPNexus selbst bei den Firewalls ankommt (im Cluster für jeden Knoten). Quellen, Ports und
  Firewall-Adressen sehen nur Administratoren. Der Hinweis „Entfernen folgt“ entfällt.
- **Update: neuere CLI aus dem Zielrelease:** `opnexus update` (auch per Klick im Webinterface) holt sich nach dem Pull die CLI aus dem Updater-Image des
  Zielreleases und startet sich damit neu, damit Korrekturen am Update-Ablauf sofort greifen statt erst beim übernächsten Update. Das geschieht nur, wenn
  `cosign` und `cosign.pub` vorhanden sind und die Signatur des Image-**Digests** in diesem Lauf geprüft wurde (ausgeführt wird genau dieser Digest);
  sonst und bei jedem Fehler läuft das Update mit der vorhandenen CLI weiter. Abschalten: `OPNEXUS_NO_CLI_REFRESH=1`. Wirksam wird es ab dem Update
  *von* dieser Version.
2026-10-22 21:04:32 +02:00

195 lines
9.8 KiB
Python

"""Rule cleanup (1.31, B4 part 2): switch hand-made rules off, delete them later, restore them (DESIGN-1.31 §4).
POST /hosts/{id}/rule-cleanup/preview operator, admin prepare (writes nothing to a firewall)
POST /hosts/{id}/rule-cleanup/{op}/cancel operator, admin the preview's own actor
POST /hosts/{id}/rule-cleanup/{op}/deploy ADMIN, token scope admin
POST /hosts/{id}/rule-cleanup/{op}/confirm operator, admin BROWSER SESSION ONLY, never a token
POST /hosts/{id}/rule-cleanup/{op}/revert admin; operator only with operators_may_deploy
POST /hosts/{id}/rule-cleanup/{op}/retry ADMIN, token scope admin
POST /hosts/{id}/rule-cleanup/items/{item}/restore ADMIN, token scope admin
GET /hosts/{id}/rule-cleanup, /{op} everybody signed in (read tokens too)
Who may do what is enforced twice: AuthMiddleware (services/api_token_service.py: _ADMIN_ONLY_CLEANUP,
_SESSION_ONLY, _ROLLOUT_POST) and here, so a gap in one layer does not open the route. The audit trail is the
service's (one ``rule_cleanup`` entry per step and rule) plus the middleware's request entry (kind ``host``,
action = last path segment; ``/preview`` is not logged by it, the service writes its own entry).
The services raise ``api_error``; nothing is translated here. MCP stays read-only (no tool, ``api_get`` is GET).
"""
import uuid
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.models.user import AdminUser
from app.routers import governance
from app.routers.auth import resolve_user
from app.schemas.rule_cleanup import CleanupOperationRead, CleanupPreviewRead, ConfirmName, PreviewRequest
from app.services import rule_cleanup_service as service
from app.services.rule_cleanup_service import ACK_TEXT_VERSION
router = APIRouter(prefix="/hosts/{host_id}/rule-cleanup", tags=["rule-cleanup"])
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
def _token(request: Request):
return getattr(request.state, "api_token", None)
def _writer(user: AdminUser, request: Request) -> None:
"""Any write: not for a read-only account, not with a read-scope token."""
if user.role == "read_only":
raise api_error(403, ErrorCode.READ_ONLY_ACCESS)
token = _token(request)
if token is not None and token.scope == "read":
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_FORBIDDEN, scope=token.scope)
def _admin_only(user: AdminUser, request: Request) -> None:
"""Deploy, retry, restore: an admin account, and with a token only one of scope admin."""
_writer(user, request)
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
token = _token(request)
if token is not None and token.scope != "admin":
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_FORBIDDEN, scope=token.scope)
async def _deployer(user: AdminUser, request: Request, db: AsyncSession) -> None:
"""Revert: an admin, or an operator while the governance switch operators_may_deploy is on."""
_writer(user, request)
if user.role == "admin":
return
if user.role == "operator" and (await governance.get_settings(db)).operators_may_deploy:
return
raise api_error(403, ErrorCode.DEPLOY_NEEDS_ADMIN)
def _session_only(user: AdminUser, request: Request) -> None:
"""Confirm: "I still have access" is a human decision in a browser session, never a token's."""
_writer(user, request)
token = _token(request)
if token is not None:
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_FORBIDDEN, scope=token.scope)
def _full_view(request: Request) -> bool:
"""Admins (session or admin-scope token) see the management sources/ports and firewall addresses in a plan."""
from app.routers.audit import full_view
return full_view(request)
async def _names(db: AsyncSession, operations: list[dict], request: Request) -> list[dict]:
"""The firewall names the UI shows next to the ids; for non-admins without the security-relevant plan parts."""
if not _full_view(request):
operations = [service.redact(op) for op in operations]
ids = {uuid.UUID(op["host_id"]) for op in operations} | {uuid.UUID(i["host_id"]) for op in operations for i in op["items"]}
names = {h.id: h.name for h in (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(ids)))).all()} if ids else {}
for op in operations:
op["host_name"] = names.get(uuid.UUID(op["host_id"]))
for item in op["items"]:
item["host_name"] = names.get(uuid.UUID(item["host_id"]))
return operations
async def _one(db: AsyncSession, operation: dict, request: Request) -> dict:
return (await _names(db, [operation], request))[0]
@router.get("", response_model=list[CleanupOperationRead])
async def list_operations(host_id: uuid.UUID, request: Request, limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db)):
"""The newest operations of this host (and its cluster peers) with their items; unexpired previews included."""
host = await _host(db, host_id)
return await _names(db, await service.list_operations(db, host, limit), request)
@router.post("/preview", response_model=CleanupPreviewRead)
async def preview(host_id: uuid.UUID, payload: PreviewRequest, request: Request, db: AsyncSession = Depends(get_db)):
"""Recompute the plan from the live firewall(s) and store a 5-minute preview bound to the caller."""
user = await resolve_user(request, db)
_writer(user, request)
host = await _host(db, host_id)
kind, uuids, acknowledge = payload.resolved()
if kind is None:
raise api_error(422, ErrorCode.BEREINIGUNG_AUSWAHL_UNGUELTIG, field="kind")
result = await service.preview(
db, host, actor=user.username, actor_id=user.id, kind=kind, uuids=uuids, acknowledge=acknowledge,
**({"unused_days": payload.unused_days} if payload.unused_days else {}),
ack_text_version=payload.ack_text_version or ACK_TEXT_VERSION)
result["operation"] = await _one(db, await service.get_operation(db, host, uuid.UUID(result["preview_id"])), request)
return result if _full_view(request) else service.redact(result)
@router.get("/{op_id}", response_model=CleanupOperationRead)
async def get_operation(host_id: uuid.UUID, op_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
return await _one(db, await service.get_operation(db, host, op_id), request)
@router.post("/{op_id}/cancel", response_model=CleanupOperationRead)
async def cancel(host_id: uuid.UUID, op_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
"""Drop an unused preview (only its own actor can)."""
user = await resolve_user(request, db)
_writer(user, request)
host = await _host(db, host_id)
return await _one(db, await service.cancel(db, host, op_id, actor=user.username), request)
@router.post("/{op_id}/deploy", response_model=CleanupOperationRead)
async def deploy(host_id: uuid.UUID, op_id: uuid.UUID, payload: ConfirmName, request: Request,
db: AsyncSession = Depends(get_db)):
"""Execute a preview. Admin only; the live state must still give the fingerprint of the preview."""
user = await resolve_user(request, db)
_admin_only(user, request)
host = await _host(db, host_id)
return await _one(db, await service.deploy(db, host, op_id, actor=user.username, confirm_name=payload.confirm_name), request)
@router.post("/{op_id}/confirm", response_model=CleanupOperationRead)
async def confirm(host_id: uuid.UUID, op_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
""""I still have access": re-reads every node first. Browser session only, never with an API token."""
user = await resolve_user(request, db)
_session_only(user, request)
host = await _host(db, host_id)
return await _one(db, await service.confirm(db, host, op_id), request)
@router.post("/{op_id}/revert", response_model=CleanupOperationRead)
async def revert(host_id: uuid.UUID, op_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
"""Take the change back now (the same code the sweeper runs when the window passes)."""
user = await resolve_user(request, db)
await _deployer(user, request, db)
host = await _host(db, host_id)
return await _one(db, await service.revert_now(db, host, op_id), request)
@router.post("/{op_id}/retry", response_model=CleanupOperationRead)
async def retry(host_id: uuid.UUID, op_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
"""Revert again after revert_failed, or read the firewall again for a delete/restore with an unclear item."""
user = await resolve_user(request, db)
_admin_only(user, request)
host = await _host(db, host_id)
return await _one(db, await service.retry(db, host, op_id), request)
@router.post("/items/{item_id}/restore", response_model=CleanupOperationRead)
async def restore(host_id: uuid.UUID, item_id: uuid.UUID, payload: ConfirmName, request: Request,
db: AsyncSession = Depends(get_db)):
"""Bring a deleted rule back, switched OFF, at its old position (a new UUID). The result is the restore run;
``warnings`` can hold BEREINIGUNG_POSITION_NICHT_WIEDERHERSTELLBAR."""
user = await resolve_user(request, db)
_admin_only(user, request)
host = await _host(db, host_id)
return await _one(db, await service.restore_item(
db, host, item_id, actor=user.username, actor_id=user.id, confirm_name=payload.confirm_name), request)