Files
OPNexus/backend/app/services/api_token_service.py
T
Julian Boldt 4eea6424e7
Release / verify (push) Waiting to run
Release / test (push) Blocked by required conditions
CI / backend (push) Blocked by required conditions
CI / frontend (push) Blocked by required conditions
CI / checks (push) Blocked by required conditions
Release v1.32.0
- **Konfiguration als Code:** Unter **System › Konfiguration als Code** (und per `opnexus export` / `opnexus apply`) lassen sich Gerätegruppen, Hierarchiegruppen,
  Aliase, Filterregeln, Port-Forwards, 1:1-NAT, Outbound-NAT, Hierarchie-Regeln und Standort-Variablen als **eine YAML-Datei exportieren** (sortiert, ohne Zeitstempel,
  ohne Zugänge oder Schlüssel, für Git und Reviews) und wieder **importieren**: erst ein **Plan** (neu, geändert, entfernt, unverändert, blockiert, mit Hinweisen), dann
  das **Übernehmen** durch einen Administrator in einer Transaktion mit dem Token genau dieses Plans. **Der Import ändert nur die Entwürfe in OPNexus, nie eine Firewall**;
  ausgerollt wird wie immer über Vorschau → Ausrollen → Bestätigen. Ausgerollte oder noch verwendete Objekte entfernt er nie (Prune blockiert den Plan). Die Datei wird
  nur als Daten gelesen (keine Anker, Tags, Merge-Schlüssel, Mehrfach-Dokumente; 1 MiB, strenges Schema mit Zeile und Pfad bei Fehlern). Plan und Export gehen auch mit „Lesen“-Token
  und Nur-Lese-Konten, `opnexus apply --dry-run` liefert Exit-Codes für CI (0 / 2 / 3 / 4). Regeln und NAT-Objekte tragen dafür einen stabilen `key` (Migration, wird für
  Bestand automatisch vergeben). Gruppenänderungen stehen jetzt im Audit.
- **Rückweg zur vorherigen Firmware:** Vor jedem Firmware-Update und Major-Upgrade (manuell, geplant, Rollout, CARP-Übergabe) legt OPNexus auf der Firewall ein
  **ZFS-Boot-Environment** `opnexus-pre-<Version>-<Zeit>` als Rückweg-Punkt an (ohne den Punkt startet das Update nicht; ohne ZFS mit Hinweis). Neue Aktion **Zur vorherigen Firmware**
  (Host-Wartung): Vorschau, Hostname, Downtime-Bestätigung, vorher lokale Sicherung der Konfiguration, dann Aktivieren, Prüfung und Neustart (scheitert ein Schritt, wird das
  vorherige Boot-Environment wieder eingeschaltet). **Der Rückweg setzt auch die Konfiguration zurück** (live belegt): Ausrollvorgänge seit dem Punkt werden als unklar markiert,
  damit das nächste Ausrollen wirklich schreibt. Ein Rollout macht nie von selbst einen Rückweg.
- **Rollout-Gate: Erreichbarkeitsproben.** Bis zu zehn Ping-/TCP-Proben je Plan, von der Firewall selbst ausgeführt, vor der Welle und nach der Beobachtungszeit:
  vorher ok und jetzt nicht mehr hält den Rollout an, schon vorher gestört ist nur ein Hinweis. (Erreichbarkeit von der Firewall aus, nicht der Verkehr durch sie hindurch.)
- Migrationen 0030 (`config_key`) und 0031 (`gate_probes`).
2026-10-23 11:27:47 +02:00

187 lines
8.5 KiB
Python

