Version 2.8.0: Rechtekonzept -- eine Zeile je Funktion, Administrator heisst Verwaltung

This commit is contained in:
alientim committed 2026-09-12 13:27:45 +02:00
1 parent 713ff12db2
commit 0d99760d89
59 files changed
+1612 -398

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.7.8"
version = "2.8.0"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+3 -3
View File
@@ -19,7 +19,7 @@ from tesm_core.extension import core
from tesm_core.views import trash as trash_views
from .navigation import NAVIGATION
from .permissions import PERMISSIONS, ROLE_PRESETS
from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
from .schema import TESM_MIGRATIONS
from .settings_spec import TESM_SETTINGS
@@ -205,7 +205,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
if user.has_any(("devices.view", "monitoring.view")):
if user.has_any(("devices.read", "monitoring.read")):
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
faellig = monitor.next_check_at(verbindung, intervall)
if faellig is not None:
@@ -237,7 +237,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
}
)
if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None:
if user.has_any(DHCP_PAGE) and extension.license is not None:
if extension.license.module_active("dhcp") and extension.sysops is not None:
state = extension.sysops.service_state(kea.SERVICE_UNIT)
if state["installed"] == "ja":
+8 -8
View File
@@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin:
continue
if category.sensitive and not current_user.has("backup.secrets"):
if category.sensitive and not current_user.has("backup_export.secrets"):
continue
allowed.append(category)
return allowed
@bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
@permission_required("backup_export.read", "backup_import.read", mode="any")
def overview():
extension = core()
conn = db()
@@ -72,12 +72,12 @@ def overview():
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
license_hint=license_hint,
can_secrets=current_user.has("backup.secrets"),
can_secrets=current_user.has("backup_export.secrets"),
)
@bp.post("/export")
@permission_required("backup.export")
@permission_required("backup_export.export")
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export():
extension = core()
@@ -96,7 +96,7 @@ def export():
return redirect(url_for("backup.overview"))
include_secrets = (
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets")
request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
)
passphrase = request.form.get("passphrase") or ""
repeat = request.form.get("passphrase_repeat") or ""
@@ -130,7 +130,7 @@ def export():
@bp.post("/import/preview")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_preview():
conn = db()
@@ -162,7 +162,7 @@ def import_preview():
@bp.post("/import/apply")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_apply():
extension = core()
@@ -226,7 +226,7 @@ def import_apply():
@bp.post("/import/discard")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "")
if token:
+2 -2
View File
@@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str
@bp.get("/")
@permission_required("credentials.view")
@permission_required("credentials.read")
def overview():
conn = db()
return render_template(
@@ -62,7 +62,7 @@ def create():
@bp.post("/<int:credential_id>")
@permission_required("credentials.edit")
@permission_required("credentials.write")
def update(credential_id: int):
try:
with transaction() as conn:
+6 -2
View File
@@ -43,7 +43,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
counts = monitor.dashboard_counts(conn)
rows = inventory.list_devices(conn, only_active=not authenticated)
detailed = authenticated and current_user.has("devices.view")
detailed = authenticated and current_user.has("devices.read")
tiles: list[dict[str, Any]] = []
for row in rows:
entry: dict[str, Any] = {
@@ -67,8 +67,12 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
"credential_category": str(row["credential_category"] or ""),
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
# Mit Rechtefilter: die Kachel zeigt nur Wege, die
# dieser Benutzer auch ausloesen darf.
"restart_methods": restart.available(
row, maintenance_active=_maintenance_active()
row,
maintenance_active=_maintenance_active(),
darf=current_user.has,
),
}
)
+17 -9
View File
@@ -60,7 +60,7 @@ def _form_values() -> dict[str, Any]:
@bp.get("/")
@permission_required("devices.view")
@permission_required("devices.read")
def overview():
conn = db()
devices = inventory.list_devices(conn)
@@ -138,7 +138,7 @@ def create():
@bp.post("/<int:device_id>")
@permission_required("devices.edit")
@permission_required("devices.write")
def update(device_id: int):
try:
with transaction() as conn:
@@ -162,7 +162,7 @@ def update(device_id: int):
@bp.post("/<int:device_id>/active")
@permission_required("devices.edit")
@permission_required("devices.write")
def toggle_active(device_id: int):
active = request.form.get("active") == "1"
with transaction() as conn:
@@ -194,7 +194,12 @@ def delete(device_id: int):
@bp.post("/<int:device_id>/restart")
@permission_required("devices.execute")
# Die Seite darf sehen, wer Clients sehen darf; welcher Weg offensteht,
# entscheidet das Recht des Weges -- geprueft in restart.resolve() ueber
# "darf". Ein Weg, den der Benutzer nicht ausloesen darf, existiert fuer ihn
# nicht, und die Antwort ist dieselbe wie bei einem technisch unmoeglichen:
# "dieser Weg steht nicht zur Verfuegung".
@permission_required("devices.read")
def restart_device(device_id: int):
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
@@ -214,9 +219,12 @@ def restart_device(device_id: int):
dict(device),
(request.form.get("method") or "").strip(),
maintenance_active=maintenance_active,
darf=current_user.has,
)
if method is None:
options = restart.available(dict(device), maintenance_active=maintenance_active)
options = restart.available(
dict(device), maintenance_active=maintenance_active, darf=current_user.has
)
if options:
flash(
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
@@ -326,7 +334,7 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
@bp.get("/<int:device_id>")
@permission_required("devices.view")
@permission_required("devices.read")
def detail(device_id: int):
conn = db()
device = inventory.get_device(conn, device_id)
@@ -361,7 +369,7 @@ def detail(device_id: int):
@bp.post("/<int:device_id>/host-key/probe")
@permission_required("devices.edit")
@permission_required("devices.write")
def probe_host_key(device_id: int):
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
@@ -391,7 +399,7 @@ def probe_host_key(device_id: int):
@bp.post("/<int:device_id>/host-key/trust")
@permission_required("devices.edit")
@permission_required("devices.write")
@reauth_required
def trust_host_key(device_id: int):
conn = db()
@@ -425,7 +433,7 @@ def trust_host_key(device_id: int):
@bp.post("/<int:device_id>/host-key/forget")
@permission_required("devices.edit")
@permission_required("devices.write")
def forget_host_key(device_id: int):
conn = db()
device = inventory.get_device(conn, device_id)
+14 -13
View File
@@ -21,6 +21,7 @@ from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import kea
from ..permissions import DHCP_PAGE
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]:
@bp.get("/")
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any")
@permission_required(*DHCP_PAGE, mode="any")
def overview():
extension = core()
conn = db()
@@ -106,7 +107,7 @@ def overview():
@bp.post("/settings")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.write")
def save_settings():
extension = core()
conn = db()
@@ -123,7 +124,7 @@ def save_settings():
@bp.post("/subnet")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.create")
def add_subnet():
extension = core()
conn = db()
@@ -159,7 +160,7 @@ def add_subnet():
@bp.post("/subnet/<int:subnet_id>")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.write")
def update_subnet(subnet_id: int):
extension = core()
conn = db()
@@ -217,7 +218,7 @@ def update_subnet(subnet_id: int):
@bp.post("/subnet/<int:subnet_id>/delete")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.delete")
def delete_subnet(subnet_id: int):
with transaction() as conn:
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
@@ -232,7 +233,7 @@ def delete_subnet(subnet_id: int):
@bp.post("/option/value")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def set_option_value():
conn = db()
option_id = int(request.form.get("option_id") or 0)
@@ -271,7 +272,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
@bp.post("/option/device")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def set_device_option_value():
"""Setzt eine Option fuer ein einzelnes Geraet.
@@ -319,7 +320,7 @@ def set_device_option_value():
@bp.post("/option/new")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def add_custom_option():
conn = db()
try:
@@ -350,7 +351,7 @@ def add_custom_option():
@bp.post("/option/<int:option_id>/delete")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def delete_custom_option(option_id: int):
conn = db()
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
@@ -375,7 +376,7 @@ def delete_custom_option(option_id: int):
@bp.post("/reservation")
@permission_required("dhcp.edit")
@permission_required("dhcp_reservations.create")
def add_reservation():
conn = db()
mac = kea.normalize_mac(request.form.get("mac", ""))
@@ -447,7 +448,7 @@ def add_reservation():
@bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp.edit")
@permission_required("dhcp_reservations.delete")
def delete_reservation(reservation_id: int):
with transaction() as conn:
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
@@ -515,13 +516,13 @@ def _write_and_redirect():
@bp.post("/write")
@permission_required("dhcp.edit")
@permission_required("dhcp_config.execute")
def write_config():
return _write_and_redirect()
@bp.post("/service/<action>")
@permission_required("dhcp.execute")
@permission_required("dhcp_service.execute")
def service_action(action: str):
allowed = {"install", "start", "stop"}
if action not in allowed:
+16 -11
View File
@@ -84,7 +84,7 @@ def _mount_or_404(token: str) -> service.Mount:
@bp.get("/")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def browse():
conn = db()
mounts = service.doppelte_zusammenfassen(
@@ -148,7 +148,7 @@ def download(token: str):
@bp.get("/<token>/preview")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def preview(token: str):
mount = _mount_or_404(token)
relative = request.args.get("path") or ""
@@ -221,7 +221,7 @@ def upload(token: str):
@bp.get("/<token>/signature")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def signature_state(token: str):
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
@@ -237,7 +237,7 @@ def signature_state(token: str):
@bp.get("/<token>/folders")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def folders(token: str):
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
@@ -299,7 +299,7 @@ def save_form(token: str):
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
# niemandem weiter.
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"):
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.write"):
return {
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
@@ -351,6 +351,11 @@ def save_form(token: str):
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
pruefsumme = hashlib.sha256(daten).hexdigest()
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
# Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
# mehr bearbeiten kann, mit Namen im Dokument und Pruefsumme im Protokoll.
# Wer Dateien ablegen darf, darf das nicht automatisch.
if unterschriften and not current_user.has("fileshare_signature.execute"):
return {"error": "Zum Unterschreiben fehlt die Berechtigung."}, 403
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
# da: der Name **in** der Unterschrift ist eine Angabe des
@@ -412,7 +417,7 @@ def make_directory(token: str):
@bp.post("/<token>/rename")
@permission_required("fileshare.edit")
@permission_required("fileshare.write")
def rename(token: str):
mount = _mount_or_404(token)
relative = request.form.get("path") or ""
@@ -446,7 +451,7 @@ def delete(token: str):
@bp.post("/<token>/release")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def release(token: str):
mount = _mount_or_404(token)
with transaction() as conn:
@@ -462,7 +467,7 @@ def release(token: str):
@bp.get("/mappings")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.read")
def mappings():
from tesm_core.auth import directory
@@ -476,7 +481,7 @@ def mappings():
@bp.post("/mappings")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.create")
def mapping_add():
try:
with transaction() as conn:
@@ -506,7 +511,7 @@ def mapping_add():
@bp.post("/mappings/<int:mapping_id>")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.write")
def mapping_update(mapping_id: int):
try:
with transaction() as conn:
@@ -536,7 +541,7 @@ def mapping_update(mapping_id: int):
@bp.post("/mappings/<int:mapping_id>/delete")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.delete")
def mapping_delete(mapping_id: int):
with transaction() as conn:
entfernt = service.get_mapping(conn, mapping_id)
+11 -7
View File
@@ -36,8 +36,12 @@ bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
KIND_DEVICE = "client"
KIND_SWITCH = "switch"
#: Welches Recht welche Art von Ziel freigibt.
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"}
#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
#: ("Gerät ändern"), obwohl es der Vorgang ist, mit dem man bestaetigt, dass
#: ein Geraet wirklich das ist, fuer das es sich ausgibt. Die Art des Ziels
#: entscheidet nicht mehr mit -- Schluessel ist Schluessel.
FREIGABE_RECHT = "hostkeys.execute"
EDIT_RIGHT = {KIND_DEVICE: FREIGABE_RECHT, KIND_SWITCH: FREIGABE_RECHT}
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -112,7 +116,7 @@ def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
@bp.get("/")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
def overview():
conn = db()
targets = _targets(conn)
@@ -143,7 +147,7 @@ def _require(kind: str) -> None:
@bp.post("/<kind>/<int:target_id>/probe")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
def probe(kind: str, target_id: int):
conn = db()
_require(kind)
@@ -170,7 +174,7 @@ def probe(kind: str, target_id: int):
@bp.post("/<kind>/<int:target_id>/trust")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
@reauth_required
def trust(kind: str, target_id: int):
conn = db()
@@ -204,7 +208,7 @@ def trust(kind: str, target_id: int):
@bp.post("/<kind>/<int:target_id>/forget")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
def forget(kind: str, target_id: int):
conn = db()
_require(kind)
@@ -223,7 +227,7 @@ def forget(kind: str, target_id: int):
@bp.post("/orphaned/forget")
@permission_required("devices.edit", "switches.edit", mode="any")
@permission_required("hostkeys.execute")
def forget_orphan():
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
host = (request.form.get("host") or "").strip()
+7 -7
View File
@@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf
@bp.get("/")
@permission_required("settings_license.view")
@permission_required("settings_license.read")
def overview():
extension = core()
conn = db()
@@ -70,7 +70,7 @@ def overview():
@bp.post("/install")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def upload():
extension = core()
manager = extension.license
@@ -111,13 +111,13 @@ def upload():
@bp.post("/activate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def activate():
return _handshake("activate")
@bp.post("/deactivate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
@reauth_required
def deactivate():
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
@@ -129,7 +129,7 @@ def deactivate():
@bp.post("/heartbeat")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def heartbeat():
return _handshake("heartbeat")
@@ -159,7 +159,7 @@ def _handshake(action: str):
@bp.post("/offline-response")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def offline_response():
extension = core()
manager = extension.license
@@ -178,7 +178,7 @@ def offline_response():
@bp.post("/server-certificate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def server_certificate():
"""Hinterlegt das Zertifikat des Lizenzservers.
+2 -2
View File
@@ -19,7 +19,7 @@ MAX_TAIL_LINES = 2000
@bp.get("/dhcp")
@permission_required("logs_dhcp.view")
@permission_required("logs_dhcp.read")
def dhcp():
extension = core()
if extension.license is not None and not extension.license.module_active("dhcp"):
@@ -39,7 +39,7 @@ def dhcp():
@bp.get("/restarts")
@permission_required("devices.view")
@permission_required("devices.read")
def restarts():
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
+8 -7
View File
@@ -23,6 +23,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..permissions import SERVICING_PAGE
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
@@ -75,7 +76,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
@bp.get("/")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
def overview():
conn = db()
return render_template(
@@ -106,7 +107,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
@bp.post("/install-package")
@permission_required("maintenance.execute")
@permission_required("servicing_tool.execute")
def install_windows_tool():
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
@@ -139,7 +140,7 @@ def install_windows_tool():
@bp.get("/status")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
def status():
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
conn = db()
@@ -166,7 +167,7 @@ def status():
@bp.get("/job/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
def job_detail(job_id: str):
conn = db()
job = service.get_job(conn, job_id)
@@ -176,7 +177,7 @@ def job_detail(job_id: str):
@bp.get("/job/<job_id>/output")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
@fragment
def job_output(job_id: str):
conn = db()
@@ -277,14 +278,14 @@ def _start(kind: str) -> None:
@bp.post("/update")
@permission_required("maintenance.execute")
@permission_required("servicing_update.execute")
def start_update():
_start(service.JOB_KIND_UPDATE)
return redirect(url_for("maintenance.overview"))
@bp.post("/restart")
@permission_required("maintenance.execute")
@permission_required("servicing_reboot.execute")
def start_reboot():
_start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview"))
+2 -2
View File
@@ -65,7 +65,7 @@ def _timezone_choices() -> list[str]:
@bp.get("/system")
@permission_required("settings_system.view")
@permission_required("settings_system.read")
def system():
extension = core()
conn = db()
@@ -103,7 +103,7 @@ def system():
@bp.post("/system")
@permission_required("settings_system.edit")
@permission_required("settings_system.write")
def system_save():
extension = core()
conn = db()
+7 -7
View File
@@ -47,7 +47,7 @@ def _form_values() -> dict[str, Any]:
@bp.get("/")
@permission_required("switches.view")
@permission_required("switches.read")
def overview():
conn = db()
return render_template(
@@ -79,7 +79,7 @@ def create():
@bp.post("/<int:switch_id>")
@permission_required("switches.edit")
@permission_required("switches.write")
def update(switch_id: int):
try:
with transaction() as conn:
@@ -115,7 +115,7 @@ def delete(switch_id: int):
@bp.get("/<int:switch_id>")
@permission_required("switches.view")
@permission_required("switches.read")
def detail(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
@@ -152,7 +152,7 @@ def detail(switch_id: int):
@bp.post("/<int:switch_id>/host-key/probe")
@permission_required("switches.edit")
@permission_required("switches.write")
def probe_host_key(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
@@ -177,7 +177,7 @@ def probe_host_key(switch_id: int):
@bp.post("/<int:switch_id>/host-key/trust")
@permission_required("switches.edit")
@permission_required("switches.write")
@reauth_required
def trust_host_key(switch_id: int):
"""Gibt einen Host-Schluessel ausdruecklich frei.
@@ -216,7 +216,7 @@ def trust_host_key(switch_id: int):
@bp.post("/<int:switch_id>/host-key/forget")
@permission_required("switches.edit")
@permission_required("switches.write")
def forget_host_key(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
@@ -235,7 +235,7 @@ def forget_host_key(switch_id: int):
@bp.post("/<int:switch_id>/command")
@permission_required("switches.execute")
@permission_required("switch_console.execute")
@reauth_required
def run_command(switch_id: int):
"""Fuehrt ein Kommando aus der Positivliste aus.
+6 -4
View File
@@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf:
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
:class:`~tesm.services.switchcli.SwitchCli`.
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul
* **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
:class:`~tesm.services.shellcli.ShellSession`.
@@ -167,7 +167,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
@bp.get("/monitoring/switches/<int:switch_id>/console")
@permission_required("switches.execute")
@permission_required("switch_console.execute")
@reauth_required
def switch_console(switch_id: int):
target = _load_target(db(), KIND_SWITCH, switch_id)
@@ -177,7 +177,7 @@ def switch_console(switch_id: int):
@bp.get("/monitoring/clients/<int:device_id>/console")
@permission_required("maintenance.execute")
@permission_required("device_console.execute")
@reauth_required
def device_console(device_id: int):
if not _client_module_active():
@@ -225,7 +225,9 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
required = (
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
)
if not current_user.is_authenticated or not current_user.has(required):
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
return
+26 -24
View File
@@ -13,6 +13,8 @@ from typing import Any
from tesm_core.extension import core
from tesm_core.nav import NavItem, NavTree
from .permissions import DHCP_PAGE, SERVICING_PAGE
def _module(name: str):
"""Bedingung: Vollmodul lizenziert?"""
@@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool:
return False
if not _module("fileshare")(user):
return False
if user.has("fileshare.edit"):
if user.has("fileshare.write"):
return True
from .services import fileshare
@@ -56,28 +58,28 @@ NAVIGATION = NavTree(
label="Clients",
icon="cpu",
endpoint="devices.overview",
permissions=("devices.view",),
permissions=("devices.read",),
),
NavItem(
key="switches",
label="Switche",
icon="switch",
endpoint="switches.overview",
permissions=("switches.view",),
permissions=("switches.read",),
),
NavItem(
key="credentials",
label="Zugangsdaten",
icon="key",
endpoint="credentials.overview",
permissions=("credentials.view",),
permissions=("credentials.read",),
),
NavItem(
key="hostkeys",
label="Host-Schlüssel",
icon="shield",
endpoint="hostkeys.overview",
permissions=("devices.view", "switches.view"),
permissions=("devices.read", "switches.read"),
),
),
),
@@ -86,7 +88,7 @@ NAVIGATION = NavTree(
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
permissions=SERVICING_PAGE,
condition=_module("maintenance"),
),
NavItem(
@@ -94,7 +96,7 @@ NAVIGATION = NavTree(
label="DHCP-Server",
icon="network",
endpoint="dhcp.overview",
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"),
permissions=DHCP_PAGE,
condition=_module("dhcp"),
),
NavItem(
@@ -108,14 +110,14 @@ NAVIGATION = NavTree(
label="Meine Freigaben",
icon="folder",
endpoint="fileshare.browse",
permissions=("fileshare.view",),
permissions=("fileshare.read",),
),
NavItem(
key="fileshare_mappings",
label="Zuordnungen",
icon="link",
endpoint="fileshare.mappings",
permissions=("fileshare.edit",),
permissions=("fileshare.write",),
),
),
),
@@ -129,28 +131,28 @@ NAVIGATION = NavTree(
label="Laufend",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.view",),
permissions=("logs_live.read",),
),
NavItem(
key="logs_history",
label="Verlauf",
icon="clock",
endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"),
permissions=("logs_history.read", "logs_history.export"),
),
NavItem(
key="logs_audit",
label="Audit",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
permissions=("logs_audit.read", "logs_audit.export"),
),
NavItem(
key="logs_dhcp",
label="DHCP",
icon="network",
endpoint="tesmlogs.dhcp",
permissions=("logs_dhcp.view",),
permissions=("logs_dhcp.read",),
condition=_module("dhcp"),
),
NavItem(
@@ -158,7 +160,7 @@ NAVIGATION = NavTree(
label="Neustarts",
icon="power",
endpoint="tesmlogs.restarts",
permissions=("devices.view",),
permissions=("devices.read",),
),
),
),
@@ -172,42 +174,42 @@ NAVIGATION = NavTree(
label="Benutzer",
icon="users",
endpoint="admin.users",
permissions=("users.view",),
permissions=("users.read",),
),
NavItem(
key="groups",
label="Gruppen und Rechte",
icon="shield-users",
endpoint="admin.groups",
permissions=("groups.view",),
permissions=("groups.read",),
),
NavItem(
key="settings_system",
label="System",
icon="sliders",
endpoint="settings.system",
permissions=("settings_system.view",),
permissions=("settings_system.read",),
),
NavItem(
key="settings_network",
label="Netzwerk",
icon="network",
endpoint="hostsettings.network",
permissions=("settings_network.view",),
permissions=("settings_network.read",),
),
NavItem(
key="settings_web",
label="Webserver und TLS",
icon="server",
endpoint="hostsettings.web",
permissions=("settings_web.view",),
permissions=("settings_web.read",),
),
NavItem(
key="settings_directory",
label="Verzeichnisdienst",
icon="users",
endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",),
permissions=("settings_directory.read",),
),
NavItem(
key="settings_license",
@@ -216,28 +218,28 @@ NAVIGATION = NavTree(
endpoint="license.overview",
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
# sonst liesse sich eine neue Lizenz nie einspielen.
permissions=("settings_license.view",),
permissions=("settings_license.read",),
),
NavItem(
key="backup",
label="Sicherung",
icon="transfer",
endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"),
permissions=("backup_export.read", "backup_import.read"),
),
NavItem(
key="trash",
label="Papierkorb",
icon="trash",
endpoint="trash.overview",
permissions=("trash.view",),
permissions=("trash.read",),
),
NavItem(
key="diagnostics",
label="Diagnose",
icon="info",
endpoint="diagnostics.overview",
permissions=("diagnostics.view",),
permissions=("diagnostics.read",),
),
),
),
+309 -67
View File
@@ -30,67 +30,219 @@ A = Action
DEVICES = Resource(
key="devices",
label="Clients",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Überwachte PoE-Endgeräte",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
action_hints={
A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische "
"Neustart bei Ausfall ist davon unabhängig und läuft immer.",
A.DELETE: "Gerät in den Papierkorb legen.",
},
)
#: Drei Wege, drei Rechte -- bisher hingen alle an ``devices.execute``.
#:
#: Wer jemandem den PoE-Neustart erlauben wollte (der Weg, der auch bei einem
#: haengenden Geraet noch wirkt und nichts Laufendes unterbricht), erlaubte ihm
#: damit zugleich, ein arbeitendes System ueber SSH herunterzufahren. Das sind
#: drei verschiedene Eingriffe mit drei verschiedenen Folgen -- also drei
#: Zeilen. Die Reihenfolge ist die der Rangfolge in ``services.restart``.
DEVICE_RESTART_POE = Resource(
key="device_restart_poe",
parent="devices",
label="Clients: Neustart über PoE",
actions=(A.READ, A.EXECUTE),
hint="Portzyklus am Switch – wirkt in Sekunden, auch wenn das Gerät hängt",
action_hints={
A.READ: "Den Knopf sehen.",
A.EXECUTE: "Neustart auslösen. Bewusst nicht lizenzpflichtig: ein hängendes "
"Gerät soll man auch ohne gültige Lizenz von Hand neu starten können.",
},
)
DEVICE_RESTART_SSH = Resource(
key="device_restart_ssh",
parent="devices",
label="Clients: Neustart über SSH",
actions=(A.READ, A.EXECUTE),
hint="Sauberes Herunterfahren eines Linux-Geräts (Modul Wartung)",
action_hints={
A.EXECUTE: "Neustartauftrag anlegen. Dauert Minuten und setzt ein "
"antwortendes Gerät samt freigegebenem Host-Schlüssel voraus.",
},
)
DEVICE_RESTART_RPC = Resource(
key="device_restart_rpc",
parent="devices",
label="Clients: Neustart über RPC",
actions=(A.READ, A.EXECUTE),
hint="Windows-Gerät ohne SSH und ohne Host-Schlüssel (Modul Wartung)",
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
)
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
DEVICE_CONSOLE = Resource(
key="device_console",
parent="devices",
label="Clients: Konsole",
actions=(A.READ, A.EXECUTE),
hint="Browser-Terminal auf einem Client (Modul Wartung)",
action_hints={
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
"jede Sitzung wird vollständig mitgeschrieben.",
},
)
SWITCHES = Resource(
key="switches",
label="Switche",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Aruba-Switche und ihre Erreichbarkeit",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Aruba-Switche, ihre Erreichbarkeit und Ports",
)
SWITCH_CONSOLE = Resource(
key="switch_console",
parent="switches",
label="Switche: Konsole",
actions=(A.READ, A.EXECUTE),
hint="Kommandos auf dem Switch ausführen",
action_hints={
A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. "
"Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird "
"vollständig protokolliert.",
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
"jede Sitzung wird vollständig mitgeschrieben.",
},
)
#: Einen geaenderten Host-Schluessel freizugeben hing an "Gerät ändern" bzw.
#: "Switch ändern" -- also an einem Recht fuer Stammdaten. Es ist aber der
#: Vorgang, mit dem man sagt: "dieses Geraet ist wirklich das, fuer das es sich
#: ausgibt". Wer Namen und Standort pflegt, muss das nicht entscheiden duerfen.
HOSTKEYS = Resource(
key="hostkeys",
label="Host-Schlüssel",
actions=(A.READ, A.EXECUTE),
hint="Ein Gerät meldet sich mit einem anderen SSH-Schlüssel als beim letzten Mal",
action_hints={
A.READ: "Offene und freigegebene Schlüssel sehen.",
A.EXECUTE: "Einen geänderten Schlüssel freigeben.",
},
)
CREDENTIALS = Resource(
key="credentials",
label="Zugangsdaten",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
hint="Wiederverwendbare SSH-Logins",
action_hints={
A.VIEW: "Namen und Benutzer sehen – nicht das Passwort.",
A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
"Passwortbestätigung und wird protokolliert.",
},
)
MAINTENANCE = Resource(
key="maintenance",
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
label="Aktualisierung und Neustart",
actions=(A.VIEW, A.EXECUTE),
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."},
#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
#: Clients steht, wo sie hingehoert.
SERVICING_UPDATE = Resource(
key="servicing_update",
label="Paketaktualisierung",
actions=(A.READ, A.EXECUTE),
hint="Pakete auf ausgewählten Geräten aktualisieren (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Auftrag starten."},
)
DHCP = Resource(
key="dhcp",
label="DHCP-Server",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
SERVICING_REBOOT = Resource(
key="servicing_reboot",
label="Neustart als Auftrag",
actions=(A.READ, A.EXECUTE),
hint="Reihenweise Neustarts mit Wartezeit und Rückmeldung",
action_hints={A.EXECUTE: "Auftrag starten."},
)
SERVICING_TOOL = Resource(
key="servicing_tool",
label="Windows-Werkzeug installieren",
actions=(A.READ, A.EXECUTE),
hint="Voraussetzung für den RPC-Weg auf einem Windows-Gerät",
action_hints={A.EXECUTE: "Installation auslösen."},
)
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
SERVICING_PAGE: tuple[str, ...] = (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
)
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
#: Optionen, Geraeteoptionen, eigene Optionen, Reservierungen und das
#: Schreiben der Dienstkonfiguration. Wer eine Reservierung eintragen sollte,
#: durfte damit auch Subnetze loeschen und die Konfiguration ueberschreiben.
DHCP_SUBNETS = Resource(
key="dhcp_subnets",
label="DHCP: Subnetze",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Bereiche, Gateway, DNS, Reihenfolge (lizenzpflichtiges Modul)",
)
DHCP_OPTIONS = Resource(
key="dhcp_options",
parent="dhcp_subnets",
label="DHCP: Optionen",
actions=(A.READ, A.WRITE),
hint="Globale Optionen, Optionen je Gerät und eigene Optionen",
action_hints={A.WRITE: "Werte setzen, eigene Optionen anlegen und entfernen."},
)
DHCP_RESERVATIONS = Resource(
key="dhcp_reservations",
parent="dhcp_subnets",
label="DHCP: Reservierungen",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Feste Adressen zu einer MAC",
)
DHCP_CONFIG = Resource(
key="dhcp_config",
parent="dhcp_subnets",
label="DHCP: Konfiguration schreiben",
actions=(A.READ, A.EXECUTE),
hint="Den bearbeiteten Stand in die Dienstkonfiguration übernehmen",
action_hints={
A.EDIT: "Konfiguration ändern und schreiben.",
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
"Dienst nichts – Bearbeiten und Wirksamwerden sind zwei Schritte.",
},
)
DHCP_SERVICE = Resource(
key="dhcp_service",
parent="dhcp_subnets",
label="DHCP: Dienst",
actions=(A.READ, A.EXECUTE),
hint="Kea installieren, starten, stoppen",
action_hints={A.EXECUTE: "Dienstzustand ändern."},
)
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
DHCP_PAGE: tuple[str, ...] = (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
)
FILESHARE = Resource(
key="fileshare",
label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.",
A.WRITE: "Umbenennen und verschieben.",
# Genau die Bedeutung aus der Legende: Daten aus dem System
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
# bleibt im Haus, eine heruntergeladene Datei nicht.
@@ -109,20 +261,47 @@ FILESHARE = Resource(
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
FILESHARE_PDF = Resource(
key="fileshare_pdf",
parent="fileshare",
label="PDF-Formulare",
actions=(A.VIEW, A.CREATE, A.EDIT),
actions=(A.READ, A.CREATE, A.WRITE),
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
action_hints={
A.VIEW: "In der Vorschau ausfüllen (ohne Speichern).",
A.READ: "In der Vorschau ausfüllen (ohne Speichern).",
A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
A.EDIT: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
A.WRITE: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
},
)
#: Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
#: mehr bearbeiten kann, mit Namen und Zeitpunkt im Dokument und einer
#: Pruefsumme im Aenderungsprotokoll. Wer Dateien umbenennen darf, soll das
#: nicht automatisch duerfen.
FILESHARE_SIGNATURE = Resource(
key="fileshare_signature",
parent="fileshare",
label="Unterschriften",
actions=(A.READ, A.EXECUTE),
hint="Mit dem Finger unterschreiben; das Ergebnis ist unveränderbar",
action_hints={
A.READ: "Unterschriebene Dateien öffnen und die Prüfung sehen.",
A.EXECUTE: "Unterschreiben und abgeschlossen ablegen.",
},
)
#: Welche Verzeichnisfreigabe unter welchem Namen erscheint, ist Administration
#: und kein Dateibetrieb -- es lief unter demselben Recht wie "umbenennen".
FILESHARE_MAPPINGS = Resource(
key="fileshare_mappings",
parent="fileshare",
label="Freigabe-Zuordnungen",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Verzeichnisgruppe, UNC-Pfad und Anzeigename einer Freigabe",
)
LOGS_DHCP = Resource(
key="logs_dhcp",
label="DHCP-Protokoll",
actions=(A.VIEW,),
actions=(A.READ,),
)
MONITORING_AREA = Area(
@@ -131,7 +310,17 @@ MONITORING_AREA = Area(
icon="cpu",
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS),
resources=(
DEVICES,
DEVICE_RESTART_POE,
DEVICE_RESTART_SSH,
DEVICE_RESTART_RPC,
DEVICE_CONSOLE,
SWITCHES,
SWITCH_CONSOLE,
HOSTKEYS,
CREDENTIALS,
),
)
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
@@ -144,21 +333,21 @@ MAINTENANCE_AREA = Area(
icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(MAINTENANCE,),
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
)
NETWORK_AREA = Area(
key="network",
label="Netzwerkdienste",
icon="network",
resources=(DHCP,),
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
)
FILES_AREA = Area(
key="files",
label="Dateifreigaben",
icon="folder",
resources=(FILESHARE, FILESHARE_PDF),
resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
)
PERMISSIONS = PermissionTree(
@@ -173,15 +362,41 @@ PERMISSIONS = PermissionTree(
)
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
#:
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
#: was vorher ging. Entfernen kann man sie mit einem Klick.
#:
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
#: Matrix ist sonst genau die, die hier vergessen wird.
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
key="functions",
name="Funktionen (Vollzugriff)",
description=(
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
"die nicht nur einstellen, sondern auch bedienen."
),
grants=tuple(
(resource.key, "*")
for area in PERMISSIONS.areas
if area.key != PermissionTree.VERWALTUNGSBEREICH
for resource in area.resources
),
)
ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="viewer",
name="Benutzer",
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
grants=(
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("devices", (A.READ,)),
("switches", (A.READ,)),
("logs_live", (A.READ,)),
),
is_default=True,
),
@@ -191,12 +406,20 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
grants=(
("devices", "*"),
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
("credentials", (A.VIEW,)),
("maintenance", "*"),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW,)),
("trash", (A.VIEW, A.EDIT)),
("device_restart_poe", "*"),
("device_restart_ssh", "*"),
("device_restart_rpc", "*"),
("device_console", "*"),
("switches", (A.READ, A.CREATE, A.WRITE)),
("switch_console", "*"),
("hostkeys", "*"),
("credentials", (A.READ,)),
("servicing_update", "*"),
("servicing_reboot", "*"),
("servicing_tool", "*"),
("logs_live", (A.READ,)),
("logs_history", (A.READ,)),
("trash", (A.READ, A.WRITE)),
),
),
RolePreset(
@@ -204,9 +427,13 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
grants=(
("devices", (A.VIEW, A.EXECUTE)),
("switches", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("devices", (A.READ,)),
# Nur der PoE-Weg: der wirkt auch bei einem haengenden Geraet und
# unterbricht nichts Laufendes. SSH und RPC fahren ein arbeitendes
# System herunter -- das gehoert nicht in die erste Hilfe.
("device_restart_poe", "*"),
("switches", (A.READ,)),
("logs_live", (A.READ,)),
),
),
RolePreset(
@@ -214,11 +441,15 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Netzwerk",
description="DHCP und Host-Netzwerkeinstellungen.",
grants=(
("dhcp", "*"),
("dhcp_subnets", "*"),
("dhcp_options", "*"),
("dhcp_reservations", "*"),
("dhcp_config", "*"),
("dhcp_service", "*"),
("settings_network", "*"),
("logs_dhcp", (A.VIEW,)),
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("logs_dhcp", (A.READ,)),
("devices", (A.READ,)),
("switches", (A.READ,)),
),
),
RolePreset(
@@ -226,17 +457,19 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Revision",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=(
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("credentials", (A.VIEW,)),
("dhcp", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
("trash", (A.VIEW,)),
("devices", (A.READ,)),
("switches", (A.READ,)),
("credentials", (A.READ,)),
("dhcp_subnets", (A.READ,)),
("dhcp_options", (A.READ,)),
("dhcp_reservations", (A.READ,)),
("users", (A.READ,)),
("groups", (A.READ,)),
("logs_live", (A.READ,)),
("logs_history", (A.READ, A.EXPORT)),
("logs_audit", (A.READ, A.EXPORT)),
("diagnostics", (A.READ,)),
("trash", (A.READ,)),
),
),
RolePreset(
@@ -247,16 +480,25 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
("users", "*"),
("groups", "*"),
("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"),
("settings_network", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
("backup_export", (A.READ, A.EXPORT)),
("backup_import", "*"),
("trash", "*"),
("diagnostics", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
("diagnostics", (A.READ,)),
("logs_audit", (A.READ,)),
),
),
FUNKTIONEN_VOLLZUGRIFF,
)
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"]
__all__ = [
"PERMISSIONS",
"ROLE_PRESETS",
"LOGS_DHCP",
"SERVICING_PAGE",
"DHCP_PAGE",
]
+43 -10
View File
@@ -257,19 +257,19 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
V4 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare.export' FROM group_permissions
WHERE permission = 'fileshare.view';
WHERE permission = 'fileshare.read';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
WHERE permission = 'fileshare.view';
SELECT group_id, 'fileshare_pdf.read' FROM group_permissions
WHERE permission = 'fileshare.read';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
WHERE permission = 'fileshare.create';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
WHERE permission = 'fileshare.edit';
SELECT group_id, 'fileshare_pdf.write' FROM group_permissions
WHERE permission = 'fileshare.write';
"""
@@ -297,13 +297,21 @@ DELETE FROM settings WHERE key = 'terminal_enabled';
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
# darf Zugang erhalten, aber keinen neuen schaffen.
#
# Die Bedingung nennt absichtlich keine festen Schluessel, sondern Praefixe:
# Wer von einer Fassung vor diesem Schritt direkt auf 2.8 geht, hat vorher die
# Migrationen des Kerns durchlaufen (core laeuft vor der Anwendung) -- die
# Rechte heissen dann schon "read"/"write" und die Wartungszeilen "servicing_".
# Mit ausgeschriebenen Schluesseln fand dieser Schritt in genau diesem Fall
# nichts und der Bereich waere doch weg. Ein Ueberrest ist die Zeile nicht:
# eine angewendete Migration laeuft nie wieder.
V6 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT DISTINCT group_id, 'servicing.view'
SELECT DISTINCT group_id, 'servicing.read'
FROM group_permissions
WHERE permission IN ('maintenance.view', 'maintenance.execute')
WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%')
AND group_id IN (
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.read'
);
"""
@@ -371,12 +379,37 @@ def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
)
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
"""Bestehende Administratoren behalten ihre Funktionen -- ueber eine Gruppe.
Steht hier und nicht im Kern, weil erst die Anwendung weiss, welche Zeilen
ausserhalb der Verwaltung es gibt. Laeuft nach den Migrationen des Kerns
(die Rechte heissen dann schon read/write und sind aufgeteilt).
"""
from tesm_core.rbac import service as rbac
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
Migration(
version=4,
name="fileshare-pdf-rechte",
sql=V4,
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
),
Migration(version=5, name="terminal-getrennt", sql=V5),
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
Migration(
version=6,
name="wartung-eigener-bereich",
sql=V6,
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
),
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
)
+46 -7
View File
@@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Callable
METHOD_POE = "poe"
METHOD_SSH = "ssh"
@@ -40,6 +40,16 @@ class Method:
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
licensed: bool = False
@property
def recht(self) -> str:
"""Das Recht, das genau diesen Weg freigibt.
Am Weg und nicht in der Route: die Liste der Wege wird an vier Stellen
gebraucht (Dashboard, Geraeteliste, Detailseite, Route), und ein Recht,
das man an vier Stellen von Hand dazuschreibt, fehlt an der fuenften.
"""
return f"device_restart_{self.key}.execute"
@property
def is_poe(self) -> bool:
return self.key == METHOD_POE
@@ -78,12 +88,22 @@ DEFINITIONS: dict[str, Method] = {
}
def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
def available(
device: Any,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> list[Method]:
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
schlimmer als kein Knopf.
``darf`` filtert zusaetzlich nach dem Recht des jeweiligen Weges (in der
Anwendung ``current_user.has``). Damit verschwindet der Knopf fuer den, der
ihn nicht druecken darf -- und zwar an allen Stellen zugleich, weil alle
dieselbe Liste benutzen. Ohne ``darf`` ist die Antwort rein technisch.
"""
def field(name: str) -> Any:
@@ -102,7 +122,10 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
# wieder ein, statt ein laufendes System neu zu starten.
if str(field("state") or "") == "offline":
return [DEFINITIONS[POWER_ON]] if hat_poe else []
einschalten = [DEFINITIONS[POWER_ON]] if hat_poe else []
if darf is not None:
einschalten = [entry for entry in einschalten if darf(entry.recht)]
return einschalten
category = str(field("credential_category") or "")
has_login = bool(field("credential_id"))
@@ -112,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
elif category == "linux":
methods.append(DEFINITIONS[METHOD_SSH])
if darf is not None:
methods = [entry for entry in methods if darf(entry.recht)]
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None:
def preferred(
device: Any,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> Method | None:
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
options = available(device, maintenance_active=maintenance_active)
options = available(device, maintenance_active=maintenance_active, darf=darf)
return options[0] if options else None
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None:
def resolve(
device: Any,
requested: str,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> Method | None:
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
sonst startet jemand ein Geraet anders neu, als er dachte.
"""
options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)}
options = {
entry.key: entry
for entry in available(device, maintenance_active=maintenance_active, darf=darf)
}
if not options:
return None
if not requested:
+30 -1
View File
@@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable
from tesm_core.db import Database
from tesm_core.keystore import Keystore
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES,
@@ -254,6 +255,9 @@ def apply_import(
"""
from .inventory import ValidationError, aad_for_credential, normalize_mac
#: Merkt, ob das Paket alte Rechteschluessel mitbringt (siehe unten).
alte_fassung: set[bool] = set()
def mac_or_none(value: Any) -> str | None:
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
@@ -574,9 +578,21 @@ def apply_import(
group_id = int(existing["id"])
bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
# Eine Sicherung aus 2.7.x nennt devices.view und devices.edit.
# Ohne die Umschreibung landen lauter unbekannte Schluessel in der
# Gruppe, und sanitize() wirft Unbekanntes still weg: die
# wiederhergestellte Gruppe waere leer, ohne eine Zeile Meldung.
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
neue = umgeschrieben(mitgebracht)
if sorted(set(mitgebracht)) != neue:
# Das Paket kennt die alten Schluessel -- es stammt also von
# vor 2.8.0. Ein Versionsfeld gibt es im Umschlag nicht, aber
# die Schluessel selbst sind das verlaesslichere Kennzeichen:
# sie stehen genau dann drin, wenn die Sicherung aelter ist.
alte_fassung.add(True)
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
[(group_id, permission) for permission in neue],
)
if "users" in wanted:
@@ -683,4 +699,17 @@ def apply_import(
set_setting(str(key), value)
bump("settings", "updated")
if alte_fassung and "users" in wanted:
# Dieselbe Ueberlegung wie in der Migration: in der Welt, aus der
# diese Sicherung kommt, konnte ein Administrator alles. Ohne die
# Gruppe fehlt ihm nach dem Einlesen die halbe Anwendung -- und er
# haelt das fuer einen Fehler der Wiederherstellung.
from tesm_core.rbac import service as rbac
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
return report
@@ -84,7 +84,7 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
Details
</a>
{% if current_user.has('devices.execute') and tile.restart_methods %}
{% if tile.restart_methods %}
{% set method = tile.restart_methods[0] %}
<form method="post" data-no-guard
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
@@ -99,7 +99,7 @@
</button>
</form>
{% endif %}
{% if current_user.has('devices.edit') and tile.state == 'disabled' %}
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -79,7 +79,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
</form>
{% endif %}
{% if current_user.has('credentials.edit') %}
{% if current_user.has('credentials.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
@@ -121,7 +121,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
</form>
{% endcall %}
{% endif %}
{% if current_user.has('credentials.edit') %}
{% if current_user.has('credentials.write') %}
{% for entry in credentials %}
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
@@ -11,12 +11,12 @@
{% endblock %}
{% block topbar_extra %}
{% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %}
{% if terminal_enabled and current_user.has('device_console.execute') and device.credential_id %}
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=15) }} Konsole
</a>
{% endif %}
{% if current_user.has('devices.execute') and restart_methods %}
{% if restart_methods %}
{% for method in restart_methods %}
<form method="post" style="display:inline"
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
@@ -138,7 +138,7 @@
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
</div>
</div>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -151,7 +151,7 @@
<div class="card__body stack">
{% set probed_type = request.args.get('probed_type') %}
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
{% if probed_type and probed_fingerprint and current_user.has('devices.edit') %}
{% if probed_type and probed_fingerprint and current_user.has('devices.write') %}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
@@ -182,7 +182,7 @@
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
{% endfor %}
</dl>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<form method="post"
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
data-no-guard>
@@ -206,7 +206,7 @@
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
{% endif %}
</div>
@@ -232,7 +232,7 @@
</dl>
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
</div>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<div class="card__footer">
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -213,10 +213,10 @@
{% endif %}
</td>
<td class="actions">
{% if current_user.has('devices.execute') %}
{% if restart_methods.get(device.id) %}
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
{% endif %}
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
@@ -263,7 +263,7 @@ nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }
{% endcall %}
{% endif %}
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
{% for device in devices %}
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
+11 -11
View File
@@ -27,7 +27,7 @@
<div class="stat">
<span class="stat__label">Reservierungen</span>
<span class="stat__value">{{ reservations | length }}</span>
<span class="stat__hint">automatisch + manüll</span>
<span class="stat__hint">automatisch + manuell</span>
</div>
<div class="stat">
<span class="stat__label">Leases</span>
@@ -39,7 +39,7 @@
</div>
</div>
{% if current_user.has('dhcp.execute') %}
{% if current_user.has('dhcp_service.execute') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -89,7 +89,7 @@
<td class="mono fs-sm">{{ subnet.dns or '&ndash;' | safe }}</td>
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
<td class="actions">
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_subnets.write') %}
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -109,7 +109,7 @@
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
{% endif %}
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_subnets.create') %}
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
@@ -135,7 +135,7 @@
</section>
{# -- Globale Einstellungen --------------------------------------------- #}
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_subnets.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
@@ -183,7 +183,7 @@
</td>
<td class="fs-xs mono">{{ option.type }}</td>
<td>
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -197,7 +197,7 @@
{% endif %}
</td>
<td class="actions">
{% if current_user.has('dhcp.edit') and not option.is_standard %}
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -213,7 +213,7 @@
</tbody>
</table>
</div>
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
@@ -339,7 +339,7 @@
{% endif %}
</td>
<td class="actions">
{% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %}
{% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
<form method="post" data-no-guard
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -355,7 +355,7 @@
</tbody>
</table>
</div>
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_reservations.create') %}
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
@@ -434,7 +434,7 @@
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ preview }}</pre>
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_config.execute') %}
<div class="card__footer">
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -5,7 +5,7 @@
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
{% block topbar_extra %}
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
{{ icon('settings', size=15) }} Zuordnungen
</a>
@@ -152,7 +152,7 @@
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %}
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}"
@@ -237,7 +237,7 @@
<div class="modal__body modal__body--flush" data-viewer
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.read') else '' }}"
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
data-viewer-konto="{{ current_user.username }}"
@@ -330,7 +330,7 @@
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
<select class="select" data-viewer-modus>
{% if current_user.has('fileshare_pdf.edit') %}
{% if current_user.has('fileshare_pdf.write') %}
<option value="formular">Formular – weiter ausfüllbar</option>
{% endif %}
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
@@ -374,10 +374,16 @@
{{ icon('download', size=15) }} Herunterladen
</a>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
{# Unterschreiben ist eine eigene Zeile der Matrix: wer ausgefuellte
Formulare ablegen darf, darf damit nicht unterschreiben. Der Server
weist es ohnehin ab -- aber ein Knopf, der immer in eine Meldung
laeuft, ist keiner. #}
{% if current_user.has('fileshare_signature.execute') %}
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
{{ icon('pencil', size=15) }} Unterschreiben
</button>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern
@@ -388,7 +394,7 @@
{% endcall %}
{% endif %}
{% if listing and current_user.has('fileshare.edit') %}
{% if listing and current_user.has('fileshare.write') %}
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -33,7 +33,7 @@ Einbindungen.', 'info') }}
<td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td class="actions">
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="mapping-edit-{{ entry.id }}"
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
@@ -80,7 +80,7 @@ Einbindungen.', 'info') }}
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
{% for entry in mappings %}
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
@@ -160,7 +160,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
<td class="mono fs-xs break-all">{{ row.fingerprint }}</td>
<td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
<td>
{% if current_user.has('devices.edit') or current_user.has('switches.edit') %}
{% if current_user.has('devices.write') or current_user.has('switches.write') %}
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="host" value="{{ row.host }}">
@@ -76,7 +76,7 @@
</dd>
</dl>
</div>
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<div class="card__footer">
{% if not state.activated %}
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
@@ -155,7 +155,7 @@
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -190,7 +190,7 @@
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -5,7 +5,7 @@
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
{% block content %}
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
<section class="card">
@@ -54,7 +54,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span>Alle auswählen</span>
</label>
<span class="table-toolbar__spacer"></span>
{% if current_user.has('maintenance.execute') %}
{% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}"
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
@@ -91,7 +91,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<label class="check">
<input type="checkbox" name="device_ids" value="{{ device.id }}"
class="device-select" data-category="{{ device.category }}"
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
{% if not current_user.has('servicing_update.execute') %}disabled{% endif %}>
<span class="sr-only">{{ device.name }} auswählen</span>
</label>
</td>
@@ -121,7 +121,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</td>
<td class="nowrap">
{% if terminal_enabled and device.ssh_managed
and current_user.has('maintenance.execute') %}
and current_user.has('servicing_update.execute') %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=14) }} Konsole
@@ -41,7 +41,7 @@
</div>
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
checked=values.monitor_auto_restart,
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
hint_text='Zusätzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
funktioniert unabhängig davon.') }}
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public,
@@ -11,7 +11,7 @@
{% endblock %}
{% block topbar_extra %}
{% if terminal_enabled and current_user.has('switches.execute') and host_keys %}
{% if terminal_enabled and current_user.has('switch_console.execute') and host_keys %}
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }} Konsole
</a>
@@ -32,7 +32,7 @@
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
</div>
</div>
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -53,7 +53,7 @@
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
Switch, Inventardokumentation), bevor Sie ihn freigeben.
</p>
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<form method="post" class="mt-2"
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -78,7 +78,7 @@
</dd>
{% endfor %}
</dl>
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -95,7 +95,7 @@
</div>
</section>
{% if current_user.has('switches.execute') %}
{% if current_user.has('switch_console.execute') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -83,13 +83,13 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
{% endif %}
</td>
<td class="actions">
{% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %}
{% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %}
<a class="btn btn--sm btn--ghost" title="Konsole"
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }}
</a>
{% endif %}
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
@@ -135,7 +135,7 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
</form>
{% endcall %}
{% endif %}
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
{% for switch in switches %}
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">