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
- **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.
184 lines
8.2 KiB
Python
184 lines
8.2 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.
|
|
_MCP_PATH = "/api/mcp"
|
|
_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$"
|
|
)
|
|
|
|
|
|
# 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 == _MCP_PATH:
|
|
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()
|