"""Personal API tokens: creation, lookup and scope rules.
Scopes (a token can never do more than its owner's role allows; a read_only
user's token is read-only whatever its scope says):
* ``read`` — GET/HEAD/OPTIONS only, except endpoints that return secrets (raw
config backups, notification settings): those need ``admin``. For Prometheus,
dashboards, MCP clients.
* ``deploy`` — additionally every write that rolls out or operates on the fleet
(rules, aliases, NAT, VPN, maintenance, backups...). NOT the writes that
change who can access OPNexus or where it sends data: users, settings,
notifications, host registration/removal, log settings, OPNexus self-update.
* ``admin`` — everything the owner may do, except what is session-only.
Session-only for every scope: managing tokens themselves and the account
endpoints (/api/auth/*), so a leaked token can neither mint more tokens nor
change a password or second factor.
The enforcement lives in AuthMiddleware (one place, like the read_only role).
"""
import hashlib
import re
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.models.api_token import ApiToken
from app.models.user import AdminUser
TOKEN_PREFIX = "opx_"
SCOPES = ("read", "deploy", "admin")
MAX_TOKENS_PER_USER = 50
# last_used_at is only rewritten when older than this, so a scraper hitting
# /api/metrics every few seconds does not turn into a write per request.
_LAST_USED_GRANULARITY = timedelta(minutes=1)
_SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
# GUI tickets open a firewall's web interface in the browser: an interactive admin session only.
# Rule cleanup (1.31): "I still have access" is a human decision, so confirming a cleanup is never possible with a
# token, whatever its scope (a script must not be able to confirm blindly).
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)|^/api/hosts/[^/]+/gui-ticket$|^/api/hosts/[^/]+/rule-cleanup/[^/]+/confirm/?$")
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
# notification settings the webhook URL/SMTP data): admin scope only.
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)|^/api/settings/|^/api/overlay/peers/[^/]+/instructions$")
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read; so does the configuration-as-code plan (1.32):
# it compares a posted file with the database and writes nothing.
_READ_ONLY_POSTS = ("/api/mcp", "/api/config/import/plan")
_ADMIN_ONLY_WRITES = re.compile(
r"^/api/(users|settings|system|notifications|overlay)(/|$)"
r"|^/api/logs/settings$"
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
r"|^/api/hosts/[^/]+/gui-ticket$"
# The per-gateway alert switch is a notification setting (same rights as PUT /api/notifications/settings).
r"|^/api/hosts/[^/]+/gateways/[^/]+/alert$"
# Configuration as code (1.32): a bulk change incl. prune and host hierarchy membership (host writes are admin-only).
r"|^/api/config/import/apply$"
)
# Rule cleanup: executing a cleanup, retrying a failed revert/reconcile and restoring a deleted rule are admin-only.
# operators_may_deploy defaults to True, so _ROLLOUT_POST alone cannot express "admin only"; this pattern is checked
# for operator accounts (operator_denies) and for tokens below scope admin (denies). Not part of _ADMIN_ONLY_WRITES:
# that one means "who may access OPNexus / where data goes". The router checks the same rule once more.
_ADMIN_ONLY_CLEANUP = re.compile(r"^/api/hosts/[^/]+/rule-cleanup/([^/]+/(deploy|retry)|items/[^/]+/restore)/?$")
def _now() -> datetime:
return datetime.now(timezone.utc)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def denies(scope: str, method: str, path: str) -> bool:
"""True if a token with this scope must not make this request."""
if _SESSION_ONLY.match(path):
return True
if path in _READ_ONLY_POSTS:
return False
if method in _SAFE_METHODS:
return scope != "admin" and bool(_ADMIN_SCOPE_READS.match(path))
if scope == "read":
return True
if scope == "deploy" and (_ADMIN_ONLY_WRITES.match(path) or _ADMIN_ONLY_CLEANUP.match(path)):
return True
return False
def operator_denies(method: str, path: str) -> bool:
"""True if an `operator` account must not make this request: the writes that
change who may access OPNexus or where it sends data. (Their own theme at
PUT /api/settings stays open; account endpoints are not matched here.)"""
return method not in _SAFE_METHODS and path != "/api/settings" and bool(
_ADMIN_ONLY_WRITES.match(path) or _ADMIN_ONLY_CLEANUP.match(path))
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
_ROLLOUT_POST = re.compile(
r"/(deploy|push|sign|poll|register|reassert-ordering"
r"|deployments/[^/]+/(remove|retry-rollback)"
# Rule cleanup (1.31): switching rules off (/deploy matches above) and taking it back. Confirming and
# previewing stay open, like confirm_rule.
r"|rule-cleanup/[^/]+/(revert|retry)"
r"|maintenance/(execute|carp-execute|schedule)"
r"|network/changes"
r"|services/[^/]+/(start|stop|restart)"
# Firmware rollouts (1.25): starting/arming a plan and moving a run forward. Pausing is deliberately absent.
r"|rollouts/plans/[^/]+/(start|activate)"
r"|rollouts/runs/[^/]+/(resume|approve|skip-host|abort))$"
)
_ROLLOUT_DELETE = re.compile(
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
)
# Recurring maintenance windows decide WHEN unattended firmware actions run (1.25): every write counts.
_ROLLOUT_ANY_WRITE = re.compile(r"^/api/maintenance-windows(/|$)")
def is_rollout(method: str, path: str) -> bool:
if method in ("POST", "PUT", "PATCH", "DELETE") and _ROLLOUT_ANY_WRITE.match(path):
return True
if method == "POST":
return bool(_ROLLOUT_POST.search(path))
return method == "DELETE" and bool(_ROLLOUT_DELETE.match(path))
def bearer_from_header(header: str) -> str | None:
"""The token of an ``Authorization: Bearer opx_...`` header, else None."""
scheme, _, value = header.partition(" ")
value = value.strip()
if scheme.lower() == "bearer" and value.startswith(TOKEN_PREFIX):
return value
return None
async def create(
db: AsyncSession, user: AdminUser, *, name: str, scope: str, expires_in_days: int | None
) -> tuple[ApiToken, str]:
count = len((await db.scalars(select(ApiToken.id).where(ApiToken.user_id == user.id))).all())
if count >= MAX_TOKENS_PER_USER:
raise api_error(409, ErrorCode.API_TOKEN_LIMIT_REACHED, limit=MAX_TOKENS_PER_USER)
raw = TOKEN_PREFIX + secrets.token_urlsafe(32)
token = ApiToken(
user_id=user.id,
name=name,
scope=scope,
token_hash=hash_token(raw),
prefix=raw[: len(TOKEN_PREFIX) + 6],
expires_at=_now() + timedelta(days=expires_in_days) if expires_in_days else None,
)
db.add(token)
await db.commit()
await db.refresh(token)
return token, raw
async def resolve(db: AsyncSession, raw: str, ip: str | None) -> tuple[AdminUser, ApiToken] | None:
"""The owner and token for a raw bearer token, or None if unknown/expired."""
token = await db.scalar(select(ApiToken).where(ApiToken.token_hash == hash_token(raw)))
if token is None or (token.expires_at is not None and token.expires_at <= _now()):
return None
user = await db.get(AdminUser, token.user_id)
if user is None:
return None
if token.last_used_at is None or _now() - token.last_used_at > _LAST_USED_GRANULARITY:
token.last_used_at = _now()
token.last_used_ip = ip
await db.commit()
return user, token
async def revoke_all(db: AsyncSession, user_id, *, commit: bool = True) -> None:
"""Delete every token of an account (password reset: answer to a suspected compromise)."""
await db.execute(delete(ApiToken).where(ApiToken.user_id == user_id))
if commit:
await db.commit()