Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba954c05bf | ||
|
|
63d014b14c | ||
|
|
4838dad588 |
No files matched your search
@@ -31,6 +31,30 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.23.3] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
|
||||
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
|
||||
Garage, MinIO, RustFS und Versity S3 Gateway.
|
||||
|
||||
## [1.23.2] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
|
||||
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
|
||||
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
|
||||
|
||||
## [1.23.1] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
|
||||
- Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
|
||||
- Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
|
||||
- SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
|
||||
- Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
|
||||
- Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
|
||||
- Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
|
||||
- Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
|
||||
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
|
||||
|
||||
## [1.23.0] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **Stand:** `1.23.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
> **Stand:** `1.23.3` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -62,7 +62,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.23.0
|
||||
OPNEXUS_VERSION=1.23.3
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **Status:** `1.23.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
> **Status:** `1.23.3` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -64,7 +64,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.23.0
|
||||
OPNEXUS_VERSION=1.23.3
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -22,7 +22,7 @@ class OidcSettings(Base):
|
||||
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email groups")
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
|
||||
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
|
||||
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -99,6 +99,12 @@ class Hub:
|
||||
code, out = self.run("iptables", "-I", *rule)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
# The hub only needs its own proxy: never route between peers or into the compose network.
|
||||
fwd = ("FORWARD", "-j", "DROP")
|
||||
if self.run("iptables", "-C", *fwd)[0] != 0:
|
||||
code, out = self.run("iptables", "-I", *fwd)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
return None
|
||||
|
||||
def status(self, error: str | None) -> dict:
|
||||
|
||||
@@ -30,11 +30,11 @@ def client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=settings.session_lifetime,
|
||||
max_age=max_age or settings.session_lifetime,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.cookie_secure,
|
||||
@@ -305,6 +305,7 @@ async def change_password(
|
||||
# an attacker may hold) and the replacement session commit as one unit.
|
||||
user.password_hash = await auth_service.hash_password_async(payload.new_password)
|
||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -10,6 +12,7 @@ from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import _set_session_cookie, client_ip, current_user
|
||||
from app.config import settings
|
||||
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
|
||||
from app.security_middleware import _origin_allowed
|
||||
from app.services import auth_service, notification_secret_store, oidc_service
|
||||
@@ -18,10 +21,21 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["oidc"])
|
||||
CALLBACK_PATH = "/api/auth/oidc/callback"
|
||||
STATE_COOKIE = "opnexus-oidc-state"
|
||||
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
|
||||
# must not keep a 30-day session.
|
||||
SSO_SESSION_SECONDS = 12 * 3600
|
||||
|
||||
|
||||
def _login_error(code: str) -> RedirectResponse:
|
||||
return RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
def _printable(value: str) -> str:
|
||||
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
|
||||
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
|
||||
|
||||
|
||||
@router.get("/auth/oidc/config")
|
||||
@@ -42,11 +56,15 @@ async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db
|
||||
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
url = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||
except oidc_service.OidcError as exc:
|
||||
logger.warning("OIDC start fehlgeschlagen: %s", exc)
|
||||
return _login_error(exc.code)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
response = RedirectResponse(url, status_code=302)
|
||||
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
|
||||
# code+state pair is useless without the cookie.
|
||||
response.set_cookie(STATE_COOKIE, state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
@@ -59,7 +77,9 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
if error or not code or not state:
|
||||
raise oidc_service.OidcError("provider", f"Anbieter meldet {error or 'keinen Code'}")
|
||||
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
|
||||
if not secrets.compare_digest(request.cookies.get(STATE_COOKIE, ""), state):
|
||||
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
|
||||
user = await oidc_service.complete(db, cfg, code, state)
|
||||
except oidc_service.OidcError as exc:
|
||||
auth_service.throttle.record_failure(ip, "oidc")
|
||||
@@ -67,9 +87,12 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
|
||||
return _login_error(exc.code)
|
||||
auth_service.throttle.reset(ip, "oidc")
|
||||
await auth_service.finalize_login(db, user, ip)
|
||||
token, _ = await auth_service.create_session(db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip)
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
|
||||
)
|
||||
response = RedirectResponse("/dashboard", status_code=302)
|
||||
_set_session_cookie(response, token)
|
||||
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
|
||||
return response
|
||||
|
||||
|
||||
@@ -81,9 +81,9 @@ async def replace(host_id: uuid.UUID, payload: VariablesUpdate, db: AsyncSession
|
||||
"""Replace this host's variables with exactly the given set."""
|
||||
host = await _host(db, host_id)
|
||||
for name, value in payload.variables.items():
|
||||
if not variables_service.NAME_RE.match(name):
|
||||
if not variables_service.NAME_RE.fullmatch(name):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Name „{name}“: Großbuchstaben, Ziffern und _, beginnend mit einem Buchstaben")
|
||||
if not variables_service.VALUE_RE.match(value):
|
||||
if not variables_service.VALUE_RE.fullmatch(value):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Wert von {name}: 1–255 Zeichen aus Buchstaben, Ziffern und . : / , @ + - _ Leerzeichen")
|
||||
existing = {v.name: v for v in host.variables}
|
||||
for name in set(existing) - set(payload.variables):
|
||||
|
||||
@@ -82,14 +82,27 @@ def operator_denies(method: str, path: str) -> bool:
|
||||
|
||||
|
||||
# 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, and applying network changes.
|
||||
_ROLLOUT = re.compile(
|
||||
r"/(deploy|deployments/[^/]+/(remove|retry-rollback)|maintenance/(execute|carp-execute|schedule)|network/changes)$"
|
||||
# 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)"
|
||||
r"|maintenance/(execute|carp-execute|schedule)"
|
||||
r"|network/changes"
|
||||
r"|services/[^/]+/(start|stop|restart))$"
|
||||
)
|
||||
_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)/[^/]+$"
|
||||
)
|
||||
|
||||
|
||||
def is_rollout(method: str, path: str) -> bool:
|
||||
return method == "POST" and bool(_ROLLOUT.search(path))
|
||||
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:
|
||||
|
||||
@@ -236,6 +236,7 @@ async def create_session(
|
||||
user_agent: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
verified_hash: str | None = None,
|
||||
lifetime_seconds: int | None = None,
|
||||
) -> tuple[str, datetime]:
|
||||
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
|
||||
|
||||
@@ -253,7 +254,7 @@ async def create_session(
|
||||
await db.rollback()
|
||||
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
||||
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
|
||||
expires_at = _now() + timedelta(seconds=settings.session_lifetime)
|
||||
expires_at = _now() + timedelta(seconds=lifetime_seconds or settings.session_lifetime)
|
||||
session = Session(
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(token),
|
||||
|
||||
@@ -7,6 +7,8 @@ but its value never leaves the server. Output is a unified diff, capped in size.
|
||||
"""
|
||||
import difflib
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
from xml.etree.ElementTree import Element, indent, tostring
|
||||
|
||||
@@ -14,7 +16,7 @@ import defusedxml.ElementTree as safe_ET
|
||||
|
||||
MAX_LINES = 3000
|
||||
MAX_BYTES = 32 * 1024 * 1024
|
||||
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
|
||||
_SECRET = re.compile(r"pass|pwd|psk|secret|key|token|prv|hash|community|bindpw|privpass|otp|seed|cred", re.I)
|
||||
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
|
||||
_BLOB = 120
|
||||
|
||||
@@ -23,15 +25,17 @@ class InvalidConfig(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _redact(root: Element) -> None:
|
||||
def _redact(root: Element, salt: bytes) -> None:
|
||||
for el in root.iter():
|
||||
if len(el) == 0 and el.text and el.text.strip():
|
||||
text = el.text.strip()
|
||||
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
|
||||
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
|
||||
# Salted per diff: both sides of one diff share the salt (so a change shows), but the value cannot be
|
||||
# looked up in a dictionary or compared across hosts/requests.
|
||||
el.text = "‹" + hmac.new(salt, text.encode(), hashlib.sha256).hexdigest()[:12] + "›"
|
||||
|
||||
|
||||
def normalise(xml_bytes: bytes) -> list[str]:
|
||||
def normalise(xml_bytes: bytes, salt: bytes) -> list[str]:
|
||||
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
|
||||
raise InvalidConfig("leer oder zu groß")
|
||||
try:
|
||||
@@ -42,13 +46,14 @@ def normalise(xml_bytes: bytes) -> list[str]:
|
||||
raise InvalidConfig("keine OPNsense-Konfiguration")
|
||||
for volatile in root.findall("revision"):
|
||||
root.remove(volatile)
|
||||
_redact(root)
|
||||
_redact(root, salt)
|
||||
indent(root, space=" ")
|
||||
return tostring(root, encoding="unicode").splitlines()
|
||||
|
||||
|
||||
def diff(old: bytes, new: bytes) -> dict:
|
||||
a, b = normalise(old), normalise(new)
|
||||
salt = os.urandom(16)
|
||||
a, b = normalise(old, salt), normalise(new, salt)
|
||||
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
|
||||
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||||
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||||
|
||||
@@ -12,6 +12,7 @@ in ``filter/rule`` (legacy). Both are normalised to the same shape below.
|
||||
decided. The score starts at 100 and loses a penalty per failed check, so one
|
||||
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from xml.etree.ElementTree import Element
|
||||
@@ -276,15 +277,18 @@ async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
|
||||
await rule_hit_service.sample_host(db, host)
|
||||
except OpnsenseAPIError:
|
||||
pass # usage data is a bonus; the check does not depend on it
|
||||
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id))
|
||||
# Untrusted XML, O(n^2) rule analysis: keep it off the event loop, and treat ANY parse failure as 'invalid'.
|
||||
findings = await asyncio.to_thread(
|
||||
evaluate, xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id),
|
||||
)
|
||||
row.findings, row.score, row.error = findings, score(findings), None
|
||||
except OpnsenseAPIError as exc:
|
||||
row.findings, row.score, row.error = [], None, "unreachable"
|
||||
row.error_detail = str(exc)[:300]
|
||||
except ValueError as exc:
|
||||
except Exception as exc: # ParseError is a SyntaxError, not a ValueError; one bad config must not stop the other hosts
|
||||
row.findings, row.score, row.error = [], None, "invalid_config"
|
||||
row.error_detail = str(exc)[:300]
|
||||
row.error_detail = f"{type(exc).__name__}: {exc}"[:300]
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ async def get_settings(db: AsyncSession) -> OidcSettings:
|
||||
if row is None:
|
||||
row = OidcSettings(
|
||||
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
|
||||
scopes="openid profile email groups", groups_claim="groups",
|
||||
scopes="openid profile email", groups_claim="groups",
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
@@ -130,7 +130,7 @@ def _challenge(verifier: str) -> str:
|
||||
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
|
||||
|
||||
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
|
||||
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> tuple[str, str]:
|
||||
"""Remember a fresh login attempt and return the provider URL to send the browser to."""
|
||||
doc = await discovery(cfg.issuer)
|
||||
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(24), secrets.token_urlsafe(48)
|
||||
@@ -141,7 +141,7 @@ async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
|
||||
"response_type": "code", "client_id": cfg.client_id, "redirect_uri": redirect_uri, "scope": cfg.scopes,
|
||||
"state": state, "nonce": nonce, "code_challenge": _challenge(verifier), "code_challenge_method": "S256",
|
||||
})
|
||||
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}"
|
||||
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}", state
|
||||
|
||||
|
||||
async def _consume_state(db: AsyncSession, state: str) -> OidcLoginState:
|
||||
@@ -207,7 +207,8 @@ async def _verify_id_token(cfg: OidcSettings, doc: dict, id_token: str, nonce: s
|
||||
|
||||
def _as_groups(value) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [g for g in re.split(r"[,\s]+", value) if g]
|
||||
# Group names may contain spaces ("Network Admins"): a string claim is split on commas and line breaks only.
|
||||
return [g.strip() for g in re.split(r"[,\n]+", value) if g.strip()]
|
||||
if isinstance(value, list):
|
||||
return [str(g) for g in value]
|
||||
return []
|
||||
@@ -224,14 +225,31 @@ def role_for(cfg: OidcSettings, groups: list[str]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
||||
if cfg.groups_claim in claims:
|
||||
return _as_groups(claims[cfg.groups_claim])
|
||||
async def _userinfo(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||
"""The provider's userinfo answer for THIS identity ({} if there is none or it belongs to somebody else)."""
|
||||
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
|
||||
info = await _get_json(doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"})
|
||||
if info.get("sub") == claims.get("sub"): # a userinfo answer for someone else is worthless
|
||||
return _as_groups(info.get(cfg.groups_claim))
|
||||
return []
|
||||
return info
|
||||
return {}
|
||||
|
||||
|
||||
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
||||
if cfg.groups_claim in claims:
|
||||
return _as_groups(claims[cfg.groups_claim])
|
||||
return _as_groups((await _userinfo(doc, claims, access_token)).get(cfg.groups_claim))
|
||||
|
||||
|
||||
async def _with_display_name(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||
"""Some providers (Authelia) keep preferred_username/email out of the ID token: ask userinfo, else the user would be named
|
||||
after the opaque ``sub``. Best effort: a failing userinfo call must not block a login that already has its role."""
|
||||
if claims.get("preferred_username") or claims.get("email"):
|
||||
return claims
|
||||
try:
|
||||
info = await _userinfo(doc, claims, access_token)
|
||||
except OidcError:
|
||||
return claims
|
||||
return {**claims, **{k: info[k] for k in ("preferred_username", "email") if info.get(k)}}
|
||||
|
||||
|
||||
def _username(claims: dict) -> str:
|
||||
@@ -267,4 +285,4 @@ async def complete(db: AsyncSession, cfg: OidcSettings, code: str, state_value:
|
||||
role = role_for(cfg, await _groups(cfg, doc, claims, tokens.get("access_token")))
|
||||
if role is None:
|
||||
raise OidcError("no_role", "Kein zugeordnete Gruppe")
|
||||
return await provision(db, cfg, claims, role)
|
||||
return await provision(db, cfg, await _with_display_name(doc, claims, tokens.get("access_token")), role)
|
||||
@@ -104,7 +104,8 @@ def parse_subnet(value: str) -> ipaddress.IPv4Network:
|
||||
net = ipaddress.ip_network(value.strip(), strict=True)
|
||||
except ValueError:
|
||||
raise OverlayError("subnet", "kein gültiges Netz") from None
|
||||
if not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29):
|
||||
if (not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29)
|
||||
or net.is_loopback or net.is_link_local or net.is_multicast or net.is_unspecified):
|
||||
raise OverlayError("subnet", "ein privates IPv4-Netz zwischen /16 und /29 ist nötig")
|
||||
return net
|
||||
|
||||
|
||||
@@ -58,7 +58,12 @@ class S3Client:
|
||||
if parts.scheme == "http" and not settings.allow_insecure_offsite:
|
||||
# Credentials and (encrypted, but still) backups must not travel in the clear.
|
||||
raise S3Error("Der Endpunkt muss https:// sein")
|
||||
self.scheme, self.netloc = parts.scheme, parts.netloc
|
||||
# httpx leaves a scheme's default port out of the Host header, so the signed host must not carry it either.
|
||||
port = parts.port
|
||||
default = {"https": 443, "http": 80}[parts.scheme]
|
||||
host = parts.hostname if ":" not in parts.hostname else f"[{parts.hostname}]"
|
||||
self.scheme = parts.scheme
|
||||
self.netloc = host if port in (None, default) else f"{host}:{port}"
|
||||
self.region, self.bucket, self.path_style = region or "us-east-1", bucket, path_style
|
||||
self.access_key, self.secret_key = access_key, secret_key
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
"""
|
||||
|
||||
APP_VERSION = "1.23.0"
|
||||
APP_VERSION = "1.23.3"
|
||||
APP_CODENAME = "Harrier"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""OIDC: Standard-Scopes ohne "groups" (Keycloak und Entra ID lehnen den Scope als ungültig ab).
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Only rows still carrying the old default; a value somebody typed on purpose stays.
|
||||
op.execute("UPDATE oidc_settings SET scopes = 'openid profile email' WHERE scopes = 'openid profile email groups'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -279,3 +279,18 @@ async def test_openapi_schema_is_served_behind_auth_with_bearer_scheme(client, a
|
||||
assert (await api.get("/api/openapi.json")).status_code == 200
|
||||
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
|
||||
assert (await client.get("/docs")).status_code != 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changing_your_own_password_revokes_your_api_tokens(client):
|
||||
assert (await client.post("/api/users", json={"username": "pwtok", "password": "first-password-12", "role": "operator"})).status_code == 201
|
||||
user = await _login("pwtok", "first-password-12")
|
||||
try:
|
||||
token = (await user.post("/api/tokens", json={"name": "ci", "scope": "read", "current_password": "first-password-12"})).json()["token"]
|
||||
async with _bearer(token) as api:
|
||||
assert (await api.get("/api/hosts")).status_code == 200
|
||||
resp = await user.post("/api/auth/change-password", json={"current_password": "first-password-12", "new_password": "second-password-34"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert (await api.get("/api/hosts")).status_code == 401
|
||||
finally:
|
||||
await user.aclose()
|
||||
@@ -374,9 +374,9 @@ async def test_settings_changes_are_audited_without_secrets(client):
|
||||
|
||||
# ── config diff ──────────────────────────────────────────────────────────
|
||||
|
||||
def _xml(*, hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||
def _xml(*, otp="OTPSEED1", hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||
return (f"<opnsense><revision><time>{revision}</time></revision><system><hostname>{hostname}</hostname>"
|
||||
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey></user>"
|
||||
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey><otp_seed>{otp}</otp_seed><pwd>PW-{password}</pwd></user>"
|
||||
f"<dnsserver>{dns}</dnsserver></system><cert><crt>{cert}</crt><keylength>{keylength}</keylength></cert></opnsense>").encode()
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ async def test_diff_shows_changes_and_hides_every_secret(client):
|
||||
assert "9.9.9.9" in text and "<keylength>2048</keylength>" in text # ordinary values stay readable
|
||||
for secret in ("SUPERSECRET2", "KEY-SUPERSECRET2", "p1", "A" * 50, "B" * 50):
|
||||
assert secret not in text, secret
|
||||
assert "‹sha256:" in text # a changed secret is still visible as a change
|
||||
assert "‹" in text # a changed secret is still visible as a change
|
||||
assert "r1" not in text and "r2" not in text # the volatile revision block is ignored
|
||||
|
||||
|
||||
@@ -433,3 +433,25 @@ async def test_diff_is_capped(client):
|
||||
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many.replace('1.1.1', '2.2.2')}</system></opnsense>".encode())
|
||||
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
assert body["truncated"] is True and len(body["lines"]) == 3000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_hides_otp_seeds_and_uses_a_salt_per_request(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
backup_store.save_snapshot(host["id"], _xml())
|
||||
backup_store.save_snapshot(host["id"], _xml(otp="OTPSEED2", password="p9", dns="9.9.9.9"))
|
||||
first = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
second = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
text = "\n".join(first["lines"])
|
||||
for secret in ("OTPSEED1", "OTPSEED2", "PW-p9", "PW-p1"):
|
||||
assert secret not in text, secret
|
||||
import hashlib
|
||||
assert hashlib.sha256(b"OTPSEED1").hexdigest()[:12] not in text # no unsalted hash to run a dictionary against
|
||||
assert first["lines"] != second["lines"] # a fresh salt per request: hashes cannot be matched across requests
|
||||
|
||||
|
||||
def test_signed_host_has_no_default_port_but_keeps_a_custom_one():
|
||||
plain = s3_client.S3Client(endpoint="https://s3.test:443", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
assert plain.netloc == "s3.test" and plain._target("k")[0] == "s3.test"
|
||||
custom = s3_client.S3Client(endpoint="https://s3.test:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
assert custom.netloc == "s3.test:9000"
|
||||
@@ -363,3 +363,19 @@ async def test_alerts_are_off_by_default_and_unreachable_hosts_raise_none(client
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_truncated_config_does_not_stop_the_others(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
bad = await _create_host(client, "a-bad", "https://bad.test")
|
||||
good = await _create_host(client, "b-good", "https://good.test")
|
||||
for base, body in (("bad", b"<opnsense><system><webgui>"), ("good", _config())):
|
||||
respx_mock.get(f"https://{base}.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=body))
|
||||
respx_mock.get(f"https://{base}.test/api/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": "none"}))
|
||||
respx_mock.get(f"https://{base}.test/api/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
|
||||
respx_mock.get(f"https://{base}.test/api/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
resp = await client.post("/api/compliance/check")
|
||||
assert resp.status_code == 200, resp.text
|
||||
by_name = {h["host_name"]: h for h in resp.json()}
|
||||
assert by_name["a-bad"]["error"] == "invalid_config" and by_name["b-good"]["score"] is not None
|
||||
@@ -166,11 +166,24 @@ async def test_groups_can_come_from_userinfo_but_only_for_the_same_subject(clien
|
||||
assert _error(resp) == "no_role"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_username_comes_from_userinfo_if_the_id_token_has_none_and_a_failing_userinfo_does_not_block_the_login(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
# Authelia keeps preferred_username/email out of the ID token; without userinfo the account would be named after the opaque sub.
|
||||
query = await _start(anon_client, respx_mock, userinfo={"sub": "user-1", "preferred_username": "alice", "email": "[email protected]"})
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None))
|
||||
assert resp.headers["location"] == "/dashboard" and list(await _users("alice")) == ["alice"]
|
||||
respx_mock.clear()
|
||||
query = await _start(anon_client, respx_mock, userinfo={"sub": "someone-else", "preferred_username": "mallory"})
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None, sub="user-2"))
|
||||
assert resp.headers["location"] == "/dashboard" and "mallory" not in await _users("x") and "user-2" in await _users("x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers bar"))
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers"))
|
||||
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "read_only"
|
||||
|
||||
|
||||
@@ -342,3 +355,57 @@ async def test_failed_callbacks_are_throttled(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
results = [_error(await anon_client.get("/api/auth/oidc/callback", params={"code": "x", "state": f"s{i}"})) for i in range(12)]
|
||||
assert "throttled" in results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_string_group_claim_keeps_names_with_spaces_together(client, anon_client, respx_mock):
|
||||
await _configure(client, admin_group="Network Admins", operator_group="Admins", read_only_group="")
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network Admins"))
|
||||
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "admin" # not "Admins" = operator, not split
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network, Team"))
|
||||
assert _error(resp) == "no_role"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_callback_only_works_in_the_browser_that_started_the_login(client, anon_client, respx_mock):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from app.main import app
|
||||
await _configure(client)
|
||||
query = await _start(anon_client, respx_mock)
|
||||
# Somebody else (another browser, no state cookie) opens the callback URL: refused, and the state is not burned for the owner.
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as stranger:
|
||||
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(200, json={"id_token": _id_token(_claims(query["nonce"]))}))
|
||||
resp = await stranger.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
|
||||
assert _error(resp) == "state"
|
||||
assert await _users("alice") == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sso_sessions_are_short_and_the_state_cookie_is_httponly(client, anon_client, respx_mock):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.models.user import Session
|
||||
await _configure(client)
|
||||
_mock_idp(respx_mock)
|
||||
start = await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})
|
||||
cookie = start.headers["set-cookie"].lower()
|
||||
assert "opnexus-oidc-state=" in cookie and "httponly" in cookie and "samesite=lax" in cookie and "path=/api/auth/oidc" in cookie
|
||||
query = {k: v[0] for k, v in parse_qs(urlsplit(start.headers["location"]).query).items()}
|
||||
resp, _ = await _callback(anon_client, respx_mock, query)
|
||||
assert resp.headers["location"] == "/dashboard"
|
||||
assert "max-age=43200" in resp.headers["set-cookie"].lower()
|
||||
async with SessionLocal() as db:
|
||||
user = (await _users("alice"))["alice"]
|
||||
session = (await db.scalars(select(Session).where(Session.user_id == user.id))).one()
|
||||
assert session.expires_at - datetime.now(timezone.utc) < timedelta(hours=12, minutes=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_text_from_the_provider_cannot_forge_log_lines(client, anon_client, caplog):
|
||||
await _configure(client)
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await anon_client.get("/api/auth/oidc/callback", params={"error": "x\r\nFAKE LOG LINE\x1b[31m", "state": "s"})
|
||||
text = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "FAKE LOG LINE" in text and "\r" not in text and "\x1b" not in text and "\nFAKE" not in text
|
||||
@@ -113,6 +113,23 @@ async def test_demoting_an_admin_to_operator_takes_effect_at_once(client):
|
||||
("POST", "/api/hosts/abc/maintenance/carp-execute", True),
|
||||
("POST", "/api/hosts/abc/maintenance/schedule", True),
|
||||
("POST", "/api/hosts/abc/network/changes", True),
|
||||
("DELETE", "/api/rules/abc", True),
|
||||
("DELETE", "/api/aliases/abc", True),
|
||||
("DELETE", "/api/port-forward/abc", True),
|
||||
("DELETE", "/api/one-to-one/abc", True),
|
||||
("DELETE", "/api/outbound-nat/abc", True),
|
||||
("DELETE", "/api/policy-hierarchy/rules/abc", True),
|
||||
("DELETE", "/api/ipsec/tunnels/abc", True),
|
||||
("DELETE", "/api/certificates/managed/abc", True),
|
||||
("DELETE", "/api/hosts/abc/snapshots/def", True),
|
||||
("POST", "/api/certificates/managed/abc/push", True),
|
||||
("POST", "/api/acme/accounts/abc/register", True),
|
||||
("POST", "/api/acme/certificates/abc/sign", True),
|
||||
("POST", "/api/acme/certificates/abc/poll", True),
|
||||
("POST", "/api/policy-hierarchy/groups/abc/reassert-ordering", True),
|
||||
("POST", "/api/hosts/abc/services/unbound/restart", True),
|
||||
("DELETE", "/api/groups/abc", False),
|
||||
("DELETE", "/api/rules/abc/extra", False),
|
||||
("POST", "/api/rules/abc/preview", False),
|
||||
("POST", "/api/rules", False),
|
||||
("PUT", "/api/rules/abc", False),
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_public_key_validation(value, ok):
|
||||
|
||||
def test_subnets():
|
||||
assert str(svc.parse_subnet("10.99.0.0/24")) == "10.99.0.0/24"
|
||||
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", ""):
|
||||
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", "", "127.0.0.0/24", "169.254.1.0/24"):
|
||||
with pytest.raises(svc.OverlayError):
|
||||
svc.parse_subnet(bad)
|
||||
|
||||
@@ -259,6 +259,7 @@ def test_hub_applies_the_interface_peers_and_the_inbound_drop_rule(tmp_path):
|
||||
cmds = [c[:3] for c in runner.calls]
|
||||
assert ("ip", "link", "add") in cmds and ("ip", "address", "replace") in cmds and ("wg", "syncconf", "wg0") in cmds
|
||||
assert any(c[0] == "iptables" and c[1] == "-I" and "-i" in c and "NEW" in c and c[-1] == "DROP" for c in runner.calls)
|
||||
assert ("iptables", "-I", "FORWARD", "-j", "DROP") in runner.calls # no routing between peers or into the compose network
|
||||
assert not list(tmp_path.glob(".wg0.conf")) # the private key file does not linger
|
||||
assert hub.subnet == ipaddress.ip_network("10.99.0.0/24")
|
||||
# An existing interface is reused, not re-created.
|
||||
|
||||
@@ -46,11 +46,11 @@ def test_values_are_inserted_literally_not_interpreted():
|
||||
|
||||
|
||||
def test_value_and_name_validation():
|
||||
assert vs.NAME_RE.match("LAN_NET2") and not vs.NAME_RE.match("lan") and not vs.NAME_RE.match("2LAN") and not vs.NAME_RE.match("A" * 41)
|
||||
assert vs.NAME_RE.fullmatch("LAN_NET2") and not vs.NAME_RE.fullmatch("lan") and not vs.NAME_RE.fullmatch("2LAN") and not vs.NAME_RE.fullmatch("A" * 41)
|
||||
for ok in ("192.168.7.0/24", "fw-nord.example.com", "10.0.0.1,10.0.0.2", "Filiale Nord", "a@b+c"):
|
||||
assert vs.VALUE_RE.match(ok)
|
||||
for bad in ("", "{{X}}", "a\nb", 'a"b', "a;b", "a" * 256, "<x>"):
|
||||
assert not vs.VALUE_RE.match(bad)
|
||||
assert vs.VALUE_RE.fullmatch(ok)
|
||||
for bad in ("", "{{X}}", "a\nb", "ok\n", 'a"b', "a;b", "a" * 256, "<x>"):
|
||||
assert not vs.VALUE_RE.fullmatch(bad)
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -218,6 +218,8 @@ services:
|
||||
command: ["python", "-m", "app.overlay_hub"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
net.ipv4.ip_forward: "0"
|
||||
environment:
|
||||
OVERLAY_DIR: /data/overlay
|
||||
volumes:
|
||||
|
||||
+13
-5
@@ -238,7 +238,7 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
|
||||
|
||||
- **Rolle aus Gruppen, bei jeder Anmeldung:** gilt die höchste passende Rolle; wer in keiner der Gruppen ist,
|
||||
kommt nicht hinein (es wird auch kein Konto angelegt). Wird jemand aus der Gruppe entfernt, endet der Zugang
|
||||
bei der nächsten Anmeldung (laufende Sitzungen enden nach ihrer Laufzeit; beim Anbieter gesperrte Personen
|
||||
bei der nächsten Anmeldung (laufende SSO-Sitzungen enden spätestens nach 12 Stunden; beim Anbieter gesperrte Personen
|
||||
daher zusätzlich in der Benutzerliste löschen).
|
||||
- **Konten:** SSO-Benutzer werden bei der ersten Anmeldung angelegt (Kennzeichen „SSO“). Rolle und Passwort
|
||||
ändern sich nur beim Anbieter; die Zwei-Faktor-Anmeldung übernimmt der Anbieter. Ein SSO-Konto wird nie mit
|
||||
@@ -248,8 +248,11 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
|
||||
- **Geprüft** werden Signatur (nur asymmetrische Verfahren, nie `none` oder HMAC), Aussteller, Zielgruppe, Ablauf
|
||||
und Nonce des ID-Tokens; jeder Anmeldeversuch (State) gilt genau einmal und 10 Minuten. Gruppen kommen aus dem
|
||||
ID-Token, ersatzweise aus dem Userinfo-Endpunkt. Aussteller und Endpunkte müssen `https://` sein. Das ist
|
||||
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token), noch nicht gegen jeden echten
|
||||
Anbieter: melde, was bei deinem nicht klappt.
|
||||
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token) und gegen echte Server von Keycloak 26 und
|
||||
Authelia 4.39 (Anmeldung, Rollen aus Gruppen, Rollenentzug, abgefangener Callback); andere Anbieter: melde, was bei deinem nicht klappt.
|
||||
Die Standard-Scopes sind `openid profile email`; die Gruppen müssen als Claim im ID-Token oder Userinfo ankommen
|
||||
(Keycloak: Mapper „Group Membership“ am Client; Entra ID: optionaler Gruppen-Claim). Anbieter, die Gruppen nur auf
|
||||
Anfrage senden (z. B. Okta, Authelia), brauchen zusätzlich den Scope `groups`.
|
||||
|
||||
**Aufgabentrennung.** Einstellungen › Aufgabentrennung: Schaltest du „Operatoren dürfen Änderungen selbst
|
||||
ausrollen“ aus, bereiten Operatoren Änderungen nur vor (anlegen, bearbeiten, Vorschau), und **nur ein Admin
|
||||
@@ -821,8 +824,9 @@ docker compose exec -T -e BACKUP_PASSPHRASE='…' backend python -m app.backup_d
|
||||
|
||||
Die Benachrichtigung „Kein aktuelles Backup“ meldet sich, wenn ein Host drei Intervalle (mindestens
|
||||
6 Stunden) ohne Sicherung war, „Offsite-Backup fehlgeschlagen“ bei Problemen mit dem Ziel. Die
|
||||
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele geprüft, aber noch nicht gegen einen
|
||||
laufenden S3-Anbieter; „Ziel testen“ zeigt dir sofort, ob dein Anbieter funktioniert.
|
||||
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele und gegen einen laufenden S3-Server mit echter
|
||||
Signaturprüfung (Garage, MinIO, RustFS und Versity S3 Gateway: Upload, Download, Löschen, 20-MB-Objekt, Sonderzeichen im
|
||||
Schlüssel, falsches Geheimnis wird abgelehnt) geprüft; bei AWS selbst, Backblaze, Wasabi & Co. zeigt dir „Ziel testen“ sofort, ob dein Anbieter funktioniert.
|
||||
|
||||
**Änderungen ansehen:** Der Knopf „Änderungen“ neben einer Sicherung zeigt, was sich gegenüber der
|
||||
vorherigen geändert hat. Geheimnisse (Passwörter, Schlüssel, Tokens, Zertifikate) erscheinen nur als
|
||||
@@ -1031,6 +1035,10 @@ scrape_configs:
|
||||
| `opnexus_problems_open{type}` | Offene Probleme der Benachrichtigungen |
|
||||
| `opnexus_scheduled_maintenance_pending` | Noch nicht gelaufene geplante Wartungen |
|
||||
|
||||
Ein fertiges **Grafana-Dashboard** liegt unter [`docs/grafana-opnexus.json`](grafana-opnexus.json)
|
||||
(Grafana › Dashboards › Importieren, Prometheus-Datenquelle wählen): erreichbare Firewalls, offene Aktionen
|
||||
und Probleme, Sicherheitswert und Befunde, Last, Speicher, Platte und CARP-Rolle.
|
||||
|
||||
### KI-Assistenten (MCP)
|
||||
|
||||
OPNexus spricht das Model Context Protocol, damit ein KI-Assistent die Flotte
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
{
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_PROMETHEUS",
|
||||
"label": "Prometheus",
|
||||
"description": "",
|
||||
"type": "datasource",
|
||||
"pluginId": "prometheus",
|
||||
"pluginName": "Prometheus"
|
||||
}
|
||||
],
|
||||
"__requires": [
|
||||
{
|
||||
"type": "datasource",
|
||||
"id": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"title": "OPNexus",
|
||||
"uid": "opnexus-fleet",
|
||||
"tags": [
|
||||
"opnexus",
|
||||
"opnsense"
|
||||
],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "1m",
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Firewalls erreichbar / Reachable firewalls",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(opnexus_host_up)",
|
||||
"legendFormat": "up",
|
||||
"instant": true
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_hosts",
|
||||
"legendFormat": "total",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Offline",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "count(opnexus_host_up == 0) or vector(0)",
|
||||
"legendFormat": "offline",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "background"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Offene Aktionen / Pending actions",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 10,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(opnexus_pending_actions) or vector(0)",
|
||||
"legendFormat": "pending",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "background"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Offene Probleme / Open problems",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 14,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(opnexus_problems_open) or vector(0)",
|
||||
"legendFormat": "problems",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "background"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Geplante Wartung / Scheduled maintenance",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"w": 3,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_scheduled_maintenance_pending",
|
||||
"legendFormat": "pending",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Version",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 21,
|
||||
"y": 0,
|
||||
"w": 3,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_info",
|
||||
"legendFormat": "{{version}}",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"textMode": "name"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Sicherheitswert / Security score",
|
||||
"type": "bargauge",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_compliance_score",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"displayMode": "gradient"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Befunde / Findings",
|
||||
"type": "table",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_compliance_findings{severity=~\"critical|high|medium|low\"} > 0",
|
||||
"legendFormat": "",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"showHeader": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "CPU-Last (1 min) / Load",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"w": 8,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_load1",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Speicher / Memory %",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 8,
|
||||
"y": 12,
|
||||
"w": 8,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_memory_used_percent",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"max": 100,
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Platte / Disk %",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 16,
|
||||
"y": 12,
|
||||
"w": 8,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_disk_used_percent",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"max": 100,
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "CARP (1 = MASTER)",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 20,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_carp_master",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "stepAfter"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Erreichbarkeit / Up",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 20,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_up",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"custom": {
|
||||
"lineInterpolation": "stepAfter"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
],
|
||||
"description": "Fleet overview from the OPNexus /api/metrics endpoint (see the OPNexus handbook, chapter Automation)."
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function OidcSection() {
|
||||
const [issuer, setIssuer] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [scopes, setScopes] = useState("openid profile email groups");
|
||||
const [scopes, setScopes] = useState("openid profile email");
|
||||
const [claim, setClaim] = useState("groups");
|
||||
const [admin, setAdmin] = useState("");
|
||||
const [operator, setOperator] = useState("");
|
||||
|
||||
@@ -1604,7 +1604,7 @@
|
||||
"scopes": "Scopes",
|
||||
"claim": "Gruppen-Claim",
|
||||
"mapping": "Welche Gruppe welche Rolle bekommt",
|
||||
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert.",
|
||||
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert. Anbieter, die Gruppen nur auf Anfrage senden (z. B. Okta), brauchen zusätzlich den Scope „groups“ in den Scopes.",
|
||||
"test": "Anbieter testen",
|
||||
"dutiesTitle": "Aufgabentrennung",
|
||||
"dutiesIntro": "Ist es ausgeschaltet, können Operatoren Änderungen vorbereiten (bearbeiten, Vorschau), aber nur ein Administrator rollt sie aus: ausrollen, ein Deployment entfernen oder zurückrollen, Wartung ausführen oder planen und Netzwerkänderungen anwenden. Die Ablehnung steht im Audit.",
|
||||
|
||||
@@ -1604,7 +1604,7 @@
|
||||
"scopes": "Scopes",
|
||||
"claim": "Groups claim",
|
||||
"mapping": "Which group gets which role",
|
||||
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login.",
|
||||
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login. Providers that only send groups when asked (e.g. Okta) need the scope \"groups\" added to the scopes.",
|
||||
"test": "Test the provider",
|
||||
"dutiesTitle": "Separation of duties",
|
||||
"dutiesIntro": "When switched off, operators can prepare changes (edit, preview) but only an administrator may roll them out: deploy, remove or roll back a deployment, run or schedule maintenance and apply network changes. The refusal is recorded in the audit.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||
// entries already kept in the project README/CHANGELOG.md, no build
|
||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
export const APP_VERSION = "1.23.0";
|
||||
export const APP_VERSION = "1.23.3";
|
||||
export const APP_CODENAME = "Harrier";
|
||||
export const APP_MAINTAINER = "Julian Boldt";
|
||||
Reference in new issue
Block a user