Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5eac1db2 | ||
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc | ||
|
|
30c2c56656 | ||
|
|
292ea622a7 | ||
|
|
0d99760d89 | ||
|
|
713ff12db2 | ||
|
|
2db7e03154 | ||
|
|
5dcb87d6ec | ||
|
|
dc94a8e666 | ||
|
|
c639f4f507 | ||
|
|
ad0195bab0 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.7.3"
|
version = "2.8.7"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from tesm_core.extension import core
|
|||||||
from tesm_core.views import trash as trash_views
|
from tesm_core.views import trash as trash_views
|
||||||
|
|
||||||
from .navigation import NAVIGATION
|
from .navigation import NAVIGATION
|
||||||
from .permissions import PERMISSIONS, ROLE_PRESETS
|
from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
|
||||||
from .schema import TESM_MIGRATIONS
|
from .schema import TESM_MIGRATIONS
|
||||||
from .settings_spec import TESM_SETTINGS
|
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
|
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
|
||||||
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
|
# 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)
|
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
||||||
faellig = monitor.next_check_at(verbindung, intervall)
|
faellig = monitor.next_check_at(verbindung, intervall)
|
||||||
if faellig is not None:
|
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:
|
if extension.license.module_active("dhcp") and extension.sysops is not None:
|
||||||
state = extension.sysops.service_state(kea.SERVICE_UNIT)
|
state = extension.sysops.service_state(kea.SERVICE_UNIT)
|
||||||
if state["installed"] == "ja":
|
if state["installed"] == "ja":
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]:
|
|||||||
for category in transfer.CATEGORIES:
|
for category in transfer.CATEGORIES:
|
||||||
if category.admin_only and not current_user.is_admin:
|
if category.admin_only and not current_user.is_admin:
|
||||||
continue
|
continue
|
||||||
if category.sensitive and not current_user.has("backup.secrets"):
|
if category.sensitive and not current_user.has("backup_export.secrets"):
|
||||||
continue
|
continue
|
||||||
allowed.append(category)
|
allowed.append(category)
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
|
@permission_required("backup_export.read", "backup_import.read", mode="any")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -72,12 +72,12 @@ def overview():
|
|||||||
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
||||||
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
||||||
license_hint=license_hint,
|
license_hint=license_hint,
|
||||||
can_secrets=current_user.has("backup.secrets"),
|
can_secrets=current_user.has("backup_export.secrets"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/export")
|
@bp.post("/export")
|
||||||
@permission_required("backup.export")
|
@permission_required("backup_export.export")
|
||||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def export():
|
def export():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -96,7 +96,7 @@ def export():
|
|||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
include_secrets = (
|
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 ""
|
passphrase = request.form.get("passphrase") or ""
|
||||||
repeat = request.form.get("passphrase_repeat") or ""
|
repeat = request.form.get("passphrase_repeat") or ""
|
||||||
@@ -130,7 +130,7 @@ def export():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/import/preview")
|
@bp.post("/import/preview")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_preview():
|
def import_preview():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -162,7 +162,7 @@ def import_preview():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/import/apply")
|
@bp.post("/import/apply")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_apply():
|
def import_apply():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -226,7 +226,7 @@ def import_apply():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/import/discard")
|
@bp.post("/import/discard")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
def import_discard():
|
def import_discard():
|
||||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||||
if token:
|
if token:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("credentials.view")
|
@permission_required("credentials.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -62,7 +62,7 @@ def create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:credential_id>")
|
@bp.post("/<int:credential_id>")
|
||||||
@permission_required("credentials.edit")
|
@permission_required("credentials.write")
|
||||||
def update(credential_id: int):
|
def update(credential_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
counts = monitor.dashboard_counts(conn)
|
counts = monitor.dashboard_counts(conn)
|
||||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
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]] = []
|
tiles: list[dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
@@ -67,8 +67,12 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
"credential_category": str(row["credential_category"] or ""),
|
"credential_category": str(row["credential_category"] or ""),
|
||||||
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
|
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
|
||||||
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
|
# 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(
|
"restart_methods": restart.available(
|
||||||
row, maintenance_active=_maintenance_active()
|
row,
|
||||||
|
maintenance_active=_maintenance_active(),
|
||||||
|
darf=current_user.has,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def _form_values() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("devices.view")
|
@permission_required("devices.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
devices = inventory.list_devices(conn)
|
devices = inventory.list_devices(conn)
|
||||||
@@ -138,7 +138,7 @@ def create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>")
|
@bp.post("/<int:device_id>")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def update(device_id: int):
|
def update(device_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -162,7 +162,7 @@ def update(device_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/active")
|
@bp.post("/<int:device_id>/active")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def toggle_active(device_id: int):
|
def toggle_active(device_id: int):
|
||||||
active = request.form.get("active") == "1"
|
active = request.form.get("active") == "1"
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -194,7 +194,12 @@ def delete(device_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/restart")
|
@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):
|
def restart_device(device_id: int):
|
||||||
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
||||||
|
|
||||||
@@ -214,9 +219,12 @@ def restart_device(device_id: int):
|
|||||||
dict(device),
|
dict(device),
|
||||||
(request.form.get("method") or "").strip(),
|
(request.form.get("method") or "").strip(),
|
||||||
maintenance_active=maintenance_active,
|
maintenance_active=maintenance_active,
|
||||||
|
darf=current_user.has,
|
||||||
)
|
)
|
||||||
if method is None:
|
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:
|
if options:
|
||||||
flash(
|
flash(
|
||||||
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
|
"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>")
|
@bp.get("/<int:device_id>")
|
||||||
@permission_required("devices.view")
|
@permission_required("devices.read")
|
||||||
def detail(device_id: int):
|
def detail(device_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
device = inventory.get_device(conn, device_id)
|
device = inventory.get_device(conn, device_id)
|
||||||
@@ -361,7 +369,7 @@ def detail(device_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/host-key/probe")
|
@bp.post("/<int:device_id>/host-key/probe")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def probe_host_key(device_id: int):
|
def probe_host_key(device_id: int):
|
||||||
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
"""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")
|
@bp.post("/<int:device_id>/host-key/trust")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust_host_key(device_id: int):
|
def trust_host_key(device_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -425,7 +433,7 @@ def trust_host_key(device_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/host-key/forget")
|
@bp.post("/<int:device_id>/host-key/forget")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def forget_host_key(device_id: int):
|
def forget_host_key(device_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
device = inventory.get_device(conn, device_id)
|
device = inventory.get_device(conn, device_id)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from tesm_core.db import Database
|
|||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
from ..services import kea
|
from ..services import kea
|
||||||
|
from ..permissions import DHCP_PAGE
|
||||||
|
|
||||||
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
|
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any")
|
@permission_required(*DHCP_PAGE, mode="any")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -106,7 +107,7 @@ def overview():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/settings")
|
@bp.post("/settings")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.write")
|
||||||
def save_settings():
|
def save_settings():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -123,7 +124,7 @@ def save_settings():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/subnet")
|
@bp.post("/subnet")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.create")
|
||||||
def add_subnet():
|
def add_subnet():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -159,7 +160,7 @@ def add_subnet():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/subnet/<int:subnet_id>")
|
@bp.post("/subnet/<int:subnet_id>")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.write")
|
||||||
def update_subnet(subnet_id: int):
|
def update_subnet(subnet_id: int):
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -173,6 +174,9 @@ def update_subnet(subnet_id: int):
|
|||||||
flash(problem, "danger")
|
flash(problem, "danger")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
# Vor der Aenderung lesen -- danach ist der alte Stand weg, und "Subnetz
|
||||||
|
# gespeichert" allein sagt nicht, was vorher eingestellt war.
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
||||||
@@ -189,13 +193,32 @@ def update_subnet(subnet_id: int):
|
|||||||
subnet_id,
|
subnet_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.subnet_updated",
|
||||||
|
f"{interface} {start}-{end}",
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(
|
||||||
|
("interface", "Schnittstelle"),
|
||||||
|
("range_start", "Von"),
|
||||||
|
("range_end", "Bis"),
|
||||||
|
("gateway", "Gateway"),
|
||||||
|
("dns", "DNS"),
|
||||||
|
("comment", "Kommentar"),
|
||||||
|
("enabled", "Aktiv"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
or "keine inhaltliche Änderung",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Subnetz gespeichert.", "success")
|
flash("Subnetz gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/subnet/<int:subnet_id>/delete")
|
@bp.post("/subnet/<int:subnet_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.delete")
|
||||||
def delete_subnet(subnet_id: int):
|
def delete_subnet(subnet_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
@@ -210,7 +233,7 @@ def delete_subnet(subnet_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/option/value")
|
@bp.post("/option/value")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def set_option_value():
|
def set_option_value():
|
||||||
conn = db()
|
conn = db()
|
||||||
option_id = int(request.form.get("option_id") or 0)
|
option_id = int(request.form.get("option_id") or 0)
|
||||||
@@ -249,7 +272,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/option/device")
|
@bp.post("/option/device")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def set_device_option_value():
|
def set_device_option_value():
|
||||||
"""Setzt eine Option fuer ein einzelnes Geraet.
|
"""Setzt eine Option fuer ein einzelnes Geraet.
|
||||||
|
|
||||||
@@ -297,7 +320,7 @@ def set_device_option_value():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/option/new")
|
@bp.post("/option/new")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def add_custom_option():
|
def add_custom_option():
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -328,7 +351,7 @@ def add_custom_option():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/option/<int:option_id>/delete")
|
@bp.post("/option/<int:option_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def delete_custom_option(option_id: int):
|
def delete_custom_option(option_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
||||||
@@ -353,7 +376,7 @@ def delete_custom_option(option_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/reservation")
|
@bp.post("/reservation")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_reservations.create")
|
||||||
def add_reservation():
|
def add_reservation():
|
||||||
conn = db()
|
conn = db()
|
||||||
mac = kea.normalize_mac(request.form.get("mac", ""))
|
mac = kea.normalize_mac(request.form.get("mac", ""))
|
||||||
@@ -395,6 +418,7 @@ def add_reservation():
|
|||||||
)
|
)
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||||
@@ -407,13 +431,24 @@ def add_reservation():
|
|||||||
(request.form.get("comment") or "").strip(),
|
(request.form.get("comment") or "").strip(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.reservation_saved",
|
||||||
|
mac,
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(("ip", "IP"), ("hostname", "Hostname"), ("comment", "Kommentar")),
|
||||||
|
)
|
||||||
|
or f"neu angelegt: {ip}",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Reservierung gespeichert.", "success")
|
flash("Reservierung gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/reservation/<int:reservation_id>/delete")
|
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_reservations.delete")
|
||||||
def delete_reservation(reservation_id: int):
|
def delete_reservation(reservation_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
|
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
|
||||||
@@ -481,13 +516,13 @@ def _write_and_redirect():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/write")
|
@bp.post("/write")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_config.execute")
|
||||||
def write_config():
|
def write_config():
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/service/<action>")
|
@bp.post("/service/<action>")
|
||||||
@permission_required("dhcp.execute")
|
@permission_required("dhcp_service.execute")
|
||||||
def service_action(action: str):
|
def service_action(action: str):
|
||||||
allowed = {"install", "start", "stop"}
|
allowed = {"install", "start", "stop"}
|
||||||
if action not in allowed:
|
if action not in allowed:
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ def _mount_or_404(token: str) -> service.Mount:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def browse():
|
def browse():
|
||||||
conn = db()
|
conn = db()
|
||||||
mounts = service.doppelte_zusammenfassen(
|
mounts = service.doppelte_zusammenfassen(
|
||||||
@@ -148,7 +148,7 @@ def download(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/<token>/preview")
|
@bp.get("/<token>/preview")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def preview(token: str):
|
def preview(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.args.get("path") or ""
|
relative = request.args.get("path") or ""
|
||||||
@@ -221,7 +221,7 @@ def upload(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/<token>/signature")
|
@bp.get("/<token>/signature")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def signature_state(token: str):
|
def signature_state(token: str):
|
||||||
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
|
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ def signature_state(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/<token>/folders")
|
@bp.get("/<token>/folders")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def folders(token: str):
|
def folders(token: str):
|
||||||
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
|
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
|
||||||
|
|
||||||
@@ -272,6 +272,19 @@ def save_form(token: str):
|
|||||||
if upload_file is None or not upload_file.filename:
|
if upload_file is None or not upload_file.filename:
|
||||||
return {"error": "Es kam keine Datei an."}, 400
|
return {"error": "Es kam keine Datei an."}, 400
|
||||||
|
|
||||||
|
# Ein unterschriebenes Dokument ist abgeschlossen. Es als Vorlage fuer eine
|
||||||
|
# neue Fassung zu benutzen hiesse, eine Unterschrift unter einen anderen
|
||||||
|
# Text zu stellen -- die Oberflaeche blendet das aus, hier wird es
|
||||||
|
# abgewiesen.
|
||||||
|
if service.signaturstand(db(), mount, relative).get("status") in {
|
||||||
|
"unveraendert",
|
||||||
|
"veraendert",
|
||||||
|
}:
|
||||||
|
return {
|
||||||
|
"error": "Dieses Dokument ist unterschrieben und damit abgeschlossen. "
|
||||||
|
"Legen Sie eine neue Fassung aus der ursprünglichen Vorlage an."
|
||||||
|
}, 409
|
||||||
|
|
||||||
grenze = uploads.limit_bytes(db())
|
grenze = uploads.limit_bytes(db())
|
||||||
daten = upload_file.stream.read(grenze + 1)
|
daten = upload_file.stream.read(grenze + 1)
|
||||||
if len(daten) > grenze:
|
if len(daten) > grenze:
|
||||||
@@ -286,7 +299,7 @@ def save_form(token: str):
|
|||||||
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
|
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
|
||||||
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
|
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
|
||||||
# niemandem weiter.
|
# 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 {
|
return {
|
||||||
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
|
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
|
||||||
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
|
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
|
||||||
@@ -338,15 +351,23 @@ def save_form(token: str):
|
|||||||
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
||||||
pruefsumme = hashlib.sha256(daten).hexdigest()
|
pruefsumme = hashlib.sha256(daten).hexdigest()
|
||||||
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
|
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
|
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
|
||||||
# da: der Name **in** der Unterschrift ist eine Angabe des
|
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||||
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||||
# ohnehin als Handelnder in der Zeile und ist belegt.
|
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||||
beschreibung = f"{ziel_mount.label}:{wohin} sha256={pruefsumme}"
|
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
|
||||||
|
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
|
||||||
|
# Zeile drueckte der Hash den Rest aus der Spalte.
|
||||||
|
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
|
||||||
if unterschriften:
|
if unterschriften:
|
||||||
namen = ", ".join(unterschriften)
|
kopf += f" von: {', '.join(unterschriften)}"
|
||||||
beschreibung = f"{beschreibung} -- angegeben unterschrieben von: {namen}"
|
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
|
||||||
_audit(
|
_audit(
|
||||||
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||||
f"{mount.label}:{relative}",
|
f"{mount.label}:{relative}",
|
||||||
@@ -396,7 +417,7 @@ def make_directory(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/rename")
|
@bp.post("/<token>/rename")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.write")
|
||||||
def rename(token: str):
|
def rename(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("path") or ""
|
relative = request.form.get("path") or ""
|
||||||
@@ -430,7 +451,7 @@ def delete(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/release")
|
@bp.post("/<token>/release")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def release(token: str):
|
def release(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -446,7 +467,7 @@ def release(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/mappings")
|
@bp.get("/mappings")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.read")
|
||||||
def mappings():
|
def mappings():
|
||||||
from tesm_core.auth import directory
|
from tesm_core.auth import directory
|
||||||
|
|
||||||
@@ -460,7 +481,7 @@ def mappings():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/mappings")
|
@bp.post("/mappings")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.create")
|
||||||
def mapping_add():
|
def mapping_add():
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -490,7 +511,7 @@ def mapping_add():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/mappings/<int:mapping_id>")
|
@bp.post("/mappings/<int:mapping_id>")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.write")
|
||||||
def mapping_update(mapping_id: int):
|
def mapping_update(mapping_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -520,7 +541,7 @@ def mapping_update(mapping_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/mappings/<int:mapping_id>/delete")
|
@bp.post("/mappings/<int:mapping_id>/delete")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.delete")
|
||||||
def mapping_delete(mapping_id: int):
|
def mapping_delete(mapping_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
entfernt = service.get_mapping(conn, mapping_id)
|
entfernt = service.get_mapping(conn, mapping_id)
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
|
|||||||
KIND_DEVICE = "client"
|
KIND_DEVICE = "client"
|
||||||
KIND_SWITCH = "switch"
|
KIND_SWITCH = "switch"
|
||||||
|
|
||||||
#: Welches Recht welche Art von Ziel freigibt.
|
#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
|
||||||
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"}
|
#: ("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:
|
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("/")
|
@bp.get("/")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("hostkeys.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
targets = _targets(conn)
|
targets = _targets(conn)
|
||||||
@@ -143,7 +147,7 @@ def _require(kind: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/probe")
|
@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):
|
def probe(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
_require(kind)
|
_require(kind)
|
||||||
@@ -170,7 +174,7 @@ def probe(kind: str, target_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/trust")
|
@bp.post("/<kind>/<int:target_id>/trust")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("hostkeys.read")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust(kind: str, target_id: int):
|
def trust(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -204,7 +208,7 @@ def trust(kind: str, target_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/forget")
|
@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):
|
def forget(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
_require(kind)
|
_require(kind)
|
||||||
@@ -223,7 +227,7 @@ def forget(kind: str, target_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/orphaned/forget")
|
@bp.post("/orphaned/forget")
|
||||||
@permission_required("devices.edit", "switches.edit", mode="any")
|
@permission_required("hostkeys.execute")
|
||||||
def forget_orphan():
|
def forget_orphan():
|
||||||
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
||||||
host = (request.form.get("host") or "").strip()
|
host = (request.form.get("host") or "").strip()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("settings_license.view")
|
@permission_required("settings_license.read")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -70,7 +70,7 @@ def overview():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/install")
|
@bp.post("/install")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def upload():
|
def upload():
|
||||||
extension = core()
|
extension = core()
|
||||||
manager = extension.license
|
manager = extension.license
|
||||||
@@ -111,13 +111,13 @@ def upload():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/activate")
|
@bp.post("/activate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def activate():
|
def activate():
|
||||||
return _handshake("activate")
|
return _handshake("activate")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/deactivate")
|
@bp.post("/deactivate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def deactivate():
|
def deactivate():
|
||||||
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
|
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
|
||||||
@@ -129,7 +129,7 @@ def deactivate():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/heartbeat")
|
@bp.post("/heartbeat")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
return _handshake("heartbeat")
|
return _handshake("heartbeat")
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ def _handshake(action: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/offline-response")
|
@bp.post("/offline-response")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def offline_response():
|
def offline_response():
|
||||||
extension = core()
|
extension = core()
|
||||||
manager = extension.license
|
manager = extension.license
|
||||||
@@ -178,7 +178,7 @@ def offline_response():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/server-certificate")
|
@bp.post("/server-certificate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def server_certificate():
|
def server_certificate():
|
||||||
"""Hinterlegt das Zertifikat des Lizenzservers.
|
"""Hinterlegt das Zertifikat des Lizenzservers.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ MAX_TAIL_LINES = 2000
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/dhcp")
|
@bp.get("/dhcp")
|
||||||
@permission_required("logs_dhcp.view")
|
@permission_required("logs_dhcp.read")
|
||||||
def dhcp():
|
def dhcp():
|
||||||
extension = core()
|
extension = core()
|
||||||
if extension.license is not None and not extension.license.module_active("dhcp"):
|
if extension.license is not None and not extension.license.module_active("dhcp"):
|
||||||
@@ -39,7 +39,7 @@ def dhcp():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/restarts")
|
@bp.get("/restarts")
|
||||||
@permission_required("devices.view")
|
@permission_required("devices.read")
|
||||||
def restarts():
|
def restarts():
|
||||||
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from tesm_core.auth.service import client_ip
|
|||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
|
from ..permissions import SERVICING_PAGE
|
||||||
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||||
|
|
||||||
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
|
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
|
||||||
@@ -75,7 +76,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -106,7 +107,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/install-package")
|
@bp.post("/install-package")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("servicing_tool.execute")
|
||||||
def install_windows_tool():
|
def install_windows_tool():
|
||||||
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ def install_windows_tool():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/status")
|
@bp.get("/status")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
def status():
|
def status():
|
||||||
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -166,7 +167,7 @@ def status():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/job/<job_id>")
|
@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):
|
def job_detail(job_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
job = service.get_job(conn, job_id)
|
job = service.get_job(conn, job_id)
|
||||||
@@ -176,7 +177,7 @@ def job_detail(job_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/job/<job_id>/output")
|
@bp.get("/job/<job_id>/output")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
@fragment
|
@fragment
|
||||||
def job_output(job_id: str):
|
def job_output(job_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -277,14 +278,14 @@ def _start(kind: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/update")
|
@bp.post("/update")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("servicing_update.execute")
|
||||||
def start_update():
|
def start_update():
|
||||||
_start(service.JOB_KIND_UPDATE)
|
_start(service.JOB_KIND_UPDATE)
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/restart")
|
@bp.post("/restart")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("servicing_reboot.execute")
|
||||||
def start_reboot():
|
def start_reboot():
|
||||||
_start(service.JOB_KIND_REBOOT)
|
_start(service.JOB_KIND_REBOOT)
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
@@ -18,6 +18,8 @@ from tesm_core.auth.service import client_ip
|
|||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
from tesm_core.licensing_client import LicenseError
|
from tesm_core.licensing_client import LicenseError
|
||||||
|
|
||||||
|
import tesm_licensing as lic
|
||||||
|
|
||||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _module_rows(conn: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
|
||||||
|
|
||||||
|
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
|
||||||
|
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
|
||||||
|
will (dafuer ist der Schalter da).
|
||||||
|
"""
|
||||||
|
manager = core().license
|
||||||
|
if manager is None:
|
||||||
|
return []
|
||||||
|
state = manager.reload(conn)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": name,
|
||||||
|
"label": lic.MODULE_LABELS.get(name, name),
|
||||||
|
"licensed": name in state.modules,
|
||||||
|
"enabled": name not in state.modules_off,
|
||||||
|
}
|
||||||
|
for name in lic.ALL_MODULES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _current_timezone() -> str:
|
def _current_timezone() -> str:
|
||||||
sysops = core().sysops
|
sysops = core().sysops
|
||||||
if sysops is None:
|
if sysops is None:
|
||||||
@@ -65,7 +89,7 @@ def _timezone_choices() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/system")
|
@bp.get("/system")
|
||||||
@permission_required("settings_system.view")
|
@permission_required("settings_system.read")
|
||||||
def system():
|
def system():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -94,16 +118,21 @@ def system():
|
|||||||
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
||||||
timezone=_current_timezone(),
|
timezone=_current_timezone(),
|
||||||
timezones=_timezone_choices(),
|
timezones=_timezone_choices(),
|
||||||
nav_items=extension.nav.items,
|
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
|
||||||
|
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
|
||||||
|
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
|
||||||
|
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
|
||||||
|
nav_baum=extension.nav.items,
|
||||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||||
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||||
license_hint=_license_hint("monitor_interval"),
|
license_hint=_license_hint("monitor_interval"),
|
||||||
sysops_available=bool(sysops and sysops.available),
|
sysops_available=bool(sysops and sysops.available),
|
||||||
|
module_rows=_module_rows(conn),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system")
|
@bp.post("/system")
|
||||||
@permission_required("settings_system.edit")
|
@permission_required("settings_system.write")
|
||||||
def system_save():
|
def system_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -167,6 +196,56 @@ def system_save():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/system/modules")
|
||||||
|
@permission_required("settings_system.write")
|
||||||
|
def modules_save():
|
||||||
|
"""Module ein- und ausschalten.
|
||||||
|
|
||||||
|
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
|
||||||
|
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
|
||||||
|
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
|
||||||
|
wieder einschaltet, findet alles vor wie zuvor.
|
||||||
|
"""
|
||||||
|
extension = core()
|
||||||
|
manager = extension.license
|
||||||
|
if manager is None:
|
||||||
|
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||||
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
|
||||||
|
with transaction() as conn:
|
||||||
|
eingeschaltet, abgeschaltet = manager.module_abschalten(
|
||||||
|
conn, aus, actor=current_user.username
|
||||||
|
)
|
||||||
|
if eingeschaltet or abgeschaltet:
|
||||||
|
teile = []
|
||||||
|
if abgeschaltet:
|
||||||
|
teile.append(
|
||||||
|
"abgeschaltet: "
|
||||||
|
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
|
||||||
|
)
|
||||||
|
if eingeschaltet:
|
||||||
|
teile.append(
|
||||||
|
"eingeschaltet: "
|
||||||
|
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
action="settings.modules_changed",
|
||||||
|
detail="; ".join(teile),
|
||||||
|
ip=client_ip(),
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
|
||||||
|
if eingeschaltet or abgeschaltet:
|
||||||
|
flash("Module gespeichert.", "success")
|
||||||
|
else:
|
||||||
|
flash("Keine Änderungen.", "info")
|
||||||
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/hostname")
|
@bp.post("/system/hostname")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def set_hostname():
|
def set_hostname():
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def _form_values() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("switches.view")
|
@permission_required("switches.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -79,7 +79,7 @@ def create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>")
|
@bp.post("/<int:switch_id>")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
def update(switch_id: int):
|
def update(switch_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -115,7 +115,7 @@ def delete(switch_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/<int:switch_id>")
|
@bp.get("/<int:switch_id>")
|
||||||
@permission_required("switches.view")
|
@permission_required("switches.read")
|
||||||
def detail(switch_id: int):
|
def detail(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
switch = inventory.get_switch(conn, switch_id)
|
switch = inventory.get_switch(conn, switch_id)
|
||||||
@@ -152,7 +152,7 @@ def detail(switch_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/host-key/probe")
|
@bp.post("/<int:switch_id>/host-key/probe")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
def probe_host_key(switch_id: int):
|
def probe_host_key(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
switch = inventory.get_switch(conn, switch_id)
|
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")
|
@bp.post("/<int:switch_id>/host-key/trust")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust_host_key(switch_id: int):
|
def trust_host_key(switch_id: int):
|
||||||
"""Gibt einen Host-Schluessel ausdruecklich frei.
|
"""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")
|
@bp.post("/<int:switch_id>/host-key/forget")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
def forget_host_key(switch_id: int):
|
def forget_host_key(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
switch = inventory.get_switch(conn, switch_id)
|
switch = inventory.get_switch(conn, switch_id)
|
||||||
@@ -235,7 +235,7 @@ def forget_host_key(switch_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/command")
|
@bp.post("/<int:switch_id>/command")
|
||||||
@permission_required("switches.execute")
|
@permission_required("switch_console.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def run_command(switch_id: int):
|
def run_command(switch_id: int):
|
||||||
"""Fuehrt ein Kommando aus der Positivliste aus.
|
"""Fuehrt ein Kommando aus der Positivliste aus.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf:
|
|||||||
|
|
||||||
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
|
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
|
||||||
:class:`~tesm.services.switchcli.SwitchCli`.
|
: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
|
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
|
||||||
:class:`~tesm.services.shellcli.ShellSession`.
|
: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")
|
@bp.get("/monitoring/switches/<int:switch_id>/console")
|
||||||
@permission_required("switches.execute")
|
@permission_required("switch_console.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def switch_console(switch_id: int):
|
def switch_console(switch_id: int):
|
||||||
target = _load_target(db(), KIND_SWITCH, switch_id)
|
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")
|
@bp.get("/monitoring/clients/<int:device_id>/console")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("device_console.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def device_console(device_id: int):
|
def device_console(device_id: int):
|
||||||
if not _client_module_active():
|
if not _client_module_active():
|
||||||
@@ -211,7 +211,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
|
|
||||||
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
|
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler.
|
||||||
|
|
||||||
|
Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die
|
||||||
|
Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die
|
||||||
|
Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``,
|
||||||
|
nicht die Meldung des Switches.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
ws.send(json.dumps({"type": kind, "data": text}))
|
ws.send(json.dumps({"type": kind, "data": text}))
|
||||||
|
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||||
|
logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind)
|
||||||
|
|
||||||
|
|
||||||
def _transcript_name(label: str) -> str:
|
def _transcript_name(label: str) -> str:
|
||||||
@@ -220,12 +230,23 @@ def _transcript_name(label: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
|
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Eine Konsolensitzung, von der Berechtigung bis zum Abmelden.
|
||||||
|
|
||||||
|
Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde:
|
||||||
|
eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt,
|
||||||
|
die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau
|
||||||
|
diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst
|
||||||
|
nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach.
|
||||||
|
"""
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
|
_send(ws, "info", "Sitzung wird geöffnet …")
|
||||||
|
|
||||||
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
||||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
# 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):
|
if not current_user.is_authenticated or not current_user.has(required):
|
||||||
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
|
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
|
||||||
return
|
return
|
||||||
@@ -253,6 +274,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
|||||||
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
|
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
|
||||||
try:
|
try:
|
||||||
username, password = inventory.credential_secret(
|
username, password = inventory.credential_secret(
|
||||||
conn, extension.keystore, target.credential_id
|
conn, extension.keystore, target.credential_id
|
||||||
@@ -260,6 +282,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
|||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
_send(ws, "error", str(exc))
|
_send(ws, "error", str(exc))
|
||||||
return
|
return
|
||||||
|
_send(ws, "info", f"Anmeldung als {username} …")
|
||||||
|
|
||||||
with extension.database.transaction(conn):
|
with extension.database.transaction(conn):
|
||||||
audit.log(
|
audit.log(
|
||||||
@@ -341,6 +364,7 @@ def _run_switch_session( # type: ignore[no-untyped-def]
|
|||||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||||
) -> int:
|
) -> int:
|
||||||
commands = 0
|
commands = 0
|
||||||
|
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
|
||||||
with switchcli.SwitchCli(
|
with switchcli.SwitchCli(
|
||||||
conn, host=target.host, port=target.port, username=username, password=password
|
conn, host=target.host, port=target.port, username=username, password=password
|
||||||
) as cli:
|
) as cli:
|
||||||
@@ -373,6 +397,7 @@ def _run_client_session( # type: ignore[no-untyped-def]
|
|||||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||||
) -> int:
|
) -> int:
|
||||||
commands = 0
|
commands = 0
|
||||||
|
_send(ws, "info", "Verbindung wird aufgebaut …")
|
||||||
with shellcli.ShellSession(
|
with shellcli.ShellSession(
|
||||||
conn, host=target.host, port=target.port, username=username, password=password
|
conn, host=target.host, port=target.port, username=username, password=password
|
||||||
) as session:
|
) as session:
|
||||||
|
|||||||
+154
-1
@@ -69,13 +69,33 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
user_id = int(cursor.lastrowid or 0)
|
user_id = int(cursor.lastrowid or 0)
|
||||||
action = "angelegt"
|
action = "angelegt"
|
||||||
|
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
|
||||||
|
# Administratorkennzeichen den Verwaltungsbereich und sonst
|
||||||
|
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
|
||||||
|
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
|
||||||
|
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
|
||||||
|
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
|
||||||
|
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
|
||||||
|
# wieder weg.
|
||||||
|
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||||
|
|
||||||
|
gruppe = rbac.funktionsgruppe_anlegen(
|
||||||
|
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
|
(user_id, gruppe),
|
||||||
|
)
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor="cli",
|
actor="cli",
|
||||||
actor_id=user_id,
|
actor_id=user_id,
|
||||||
action="user.admin_created",
|
action="user.admin_created",
|
||||||
target=username,
|
target=username,
|
||||||
detail=f"über die Kommandozeile {action}",
|
detail=(
|
||||||
|
f"über die Kommandozeile {action}, Gruppe "
|
||||||
|
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||||
|
),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
print(f"Administrator {username!r} {action}.")
|
print(f"Administrator {username!r} {action}.")
|
||||||
@@ -216,6 +236,123 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
|
|||||||
return run_web_setup(app, extension, args)
|
return run_web_setup(app, extension, args)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_switch_test(args: argparse.Namespace) -> int:
|
||||||
|
"""Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt.
|
||||||
|
|
||||||
|
Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen
|
||||||
|
zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und
|
||||||
|
JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser
|
||||||
|
Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was
|
||||||
|
sie bekommen hat, notfalls als rohe Bytes.
|
||||||
|
|
||||||
|
Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich
|
||||||
|
geschaltet.
|
||||||
|
"""
|
||||||
|
from .services import inventory, switchcli
|
||||||
|
from .services.ssh import SshError
|
||||||
|
from tesm_core.db import Database
|
||||||
|
from tesm_core.extension import core
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
erweiterung = core(app)
|
||||||
|
|
||||||
|
def sagen(stufe: str, text: str = "") -> None:
|
||||||
|
print(f" {stufe:<28} {text}".rstrip())
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
with erweiterung.database.session() as conn:
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT id, hostname, ip, ssh_port, credential_id FROM switches "
|
||||||
|
"WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL",
|
||||||
|
(args.switch, args.switch),
|
||||||
|
)
|
||||||
|
if zeile is None:
|
||||||
|
print(f"Kein Switch mit Name oder Adresse {args.switch!r}.")
|
||||||
|
print("Vorhanden:")
|
||||||
|
for andere in Database.all(
|
||||||
|
conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL"
|
||||||
|
):
|
||||||
|
print(f" {andere['hostname']} ({andere['ip']})")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
host = str(zeile["ip"])
|
||||||
|
ssh_port = int(zeile["ssh_port"] or 22)
|
||||||
|
print(f"Switch {zeile['hostname']} ({host}:{ssh_port})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not zeile["credential_id"]:
|
||||||
|
sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet")
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
benutzer, passwort = inventory.credential_secret(
|
||||||
|
conn, erweiterung.keystore, int(zeile["credential_id"])
|
||||||
|
)
|
||||||
|
except Exception as fehler: # noqa: BLE001
|
||||||
|
sagen("Zugangsdaten", f"nicht lesbar: {fehler}")
|
||||||
|
return 1
|
||||||
|
sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)")
|
||||||
|
|
||||||
|
from .services import ssh as ssh_modul
|
||||||
|
|
||||||
|
for record in ssh_modul.all_known_keys(conn, host, ssh_port):
|
||||||
|
sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sitzung = switchcli.SwitchCli(
|
||||||
|
conn,
|
||||||
|
host=host,
|
||||||
|
port=ssh_port,
|
||||||
|
username=benutzer,
|
||||||
|
password=passwort,
|
||||||
|
)
|
||||||
|
with sitzung as cli:
|
||||||
|
sagen("Anmeldung", "erfolgreich")
|
||||||
|
erstes = cli.full_transcript
|
||||||
|
sagen("Erste Antwort", f"{len(erstes)} Zeichen")
|
||||||
|
print()
|
||||||
|
print("--- was der Switch nach der Anmeldung geschickt hat ---")
|
||||||
|
print(erstes if erstes.strip() else "(nichts)")
|
||||||
|
print("--- Ende ---")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.port:
|
||||||
|
ausgabe = cli.send(
|
||||||
|
f"show power-over-ethernet {args.port}", tolerate_error=True
|
||||||
|
)
|
||||||
|
sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen")
|
||||||
|
print("--- show power-over-ethernet ---")
|
||||||
|
print(ausgabe)
|
||||||
|
print("--- Ende ---")
|
||||||
|
sagen("Erkannter Zustand", cli.poe_state(args.port))
|
||||||
|
|
||||||
|
if args.zyklus:
|
||||||
|
if not args.port:
|
||||||
|
print("Fuer --zyklus braucht es --port.")
|
||||||
|
return 2
|
||||||
|
ergebnis = cli.cycle_poe(args.port)
|
||||||
|
sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error)
|
||||||
|
for schritt in ergebnis.steps:
|
||||||
|
sagen("", schritt)
|
||||||
|
if not ergebnis.ok:
|
||||||
|
return 1
|
||||||
|
except SshError as fehler:
|
||||||
|
sagen("FEHLGESCHLAGEN", str(fehler))
|
||||||
|
# Gerade dann sind die Bytes interessant: "kein Prompt" kann
|
||||||
|
# heissen, dass nichts kam -- oder dass etwas kam, das nicht
|
||||||
|
# erkannt wurde. Das sieht man nur im Rohtext, und
|
||||||
|
# Steuerzeichen sieht man nur als repr().
|
||||||
|
roh = "".join(sitzung.transcript)
|
||||||
|
print()
|
||||||
|
print("--- rohe Antwort des Switches (mit Steuerzeichen) ---")
|
||||||
|
print(repr(roh) if roh else "(nichts)")
|
||||||
|
print("--- Ende ---")
|
||||||
|
return 1
|
||||||
|
print()
|
||||||
|
print("Fertig.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
||||||
"""Uebersetzt einen Export des alten TESM in das neue Format.
|
"""Uebersetzt einen Export des alten TESM in das neue Format.
|
||||||
|
|
||||||
@@ -418,6 +555,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
|
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
|
||||||
check.set_defaults(func=cmd_check_now)
|
check.set_defaults(func=cmd_check_now)
|
||||||
|
|
||||||
|
pruefung = sub.add_parser(
|
||||||
|
"switch-test",
|
||||||
|
help="Eine Switch-Sitzung Stufe fuer Stufe pruefen",
|
||||||
|
description=(
|
||||||
|
"Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, "
|
||||||
|
"ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der "
|
||||||
|
"Port wirklich geschaltet."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt")
|
||||||
|
pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab")
|
||||||
|
pruefung.add_argument(
|
||||||
|
"--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten"
|
||||||
|
)
|
||||||
|
pruefung.set_defaults(func=cmd_switch_test)
|
||||||
|
|
||||||
legacy = sub.add_parser(
|
legacy = sub.add_parser(
|
||||||
"convert-legacy",
|
"convert-legacy",
|
||||||
help="Export des alten TESM in das neue Format übersetzen",
|
help="Export des alten TESM in das neue Format übersetzen",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from typing import Any
|
|||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
from tesm_core.nav import NavItem, NavTree
|
from tesm_core.nav import NavItem, NavTree
|
||||||
|
|
||||||
|
from .permissions import DHCP_PAGE, SERVICING_PAGE
|
||||||
|
|
||||||
|
|
||||||
def _module(name: str):
|
def _module(name: str):
|
||||||
"""Bedingung: Vollmodul lizenziert?"""
|
"""Bedingung: Vollmodul lizenziert?"""
|
||||||
@@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool:
|
|||||||
return False
|
return False
|
||||||
if not _module("fileshare")(user):
|
if not _module("fileshare")(user):
|
||||||
return False
|
return False
|
||||||
if user.has("fileshare.edit"):
|
if user.has("fileshare.write"):
|
||||||
return True
|
return True
|
||||||
from .services import fileshare
|
from .services import fileshare
|
||||||
|
|
||||||
@@ -56,28 +58,28 @@ NAVIGATION = NavTree(
|
|||||||
label="Clients",
|
label="Clients",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
endpoint="devices.overview",
|
endpoint="devices.overview",
|
||||||
permissions=("devices.view",),
|
permissions=("devices.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="switches",
|
key="switches",
|
||||||
label="Switche",
|
label="Switche",
|
||||||
icon="switch",
|
icon="switch",
|
||||||
endpoint="switches.overview",
|
endpoint="switches.overview",
|
||||||
permissions=("switches.view",),
|
permissions=("switches.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="credentials",
|
key="credentials",
|
||||||
label="Zugangsdaten",
|
label="Zugangsdaten",
|
||||||
icon="key",
|
icon="key",
|
||||||
endpoint="credentials.overview",
|
endpoint="credentials.overview",
|
||||||
permissions=("credentials.view",),
|
permissions=("credentials.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="hostkeys",
|
key="hostkeys",
|
||||||
label="Host-Schlüssel",
|
label="Host-Schlüssel",
|
||||||
icon="shield",
|
icon="shield",
|
||||||
endpoint="hostkeys.overview",
|
endpoint="hostkeys.overview",
|
||||||
permissions=("devices.view", "switches.view"),
|
permissions=("devices.read", "switches.read"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -86,7 +88,7 @@ NAVIGATION = NavTree(
|
|||||||
label="Wartung",
|
label="Wartung",
|
||||||
icon="wrench",
|
icon="wrench",
|
||||||
endpoint="maintenance.overview",
|
endpoint="maintenance.overview",
|
||||||
permissions=("maintenance.view", "maintenance.execute"),
|
permissions=SERVICING_PAGE,
|
||||||
condition=_module("maintenance"),
|
condition=_module("maintenance"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -94,7 +96,7 @@ NAVIGATION = NavTree(
|
|||||||
label="DHCP-Server",
|
label="DHCP-Server",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="dhcp.overview",
|
endpoint="dhcp.overview",
|
||||||
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"),
|
permissions=DHCP_PAGE,
|
||||||
condition=_module("dhcp"),
|
condition=_module("dhcp"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -108,14 +110,14 @@ NAVIGATION = NavTree(
|
|||||||
label="Meine Freigaben",
|
label="Meine Freigaben",
|
||||||
icon="folder",
|
icon="folder",
|
||||||
endpoint="fileshare.browse",
|
endpoint="fileshare.browse",
|
||||||
permissions=("fileshare.view",),
|
permissions=("fileshare.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="fileshare_mappings",
|
key="fileshare_mappings",
|
||||||
label="Zuordnungen",
|
label="Zuordnungen",
|
||||||
icon="link",
|
icon="link",
|
||||||
endpoint="fileshare.mappings",
|
endpoint="fileshare.mappings",
|
||||||
permissions=("fileshare.edit",),
|
permissions=("fileshare.write",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -129,28 +131,28 @@ NAVIGATION = NavTree(
|
|||||||
label="Laufend",
|
label="Laufend",
|
||||||
icon="terminal",
|
icon="terminal",
|
||||||
endpoint="logs.live",
|
endpoint="logs.live",
|
||||||
permissions=("logs_live.view",),
|
permissions=("logs_live.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_history",
|
key="logs_history",
|
||||||
label="Verlauf",
|
label="Verlauf",
|
||||||
icon="clock",
|
icon="clock",
|
||||||
endpoint="logs.history",
|
endpoint="logs.history",
|
||||||
permissions=("logs_history.view", "logs_history.export"),
|
permissions=("logs_history.read", "logs_history.export"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Audit",
|
label="Audit",
|
||||||
icon="history",
|
icon="history",
|
||||||
endpoint="logs.audit_view",
|
endpoint="logs.audit_view",
|
||||||
permissions=("logs_audit.view", "logs_audit.export"),
|
permissions=("logs_audit.read", "logs_audit.export"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_dhcp",
|
key="logs_dhcp",
|
||||||
label="DHCP",
|
label="DHCP",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="tesmlogs.dhcp",
|
endpoint="tesmlogs.dhcp",
|
||||||
permissions=("logs_dhcp.view",),
|
permissions=("logs_dhcp.read",),
|
||||||
condition=_module("dhcp"),
|
condition=_module("dhcp"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -158,7 +160,7 @@ NAVIGATION = NavTree(
|
|||||||
label="Neustarts",
|
label="Neustarts",
|
||||||
icon="power",
|
icon="power",
|
||||||
endpoint="tesmlogs.restarts",
|
endpoint="tesmlogs.restarts",
|
||||||
permissions=("devices.view",),
|
permissions=("devices.read",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -172,42 +174,42 @@ NAVIGATION = NavTree(
|
|||||||
label="Benutzer",
|
label="Benutzer",
|
||||||
icon="users",
|
icon="users",
|
||||||
endpoint="admin.users",
|
endpoint="admin.users",
|
||||||
permissions=("users.view",),
|
permissions=("users.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="groups",
|
key="groups",
|
||||||
label="Gruppen und Rechte",
|
label="Gruppen und Rechte",
|
||||||
icon="shield-users",
|
icon="shield-users",
|
||||||
endpoint="admin.groups",
|
endpoint="admin.groups",
|
||||||
permissions=("groups.view",),
|
permissions=("groups.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_system",
|
key="settings_system",
|
||||||
label="System",
|
label="System",
|
||||||
icon="sliders",
|
icon="sliders",
|
||||||
endpoint="settings.system",
|
endpoint="settings.system",
|
||||||
permissions=("settings_system.view",),
|
permissions=("settings_system.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_network",
|
key="settings_network",
|
||||||
label="Netzwerk",
|
label="Netzwerk",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="hostsettings.network",
|
endpoint="hostsettings.network",
|
||||||
permissions=("settings_network.view",),
|
permissions=("settings_network.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_web",
|
key="settings_web",
|
||||||
label="Webserver und TLS",
|
label="Webserver und TLS",
|
||||||
icon="server",
|
icon="server",
|
||||||
endpoint="hostsettings.web",
|
endpoint="hostsettings.web",
|
||||||
permissions=("settings_web.view",),
|
permissions=("settings_web.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_directory",
|
key="settings_directory",
|
||||||
label="Verzeichnisdienst",
|
label="Verzeichnisdienst",
|
||||||
icon="users",
|
icon="users",
|
||||||
endpoint="hostsettings.directory_settings",
|
endpoint="hostsettings.directory_settings",
|
||||||
permissions=("settings_directory.view",),
|
permissions=("settings_directory.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_license",
|
key="settings_license",
|
||||||
@@ -216,28 +218,28 @@ NAVIGATION = NavTree(
|
|||||||
endpoint="license.overview",
|
endpoint="license.overview",
|
||||||
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
|
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
|
||||||
# sonst liesse sich eine neue Lizenz nie einspielen.
|
# sonst liesse sich eine neue Lizenz nie einspielen.
|
||||||
permissions=("settings_license.view",),
|
permissions=("settings_license.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="backup",
|
key="backup",
|
||||||
label="Sicherung",
|
label="Sicherung",
|
||||||
icon="transfer",
|
icon="transfer",
|
||||||
endpoint="backup.overview",
|
endpoint="backup.overview",
|
||||||
permissions=("backup.view", "backup.export", "backup.execute"),
|
permissions=("backup_export.read", "backup_import.read"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="trash",
|
key="trash",
|
||||||
label="Papierkorb",
|
label="Papierkorb",
|
||||||
icon="trash",
|
icon="trash",
|
||||||
endpoint="trash.overview",
|
endpoint="trash.overview",
|
||||||
permissions=("trash.view",),
|
permissions=("trash.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
icon="info",
|
icon="info",
|
||||||
endpoint="diagnostics.overview",
|
endpoint="diagnostics.overview",
|
||||||
permissions=("diagnostics.view",),
|
permissions=("diagnostics.read",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,67 +30,219 @@ A = Action
|
|||||||
DEVICES = Resource(
|
DEVICES = Resource(
|
||||||
key="devices",
|
key="devices",
|
||||||
label="Clients",
|
label="Clients",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Überwachte PoE-Endgeräte",
|
hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
|
||||||
action_hints={
|
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.",
|
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(
|
SWITCHES = Resource(
|
||||||
key="switches",
|
key="switches",
|
||||||
label="Switche",
|
label="Switche",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Aruba-Switche und ihre Erreichbarkeit",
|
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={
|
action_hints={
|
||||||
A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. "
|
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||||
"Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird "
|
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||||
"vollständig protokolliert.",
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 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(
|
CREDENTIALS = Resource(
|
||||||
key="credentials",
|
key="credentials",
|
||||||
label="Zugangsdaten",
|
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",
|
hint="Wiederverwendbare SSH-Logins",
|
||||||
action_hints={
|
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 "
|
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
||||||
"Passwortbestätigung und wird protokolliert.",
|
"Passwortbestätigung und wird protokolliert.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
MAINTENANCE = Resource(
|
#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
|
||||||
key="maintenance",
|
#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
|
||||||
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
|
#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
|
||||||
label="Aktualisierung und Neustart",
|
#: Clients steht, wo sie hingehoert.
|
||||||
actions=(A.VIEW, A.EXECUTE),
|
SERVICING_UPDATE = Resource(
|
||||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
key="servicing_update",
|
||||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."},
|
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(
|
SERVICING_REBOOT = Resource(
|
||||||
key="dhcp",
|
key="servicing_reboot",
|
||||||
label="DHCP-Server",
|
label="Neustart als Auftrag",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.EXECUTE),
|
||||||
hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
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={
|
action_hints={
|
||||||
A.EDIT: "Konfiguration ändern und schreiben.",
|
A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
|
||||||
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
|
"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(
|
FILESHARE = Resource(
|
||||||
key="fileshare",
|
key="fileshare",
|
||||||
label="Dateifreigaben",
|
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)",
|
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
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
|
# Genau die Bedeutung aus der Legende: Daten aus dem System
|
||||||
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
|
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
|
||||||
# bleibt im Haus, eine heruntergeladene Datei nicht.
|
# bleibt im Haus, eine heruntergeladene Datei nicht.
|
||||||
@@ -109,20 +261,47 @@ FILESHARE = Resource(
|
|||||||
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
|
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
|
||||||
FILESHARE_PDF = Resource(
|
FILESHARE_PDF = Resource(
|
||||||
key="fileshare_pdf",
|
key="fileshare_pdf",
|
||||||
|
parent="fileshare",
|
||||||
label="PDF-Formulare",
|
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",
|
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
|
||||||
action_hints={
|
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.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(
|
LOGS_DHCP = Resource(
|
||||||
key="logs_dhcp",
|
key="logs_dhcp",
|
||||||
label="DHCP-Protokoll",
|
label="DHCP-Protokoll",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
)
|
)
|
||||||
|
|
||||||
MONITORING_AREA = Area(
|
MONITORING_AREA = Area(
|
||||||
@@ -131,7 +310,17 @@ MONITORING_AREA = Area(
|
|||||||
icon="cpu",
|
icon="cpu",
|
||||||
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||||
"gesamte Bereich verborgen.",
|
"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
|
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
|
||||||
@@ -144,21 +333,21 @@ MAINTENANCE_AREA = Area(
|
|||||||
icon="wrench",
|
icon="wrench",
|
||||||
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||||
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||||
resources=(MAINTENANCE,),
|
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
|
||||||
)
|
)
|
||||||
|
|
||||||
NETWORK_AREA = Area(
|
NETWORK_AREA = Area(
|
||||||
key="network",
|
key="network",
|
||||||
label="Netzwerkdienste",
|
label="Netzwerkdienste",
|
||||||
icon="network",
|
icon="network",
|
||||||
resources=(DHCP,),
|
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
|
||||||
)
|
)
|
||||||
|
|
||||||
FILES_AREA = Area(
|
FILES_AREA = Area(
|
||||||
key="files",
|
key="files",
|
||||||
label="Dateifreigaben",
|
label="Dateifreigaben",
|
||||||
icon="folder",
|
icon="folder",
|
||||||
resources=(FILESHARE, FILESHARE_PDF),
|
resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
|
||||||
)
|
)
|
||||||
|
|
||||||
PERMISSIONS = PermissionTree(
|
PERMISSIONS = PermissionTree(
|
||||||
@@ -173,72 +362,53 @@ 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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
|
||||||
|
#:
|
||||||
|
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
|
||||||
|
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
|
||||||
|
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
|
||||||
|
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
|
||||||
|
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
|
||||||
|
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
|
||||||
|
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
|
||||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="viewer",
|
key="viewer",
|
||||||
name="Benutzer",
|
name="Benutzer",
|
||||||
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
|
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
|
||||||
grants=(
|
grants=(
|
||||||
("devices", (A.VIEW,)),
|
("devices", (A.READ,)),
|
||||||
("switches", (A.VIEW,)),
|
("switches", (A.READ,)),
|
||||||
("logs_live", (A.VIEW,)),
|
("logs_live", (A.READ,)),
|
||||||
),
|
),
|
||||||
is_default=True,
|
is_default=True,
|
||||||
),
|
),
|
||||||
RolePreset(
|
|
||||||
key="operations",
|
|
||||||
name="Betrieb",
|
|
||||||
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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="support",
|
|
||||||
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,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="network",
|
|
||||||
name="Netzwerk",
|
|
||||||
description="DHCP und Host-Netzwerkeinstellungen.",
|
|
||||||
grants=(
|
|
||||||
("dhcp", "*"),
|
|
||||||
("settings_network", "*"),
|
|
||||||
("logs_dhcp", (A.VIEW,)),
|
|
||||||
("devices", (A.VIEW,)),
|
|
||||||
("switches", (A.VIEW,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="auditor",
|
|
||||||
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,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="administration",
|
key="administration",
|
||||||
name="Administration",
|
name="Administration",
|
||||||
@@ -247,16 +417,25 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
|||||||
("users", "*"),
|
("users", "*"),
|
||||||
("groups", "*"),
|
("groups", "*"),
|
||||||
("settings_system", "*"),
|
("settings_system", "*"),
|
||||||
|
("settings_nav", "*"),
|
||||||
("settings_license", "*"),
|
("settings_license", "*"),
|
||||||
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
|
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
|
||||||
("settings_web", "*"),
|
("settings_web", "*"),
|
||||||
("settings_network", "*"),
|
("settings_network", "*"),
|
||||||
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
|
("backup_export", (A.READ, A.EXPORT)),
|
||||||
|
("backup_import", "*"),
|
||||||
("trash", "*"),
|
("trash", "*"),
|
||||||
("diagnostics", (A.VIEW,)),
|
("diagnostics", (A.READ,)),
|
||||||
("logs_audit", (A.VIEW,)),
|
("logs_audit", (A.READ,)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
FUNKTIONEN_VOLLZUGRIFF,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"]
|
__all__ = [
|
||||||
|
"PERMISSIONS",
|
||||||
|
"ROLE_PRESETS",
|
||||||
|
"LOGS_DHCP",
|
||||||
|
"SERVICING_PAGE",
|
||||||
|
"DHCP_PAGE",
|
||||||
|
]
|
||||||
+163
-10
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from tesm_core.migrations import Migration
|
from tesm_core.migrations import Migration
|
||||||
|
|
||||||
V1 = """
|
V1 = """
|
||||||
@@ -257,19 +259,19 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
|
|||||||
V4 = """
|
V4 = """
|
||||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
SELECT group_id, 'fileshare.export' FROM group_permissions
|
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)
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
|
SELECT group_id, 'fileshare_pdf.read' FROM group_permissions
|
||||||
WHERE permission = 'fileshare.view';
|
WHERE permission = 'fileshare.read';
|
||||||
|
|
||||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
|
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
|
||||||
WHERE permission = 'fileshare.create';
|
WHERE permission = 'fileshare.create';
|
||||||
|
|
||||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
|
SELECT group_id, 'fileshare_pdf.write' FROM group_permissions
|
||||||
WHERE permission = 'fileshare.edit';
|
WHERE permission = 'fileshare.write';
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -297,13 +299,21 @@ DELETE FROM settings WHERE key = 'terminal_enabled';
|
|||||||
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
|
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
|
||||||
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
|
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
|
||||||
# darf Zugang erhalten, aber keinen neuen schaffen.
|
# 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 = """
|
V6 = """
|
||||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
SELECT DISTINCT group_id, 'servicing.view'
|
SELECT DISTINCT group_id, 'servicing.read'
|
||||||
FROM group_permissions
|
FROM group_permissions
|
||||||
WHERE permission IN ('maintenance.view', 'maintenance.execute')
|
WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%')
|
||||||
AND group_id IN (
|
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 +381,155 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
|
||||||
|
#:
|
||||||
|
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
|
||||||
|
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
|
||||||
|
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
|
||||||
|
#: unveraendert die ausgelieferte ist.
|
||||||
|
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
|
||||||
|
(
|
||||||
|
"operations",
|
||||||
|
"Betrieb",
|
||||||
|
(
|
||||||
|
("devices", "*"),
|
||||||
|
("device_restart_poe", "*"),
|
||||||
|
("device_restart_ssh", "*"),
|
||||||
|
("device_restart_rpc", "*"),
|
||||||
|
("device_console", "*"),
|
||||||
|
("switches", ("read", "create", "write")),
|
||||||
|
("switch_console", "*"),
|
||||||
|
("hostkeys", "*"),
|
||||||
|
("credentials", ("read",)),
|
||||||
|
("servicing_update", "*"),
|
||||||
|
("servicing_reboot", "*"),
|
||||||
|
("servicing_tool", "*"),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_history", ("read",)),
|
||||||
|
("trash", ("read", "write")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"support",
|
||||||
|
"Support",
|
||||||
|
(
|
||||||
|
("devices", ("read",)),
|
||||||
|
("device_restart_poe", "*"),
|
||||||
|
("switches", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"network",
|
||||||
|
"Netzwerk",
|
||||||
|
(
|
||||||
|
("dhcp_subnets", "*"),
|
||||||
|
("dhcp_options", "*"),
|
||||||
|
("dhcp_reservations", "*"),
|
||||||
|
("dhcp_config", "*"),
|
||||||
|
("dhcp_service", "*"),
|
||||||
|
("settings_network", "*"),
|
||||||
|
("logs_dhcp", ("read",)),
|
||||||
|
("devices", ("read",)),
|
||||||
|
("switches", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"auditor",
|
||||||
|
"Revision",
|
||||||
|
(
|
||||||
|
("devices", ("read",)),
|
||||||
|
("switches", ("read",)),
|
||||||
|
("credentials", ("read",)),
|
||||||
|
("dhcp_subnets", ("read",)),
|
||||||
|
("dhcp_options", ("read",)),
|
||||||
|
("dhcp_reservations", ("read",)),
|
||||||
|
("users", ("read",)),
|
||||||
|
("groups", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_history", ("read", "export")),
|
||||||
|
("logs_audit", ("read", "export")),
|
||||||
|
("diagnostics", ("read",)),
|
||||||
|
("trash", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def eingefrorene_vorlagen(
|
||||||
|
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
|
||||||
|
|
||||||
|
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
|
||||||
|
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac.model import Action, RolePreset
|
||||||
|
|
||||||
|
vorlagen = []
|
||||||
|
for key, name, grants in beschreibung:
|
||||||
|
gewandelt = tuple(
|
||||||
|
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
|
||||||
|
for ressource, aktionen in grants
|
||||||
|
)
|
||||||
|
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
|
||||||
|
return vorlagen
|
||||||
|
|
||||||
|
|
||||||
|
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||||
|
|
||||||
|
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
|
||||||
|
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
|
||||||
|
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
|
||||||
|
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
|
||||||
|
"Administration" und "Funktionen (Vollzugriff)".
|
||||||
|
|
||||||
|
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
|
||||||
|
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
|
||||||
|
Weggeraeumte liegt nur im Papierkorb.
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac import service as rbac
|
||||||
|
|
||||||
|
from .permissions import PERMISSIONS
|
||||||
|
|
||||||
|
rbac.unberuehrte_vorlagen_entfernen(
|
||||||
|
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1),
|
Migration(version=1, name="baseline", sql=V1),
|
||||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||||
Migration(version=3, name="restart-events", sql=V3),
|
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=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=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||||
|
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||||
|
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||||
)
|
)
|
||||||
@@ -696,7 +696,10 @@ def signaturdetail_lesen(detail: str) -> dict[str, Any]:
|
|||||||
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
|
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
|
||||||
alter Eintraege und wenn das Verzeichnis nichts weiss.
|
alter Eintraege und wenn das Verzeichnis nichts weiss.
|
||||||
"""
|
"""
|
||||||
|
# Der Trenner ist heute ein Zeilenumbruch, frueher ein Leerzeichen -- die
|
||||||
|
# Zerlegung muss beides vertragen, sonst sind alte Eintraege stumm.
|
||||||
ort, _, rest = detail.partition("sha256=")
|
ort, _, rest = detail.partition("sha256=")
|
||||||
|
ort = ort.strip()
|
||||||
label, _, pfad = ort.partition(":")
|
label, _, pfad = ort.partition(":")
|
||||||
summe = rest.split(" ", 1)[0].strip() if rest else ""
|
summe = rest.split(" ", 1)[0].strip() if rest else ""
|
||||||
return {
|
return {
|
||||||
@@ -892,6 +895,31 @@ def _namen_aus_detail(detail: str) -> list[str]:
|
|||||||
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
#: Mehr als das zaehlt niemand mehr mit dem Auge -- und auf einer
|
||||||
|
#: Netzwerkfreigabe kostet jeder weitere Eintrag Zeit.
|
||||||
|
ZAEHLGRENZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _inhalt_zaehlen(verzeichnis: Path) -> int:
|
||||||
|
"""Wie viele Eintraege liegen im Ordner? Begrenzt gezaehlt.
|
||||||
|
|
||||||
|
Gezeigt wird die Zahl an der Stelle, an der bei einer Datei die Groesse
|
||||||
|
steht -- dort stand bisher ein Gedankenstrich. Bei mehr als
|
||||||
|
``ZAEHLGRENZE`` bricht das Zaehlen ab: die Oberflaeche schreibt dann
|
||||||
|
"500+", und niemand wartet auf eine Freigabe mit 40.000 Dateien.
|
||||||
|
"""
|
||||||
|
anzahl = 0
|
||||||
|
try:
|
||||||
|
with os.scandir(verzeichnis) as eintraege:
|
||||||
|
for _ in eintraege:
|
||||||
|
anzahl += 1
|
||||||
|
if anzahl >= ZAEHLGRENZE:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
return anzahl
|
||||||
|
|
||||||
|
|
||||||
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
@@ -913,6 +941,7 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
|
record["entries"] = _inhalt_zaehlen(entry)
|
||||||
directories.append(record)
|
directories.append(record)
|
||||||
else:
|
else:
|
||||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||||
|
|||||||
@@ -677,6 +677,12 @@ def update_device(
|
|||||||
("Port", str(row["port"] or ""), clean_port),
|
("Port", str(row["port"] or ""), clean_port),
|
||||||
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
||||||
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
||||||
|
# Diese vier fehlten: eine geaenderte Kategorie oder ein anderer
|
||||||
|
# SSH-Port stand nirgends, obwohl beides das Verhalten aendert.
|
||||||
|
("SSH-Port", str(row["ssh_port"] or ""), str(validate_ssh_port(ssh_port) or "")),
|
||||||
|
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
|
||||||
|
("Kategorie", str(row["category"] or ""), (category or "").strip()),
|
||||||
|
("Standort", str(row["location"] or ""), (location or "").strip()),
|
||||||
):
|
):
|
||||||
if before != after:
|
if before != after:
|
||||||
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
||||||
|
|||||||
@@ -16,10 +16,20 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
|
|||||||
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
||||||
die niemand pflegt.
|
die niemand pflegt.
|
||||||
|
|
||||||
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
|
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
|
||||||
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
|
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
|
||||||
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
|
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
|
||||||
ist.
|
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
|
||||||
|
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
|
||||||
|
was von Hand nachzuziehen ist.
|
||||||
|
|
||||||
|
Die Freigaben standen bis 2.8.0 auf der Liste "von Hand nachtragen", obwohl es
|
||||||
|
dafuer keinen Grund gab: der Vorgaenger fuehrt sie in
|
||||||
|
``ldap_fileshare_mappings`` mit denselben vier Angaben, die diese Anwendung in
|
||||||
|
``fileshare_mappings`` braucht (Verzeichnisgruppe, deren Name, Beschriftung,
|
||||||
|
UNC-Pfad), und einen Import dafuer gibt es hier ohnehin. Was sich uebernehmen
|
||||||
|
laesst, soll man nicht abtippen muessen -- ein UNC-Pfad von Hand ist ein
|
||||||
|
Tippfehler, der erst auffaellt, wenn jemand die Freigabe braucht.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,7 +46,7 @@ LEGACY_KDF_ITERATIONS = 390000
|
|||||||
LEGACY_APP_TAG = "tesm"
|
LEGACY_APP_TAG = "tesm"
|
||||||
|
|
||||||
#: Diese Abschnitte werden uebersetzt.
|
#: Diese Abschnitte werden uebersetzt.
|
||||||
SUPPORTED = ("credentials", "switches", "devices", "dhcp")
|
SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
|
||||||
|
|
||||||
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
||||||
KNOWN_UNSUPPORTED: dict[str, str] = {
|
KNOWN_UNSUPPORTED: dict[str, str] = {
|
||||||
@@ -46,10 +56,9 @@ KNOWN_UNSUPPORTED: dict[str, str] = {
|
|||||||
"Zweck. Konten neu anlegen."
|
"Zweck. Konten neu anlegen."
|
||||||
),
|
),
|
||||||
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||||
"ldap": (
|
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
|
||||||
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
|
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
|
||||||
"Hand übertragen."
|
# pauschale Zeile hier es koennte.
|
||||||
),
|
|
||||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||||
"known_hosts": (
|
"known_hosts": (
|
||||||
@@ -272,6 +281,168 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
|
||||||
|
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
|
||||||
|
|
||||||
|
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
|
||||||
|
|
||||||
|
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
|
||||||
|
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
|
||||||
|
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
|
||||||
|
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
|
||||||
|
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
|
||||||
|
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
|
||||||
|
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
|
||||||
|
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
|
||||||
|
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
|
||||||
|
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
|
||||||
|
Waren sie gesetzt, steht es im Bericht.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
|
||||||
|
ergebnis: dict[str, Any] = {}
|
||||||
|
einstellungen: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def ja(schluessel: str) -> bool:
|
||||||
|
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
server = str(alt.get("ldap_server") or "").strip()
|
||||||
|
if server:
|
||||||
|
einstellungen["ldap_host"] = server
|
||||||
|
if str(alt.get("ldap_port") or "").strip().isdigit():
|
||||||
|
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
|
||||||
|
if "ldap_enabled" in alt:
|
||||||
|
einstellungen["ldap_enabled"] = ja("ldap_enabled")
|
||||||
|
if str(alt.get("ldap_base_dn") or "").strip():
|
||||||
|
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
|
||||||
|
if str(alt.get("ldap_user_filter_attr") or "").strip():
|
||||||
|
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
|
||||||
|
|
||||||
|
if "ldap_use_ssl" in alt:
|
||||||
|
if ja("ldap_use_ssl"):
|
||||||
|
einstellungen["ldap_security"] = "ldaps"
|
||||||
|
else:
|
||||||
|
einstellungen["ldap_security"] = "starttls"
|
||||||
|
report.note(
|
||||||
|
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
|
||||||
|
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
|
||||||
|
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
|
||||||
|
"Verbindungstest ausführen."
|
||||||
|
)
|
||||||
|
if ja("ldap_tls_skip_verify"):
|
||||||
|
einstellungen["ldap_verify_cert"] = False
|
||||||
|
report.note(
|
||||||
|
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
|
||||||
|
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
|
||||||
|
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
|
||||||
|
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
|
||||||
|
)
|
||||||
|
if einstellungen:
|
||||||
|
ergebnis["settings"] = einstellungen
|
||||||
|
|
||||||
|
for schluessel, text in (
|
||||||
|
(
|
||||||
|
"ldap_default_group",
|
||||||
|
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
|
||||||
|
"(Verwaltung -> Gruppen). Bitte dort setzen.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ldap_required_login_group",
|
||||||
|
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
|
||||||
|
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
wert = str(alt.get(schluessel) or "").strip()
|
||||||
|
if wert:
|
||||||
|
report.note(text.format(wert=wert))
|
||||||
|
|
||||||
|
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
|
||||||
|
passwort = str(data.get("bind_password") or "")
|
||||||
|
if benutzer:
|
||||||
|
ergebnis["bind_username"] = benutzer
|
||||||
|
ergebnis["bind_password"] = passwort
|
||||||
|
if not passwort:
|
||||||
|
report.note(
|
||||||
|
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
|
||||||
|
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
|
||||||
|
)
|
||||||
|
|
||||||
|
zuordnungen: list[dict[str, Any]] = []
|
||||||
|
offen: list[str] = []
|
||||||
|
for eintrag in _liste(data.get("group_mappings")):
|
||||||
|
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not dn:
|
||||||
|
continue
|
||||||
|
name = str(eintrag.get("ad_group_name") or "").strip() or dn
|
||||||
|
ziel = str(
|
||||||
|
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
|
||||||
|
).strip()
|
||||||
|
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
|
||||||
|
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
|
||||||
|
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
|
||||||
|
if ziel.lower() == "admin":
|
||||||
|
zuordnungen.append(
|
||||||
|
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if ziel:
|
||||||
|
offen.append(f"{name} -> {ziel}")
|
||||||
|
if zuordnungen:
|
||||||
|
ergebnis["group_mappings"] = zuordnungen
|
||||||
|
if offen:
|
||||||
|
report.note(
|
||||||
|
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
|
||||||
|
"werden (die Rechteschlüssel sind neu geschnitten): "
|
||||||
|
+ ", ".join(sorted(offen))
|
||||||
|
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
|
||||||
|
"nachtragen."
|
||||||
|
)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
|
||||||
|
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
|
||||||
|
|
||||||
|
Der Vorgaenger legt sie unter ``ldap.fileshare_mappings`` ab; beim Lesen
|
||||||
|
einer Installation stehen sie unmittelbar in der Tabelle. Beides landet
|
||||||
|
hier, damit es nur eine Stelle gibt, die die Felder umbenennt.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
eintraege = _liste(data.get("fileshare_mappings"))
|
||||||
|
else:
|
||||||
|
eintraege = _liste(data)
|
||||||
|
ergebnis: list[dict[str, Any]] = []
|
||||||
|
ohne_pfad = 0
|
||||||
|
for eintrag in eintraege:
|
||||||
|
# "share_label"/"share_unc" heissen hier "label"/"unc" -- dieselbe
|
||||||
|
# Angabe, kuerzerer Name, weil die Tabelle schon "fileshare" heisst.
|
||||||
|
unc = str(eintrag.get("share_unc") or eintrag.get("unc") or "").strip()
|
||||||
|
gruppe = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not unc or not gruppe:
|
||||||
|
ohne_pfad += 1
|
||||||
|
continue
|
||||||
|
beschriftung = str(eintrag.get("share_label") or eintrag.get("label") or "").strip()
|
||||||
|
ergebnis.append(
|
||||||
|
{
|
||||||
|
"ad_group_dn": gruppe,
|
||||||
|
"ad_group_name": str(eintrag.get("ad_group_name") or "").strip() or gruppe,
|
||||||
|
# Ohne Beschriftung der letzte Teil des Pfades -- besser als
|
||||||
|
# eine Freigabe ohne Namen. Beide Trenner: der Vorgaenger
|
||||||
|
# speichert "//server/share", getippt wird "\server\share".
|
||||||
|
"label": beschriftung
|
||||||
|
or unc.replace(chr(92), "/").rstrip("/").rsplit("/", 1)[-1],
|
||||||
|
"unc": unc,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if ohne_pfad:
|
||||||
|
report.note(
|
||||||
|
f"{ohne_pfad} Freigabe-Zuordnung(en) ohne Verzeichnisgruppe oder UNC-Pfad "
|
||||||
|
"übersprungen."
|
||||||
|
)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
||||||
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -382,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
if devices:
|
if devices:
|
||||||
payload["devices"] = devices
|
payload["devices"] = devices
|
||||||
|
|
||||||
|
freigaben = _freigaben(legacy.get("ldap") or legacy.get("fileshare"), report)
|
||||||
|
if freigaben:
|
||||||
|
payload["fileshare"] = freigaben
|
||||||
|
|
||||||
|
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
|
||||||
|
if verzeichnis:
|
||||||
|
payload["directory"] = verzeichnis
|
||||||
|
|
||||||
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
||||||
if dhcp:
|
if dhcp:
|
||||||
payload["dhcp"] = dhcp
|
payload["dhcp"] = dhcp
|
||||||
@@ -417,8 +596,8 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
"Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
|
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
|
||||||
"Beim Export im alten TESM diese Kategorien mitwählen."
|
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
|
||||||
)
|
)
|
||||||
return payload, report
|
return payload, report
|
||||||
|
|
||||||
@@ -617,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
|||||||
continue
|
continue
|
||||||
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||||
|
|
||||||
|
entschluesseln_bind = _entschluessler(root, report)
|
||||||
if "credentials" in vorhanden:
|
if "credentials" in vorhanden:
|
||||||
entschluesseln = _entschluessler(root, report)
|
entschluesseln = entschluesseln_bind
|
||||||
payload["credentials"] = [
|
payload["credentials"] = [
|
||||||
{
|
{
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
@@ -634,6 +814,80 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
|||||||
else:
|
else:
|
||||||
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||||
|
|
||||||
|
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
|
||||||
|
# die Uebersetzung beide Wege gleich liest.
|
||||||
|
ldap: dict[str, Any] = {}
|
||||||
|
if "ldap_fileshare_mappings" in vorhanden:
|
||||||
|
freigaben = [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT ad_group_dn, ad_group_name, share_label, share_unc "
|
||||||
|
"FROM ldap_fileshare_mappings"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if freigaben:
|
||||||
|
ldap["fileshare_mappings"] = freigaben
|
||||||
|
else:
|
||||||
|
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
|
||||||
|
|
||||||
|
if "settings" in vorhanden:
|
||||||
|
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
|
||||||
|
# Bericht wie eine Uebernahme aus und ist keine.
|
||||||
|
ldap_einstellungen = {
|
||||||
|
str(row["key"]): str(row["value"])
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if ldap_einstellungen:
|
||||||
|
ldap["settings"] = ldap_einstellungen
|
||||||
|
|
||||||
|
if "ldap_group_mappings" in vorhanden:
|
||||||
|
# Der Sonderwert "admin" steht in derselben Spalte wie eine
|
||||||
|
# Gruppen-Id; der Name dahinter ist das, was ueber die
|
||||||
|
# Systemgrenze traegt.
|
||||||
|
gruppen_da = "groups" in vorhanden
|
||||||
|
zuordnungen = [
|
||||||
|
{
|
||||||
|
"ad_group_dn": str(row["ad_group_dn"]),
|
||||||
|
"ad_group_name": str(row["ad_group_name"]),
|
||||||
|
"app_group_name": str(row["ziel"] or ""),
|
||||||
|
}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
|
||||||
|
" WHEN m.app_group_id = 'admin' THEN 'admin' "
|
||||||
|
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
|
||||||
|
+ "END AS ziel FROM ldap_group_mappings m"
|
||||||
|
+ (
|
||||||
|
" LEFT JOIN groups g ON g.id = m.app_group_id"
|
||||||
|
if gruppen_da
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if zuordnungen:
|
||||||
|
ldap["group_mappings"] = zuordnungen
|
||||||
|
|
||||||
|
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
|
||||||
|
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
|
||||||
|
if "service_accounts" in vorhanden:
|
||||||
|
konto = _eine(
|
||||||
|
conn,
|
||||||
|
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
|
||||||
|
)
|
||||||
|
if konto is not None:
|
||||||
|
ldap["bind_username"] = str(konto["username"] or "")
|
||||||
|
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
|
||||||
|
if not ldap.get("bind_username"):
|
||||||
|
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
|
||||||
|
if aus_einstellungen:
|
||||||
|
ldap["bind_username"] = str(aus_einstellungen)
|
||||||
|
ldap["bind_password"] = entschluesseln_bind(
|
||||||
|
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
|
||||||
|
)
|
||||||
|
if ldap:
|
||||||
|
payload["ldap"] = ldap
|
||||||
|
|
||||||
dhcp = _alt_dhcp(conn, vorhanden)
|
dhcp = _alt_dhcp(conn, vorhanden)
|
||||||
if dhcp:
|
if dhcp:
|
||||||
payload["dhcp"] = dhcp
|
payload["dhcp"] = dhcp
|
||||||
@@ -644,9 +898,16 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
|||||||
if not payload:
|
if not payload:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
|
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
|
||||||
"Zugangsdaten, DHCP)."
|
"Zugangsdaten, DHCP, Freigaben)."
|
||||||
)
|
)
|
||||||
for abschnitt, inhalt in payload.items():
|
for abschnitt, inhalt in payload.items():
|
||||||
|
if abschnitt == "ldap" and isinstance(inhalt, dict):
|
||||||
|
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
|
||||||
|
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
|
||||||
|
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
|
||||||
|
if inhalt.get("bind_username"):
|
||||||
|
report.took("Bind-Konto", 1)
|
||||||
|
continue
|
||||||
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||||
return payload, report
|
return payload, report
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
METHOD_POE = "poe"
|
METHOD_POE = "poe"
|
||||||
METHOD_SSH = "ssh"
|
METHOD_SSH = "ssh"
|
||||||
@@ -40,6 +40,16 @@ class Method:
|
|||||||
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
|
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
|
||||||
licensed: bool = False
|
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
|
@property
|
||||||
def is_poe(self) -> bool:
|
def is_poe(self) -> bool:
|
||||||
return self.key == METHOD_POE
|
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.
|
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
|
||||||
|
|
||||||
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
|
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
|
||||||
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
|
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
|
||||||
schlimmer als kein Knopf.
|
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:
|
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
|
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
|
||||||
# wieder ein, statt ein laufendes System neu zu starten.
|
# wieder ein, statt ein laufendes System neu zu starten.
|
||||||
if str(field("state") or "") == "offline":
|
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 "")
|
category = str(field("credential_category") or "")
|
||||||
has_login = bool(field("credential_id"))
|
has_login = bool(field("credential_id"))
|
||||||
@@ -112,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
|||||||
elif category == "linux":
|
elif category == "linux":
|
||||||
methods.append(DEFINITIONS[METHOD_SSH])
|
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))
|
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."""
|
"""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
|
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.
|
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
|
||||||
|
|
||||||
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
|
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
|
||||||
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
|
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
|
||||||
sonst startet jemand ein Geraet anders neu, als er dachte.
|
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:
|
if not options:
|
||||||
return None
|
return None
|
||||||
if not requested:
|
if not requested:
|
||||||
|
|||||||
@@ -30,14 +30,37 @@ from .ssh import SshError, open_transport
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Steuerzeichen eines Terminals -- ANSI-Sequenzen, Glocke, Einzelbefehle.
|
||||||
|
#:
|
||||||
|
#: Ein ArubaOS-Switch an einem Pseudoterminal zeichnet seine Zeile neu und
|
||||||
|
#: haengt dabei Sequenzen wie ``ESC [ K`` ("bis Zeilenende loeschen") an. Im
|
||||||
|
#: Terminal sieht man davon **nichts** -- und genau das war der Fehler, der
|
||||||
|
#: Konsole und PoE-Neustart lahmgelegt hat: der Prompt stand da, aber hinter
|
||||||
|
#: ihm standen drei unsichtbare Bytes, und das verankerte Muster traf nicht
|
||||||
|
#: mehr. Im Protokoll las man "kein Prompt", waehrend der Prompt im Text
|
||||||
|
#: stand. Deshalb kommen die Steuerzeichen weg, bevor irgendetwas gesucht
|
||||||
|
#: wird -- das macht zugleich die Ausgabe in der Konsole lesbar.
|
||||||
|
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|[\x07\x00]")
|
||||||
|
|
||||||
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
||||||
#: Konfigurationsmodus (``switch(config)#``).
|
#: Konfigurationsmodus (``switch(config)#``).
|
||||||
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$")
|
#:
|
||||||
|
#: Zwei Lockerungen gegenueber der ersten Fassung, beide aus dem Betrieb:
|
||||||
|
#: der Prompt darf am Anfang des Puffers stehen (nicht nur nach einem
|
||||||
|
#: Zeilenumbruch -- so kommt er nach einem geleerten Puffer), und hinter ihm
|
||||||
|
#: darf beliebiger Leerraum stehen (nicht genau eines).
|
||||||
|
PROMPT_RE = re.compile(rb"(?:^|[\r\n])[^\r\n]{0,120}?[>#]\s*$")
|
||||||
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
|
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
|
||||||
ERROR_RE = re.compile(
|
ERROR_RE = re.compile(
|
||||||
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
|
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
|
||||||
|
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
|
||||||
|
#: das bewaehrte Skript der Vorgaengerfassung.
|
||||||
|
ABMELDUNG = ("exit", "exit", "exit")
|
||||||
|
ABMELDE_PAUSE = 0.15
|
||||||
|
|
||||||
READ_CHUNK = 65536
|
READ_CHUNK = 65536
|
||||||
DEFAULT_COMMAND_TIMEOUT = 20
|
DEFAULT_COMMAND_TIMEOUT = 20
|
||||||
POE_SETTLE_SECONDS = 2
|
POE_SETTLE_SECONDS = 2
|
||||||
@@ -104,7 +127,42 @@ class SwitchCli:
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
for closer in (self._channel, self._transport):
|
"""Meldet sich ab und legt dann erst auf.
|
||||||
|
|
||||||
|
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
|
||||||
|
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
|
||||||
|
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
|
||||||
|
"Do you want to log out (y/n)?" ein ``y``.
|
||||||
|
|
||||||
|
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
|
||||||
|
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
|
||||||
|
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
|
||||||
|
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
|
||||||
|
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
|
||||||
|
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
|
||||||
|
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
|
||||||
|
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
|
||||||
|
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
|
||||||
|
|
||||||
|
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
|
||||||
|
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
|
||||||
|
"""
|
||||||
|
kanal = self._channel
|
||||||
|
if kanal is not None:
|
||||||
|
try:
|
||||||
|
for zeile in ABMELDUNG:
|
||||||
|
kanal.send(zeile + "\r\n")
|
||||||
|
# Kurz warten, damit der Switch die Zeile verarbeitet --
|
||||||
|
# auf die Antwort zu warten wuerde beim Abmelden nur
|
||||||
|
# Zeitlimits sammeln.
|
||||||
|
time.sleep(ABMELDE_PAUSE)
|
||||||
|
# "Do you want to log out (y/n)?"
|
||||||
|
kanal.send("y\r\n")
|
||||||
|
time.sleep(ABMELDE_PAUSE)
|
||||||
|
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
|
||||||
|
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
|
||||||
|
|
||||||
|
for closer in (kanal, self._transport):
|
||||||
try:
|
try:
|
||||||
if closer is not None:
|
if closer is not None:
|
||||||
closer.close()
|
closer.close()
|
||||||
@@ -116,28 +174,62 @@ class SwitchCli:
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
|
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
|
||||||
|
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
|
||||||
|
|
||||||
|
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
|
||||||
|
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
|
||||||
|
eine leere Antwort und machte weiter: ``configure terminal`` ins
|
||||||
|
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
|
||||||
|
Zustandsabfrage dann "unknown" ergab und "unknown" als
|
||||||
|
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
|
||||||
|
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
|
||||||
|
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
|
||||||
|
"durchgefuehrt".
|
||||||
|
|
||||||
|
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
|
||||||
|
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
|
||||||
|
hereinkam, denn daran erkennt man, wo die Sitzung steht.
|
||||||
|
"""
|
||||||
assert self._channel is not None
|
assert self._channel is not None
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
collected = b""
|
# Zwei Puffer: das Rohe kommt herein, gesucht wird im Sichtbaren.
|
||||||
|
# Ohne diese Trennung entscheiden unsichtbare Steuerzeichen darueber,
|
||||||
|
# ob ein Prompt erkannt wird.
|
||||||
|
sichtbar = b""
|
||||||
|
prompt_gesehen = False
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if self._channel.recv_ready():
|
if self._channel.recv_ready():
|
||||||
chunk = self._channel.recv(READ_CHUNK)
|
chunk = self._channel.recv(READ_CHUNK)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
collected += chunk
|
sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
|
||||||
if PAGER_RE.search(collected[-200:]):
|
if PAGER_RE.search(sichtbar[-300:]):
|
||||||
self._channel.send(" ")
|
self._channel.send(" ")
|
||||||
collected = PAGER_RE.sub(b"", collected)
|
sichtbar = PAGER_RE.sub(b"", sichtbar)
|
||||||
continue
|
continue
|
||||||
if PROMPT_RE.search(collected[-200:]):
|
if PROMPT_RE.search(sichtbar[-300:]):
|
||||||
|
prompt_gesehen = True
|
||||||
break
|
break
|
||||||
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
||||||
if initial and collected.rstrip().endswith(b"continue"):
|
if initial and sichtbar.rstrip().endswith(b"continue"):
|
||||||
self._channel.send("\r\n")
|
self._channel.send("\r\n")
|
||||||
else:
|
else:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
text = collected.decode("utf-8", errors="replace")
|
text = sichtbar.decode("utf-8", errors="replace")
|
||||||
self.transcript.append(text)
|
self.transcript.append(text)
|
||||||
|
if not prompt_gesehen:
|
||||||
|
letzte = " ".join(text.split())[-200:]
|
||||||
|
if initial:
|
||||||
|
raise SshError(
|
||||||
|
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
|
||||||
|
f"(kein Prompt innerhalb von {timeout}s)."
|
||||||
|
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||||
|
)
|
||||||
|
raise SshError(
|
||||||
|
f"{self.host} hat auf das Kommando nicht geantwortet "
|
||||||
|
f"(kein Prompt innerhalb von {timeout}s)."
|
||||||
|
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||||
|
)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def send(
|
def send(
|
||||||
@@ -212,6 +304,17 @@ class SwitchCli:
|
|||||||
)
|
)
|
||||||
result.transcript = self.full_transcript
|
result.transcript = self.full_transcript
|
||||||
return result
|
return result
|
||||||
|
if before == "enabled" and off_state == "unknown":
|
||||||
|
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
|
||||||
|
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
|
||||||
|
# zu zaehlen war der Fehler, der einen Neustart meldete, den
|
||||||
|
# es nicht gab.
|
||||||
|
result.error = (
|
||||||
|
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
|
||||||
|
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
|
||||||
|
)
|
||||||
|
result.transcript = self.full_transcript
|
||||||
|
return result
|
||||||
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
|
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
|
||||||
|
|
||||||
self.set_poe(port, enabled=True)
|
self.set_poe(port, enabled=True)
|
||||||
@@ -224,6 +327,17 @@ class SwitchCli:
|
|||||||
result.transcript = self.full_transcript
|
result.transcript = self.full_transcript
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if before == "unknown" and off_state == "unknown" and after == "unknown":
|
||||||
|
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
|
||||||
|
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
|
||||||
|
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
|
||||||
|
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
|
||||||
|
# gesagt.
|
||||||
|
result.add(
|
||||||
|
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
|
||||||
|
"angenommen, bestätigt ist der Zyklus damit nicht."
|
||||||
|
)
|
||||||
|
|
||||||
result.ok = True
|
result.ok = True
|
||||||
except SshError as exc:
|
except SshError as exc:
|
||||||
result.error = str(exc)
|
result.error = str(exc)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable
|
|||||||
|
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.keystore import Keystore
|
from tesm_core.keystore import Keystore
|
||||||
|
from tesm_core.rbac.legacy_permissions import umgeschrieben
|
||||||
from tesm_core.transfer import (
|
from tesm_core.transfer import (
|
||||||
MIN_PASSPHRASE_LENGTH,
|
MIN_PASSPHRASE_LENGTH,
|
||||||
PREVIEW_TTL_MINUTES,
|
PREVIEW_TTL_MINUTES,
|
||||||
@@ -61,9 +62,24 @@ CATEGORIES: tuple[Category, ...] = (
|
|||||||
),
|
),
|
||||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||||
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
||||||
|
Category(
|
||||||
|
"directory",
|
||||||
|
"Verzeichnisdienst",
|
||||||
|
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
|
||||||
|
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
|
||||||
|
admin_only=True,
|
||||||
|
sensitive=True,
|
||||||
|
),
|
||||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
||||||
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
||||||
|
Category(
|
||||||
|
"signatures",
|
||||||
|
"Unterschriften",
|
||||||
|
"Pruefsummen unterschriebener Dokumente. Ohne sie sagt die Vorschau nach einer "
|
||||||
|
"Wiederherstellung zu jeder alten Fassung \"unbekannt\" – auch zu einer "
|
||||||
|
"veraenderten.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
||||||
@@ -178,6 +194,49 @@ def build_export(
|
|||||||
}
|
}
|
||||||
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
||||||
]
|
]
|
||||||
|
if "directory" in wanted:
|
||||||
|
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
|
||||||
|
# das man nach einer Wiederherstellung von Hand nachbauen musste --
|
||||||
|
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
|
||||||
|
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
|
||||||
|
from tesm_core.auth import directory as verzeichnis
|
||||||
|
|
||||||
|
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
|
||||||
|
gesetzt = {
|
||||||
|
str(row["key"]): str(row["value"])
|
||||||
|
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||||
|
}
|
||||||
|
payload["directory"] = {
|
||||||
|
# Nur die Schluessel der Anbindung, und nur die, die wirklich
|
||||||
|
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
|
||||||
|
# Entscheidung und ueberschreibt beim Einlesen eine echte.
|
||||||
|
"settings": {
|
||||||
|
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
|
||||||
|
if spec.key in gesetzt
|
||||||
|
},
|
||||||
|
"bind_username": bind_benutzer,
|
||||||
|
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
|
||||||
|
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
|
||||||
|
# muesste man die halbe Anbindung doch wieder tippen.
|
||||||
|
"bind_password": bind_passwort if include_secrets else "",
|
||||||
|
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
|
||||||
|
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
|
||||||
|
# Gruppen-Id nichts wert -- der Name ist es.
|
||||||
|
"group_mappings": [
|
||||||
|
{
|
||||||
|
"ad_group_dn": str(row["ad_group_dn"]),
|
||||||
|
"ad_group_name": str(row["ad_group_name"]),
|
||||||
|
"group_name": str(row["group_name"] or ""),
|
||||||
|
"grants_admin": bool(row["grants_admin"]),
|
||||||
|
}
|
||||||
|
for row in Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
|
||||||
|
"FROM ldap_group_mappings m "
|
||||||
|
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
payload["settings"] = {
|
payload["settings"] = {
|
||||||
str(row["key"]): str(row["value"])
|
str(row["key"]): str(row["value"])
|
||||||
@@ -186,6 +245,16 @@ def build_export(
|
|||||||
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
||||||
if not str(row["key"]).startswith("license_")
|
if not str(row["key"]).startswith("license_")
|
||||||
}
|
}
|
||||||
|
if "signatures" in wanted:
|
||||||
|
# Das Verzeichnis der Unterschriften. Der Beleg selbst bleibt das
|
||||||
|
# Aenderungsprotokoll (Hash-Kette) und das Journal neben dem Archiv --
|
||||||
|
# beides wandert nicht in die Sicherung. Ohne diese Zeilen waere nach
|
||||||
|
# einer Wiederherstellung jede unterschriebene Fassung "unbekannt".
|
||||||
|
payload["signatures"] = Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT sha256, share_label, path, signed_at, actor, names "
|
||||||
|
"FROM document_signatures ORDER BY id",
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -237,6 +306,9 @@ def apply_import(
|
|||||||
"""
|
"""
|
||||||
from .inventory import ValidationError, aad_for_credential, normalize_mac
|
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:
|
def mac_or_none(value: Any) -> str | None:
|
||||||
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
|
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
|
||||||
|
|
||||||
@@ -513,19 +585,33 @@ def apply_import(
|
|||||||
|
|
||||||
if "fileshare" in wanted:
|
if "fileshare" in wanted:
|
||||||
for entry in payload.get("fileshare", []):
|
for entry in payload.get("fileshare", []):
|
||||||
|
gruppe = str(entry.get("ad_group_dn") or "")
|
||||||
|
pfad = str(entry.get("unc") or "")
|
||||||
|
if not gruppe or not pfad:
|
||||||
|
bump("fileshare", "skipped")
|
||||||
|
continue
|
||||||
|
# Vorher nachsehen, damit der Bericht die Wahrheit sagt: bisher
|
||||||
|
# stand dort auch bei einer frischen Installation "2 aktualisiert",
|
||||||
|
# obwohl zwei Zuordnungen **angelegt** wurden. Wer einen Import
|
||||||
|
# prueft, liest genau diese Zahlen.
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? LIMIT 1",
|
||||||
|
(gruppe, pfad),
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
||||||
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
||||||
(
|
(
|
||||||
str(entry.get("ad_group_dn") or ""),
|
gruppe,
|
||||||
str(entry.get("ad_group_name") or ""),
|
str(entry.get("ad_group_name") or ""),
|
||||||
str(entry.get("label") or ""),
|
str(entry.get("label") or ""),
|
||||||
str(entry.get("unc") or ""),
|
pfad,
|
||||||
stamp,
|
stamp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
bump("fileshare", "updated")
|
bump("fileshare", "updated" if vorhanden else "created")
|
||||||
|
|
||||||
if "groups" in wanted:
|
if "groups" in wanted:
|
||||||
for entry in payload.get("groups", []):
|
for entry in payload.get("groups", []):
|
||||||
@@ -557,9 +643,21 @@ def apply_import(
|
|||||||
group_id = int(existing["id"])
|
group_id = int(existing["id"])
|
||||||
bump("groups", "updated")
|
bump("groups", "updated")
|
||||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
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(
|
conn.executemany(
|
||||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
"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:
|
if "users" in wanted:
|
||||||
@@ -625,6 +723,95 @@ def apply_import(
|
|||||||
(user_id, int(group_id)),
|
(user_id, int(group_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "signatures" in wanted:
|
||||||
|
for eintrag in payload.get("signatures") or []:
|
||||||
|
if not isinstance(eintrag, dict):
|
||||||
|
continue
|
||||||
|
summe = str(eintrag.get("sha256") or "").strip()
|
||||||
|
pfad = str(eintrag.get("path") or "").strip()
|
||||||
|
if not summe:
|
||||||
|
continue
|
||||||
|
# Zweimal einlesen darf nichts verdoppeln: verglichen wird ueber
|
||||||
|
# Pruefsumme und Ablageort, genau wie beim Journal.
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
|
||||||
|
(summe, pfad),
|
||||||
|
)
|
||||||
|
if vorhanden:
|
||||||
|
bump("signatures", "skipped")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
|
||||||
|
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
|
||||||
|
(
|
||||||
|
summe,
|
||||||
|
str(eintrag.get("share_label") or ""),
|
||||||
|
pfad,
|
||||||
|
str(eintrag.get("signed_at") or stamp),
|
||||||
|
str(eintrag.get("actor") or ""),
|
||||||
|
str(eintrag.get("names") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bump("signatures", "created")
|
||||||
|
|
||||||
|
if "directory" in wanted:
|
||||||
|
from tesm_core.auth import directory as verzeichnis
|
||||||
|
|
||||||
|
abschnitt = payload.get("directory") or {}
|
||||||
|
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
|
||||||
|
for key, value in (abschnitt.get("settings") or {}).items():
|
||||||
|
if str(key) not in bekannt:
|
||||||
|
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
|
||||||
|
# hin; die Kategorie "Einstellungen" nimmt alles andere.
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
set_setting(str(key), value)
|
||||||
|
bump("directory", "updated")
|
||||||
|
|
||||||
|
benutzer = str(abschnitt.get("bind_username") or "").strip()
|
||||||
|
passwort = str(abschnitt.get("bind_password") or "")
|
||||||
|
if benutzer:
|
||||||
|
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
|
||||||
|
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
|
||||||
|
# das Passwort nicht loeschen.
|
||||||
|
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
|
||||||
|
bump("directory", "updated")
|
||||||
|
|
||||||
|
for eintrag in abschnitt.get("group_mappings") or []:
|
||||||
|
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not dn:
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
kennzeichen = bool(eintrag.get("grants_admin"))
|
||||||
|
gruppe = None
|
||||||
|
if not kennzeichen:
|
||||||
|
name = str(eintrag.get("group_name") or "").strip()
|
||||||
|
gruppe = Database.value(
|
||||||
|
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
|
||||||
|
)
|
||||||
|
if gruppe is None:
|
||||||
|
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
|
||||||
|
# waere eine Zuordnung ins Leere: beim naechsten Login
|
||||||
|
# bekaeme das Konto nichts, und niemand wuesste warum.
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ldap_group_mappings "
|
||||||
|
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||||
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||||
|
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
|
||||||
|
"grants_admin=excluded.grants_admin",
|
||||||
|
(
|
||||||
|
dn,
|
||||||
|
str(eintrag.get("ad_group_name") or dn),
|
||||||
|
int(gruppe) if gruppe is not None else None,
|
||||||
|
int(kennzeichen),
|
||||||
|
stamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bump("directory", "created")
|
||||||
|
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
for key, value in (payload.get("settings") or {}).items():
|
for key, value in (payload.get("settings") or {}).items():
|
||||||
if str(key).startswith("license_"):
|
if str(key).startswith("license_"):
|
||||||
@@ -634,4 +821,17 @@ def apply_import(
|
|||||||
set_setting(str(key), value)
|
set_setting(str(key), value)
|
||||||
bump("settings", "updated")
|
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
|
return report
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
||||||
Details
|
Details
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.has('devices.execute') and tile.restart_methods %}
|
{% if tile.restart_methods %}
|
||||||
{% set method = tile.restart_methods[0] %}
|
{% set method = tile.restart_methods[0] %}
|
||||||
<form method="post" data-no-guard
|
<form method="post" data-no-guard
|
||||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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) }}"
|
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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>
|
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('credentials.edit') %}
|
{% if current_user.has('credentials.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
|
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
|
||||||
{{ icon('pencil', size=15) }}
|
{{ icon('pencil', size=15) }}
|
||||||
@@ -121,7 +121,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
|||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('credentials.edit') %}
|
{% if current_user.has('credentials.write') %}
|
||||||
{% for entry in credentials %}
|
{% for entry in credentials %}
|
||||||
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
|
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
|
||||||
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
|
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% 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) }}">
|
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
||||||
{{ icon('terminal', size=15) }} Konsole
|
{{ icon('terminal', size=15) }} Konsole
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('devices.execute') and restart_methods %}
|
{% if restart_methods %}
|
||||||
{% for method in restart_methods %}
|
{% for method in restart_methods %}
|
||||||
<form method="post" style="display:inline"
|
<form method="post" style="display:inline"
|
||||||
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
|
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.
|
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
|
||||||
</div>
|
</div>
|
||||||
</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) }}"
|
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{% set probed_type = request.args.get('probed_type') %}
|
{% set probed_type = request.args.get('probed_type') %}
|
||||||
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
|
{% 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">
|
<div class="alert alert--warning">
|
||||||
{{ icon('alert', size=18) }}
|
{{ icon('alert', size=18) }}
|
||||||
<div class="alert__body">
|
<div class="alert__body">
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
|
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dl>
|
</dl>
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
|
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
|
<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>
|
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
|
<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() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -213,10 +213,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<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) }}
|
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
|
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
|
||||||
{{ icon('pencil', size=15) }}
|
{{ icon('pencil', size=15) }}
|
||||||
@@ -263,7 +263,7 @@ nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
{% for device in devices %}
|
{% for device in devices %}
|
||||||
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
|
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
|
||||||
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
|
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat__label">Reservierungen</span>
|
<span class="stat__label">Reservierungen</span>
|
||||||
<span class="stat__value">{{ reservations | length }}</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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat__label">Leases</span>
|
<span class="stat__label">Leases</span>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if current_user.has('dhcp.execute') %}
|
{% if current_user.has('dhcp_service.execute') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<td class="mono fs-sm">{{ subnet.dns or '–' | safe }}</td>
|
<td class="mono fs-sm">{{ subnet.dns or '–' | safe }}</td>
|
||||||
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
|
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
|
||||||
<td class="actions">
|
<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) }}"
|
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
|
||||||
style="display:inline" data-no-guard>
|
style="display:inline" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
|
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_subnets.create') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
|
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# -- Globale Einstellungen --------------------------------------------- #}
|
{# -- Globale Einstellungen --------------------------------------------- #}
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_subnets.write') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="fs-xs mono">{{ option.type }}</td>
|
<td class="fs-xs mono">{{ option.type }}</td>
|
||||||
<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"
|
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<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) }}"
|
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_options.write') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<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
|
<form method="post" data-no-guard
|
||||||
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -355,7 +355,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_reservations.create') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
|
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
<pre class="log-view">{{ preview }}</pre>
|
<pre class="log-view">{{ preview }}</pre>
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_config.execute') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
|
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
|
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% 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') }}">
|
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
|
||||||
{{ icon('settings', size=15) }} Zuordnungen
|
{{ icon('settings', size=15) }} Zuordnungen
|
||||||
</a>
|
</a>
|
||||||
@@ -81,11 +81,24 @@
|
|||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
{# Symbol und Name in einer Zeile: der Zeilenumbruch dazwischen
|
||||||
|
liess den Ordner aussehen wie zwei halbe Zeilen. #}
|
||||||
|
<td class="file-cell">
|
||||||
{{ icon('folder', size=15) }}
|
{{ icon('folder', size=15) }}
|
||||||
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric faint">–</td>
|
{# An der Stelle der Dateigrösse: wie viel liegt darin. #}
|
||||||
|
<td class="numeric faint fs-sm">
|
||||||
|
{%- if entry.entries is not defined or entry.entries < 0 -%}
|
||||||
|
–
|
||||||
|
{%- elif entry.entries >= 500 -%}
|
||||||
|
500+ Einträge
|
||||||
|
{%- elif entry.entries == 1 -%}
|
||||||
|
1 Eintrag
|
||||||
|
{%- else -%}
|
||||||
|
{{ entry.entries }} Einträge
|
||||||
|
{%- endif -%}
|
||||||
|
</td>
|
||||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
<td class="actions"></td>
|
<td class="actions"></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -139,7 +152,7 @@
|
|||||||
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
|
{% 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>
|
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('fileshare.edit') %}
|
{% if current_user.has('fileshare.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="file-rename"
|
data-dialog-open="file-rename"
|
||||||
data-fill-Pfad="{{ entry.relative }}"
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
@@ -224,7 +237,7 @@
|
|||||||
<div class="modal__body modal__body--flush" data-viewer
|
<div class="modal__body modal__body--flush" data-viewer
|
||||||
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
|
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
|
||||||
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
|
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-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
||||||
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
||||||
data-viewer-konto="{{ current_user.username }}"
|
data-viewer-konto="{{ current_user.username }}"
|
||||||
@@ -317,7 +330,7 @@
|
|||||||
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
|
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
|
||||||
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
|
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
|
||||||
<select class="select" data-viewer-modus>
|
<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>
|
<option value="formular">Formular – weiter ausfüllbar</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
||||||
@@ -361,10 +374,16 @@
|
|||||||
{{ icon('download', size=15) }} Herunterladen
|
{{ icon('download', size=15) }} Herunterladen
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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>
|
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
|
||||||
{{ icon('pencil', size=15) }} Unterschreiben
|
{{ icon('pencil', size=15) }} Unterschreiben
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||||
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||||
@@ -375,7 +394,7 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if listing and current_user.has('fileshare.edit') %}
|
{% if listing and current_user.has('fileshare.write') %}
|
||||||
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
||||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_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 }}
|
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if current_user.has('fileshare.edit') %}
|
{% if current_user.has('fileshare.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="mapping-edit-{{ entry.id }}"
|
data-dialog-open="mapping-edit-{{ entry.id }}"
|
||||||
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
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
|
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
|
||||||
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
||||||
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
|
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 %}
|
{% for entry in mappings %}
|
||||||
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
||||||
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
<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="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 class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
|
||||||
<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>
|
<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="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="host" value="{{ row.host }}">
|
<input type="hidden" name="host" value="{{ row.host }}">
|
||||||
|
|||||||
@@ -60,9 +60,18 @@
|
|||||||
<dd>
|
<dd>
|
||||||
{% if state.modules %}
|
{% if state.modules %}
|
||||||
{% for module in modules %}
|
{% for module in modules %}
|
||||||
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
{% if module in state.modules and module in state.modules_off %}
|
||||||
|
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
|
||||||
|
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
||||||
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if state.modules_off %}
|
||||||
|
<div class="fs-xs faint">
|
||||||
|
Abgeschaltet wurde in
|
||||||
|
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
|
||||||
|
die Lizenz ist davon unberührt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
|
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Lizenzserver</dt>
|
<dt>Lizenzserver</dt>
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
{% if not state.activated %}
|
{% if not state.activated %}
|
||||||
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
|
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
|
||||||
@@ -155,7 +164,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -190,7 +199,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
{{ 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') }}
|
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<span>Alle auswählen</span>
|
<span>Alle auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="table-toolbar__spacer"></span>
|
<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"
|
<button class="btn btn--sm btn--primary" type="submit"
|
||||||
formaction="{{ url_for('maintenance.start_update') }}"
|
formaction="{{ url_for('maintenance.start_update') }}"
|
||||||
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
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">
|
<label class="check">
|
||||||
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
||||||
class="device-select" data-category="{{ device.category }}"
|
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>
|
<span class="sr-only">{{ device.name }} auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
@@ -121,7 +121,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
</td>
|
</td>
|
||||||
<td class="nowrap">
|
<td class="nowrap">
|
||||||
{% if terminal_enabled and device.ssh_managed
|
{% 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"
|
<a class="btn btn--sm btn--ghost"
|
||||||
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
||||||
{{ icon('terminal', size=14) }} Konsole
|
{{ icon('terminal', size=14) }} Konsole
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
||||||
checked=values.monitor_auto_restart,
|
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.') }}
|
funktioniert unabhängig davon.') }}
|
||||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||||
checked=values.dashboard_public,
|
checked=values.dashboard_public,
|
||||||
@@ -51,6 +51,34 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if module_rows %}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
|
||||||
|
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
|
||||||
|
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
|
||||||
|
sind nach dem Wiedereinschalten wieder da.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.modules_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body stack">
|
||||||
|
{% for modul in module_rows %}
|
||||||
|
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
|
||||||
|
hint_text=('In der Lizenz enthalten.' if modul.licensed
|
||||||
|
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
|
||||||
|
freischaltet. Der Haken gilt dann sofort.')) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -189,7 +217,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ nav_order_card(nav_items, nav_order, nav_child_order,
|
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||||
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% 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) }}">
|
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
||||||
{{ icon('terminal', size=15) }} Konsole
|
{{ icon('terminal', size=15) }} Konsole
|
||||||
</a>
|
</a>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
||||||
</div>
|
</div>
|
||||||
</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) }}"
|
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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
|
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
|
||||||
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
||||||
</p>
|
</p>
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
<form method="post" class="mt-2"
|
<form method="post" class="mt-2"
|
||||||
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
|
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dl>
|
</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) }}"
|
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.has('switches.execute') %}
|
{% if current_user.has('switch_console.execute') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<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"
|
<a class="btn btn--sm btn--ghost" title="Konsole"
|
||||||
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
||||||
{{ icon('terminal', size=15) }}
|
{{ icon('terminal', size=15) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
|
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
|
||||||
{{ icon('pencil', size=15) }}
|
{{ icon('pencil', size=15) }}
|
||||||
@@ -135,7 +135,7 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
|||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
{% for switch in switches %}
|
{% for switch in switches %}
|
||||||
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
|
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
|
||||||
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
|
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
|||||||
| Speicherung | Argon2id (argon2-cffi) |
|
| Speicherung | Argon2id (argon2-cffi) |
|
||||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||||
|
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
|
||||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||||
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
|
|||||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||||
|
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
|
||||||
|
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
|
||||||
|
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
|
||||||
|
aufschließt.
|
||||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||||
nicht brechen.
|
nicht brechen.
|
||||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.7.3"
|
version = "2.8.3"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
),
|
),
|
||||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||||
|
SettingSpec(
|
||||||
|
"modules_disabled",
|
||||||
|
[],
|
||||||
|
"json",
|
||||||
|
"Vor Ort abgeschaltete Module",
|
||||||
|
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||||
|
section="system",
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"license_server_ca",
|
"license_server_ca",
|
||||||
"",
|
"",
|
||||||
@@ -148,7 +156,11 @@ def create_base_app(
|
|||||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Anwendungen.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.7.3",
|
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||||
|
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||||
|
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||||
|
#: falsche Version in die Fussnote schreiben.
|
||||||
|
app_version: str = "unbekannt",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import sqlite3
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .db import Database
|
from .db import Database
|
||||||
|
|
||||||
@@ -116,6 +116,49 @@ def log(
|
|||||||
return int(cursor.lastrowid or 0)
|
return int(cursor.lastrowid or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def diff(
|
||||||
|
vorher: Mapping[str, Any] | None,
|
||||||
|
nachher: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
felder: Sequence[tuple[str, str]] | None = None,
|
||||||
|
geheim: Collection[str] = (),
|
||||||
|
) -> str:
|
||||||
|
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
|
||||||
|
|
||||||
|
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
|
||||||
|
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
|
||||||
|
|
||||||
|
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
|
||||||
|
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
|
||||||
|
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
|
||||||
|
geaendert haben.
|
||||||
|
"""
|
||||||
|
if vorher is None or nachher is None:
|
||||||
|
return ""
|
||||||
|
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
|
||||||
|
teile: list[str] = []
|
||||||
|
for name, beschriftung in paare:
|
||||||
|
if name not in vorher or name not in nachher:
|
||||||
|
continue
|
||||||
|
alt, neu = vorher[name], nachher[name]
|
||||||
|
if alt == neu:
|
||||||
|
continue
|
||||||
|
if name in geheim:
|
||||||
|
teile.append(f"{beschriftung}: geaendert")
|
||||||
|
continue
|
||||||
|
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
|
||||||
|
return ", ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _wert(wert: Any) -> str:
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
return text if len(text) <= 80 else text[:79] + "…"
|
||||||
|
|
||||||
|
|
||||||
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ssl
|
import ssl
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
|
|||||||
|
|
||||||
|
|
||||||
def _server(config: dict[str, Any]):
|
def _server(config: dict[str, Any]):
|
||||||
|
"""Das Server-Objekt -- so sparsam wie moeglich.
|
||||||
|
|
||||||
|
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
|
||||||
|
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
|
||||||
|
Schema*: bei einem Active Directory sind das Tausende Attribut- und
|
||||||
|
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
|
||||||
|
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
|
||||||
|
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
|
||||||
|
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
|
||||||
|
|
||||||
|
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
|
||||||
|
Unterschied zwischen "geht sofort" und "dauert manchmal".
|
||||||
|
"""
|
||||||
from ldap3 import Server
|
from ldap3 import Server
|
||||||
|
|
||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
|
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||||
return Server(
|
return Server(
|
||||||
str(config["ldap_host"]),
|
str(config["ldap_host"]),
|
||||||
port=int(config["ldap_port"]),
|
port=int(config["ldap_port"]),
|
||||||
use_ssl=security == "ldaps",
|
use_ssl=security == "ldaps",
|
||||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||||
get_info="ALL",
|
get_info="DSA" if braucht_basis else "NO_INFO",
|
||||||
connect_timeout=int(config["ldap_timeout"]),
|
connect_timeout=int(config["ldap_timeout"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
|
||||||
|
#:
|
||||||
|
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
|
||||||
|
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
|
||||||
|
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
|
||||||
|
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
|
||||||
|
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
|
||||||
|
#: ein sofortiger zweiter Versuch klappte.
|
||||||
|
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def _transiente_fehler() -> tuple[type[Exception], ...]:
|
||||||
|
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
|
||||||
|
global TRANSIENTE_FEHLER
|
||||||
|
if TRANSIENTE_FEHLER:
|
||||||
|
return TRANSIENTE_FEHLER
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPResponseTimeoutError,
|
||||||
|
LDAPSocketOpenError,
|
||||||
|
LDAPSocketReceiveError,
|
||||||
|
LDAPSocketSendError,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRANSIENTE_FEHLER = (
|
||||||
|
LDAPSocketOpenError,
|
||||||
|
LDAPSocketReceiveError,
|
||||||
|
LDAPSocketSendError,
|
||||||
|
LDAPResponseTimeoutError,
|
||||||
|
)
|
||||||
|
return TRANSIENTE_FEHLER
|
||||||
|
|
||||||
|
|
||||||
|
def mit_wiederholung(aufbau: Any) -> Any:
|
||||||
|
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
|
||||||
|
|
||||||
|
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
|
||||||
|
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
|
||||||
|
ist die Regel aus 1.2.8, und sie ist richtig.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return aufbau()
|
||||||
|
except _transiente_fehler() as fehler:
|
||||||
|
logger.info(
|
||||||
|
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
|
||||||
|
type(fehler).__name__,
|
||||||
|
)
|
||||||
|
return aufbau()
|
||||||
|
|
||||||
|
|
||||||
def _connect(config: dict[str, Any], user: str, password: str):
|
def _connect(config: dict[str, Any], user: str, password: str):
|
||||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||||
from ldap3 import Connection
|
from ldap3 import Connection
|
||||||
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
auto_bind=False,
|
auto_bind=False,
|
||||||
raise_exceptions=False,
|
raise_exceptions=False,
|
||||||
receive_timeout=int(config["ldap_timeout"]),
|
receive_timeout=int(config["ldap_timeout"]),
|
||||||
|
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
|
||||||
|
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
|
||||||
|
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
|
||||||
|
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
|
||||||
|
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
|
||||||
|
auto_referrals=False,
|
||||||
)
|
)
|
||||||
if security == "starttls":
|
if security == "starttls":
|
||||||
if not connection.open():
|
if not connection.open():
|
||||||
@@ -253,8 +323,19 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
if not config["ldap_verify_cert"]:
|
if not config["ldap_verify_cert"]:
|
||||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||||
|
|
||||||
|
begonnen = time.monotonic()
|
||||||
|
zeiten: list[str] = []
|
||||||
|
|
||||||
|
def gemessen(name: str, seit: float) -> float:
|
||||||
|
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
|
||||||
|
return jetzt
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
service_conn, bound = mit_wiederholung(
|
||||||
|
lambda: _connect(config, bind_user, bind_password)
|
||||||
|
)
|
||||||
except (PermissionError, ConnectionError) as exc:
|
except (PermissionError, ConnectionError) as exc:
|
||||||
logger.error("Verzeichnisdienst: %s", exc)
|
logger.error("Verzeichnisdienst: %s", exc)
|
||||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||||
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
warnings=tuple(warnings),
|
warnings=tuple(warnings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
marke = gemessen("Dienstbindung", begonnen)
|
||||||
try:
|
try:
|
||||||
base = _base_dn(service_conn, config)
|
base = _base_dn(service_conn, config)
|
||||||
|
marke = gemessen("Basis-DN", marke)
|
||||||
if not base:
|
if not base:
|
||||||
return DirectoryResult(
|
return DirectoryResult(
|
||||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||||
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
attributes=list(dict.fromkeys(wanted)),
|
attributes=list(dict.fromkeys(wanted)),
|
||||||
size_limit=2,
|
size_limit=2,
|
||||||
)
|
)
|
||||||
|
marke = gemessen("Benutzersuche", marke)
|
||||||
entries = list(service_conn.entries)
|
entries = list(service_conn.entries)
|
||||||
if len(entries) != 1:
|
if len(entries) != 1:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
|
|
||||||
entry = entries[0]
|
entry = entries[0]
|
||||||
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
|
|
||||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||||
user_conn, user_bound = _connect(config, dn, password)
|
#
|
||||||
|
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
|
||||||
|
# einem Domain-Controller in einem anderen Netz schlug der erste
|
||||||
|
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
|
||||||
|
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
|
||||||
|
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
|
||||||
|
# den Sperrzaehler des Kontos zweimal hoch.
|
||||||
|
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
|
||||||
|
marke = gemessen("Passwortpruefung", marke)
|
||||||
try:
|
try:
|
||||||
if not user_bound:
|
if not user_bound:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
group_dns: tuple[str, ...] = ()
|
group_dns: tuple[str, ...] = ()
|
||||||
if config["ldap_group_sync"]:
|
if config["ldap_group_sync"]:
|
||||||
group_dns = _resolve_groups(service_conn, base, dn)
|
group_dns = _resolve_groups(service_conn, base, dn)
|
||||||
|
marke = gemessen("Gruppen", marke)
|
||||||
|
|
||||||
profile = DirectoryProfile(
|
profile = DirectoryProfile(
|
||||||
username=_attr(entry, attribute) or username,
|
username=_attr(entry, attribute) or username,
|
||||||
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
email=_attr(entry, email_attribute),
|
email=_attr(entry, email_attribute),
|
||||||
group_dns=group_dns,
|
group_dns=group_dns,
|
||||||
)
|
)
|
||||||
|
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
|
||||||
|
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
|
||||||
|
# Verbindung ist, die Suche oder die Gruppen.
|
||||||
|
logger.info(
|
||||||
|
"Verzeichnisanmeldung %s: %s, gesamt %dms",
|
||||||
|
username,
|
||||||
|
", ".join(zeiten),
|
||||||
|
int((time.monotonic() - begonnen) * 1000),
|
||||||
|
)
|
||||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -423,18 +527,24 @@ def _apply_group_mappings(
|
|||||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||||
"""
|
"""
|
||||||
if not group_dns:
|
|
||||||
return
|
|
||||||
mappings = Database.all(
|
mappings = Database.all(
|
||||||
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
|
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
|
||||||
)
|
)
|
||||||
if not mappings:
|
if not mappings:
|
||||||
return
|
return
|
||||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
|
||||||
lowered = {dn.lower() for dn in group_dns}
|
lowered = {dn.lower() for dn in group_dns}
|
||||||
|
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
|
||||||
|
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
|
||||||
|
_kennzeichen_setzen(conn, user_id, mappings, lowered)
|
||||||
|
if not group_dns:
|
||||||
|
return
|
||||||
|
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
|
||||||
|
if not gruppenzuordnungen:
|
||||||
|
return
|
||||||
|
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
|
||||||
wanted = {
|
wanted = {
|
||||||
int(row["app_group_id"])
|
int(row["app_group_id"])
|
||||||
for row in mappings
|
for row in gruppenzuordnungen
|
||||||
if str(row["ad_group_dn"]).lower() in lowered
|
if str(row["ad_group_dn"]).lower() in lowered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +562,71 @@ def _apply_group_mappings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kennzeichen_setzen(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
user_id: int,
|
||||||
|
mappings: list[dict[str, Any]],
|
||||||
|
gruppen: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
|
||||||
|
|
||||||
|
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
|
||||||
|
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
|
||||||
|
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
|
||||||
|
hier nie ankommt.
|
||||||
|
|
||||||
|
Zwei Grenzen, beide absichtlich:
|
||||||
|
|
||||||
|
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
|
||||||
|
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
|
||||||
|
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
|
||||||
|
* Nie den letzten entsperrten Administrator herunterstufen. Ein
|
||||||
|
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
|
||||||
|
verschliessen, und niemand koennte ihn korrigieren.
|
||||||
|
"""
|
||||||
|
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
|
||||||
|
if not zuordnungen:
|
||||||
|
return
|
||||||
|
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
|
||||||
|
)
|
||||||
|
if zeile is None or str(zeile["auth_source"]) != "ldap":
|
||||||
|
return
|
||||||
|
ist = bool(zeile["is_admin"])
|
||||||
|
if ist == soll:
|
||||||
|
return
|
||||||
|
if not soll:
|
||||||
|
uebrig = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
|
||||||
|
"AND deleted_at IS NULL AND id<>?",
|
||||||
|
(user_id,),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
if not uebrig:
|
||||||
|
logger.warning(
|
||||||
|
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor="system",
|
||||||
|
action="auth.admin_flag_changed",
|
||||||
|
target=str(user_id),
|
||||||
|
detail=(
|
||||||
|
"Administrator: "
|
||||||
|
+ ("nein -> ja" if soll else "ja -> nein")
|
||||||
|
+ " (aus der Verzeichniszuordnung)"
|
||||||
|
),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||||
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
||||||
|
|
||||||
|
|||||||
@@ -77,25 +77,40 @@ class User:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_permissions(self) -> frozenset[str]:
|
def effective_permissions(self) -> frozenset[str]:
|
||||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
|
"""Rechte nach Anwendung der Bereichs-Kill-Switches.
|
||||||
|
|
||||||
|
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
|
||||||
|
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
|
||||||
|
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
|
||||||
|
indem es seine Gruppe verliert.
|
||||||
|
"""
|
||||||
if self._effective is None:
|
if self._effective is None:
|
||||||
tree = self._tree
|
tree = self._tree
|
||||||
self._effective = (
|
if tree is None:
|
||||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
self._effective = frozenset()
|
||||||
)
|
else:
|
||||||
|
eigene = set(tree.effective(self.granted))
|
||||||
|
if self.is_admin:
|
||||||
|
eigene |= set(tree.verwaltungsrechte())
|
||||||
|
self._effective = frozenset(eigene)
|
||||||
return self._effective
|
return self._effective
|
||||||
|
|
||||||
def has(self, *permissions: str) -> bool:
|
def has(self, *permissions: str) -> bool:
|
||||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
"""True, wenn **alle** angegebenen Rechte greifen.
|
||||||
if self.is_admin:
|
|
||||||
return True
|
Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
|
||||||
|
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
|
||||||
|
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
|
||||||
|
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
|
||||||
|
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
|
||||||
|
bekommen kann, sondern dass es dann eine Zeile im
|
||||||
|
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
|
||||||
|
"""
|
||||||
if not permissions:
|
if not permissions:
|
||||||
return True
|
return True
|
||||||
return all(permission in self.effective_permissions for permission in permissions)
|
return all(permission in self.effective_permissions for permission in permissions)
|
||||||
|
|
||||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||||
if self.is_admin:
|
|
||||||
return True
|
|
||||||
effective = self.effective_permissions
|
effective = self.effective_permissions
|
||||||
return any(permission in effective for permission in permissions)
|
return any(permission in effective for permission in permissions)
|
||||||
|
|
||||||
|
|||||||
@@ -359,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
|
|||||||
current_app.logger.exception(
|
current_app.logger.exception(
|
||||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||||
)
|
)
|
||||||
with core().database.transaction(conn):
|
# Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
|
||||||
audit.log(
|
# nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
|
||||||
conn,
|
# die Eintraege, um die es geht. Wer wissen will, wer wann da war,
|
||||||
actor=user.username,
|
# liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
|
||||||
actor_id=user.id,
|
# Sperren stehen weiterhin jedes Mal im Protokoll.
|
||||||
action="auth.logout",
|
|
||||||
target=user.username,
|
|
||||||
ip=client_ip(),
|
|
||||||
)
|
|
||||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||||
g.user = ANONYMOUS
|
g.user = ANONYMOUS
|
||||||
|
|
||||||
|
|||||||
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
|
||||||
|
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
|
||||||
|
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
|
||||||
|
-- nicht nur Mitglieder der Gruppe "Administration".
|
||||||
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||||
ad_group_name TEXT NOT NULL,
|
ad_group_name TEXT NOT NULL,
|
||||||
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
|
||||||
|
|
||||||
|
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
|
||||||
|
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
|
||||||
|
Schluessel, die der Rechtebaum nicht kennt -- und
|
||||||
|
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
|
||||||
|
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
|
||||||
|
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
|
||||||
|
anruft.
|
||||||
|
|
||||||
|
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
|
||||||
|
Idempotent: was schon neu heisst, wird nicht angefasst.
|
||||||
|
"""
|
||||||
|
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
|
||||||
|
|
||||||
|
for alt, neu in UMBENANNTE_AKTIONEN.items():
|
||||||
|
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
|
||||||
|
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
|
||||||
|
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
|
||||||
|
# scheitern.
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
|
||||||
|
" SELECT 1 FROM group_permissions AS andere"
|
||||||
|
" WHERE andere.group_id = group_permissions.group_id"
|
||||||
|
" AND andere.permission = substr(group_permissions.permission, 1,"
|
||||||
|
" length(group_permissions.permission) - ?) || ?)",
|
||||||
|
(f"%.{alt}", len(alt), neu),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE group_permissions"
|
||||||
|
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
|
||||||
|
" WHERE permission LIKE ?",
|
||||||
|
(len(alt), neu, f"%.{alt}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
|
||||||
|
|
||||||
|
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
|
||||||
|
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
|
||||||
|
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
|
||||||
|
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
|
||||||
|
|
||||||
|
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
|
||||||
|
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
|
||||||
|
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
|
||||||
|
``grants_admin`` sagt, dass genau das gemeint ist.
|
||||||
|
"""
|
||||||
|
spalten = {
|
||||||
|
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
|
||||||
|
}
|
||||||
|
if "grants_admin" in spalten:
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE ldap_group_mappings_neu (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||||
|
ad_group_name TEXT NOT NULL,
|
||||||
|
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ldap_group_mappings_neu "
|
||||||
|
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||||
|
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
|
||||||
|
" FROM ldap_group_mappings"
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE ldap_group_mappings")
|
||||||
|
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
|
||||||
|
|
||||||
|
|
||||||
|
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
|
||||||
|
|
||||||
|
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
|
||||||
|
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
|
||||||
|
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
|
||||||
|
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
|
||||||
|
|
||||||
|
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
|
||||||
|
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
|
||||||
|
"""
|
||||||
|
from .rbac.legacy_permissions import (
|
||||||
|
ERWEITERTE_RECHTE,
|
||||||
|
GETEILTE_RECHTE,
|
||||||
|
neue_schluessel,
|
||||||
|
)
|
||||||
|
|
||||||
|
def gruppen_mit(recht: str) -> list[int]:
|
||||||
|
return [
|
||||||
|
int(zeile[0])
|
||||||
|
for zeile in conn.execute(
|
||||||
|
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
|
||||||
|
(recht,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
|
||||||
|
for altes, neue in GETEILTE_RECHTE.items():
|
||||||
|
gruppen = gruppen_mit(altes)
|
||||||
|
if not gruppen:
|
||||||
|
continue
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||||
|
[(gid, recht) for gid in gruppen for recht in neue],
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
|
||||||
|
|
||||||
|
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
|
||||||
|
# Loeschen waere hier falsch -- es ist keines von gestern.
|
||||||
|
for recht, dazu in ERWEITERTE_RECHTE.items():
|
||||||
|
gruppen = gruppen_mit(recht)
|
||||||
|
if not gruppen:
|
||||||
|
continue
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||||
|
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
|
||||||
|
)
|
||||||
|
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
|
||||||
|
|
||||||
|
|
||||||
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1_BASELINE),
|
Migration(
|
||||||
|
version=1,
|
||||||
|
name="baseline",
|
||||||
|
sql=V1_BASELINE,
|
||||||
|
geaendert_weil=(
|
||||||
|
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
|
||||||
|
),
|
||||||
|
),
|
||||||
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
Migration(version=3, name="jobs", sql=V3_JOBS),
|
||||||
|
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
|
||||||
|
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
|
||||||
|
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
|
||||||
)
|
)
|
||||||
@@ -34,7 +34,7 @@ import urllib.request
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
|
|
||||||
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
|||||||
EXPIRY_HINT_DAYS = 90
|
EXPIRY_HINT_DAYS = 90
|
||||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||||
|
|
||||||
|
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
|
||||||
|
#:
|
||||||
|
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
|
||||||
|
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
|
||||||
|
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
|
||||||
|
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
|
||||||
|
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
|
||||||
|
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
|
||||||
|
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
|
||||||
|
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
|
||||||
|
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
|
||||||
|
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
|
||||||
|
#: mitbringen.
|
||||||
|
SETTING_MODULES_OFF = "modules_disabled"
|
||||||
|
|
||||||
STATUS_INACTIVE = "inactive"
|
STATUS_INACTIVE = "inactive"
|
||||||
STATUS_PENDING = "pending"
|
STATUS_PENDING = "pending"
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
@@ -121,6 +136,8 @@ class LicenseState:
|
|||||||
last_heartbeat_at: str = ""
|
last_heartbeat_at: str = ""
|
||||||
heartbeat_error: str = ""
|
heartbeat_error: str = ""
|
||||||
pending_action: str = ""
|
pending_action: str = ""
|
||||||
|
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||||
|
modules_off: frozenset[str] = frozenset()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role(self) -> str:
|
def role(self) -> str:
|
||||||
@@ -128,8 +145,14 @@ class LicenseState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def modules(self) -> frozenset[str]:
|
def modules(self) -> frozenset[str]:
|
||||||
|
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||||
return self.status.modules if self.status else frozenset()
|
return self.status.modules if self.status else frozenset()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modules_in_use(self) -> frozenset[str]:
|
||||||
|
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
|
||||||
|
return self.modules - self.modules_off
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def customer_name(self) -> str:
|
def customer_name(self) -> str:
|
||||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||||
@@ -179,6 +202,7 @@ class LicenseManager:
|
|||||||
"""Liest Lizenzdatei und Status neu ein."""
|
"""Liest Lizenzdatei und Status neu ein."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = LicenseState()
|
state = LicenseState()
|
||||||
|
state.modules_off = self.abgeschaltete_module(conn)
|
||||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||||
pending = self._pending(conn)
|
pending = self._pending(conn)
|
||||||
@@ -239,9 +263,51 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def module_active(self, name: str) -> bool:
|
def module_active(self, name: str) -> bool:
|
||||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
|
||||||
|
|
||||||
|
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
|
||||||
|
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
|
||||||
|
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
|
||||||
|
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.active()
|
||||||
|
and name in self._state.modules
|
||||||
|
and name not in self._state.modules_off
|
||||||
|
)
|
||||||
|
|
||||||
|
def module_licensed(self, name: str) -> bool:
|
||||||
|
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
|
||||||
return self.active() and name in self._state.modules
|
return self.active() and name in self._state.modules
|
||||||
|
|
||||||
|
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
|
||||||
|
"""Liest die Liste aus den Einstellungen."""
|
||||||
|
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
|
||||||
|
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
|
||||||
|
gespeichert = [gespeichert]
|
||||||
|
return frozenset(str(name) for name in gespeichert)
|
||||||
|
|
||||||
|
def module_abschalten(
|
||||||
|
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
|
||||||
|
) -> tuple[frozenset[str], frozenset[str]]:
|
||||||
|
"""Setzt die Liste der abgeschalteten Module neu.
|
||||||
|
|
||||||
|
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
|
||||||
|
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
|
||||||
|
Protokolleintrag.
|
||||||
|
|
||||||
|
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
|
||||||
|
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
|
||||||
|
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
|
||||||
|
das Einzige, was jetzt anders ist.
|
||||||
|
"""
|
||||||
|
vorher = self.abgeschaltete_module(conn)
|
||||||
|
nachher = frozenset(str(name) for name in namen)
|
||||||
|
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
|
||||||
|
with self._lock:
|
||||||
|
self._state.modules_off = nachher
|
||||||
|
return vorher - nachher, nachher - vorher
|
||||||
|
|
||||||
def feature_allowed(self, feature: str) -> bool:
|
def feature_allowed(self, feature: str) -> bool:
|
||||||
"""Lizenzpflichtige Einzelfunktion.
|
"""Lizenzpflichtige Einzelfunktion.
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class Migration:
|
|||||||
sql: str = ""
|
sql: str = ""
|
||||||
fn: MigrationFn | None = None
|
fn: MigrationFn | None = None
|
||||||
allow_integrity_error: bool = False
|
allow_integrity_error: bool = False
|
||||||
|
#: Begruendung, falls der Text einer bereits angewendeten Migration
|
||||||
|
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
|
||||||
|
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
|
||||||
|
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
|
||||||
|
#: Migration ist fast immer ein Versehen.
|
||||||
|
geaendert_weil: str = ""
|
||||||
tags: tuple[str, ...] = field(default=())
|
tags: tuple[str, ...] = field(default=())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -175,6 +181,22 @@ class MigrationRunner:
|
|||||||
record = known.get(migration.version)
|
record = known.get(migration.version)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
if record["checksum"] != migration.checksum:
|
if record["checksum"] != migration.checksum:
|
||||||
|
if migration.geaendert_weil:
|
||||||
|
# Mit Begruendung ist es eine Notiz, keine Warnung:
|
||||||
|
# manchmal muss der Text einer laengst angewendeten
|
||||||
|
# Migration mit, damit eine **frische** Installation
|
||||||
|
# denselben Stand erreicht. Wer das Protokoll liest,
|
||||||
|
# soll den Unterschied zu einem Versehen sehen.
|
||||||
|
logger.info(
|
||||||
|
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
|
||||||
|
"Auf dieser Installation ist sie längst angewendet und "
|
||||||
|
"wird nicht erneut ausgeführt.",
|
||||||
|
component,
|
||||||
|
migration.version,
|
||||||
|
migration.name,
|
||||||
|
migration.geaendert_weil,
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Migration %s/%s (%s) wurde nachträglich geändert "
|
"Migration %s/%s (%s) wurde nachträglich geändert "
|
||||||
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ A = Action
|
|||||||
USERS = Resource(
|
USERS = Resource(
|
||||||
key="users",
|
key="users",
|
||||||
label="Benutzer",
|
label="Benutzer",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
|
A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
|
||||||
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -27,32 +27,43 @@ USERS = Resource(
|
|||||||
GROUPS = Resource(
|
GROUPS = Resource(
|
||||||
key="groups",
|
key="groups",
|
||||||
label="Gruppen und Rechte",
|
label="Gruppen und Rechte",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Rechtematrix pflegen",
|
hint="Rechtematrix pflegen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
|
A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_SYSTEM = Resource(
|
SETTINGS_SYSTEM = Resource(
|
||||||
key="settings_system",
|
key="settings_system",
|
||||||
label="Systemeinstellungen",
|
label="Systemeinstellungen",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
|
||||||
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
|
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
|
||||||
|
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
|
||||||
|
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
|
||||||
|
SETTINGS_NAV = Resource(
|
||||||
|
key="settings_nav",
|
||||||
|
parent="settings_system",
|
||||||
|
label="Navigation",
|
||||||
|
actions=(A.READ, A.WRITE),
|
||||||
|
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
|
||||||
|
)
|
||||||
|
|
||||||
SETTINGS_LICENSE = Resource(
|
SETTINGS_LICENSE = Resource(
|
||||||
key="settings_license",
|
key="settings_license",
|
||||||
label="Lizenz",
|
label="Lizenz",
|
||||||
actions=(A.VIEW, A.EDIT),
|
actions=(A.READ, A.WRITE),
|
||||||
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_DIRECTORY = Resource(
|
SETTINGS_DIRECTORY = Resource(
|
||||||
key="settings_directory",
|
key="settings_directory",
|
||||||
label="Verzeichnisdienst (LDAP/AD)",
|
label="Verzeichnisdienst (LDAP/AD)",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
|
||||||
hint="Anbindung und Gruppen-Zuordnung",
|
hint="Anbindung und Gruppen-Zuordnung",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Verbindungstest ausführen.",
|
A.EXECUTE: "Verbindungstest ausführen.",
|
||||||
@@ -63,7 +74,7 @@ SETTINGS_DIRECTORY = Resource(
|
|||||||
SETTINGS_WEB = Resource(
|
SETTINGS_WEB = Resource(
|
||||||
key="settings_web",
|
key="settings_web",
|
||||||
label="Webserver und TLS",
|
label="Webserver und TLS",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||||
hint="Domain, Zertifikat, HSTS",
|
hint="Domain, Zertifikat, HSTS",
|
||||||
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
||||||
)
|
)
|
||||||
@@ -71,29 +82,40 @@ SETTINGS_WEB = Resource(
|
|||||||
SETTINGS_NETWORK = Resource(
|
SETTINGS_NETWORK = Resource(
|
||||||
key="settings_network",
|
key="settings_network",
|
||||||
label="Netzwerkeinstellungen",
|
label="Netzwerkeinstellungen",
|
||||||
actions=(A.VIEW, A.EDIT),
|
actions=(A.READ, A.WRITE),
|
||||||
hint="IP-Adresse, Gateway, DNS des Hosts",
|
hint="IP-Adresse, Gateway, DNS des Hosts",
|
||||||
)
|
)
|
||||||
|
|
||||||
BACKUP = Resource(
|
#: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
|
||||||
key="backup",
|
#: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
|
||||||
label="Sicherung und Übertragung",
|
#: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
|
||||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
#: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
|
||||||
hint="Verschlüsselter Export und Import",
|
BACKUP_EXPORT = Resource(
|
||||||
|
key="backup_export",
|
||||||
|
label="Sicherung erstellen",
|
||||||
|
actions=(A.READ, A.EXPORT, A.SECRETS),
|
||||||
|
hint="Verschlüsselter Export des Datenstands",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
|
||||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
||||||
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
|
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BACKUP_IMPORT = Resource(
|
||||||
|
key="backup_import",
|
||||||
|
label="Sicherung einspielen",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Prüfen, übernehmen, verwerfen",
|
||||||
|
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
|
||||||
|
)
|
||||||
|
|
||||||
TRASH = Resource(
|
TRASH = Resource(
|
||||||
key="trash",
|
key="trash",
|
||||||
label="Papierkorb",
|
label="Papierkorb",
|
||||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
actions=(A.READ, A.WRITE, A.DELETE),
|
||||||
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Einträge wiederherstellen.",
|
A.WRITE: "Einträge wiederherstellen.",
|
||||||
A.DELETE: "Einträge endgültig entfernen.",
|
A.DELETE: "Einträge endgültig entfernen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -101,20 +123,20 @@ TRASH = Resource(
|
|||||||
DIAGNOSTICS = Resource(
|
DIAGNOSTICS = Resource(
|
||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
|
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_LIVE = Resource(
|
LOGS_LIVE = Resource(
|
||||||
key="logs_live",
|
key="logs_live",
|
||||||
label="Laufendes Protokoll",
|
label="Laufendes Protokoll",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_HISTORY = Resource(
|
LOGS_HISTORY = Resource(
|
||||||
key="logs_history",
|
key="logs_history",
|
||||||
label="Protokollverlauf",
|
label="Protokollverlauf",
|
||||||
actions=(A.VIEW, A.DELETE, A.EXPORT),
|
actions=(A.READ, A.DELETE, A.EXPORT),
|
||||||
hint="Rotierte und archivierte Protokolle",
|
hint="Rotierte und archivierte Protokolle",
|
||||||
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
||||||
)
|
)
|
||||||
@@ -122,7 +144,7 @@ LOGS_HISTORY = Resource(
|
|||||||
LOGS_AUDIT = Resource(
|
LOGS_AUDIT = Resource(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Änderungsprotokoll",
|
label="Änderungsprotokoll",
|
||||||
actions=(A.VIEW, A.EXPORT),
|
actions=(A.READ, A.EXPORT),
|
||||||
hint="Wer hat wann was geändert",
|
hint="Wer hat wann was geändert",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
|
|||||||
USERS,
|
USERS,
|
||||||
GROUPS,
|
GROUPS,
|
||||||
SETTINGS_SYSTEM,
|
SETTINGS_SYSTEM,
|
||||||
|
SETTINGS_NAV,
|
||||||
SETTINGS_LICENSE,
|
SETTINGS_LICENSE,
|
||||||
SETTINGS_DIRECTORY,
|
SETTINGS_DIRECTORY,
|
||||||
SETTINGS_WEB,
|
SETTINGS_WEB,
|
||||||
SETTINGS_NETWORK,
|
SETTINGS_NETWORK,
|
||||||
BACKUP,
|
BACKUP_EXPORT,
|
||||||
|
BACKUP_IMPORT,
|
||||||
TRASH,
|
TRASH,
|
||||||
DIAGNOSTICS,
|
DIAGNOSTICS,
|
||||||
*extra,
|
*extra,
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
|
||||||
|
|
||||||
|
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
|
||||||
|
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
|
||||||
|
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
|
||||||
|
|
||||||
|
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
|
||||||
|
|
||||||
|
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
|
||||||
|
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
|
||||||
|
Zuordnung landen dort lauter unbekannte Schluessel, und
|
||||||
|
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
|
||||||
|
Aus einer Sicherung wuerden Gruppen ohne Rechte.
|
||||||
|
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
|
||||||
|
|
||||||
|
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
|
||||||
|
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
|
||||||
|
(:mod:`tesm_core.legacy_urls`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
|
||||||
|
UMBENANNTE_AKTIONEN: dict[str, str] = {
|
||||||
|
"view": "read",
|
||||||
|
"edit": "write",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
|
||||||
|
#:
|
||||||
|
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
|
||||||
|
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
|
||||||
|
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
|
||||||
|
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
|
||||||
|
#: wegnimmt, ohne dass jemand es entschieden hat.
|
||||||
|
#:
|
||||||
|
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
|
||||||
|
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
|
||||||
|
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
|
||||||
|
#: zwei Stellen, die man pflegen muss.
|
||||||
|
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||||
|
# Drei Neustartwege mit drei verschiedenen Folgen.
|
||||||
|
"devices.execute": (
|
||||||
|
"device_restart_poe.read",
|
||||||
|
"device_restart_poe.execute",
|
||||||
|
"device_restart_ssh.read",
|
||||||
|
"device_restart_ssh.execute",
|
||||||
|
"device_restart_rpc.read",
|
||||||
|
"device_restart_rpc.execute",
|
||||||
|
),
|
||||||
|
# Die Switch-Konsole war "Switch ausfuehren".
|
||||||
|
"switches.execute": ("switch_console.read", "switch_console.execute"),
|
||||||
|
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
|
||||||
|
# und die Client-Konsole, die jetzt bei den Clients steht.
|
||||||
|
"maintenance.read": (
|
||||||
|
"servicing_update.read",
|
||||||
|
"servicing_reboot.read",
|
||||||
|
"servicing_tool.read",
|
||||||
|
"device_console.read",
|
||||||
|
),
|
||||||
|
"maintenance.execute": (
|
||||||
|
"servicing_update.read",
|
||||||
|
"servicing_update.execute",
|
||||||
|
"servicing_reboot.read",
|
||||||
|
"servicing_reboot.execute",
|
||||||
|
"servicing_tool.read",
|
||||||
|
"servicing_tool.execute",
|
||||||
|
"device_console.read",
|
||||||
|
"device_console.execute",
|
||||||
|
),
|
||||||
|
# Elf Funktionen an einem Recht.
|
||||||
|
"dhcp.read": (
|
||||||
|
"dhcp_subnets.read",
|
||||||
|
"dhcp_options.read",
|
||||||
|
"dhcp_reservations.read",
|
||||||
|
"dhcp_config.read",
|
||||||
|
"dhcp_service.read",
|
||||||
|
),
|
||||||
|
"dhcp.write": (
|
||||||
|
"dhcp_subnets.read",
|
||||||
|
"dhcp_subnets.create",
|
||||||
|
"dhcp_subnets.write",
|
||||||
|
"dhcp_subnets.delete",
|
||||||
|
"dhcp_options.read",
|
||||||
|
"dhcp_options.write",
|
||||||
|
"dhcp_reservations.read",
|
||||||
|
"dhcp_reservations.create",
|
||||||
|
"dhcp_reservations.write",
|
||||||
|
"dhcp_reservations.delete",
|
||||||
|
"dhcp_config.read",
|
||||||
|
"dhcp_config.execute",
|
||||||
|
),
|
||||||
|
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
|
||||||
|
# Sichern und Einspielen.
|
||||||
|
"backup.read": ("backup_export.read", "backup_import.read"),
|
||||||
|
"backup.export": ("backup_export.read", "backup_export.export"),
|
||||||
|
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
|
||||||
|
"backup.execute": ("backup_import.read", "backup_import.execute"),
|
||||||
|
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
|
||||||
|
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
|
||||||
|
"licenses.create": ("license_issue.read", "license_issue.execute"),
|
||||||
|
"licenses.write": (
|
||||||
|
"license_renew.read",
|
||||||
|
"license_renew.execute",
|
||||||
|
"license_revoke.read",
|
||||||
|
"license_revoke.execute",
|
||||||
|
),
|
||||||
|
"licenses.execute": ("license_activation.read", "license_activation.execute"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
|
||||||
|
#:
|
||||||
|
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
|
||||||
|
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
|
||||||
|
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
|
||||||
|
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
|
||||||
|
#: mehr pruefen zu koennen, welcher Fall gemeint war.
|
||||||
|
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||||
|
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
|
||||||
|
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
|
||||||
|
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
|
||||||
|
"devices.write": ("hostkeys.read", "hostkeys.execute"),
|
||||||
|
"switches.write": ("hostkeys.read", "hostkeys.execute"),
|
||||||
|
# Umbenennen war auch "Freigaben zuordnen".
|
||||||
|
"fileshare.write": (
|
||||||
|
"fileshare_mappings.read",
|
||||||
|
"fileshare_mappings.create",
|
||||||
|
"fileshare_mappings.write",
|
||||||
|
"fileshare_mappings.delete",
|
||||||
|
),
|
||||||
|
# Ausgefuellt ablegen war auch "unterschreiben".
|
||||||
|
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def neue_schluessel(alt: str) -> tuple[str, ...]:
|
||||||
|
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
|
||||||
|
|
||||||
|
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
|
||||||
|
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
|
||||||
|
"""
|
||||||
|
if alt in GETEILTE_RECHTE:
|
||||||
|
return GETEILTE_RECHTE[alt]
|
||||||
|
ressource, punkt, aktion = alt.rpartition(".")
|
||||||
|
if not punkt:
|
||||||
|
return (alt,)
|
||||||
|
neu = UMBENANNTE_AKTIONEN.get(aktion)
|
||||||
|
selbst = f"{ressource}.{neu}" if neu else alt
|
||||||
|
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
|
||||||
|
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
|
||||||
|
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
|
||||||
|
|
||||||
|
|
||||||
|
def umgeschrieben(rechte: object) -> list[str]:
|
||||||
|
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
|
||||||
|
if not isinstance(rechte, (list, tuple, set, frozenset)):
|
||||||
|
return []
|
||||||
|
ergebnis: set[str] = set()
|
||||||
|
for recht in rechte:
|
||||||
|
ergebnis.update(neue_schluessel(str(recht)))
|
||||||
|
return sorted(ergebnis)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UMBENANNTE_AKTIONEN",
|
||||||
|
"GETEILTE_RECHTE",
|
||||||
|
"ERWEITERTE_RECHTE",
|
||||||
|
"neue_schluessel",
|
||||||
|
"umgeschrieben",
|
||||||
|
]
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
||||||
|
|
||||||
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
|
||||||
|
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
|
||||||
|
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
|
||||||
|
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
|
||||||
|
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
|
||||||
|
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
|
||||||
|
Migration ``core/4`` tut das fuer bestehende Gruppen.
|
||||||
|
|
||||||
|
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||||
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
||||||
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
||||||
|
|
||||||
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
|
|||||||
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
||||||
|
|
||||||
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
||||||
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||||
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
||||||
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
||||||
eingestellten Einzelrechte zu verlieren.
|
eingestellten Einzelrechte zu verlieren.
|
||||||
@@ -29,28 +37,55 @@ from typing import Iterable, Mapping, Sequence
|
|||||||
|
|
||||||
|
|
||||||
class Action(str, Enum):
|
class Action(str, Enum):
|
||||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
|
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
|
||||||
|
|
||||||
VIEW = "view"
|
Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
|
||||||
|
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
|
||||||
|
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
|
||||||
|
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
|
||||||
|
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ = "read"
|
||||||
CREATE = "create"
|
CREATE = "create"
|
||||||
EDIT = "edit"
|
WRITE = "write"
|
||||||
DELETE = "delete"
|
DELETE = "delete"
|
||||||
EXECUTE = "execute"
|
EXECUTE = "execute"
|
||||||
EXPORT = "export"
|
EXPORT = "export"
|
||||||
SECRETS = "secrets"
|
SECRETS = "secrets"
|
||||||
|
|
||||||
|
|
||||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
#: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
|
||||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
#:
|
||||||
Action.VIEW: ("R", "Ansehen", "Datensätze und Seiten dieses Bereichs lesen."),
|
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
|
||||||
Action.CREATE: ("C", "Anlegen", "Neue Datensätze erstellen."),
|
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
|
||||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensätze ändern – ohne Löschen."),
|
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
|
||||||
Action.DELETE: ("D", "Loeschen", "Datensätze löschen bzw. endgültig entfernen."),
|
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
|
||||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen auslösen (Neustart, Dienste, Jobs, Import)."),
|
Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
|
||||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
|
||||||
|
Action.WRITE: (
|
||||||
|
"W",
|
||||||
|
"Write",
|
||||||
|
"ändern",
|
||||||
|
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
|
||||||
|
),
|
||||||
|
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
|
||||||
|
Action.EXECUTE: (
|
||||||
|
"X",
|
||||||
|
"Execute",
|
||||||
|
"auslösen",
|
||||||
|
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
|
||||||
|
),
|
||||||
|
Action.EXPORT: (
|
||||||
|
"E",
|
||||||
|
"Export",
|
||||||
|
"mitnehmen",
|
||||||
|
"Daten aus dem System heraustragen (Datei-Download).",
|
||||||
|
),
|
||||||
Action.SECRETS: (
|
Action.SECRETS: (
|
||||||
"S",
|
"S",
|
||||||
"Geheimnisse",
|
"Secrets",
|
||||||
|
"Klartext",
|
||||||
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -58,9 +93,12 @@ ACTION_META: dict[Action, tuple[str, str, str]] = {
|
|||||||
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
||||||
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
||||||
|
|
||||||
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
|
#: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
|
||||||
|
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
|
||||||
|
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
|
||||||
|
#: Verwaltungsanwendung am haeufigsten braucht.
|
||||||
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
||||||
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
|
action: (Action.READ,) for action in Action if action is not Action.READ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -74,12 +112,33 @@ class Resource:
|
|||||||
hint: str = ""
|
hint: str = ""
|
||||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||||
|
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
|
||||||
|
#:
|
||||||
|
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
|
||||||
|
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
|
||||||
|
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
|
||||||
|
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
|
||||||
|
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
|
||||||
|
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
|
||||||
|
parent: str = ""
|
||||||
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
||||||
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||||
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||||
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||||
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_label(self) -> str:
|
||||||
|
"""Beschriftung ohne den Namen der Hauptzeile.
|
||||||
|
|
||||||
|
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
|
||||||
|
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
|
||||||
|
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
|
||||||
|
"""
|
||||||
|
if self.parent and ": " in self.label:
|
||||||
|
return self.label.split(": ", 1)[1]
|
||||||
|
return self.label
|
||||||
|
|
||||||
def permission(self, action: Action) -> str:
|
def permission(self, action: Action) -> str:
|
||||||
return f"{self.key}.{action.value}"
|
return f"{self.key}.{action.value}"
|
||||||
|
|
||||||
@@ -101,8 +160,9 @@ class Area:
|
|||||||
hint: str = ""
|
hint: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def view_key(self) -> str:
|
def read_key(self) -> str:
|
||||||
return f"{self.key}.view"
|
"""Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
|
||||||
|
return f"{self.key}.read"
|
||||||
|
|
||||||
def columns(self) -> tuple[Action, ...]:
|
def columns(self) -> tuple[Action, ...]:
|
||||||
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
||||||
@@ -128,6 +188,7 @@ class PermissionTree:
|
|||||||
self._parent: dict[str, str] = {}
|
self._parent: dict[str, str] = {}
|
||||||
self._all: list[str] = []
|
self._all: list[str] = []
|
||||||
self._resources: dict[str, tuple[Area, Resource]] = {}
|
self._resources: dict[str, tuple[Area, Resource]] = {}
|
||||||
|
self._verwaltung: frozenset[str] | None = None
|
||||||
|
|
||||||
seen_keys: set[str] = set()
|
seen_keys: set[str] = set()
|
||||||
for area in self.areas:
|
for area in self.areas:
|
||||||
@@ -135,30 +196,57 @@ class PermissionTree:
|
|||||||
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
|
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
|
||||||
seen_keys.add(area.key)
|
seen_keys.add(area.key)
|
||||||
self._by_key[area.key] = area
|
self._by_key[area.key] = area
|
||||||
self._all.append(area.view_key)
|
self._all.append(area.read_key)
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
if resource.key in seen_keys:
|
if resource.key in seen_keys:
|
||||||
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
|
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
|
||||||
seen_keys.add(resource.key)
|
seen_keys.add(resource.key)
|
||||||
if Action.VIEW not in resource.actions:
|
if Action.READ not in resource.actions:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten – "
|
f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
|
||||||
"sie ist Voraussetzung für jede andere Aktion."
|
"sie ist Voraussetzung für jede andere Aktion."
|
||||||
)
|
)
|
||||||
self._resources[resource.key] = (area, resource)
|
self._resources[resource.key] = (area, resource)
|
||||||
for permission in resource.permissions():
|
for permission in resource.permissions():
|
||||||
self._all.append(permission)
|
self._all.append(permission)
|
||||||
self._parent[permission] = area.view_key
|
self._parent[permission] = area.read_key
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def all_keys(self) -> tuple[str, ...]:
|
def all_keys(self) -> tuple[str, ...]:
|
||||||
return tuple(self._all)
|
return tuple(self._all)
|
||||||
|
|
||||||
|
#: Der Bereich, den ein echter Administrator immer ganz hat.
|
||||||
|
#:
|
||||||
|
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
|
||||||
|
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
|
||||||
|
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
|
||||||
|
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
|
||||||
|
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
|
||||||
|
#: Gruppe, und dann steht es im Aenderungsprotokoll.
|
||||||
|
VERWALTUNGSBEREICH = "administration"
|
||||||
|
|
||||||
|
def verwaltungsrechte(self) -> frozenset[str]:
|
||||||
|
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
|
||||||
|
|
||||||
|
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
|
||||||
|
entsteht beim Import.
|
||||||
|
"""
|
||||||
|
if self._verwaltung is None:
|
||||||
|
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
|
||||||
|
if bereich is None:
|
||||||
|
self._verwaltung = frozenset()
|
||||||
|
else:
|
||||||
|
rechte = {bereich.read_key}
|
||||||
|
for resource in bereich.resources:
|
||||||
|
rechte.update(resource.permissions())
|
||||||
|
self._verwaltung = frozenset(rechte)
|
||||||
|
return self._verwaltung
|
||||||
|
|
||||||
def area(self, key: str) -> Area | None:
|
def area(self, key: str) -> Area | None:
|
||||||
return self._by_key.get(key)
|
return self._by_key.get(key)
|
||||||
|
|
||||||
def parent_view_key(self, permission: str) -> str | None:
|
def parent_read_key(self, permission: str) -> str | None:
|
||||||
return self._parent.get(permission)
|
return self._parent.get(permission)
|
||||||
|
|
||||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||||
@@ -185,7 +273,7 @@ class PermissionTree:
|
|||||||
|
|
||||||
def label_for(self, permission: str) -> str:
|
def label_for(self, permission: str) -> str:
|
||||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
if permission.endswith(".read") and permission[:-5] in self._by_key:
|
||||||
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
||||||
resource_key, _, action_value = permission.rpartition(".")
|
resource_key, _, action_value = permission.rpartition(".")
|
||||||
entry = self._resources.get(resource_key)
|
entry = self._resources.get(resource_key)
|
||||||
@@ -196,7 +284,10 @@ class PermissionTree:
|
|||||||
action = Action(action_value)
|
action = Action(action_value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return permission
|
return permission
|
||||||
return f"{resource.label}: {ACTION_META[action][1]}"
|
# Die deutsche Beschriftung, nicht der englische Name: dieser Text
|
||||||
|
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
|
||||||
|
# Aenderungsprotokoll, und die sind deutsch.
|
||||||
|
return f"{resource.label}: {ACTION_META[action][2]}"
|
||||||
|
|
||||||
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
||||||
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
||||||
@@ -204,7 +295,7 @@ class PermissionTree:
|
|||||||
Regeln:
|
Regeln:
|
||||||
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
||||||
Weiterschleppen von Altrechten nach einem Refactoring).
|
Weiterschleppen von Altrechten nach einem Refactoring).
|
||||||
* Jede Aktion impliziert ``view`` derselben Zeile.
|
* Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
|
||||||
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
||||||
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
||||||
Bearbeiter wuerde sich wundern.
|
Bearbeiter wuerde sich wundern.
|
||||||
@@ -224,13 +315,13 @@ class PermissionTree:
|
|||||||
result.add(candidate)
|
result.add(candidate)
|
||||||
for area in self.areas:
|
for area in self.areas:
|
||||||
if any(p in result for resource in area.resources for p in resource.permissions()):
|
if any(p in result for resource in area.resources for p in resource.permissions()):
|
||||||
result.add(area.view_key)
|
result.add(area.read_key)
|
||||||
return sorted(result)
|
return sorted(result)
|
||||||
|
|
||||||
def effective(self, permissions: Iterable[str]) -> set[str]:
|
def effective(self, permissions: Iterable[str]) -> set[str]:
|
||||||
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
||||||
granted = set(permissions)
|
granted = set(permissions)
|
||||||
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
|
active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
|
||||||
return {
|
return {
|
||||||
key
|
key
|
||||||
for key in granted
|
for key in granted
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
|
|||||||
|
|
||||||
from ..db import Database
|
from ..db import Database
|
||||||
from ..security import passwords
|
from ..security import passwords
|
||||||
from .model import Action, PermissionTree, RolePreset
|
from .model import ACTION_META, Action, PermissionTree, RolePreset
|
||||||
|
|
||||||
|
|
||||||
class PermissionDenied(Exception):
|
class PermissionDenied(Exception):
|
||||||
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
|
|||||||
def guard_no_amplification(
|
def guard_no_amplification(
|
||||||
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
|
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
|
||||||
|
|
||||||
|
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
|
||||||
|
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
|
||||||
|
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
|
||||||
|
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
|
||||||
|
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
|
||||||
|
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
|
||||||
|
|
||||||
|
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
|
||||||
|
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
|
||||||
|
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
|
||||||
|
neue Gruppe hochstufen.
|
||||||
|
"""
|
||||||
if getattr(actor, "is_admin", False):
|
if getattr(actor, "is_admin", False):
|
||||||
return
|
return
|
||||||
missing = sorted(p for p in added if not actor.has(p))
|
missing = sorted(p for p in added if not actor.has(p))
|
||||||
@@ -315,6 +328,113 @@ def ensure_presets(
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def unberuehrte_vorlagen_entfernen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
|
||||||
|
|
||||||
|
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
|
||||||
|
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
|
||||||
|
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
|
||||||
|
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
|
||||||
|
arbeitet.
|
||||||
|
|
||||||
|
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
|
||||||
|
|
||||||
|
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
|
||||||
|
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
|
||||||
|
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
|
||||||
|
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
|
||||||
|
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
|
||||||
|
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
|
||||||
|
jemand hat sie uebernommen.
|
||||||
|
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
|
||||||
|
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
|
||||||
|
jemand hat sie angepasst, also ist sie seine, nicht unsere.
|
||||||
|
* Sie ist keine Systemgruppe.
|
||||||
|
|
||||||
|
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
|
||||||
|
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
|
||||||
|
"""
|
||||||
|
entfernt: list[str] = []
|
||||||
|
gruppen = list_groups(conn)
|
||||||
|
for preset in presets:
|
||||||
|
gruppe = next(
|
||||||
|
(
|
||||||
|
g
|
||||||
|
for g in gruppen
|
||||||
|
if g.preset_key
|
||||||
|
and g.preset_key == preset.key
|
||||||
|
and g.name.lower() == preset.name.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if gruppe is None or gruppe.is_system or gruppe.member_count:
|
||||||
|
continue
|
||||||
|
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
|
||||||
|
continue
|
||||||
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
|
||||||
|
entfernt.append(gruppe.name)
|
||||||
|
return entfernt
|
||||||
|
|
||||||
|
|
||||||
|
def funktionsgruppe_anlegen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
|
) -> int:
|
||||||
|
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
|
||||||
|
|
||||||
|
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
|
||||||
|
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
|
||||||
|
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
|
||||||
|
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
|
||||||
|
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
|
||||||
|
abgelegt hat, sie beim naechsten Aufruf wieder ein.
|
||||||
|
"""
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return int(zeile["id"])
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||||
|
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
|
||||||
|
(preset.name, preset.description, preset.key, _now(), _now()),
|
||||||
|
)
|
||||||
|
gruppe = int(cursor.lastrowid or 0)
|
||||||
|
_write_permissions(conn, gruppe, tree.expand_preset(preset))
|
||||||
|
return gruppe
|
||||||
|
|
||||||
|
|
||||||
|
def funktionsgruppe_fuer_administratoren(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
|
) -> int:
|
||||||
|
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
|
||||||
|
|
||||||
|
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
|
||||||
|
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
|
||||||
|
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
|
||||||
|
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
|
||||||
|
einen Fehler, nicht fuer eine Absicht.
|
||||||
|
|
||||||
|
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
|
||||||
|
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
|
||||||
|
ist die neue Trennung gedacht.
|
||||||
|
|
||||||
|
Gibt die Nummer der Gruppe zurueck.
|
||||||
|
"""
|
||||||
|
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
|
[
|
||||||
|
(int(row["id"]), gruppe)
|
||||||
|
for row in Database.all(
|
||||||
|
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return gruppe
|
||||||
|
|
||||||
|
|
||||||
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
||||||
value = Database.value(
|
value = Database.value(
|
||||||
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
||||||
@@ -556,14 +676,20 @@ def update_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fields: dict[str, Any] = {}
|
fields: dict[str, Any] = {}
|
||||||
|
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
|
||||||
|
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
|
||||||
|
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
|
||||||
for column, value in (
|
for column, value in (
|
||||||
("first_name", first_name),
|
("first_name", first_name),
|
||||||
("last_name", last_name),
|
("last_name", last_name),
|
||||||
("email", email),
|
("email", email),
|
||||||
):
|
):
|
||||||
if value is not None and value.strip() != str(target.get(column) or ""):
|
alt = str(target.get(column) or "")
|
||||||
|
if value is not None and value.strip() != alt:
|
||||||
fields[column] = value.strip()
|
fields[column] = value.strip()
|
||||||
changes.append(f"{column} geändert")
|
changes.append(
|
||||||
|
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
|
||||||
|
)
|
||||||
|
|
||||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||||
if not getattr(actor, "is_admin", False):
|
if not getattr(actor, "is_admin", False):
|
||||||
@@ -677,7 +803,7 @@ def matrix_state(
|
|||||||
granted = set(permissions)
|
granted = set(permissions)
|
||||||
state: dict[str, dict[str, bool]] = {}
|
state: dict[str, dict[str, bool]] = {}
|
||||||
for area in tree.areas:
|
for area in tree.areas:
|
||||||
state[area.key] = {"view": area.view_key in granted}
|
state[area.key] = {"read": area.read_key in granted}
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
state[resource.key] = {
|
state[resource.key] = {
|
||||||
action.value: resource.permission(action) in granted for action in resource.actions
|
action.value: resource.permission(action) in granted for action in resource.actions
|
||||||
@@ -690,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
|||||||
granted = set(permissions)
|
granted = set(permissions)
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for area in tree.areas:
|
for area in tree.areas:
|
||||||
if area.view_key not in granted:
|
if area.read_key not in granted:
|
||||||
continue
|
continue
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
|
||||||
|
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
|
||||||
|
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
|
||||||
ACTION_LETTERS: dict[Action, str] = {
|
ACTION_LETTERS: dict[Action, str] = {
|
||||||
Action.VIEW: "R",
|
action: meta[0] for action, meta in ACTION_META.items()
|
||||||
Action.CREATE: "C",
|
|
||||||
Action.EDIT: "U",
|
|
||||||
Action.DELETE: "D",
|
|
||||||
Action.EXECUTE: "X",
|
|
||||||
Action.EXPORT: "E",
|
|
||||||
Action.SECRETS: "S",
|
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,30 @@ class SettingSpec:
|
|||||||
return str(raw)
|
return str(raw)
|
||||||
|
|
||||||
def serialize(self, value: Any) -> str:
|
def serialize(self, value: Any) -> str:
|
||||||
|
"""Ein Wert als Text fuer die Datenbank.
|
||||||
|
|
||||||
|
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
|
||||||
|
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
|
||||||
|
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
|
||||||
|
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
|
||||||
|
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
|
||||||
|
beim Einspielen ein eingeschalteter -- auch aus
|
||||||
|
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
|
||||||
|
und aus der Navigationsreihenfolge damit eine Zeichenkette.
|
||||||
|
|
||||||
|
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
|
||||||
|
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
|
||||||
|
Wert eine Sicherung.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
if self.kind == "bool":
|
||||||
|
value = self.parse(value)
|
||||||
|
elif self.kind == "json":
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
|
||||||
|
pass
|
||||||
if self.kind == "bool":
|
if self.kind == "bool":
|
||||||
return "1" if value else "0"
|
return "1" if value else "0"
|
||||||
if self.kind == "json":
|
if self.kind == "json":
|
||||||
@@ -67,6 +91,45 @@ class SettingSpec:
|
|||||||
return "" if value is None else str(value)
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein
|
||||||
|
#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar.
|
||||||
|
WERT_GRENZE = 80
|
||||||
|
|
||||||
|
|
||||||
|
class Aenderung(str):
|
||||||
|
"""Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``.
|
||||||
|
|
||||||
|
Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste
|
||||||
|
weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben --
|
||||||
|
so gibt es keine Stelle, die man beim Umbau vergessen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
alt: str
|
||||||
|
neu: str
|
||||||
|
|
||||||
|
def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung":
|
||||||
|
alt_text = "(gesetzt)" if geheim and alt else _kurz(alt)
|
||||||
|
neu_text = "(geaendert)" if geheim else _kurz(neu)
|
||||||
|
selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}")
|
||||||
|
selbst.key = key
|
||||||
|
selbst.alt = alt_text
|
||||||
|
selbst.neu = neu_text
|
||||||
|
return selbst
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(wert: Any) -> str:
|
||||||
|
"""Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt."""
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
if len(text) > WERT_GRENZE:
|
||||||
|
return text[: WERT_GRENZE - 1] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
class SettingsStore:
|
class SettingsStore:
|
||||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||||
|
|
||||||
@@ -140,16 +203,24 @@ class SettingsStore:
|
|||||||
|
|
||||||
def set_many(
|
def set_many(
|
||||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||||
) -> list[str]:
|
) -> list["Aenderung"]:
|
||||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
"""Setzt mehrere Werte und gibt die Aenderungen zurueck.
|
||||||
changed: list[str] = []
|
|
||||||
|
Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu``
|
||||||
|
-- und traegt die Einzelteile als Attribute. Damit steht im
|
||||||
|
Aenderungsprotokoll, was sich geaendert hat, ohne dass jede
|
||||||
|
Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur
|
||||||
|
der Schluessel, und die Frage "was war vorher eingestellt?" liess sich
|
||||||
|
aus dem Protokoll nicht beantworten.
|
||||||
|
"""
|
||||||
|
changed: list[Aenderung] = []
|
||||||
current = self.get_many(conn, list(values))
|
current = self.get_many(conn, list(values))
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
spec = self.spec(key)
|
spec = self.spec(key)
|
||||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||||
continue
|
continue
|
||||||
self.set(conn, key, value, actor=actor)
|
self.set(conn, key, value, actor=actor)
|
||||||
changed.append(key)
|
changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||||
|
|||||||
@@ -1010,7 +1010,25 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.matrix thead th:first-child { text-align: left; }
|
.matrix thead th:first-child { text-align: left; }
|
||||||
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; }
|
/* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
|
||||||
|
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
|
||||||
|
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
|
||||||
|
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
|
||||||
|
.matrix__spalte b {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
|
||||||
|
.matrix__spalte small {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
|
||||||
.matrix tr.matrix__area > td {
|
.matrix tr.matrix__area > td {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -1036,6 +1054,46 @@
|
|||||||
}
|
}
|
||||||
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
||||||
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
|
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||||
|
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
|
||||||
|
.matrix__zeile b { font-weight: 600; }
|
||||||
|
|
||||||
|
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
|
||||||
|
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
|
||||||
|
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
|
||||||
|
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
|
||||||
|
|
||||||
|
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
|
||||||
|
Titelattribut: mit dem Finger gibt es kein "hover". */
|
||||||
|
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||||
|
.matrix__bedeutung b {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix__alle {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.matrix__filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||||
|
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
|
||||||
|
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
|
||||||
|
.matrix tr[hidden] { display: none; }
|
||||||
.matrix__legend {
|
.matrix__legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1456,18 +1514,23 @@
|
|||||||
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||||
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||||
zieht statt die Seite zu scrollen. */
|
zieht statt die Seite zu scrollen. */
|
||||||
|
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||||
|
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||||
.viewer__unterschrift {
|
.viewer__unterschrift {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
container-type: inline-size;
|
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||||
border: 1px dashed transparent;
|
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||||
|
outline: 1px dashed transparent;
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer__unterschrift:hover,
|
.viewer__unterschrift:hover,
|
||||||
.viewer__unterschrift.is-fassend {
|
.viewer__unterschrift.is-fassend {
|
||||||
border-color: var(--accent);
|
outline-color: var(--accent);
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,11 +1546,23 @@
|
|||||||
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||||
anderes als die Datei. */
|
anderes als die Datei. */
|
||||||
.viewer__unterschrifttext {
|
.viewer__unterschrifttext {
|
||||||
font-size: 5.6cqw;
|
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||||
|
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||||
|
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||||
|
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||||
|
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||||
|
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||||
|
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||||
|
font-size: 8px;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: #111;
|
color: #111;
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.35);
|
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||||
padding-top: 1px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
@@ -1581,3 +1656,76 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
background: rgba(201, 42, 42, 0.12);
|
background: rgba(201, 42, 42, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
|
||||||
|
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
|
||||||
|
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
|
||||||
|
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
|
||||||
|
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
|
||||||
|
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
|
||||||
|
darf notfalls hart umbrechen. */
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 52ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.table--stapelbar thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar,
|
||||||
|
.table--stapelbar tbody,
|
||||||
|
.table--stapelbar tr,
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar tr {
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8.5rem 1fr;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: baseline;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px var(--sp-3);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td::before {
|
||||||
|
content: attr(data-spalte);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leere Zellen brauchen keine Zeile. */
|
||||||
|
.table--stapelbar td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
|
||||||
|
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
|
||||||
|
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
|
||||||
|
|
||||||
|
.file-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-cell > svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
@@ -440,6 +440,56 @@ function initPermissionMatrix() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
|
||||||
|
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
|
||||||
|
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
|
||||||
|
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
|
||||||
|
haette nichts gebracht. */
|
||||||
|
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
|
||||||
|
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
|
||||||
|
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
|
||||||
|
// Schirm stehen -- und merkt es erst nach dem Speichern.
|
||||||
|
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
|
||||||
|
const felder = (bereich) =>
|
||||||
|
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
|
||||||
|
|
||||||
|
table.addEventListener("click", (event) => {
|
||||||
|
const ziel = event.target;
|
||||||
|
if (!(ziel instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const zeilenknopf = ziel.closest("[data-matrix-row]");
|
||||||
|
if (zeilenknopf) {
|
||||||
|
event.preventDefault();
|
||||||
|
const row = zeilenknopf.closest("tr");
|
||||||
|
if (!row) return;
|
||||||
|
const kaestchen = felder(row);
|
||||||
|
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
|
||||||
|
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
|
||||||
|
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||||
|
kaestchen.forEach((box) => {
|
||||||
|
box.checked = !alleGesetzt;
|
||||||
|
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaltenknopf = ziel.closest("[data-matrix-column]");
|
||||||
|
if (spaltenknopf) {
|
||||||
|
event.preventDefault();
|
||||||
|
const aktion = spaltenknopf.dataset.matrixColumn;
|
||||||
|
const bereich = spaltenknopf.dataset.area;
|
||||||
|
const kaestchen = $$(
|
||||||
|
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
|
||||||
|
table
|
||||||
|
).filter(sichtbar);
|
||||||
|
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||||
|
kaestchen.forEach((box) => {
|
||||||
|
box.checked = !alleGesetzt;
|
||||||
|
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$$("[data-area-switch]", table).forEach((master) => {
|
$$("[data-area-switch]", table).forEach((master) => {
|
||||||
const areaKey = master.dataset.areaSwitch;
|
const areaKey = master.dataset.areaSwitch;
|
||||||
syncArea(areaKey);
|
syncArea(areaKey);
|
||||||
@@ -451,8 +501,12 @@ function initPermissionMatrix() {
|
|||||||
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
||||||
const row = box.closest("tr");
|
const row = box.closest("tr");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const viewBox = $('input[data-action="view"]', row);
|
// "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
|
||||||
if (box.dataset.action === "view") {
|
// tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
|
||||||
|
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
|
||||||
|
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
|
||||||
|
const viewBox = $('input[data-action="read"]', row);
|
||||||
|
if (box.dataset.action === "read") {
|
||||||
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
||||||
if (!box.checked) {
|
if (!box.checked) {
|
||||||
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
||||||
@@ -469,6 +523,30 @@ function initPermissionMatrix() {
|
|||||||
master.dispatchEvent(new Event("change"));
|
master.dispatchEvent(new Event("change"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const suche = $("[data-matrix-filter]");
|
||||||
|
const zaehler = $("[data-matrix-count]");
|
||||||
|
if (suche) {
|
||||||
|
const zeilen = $$("tr[data-suchtext]", table);
|
||||||
|
const filtern = () => {
|
||||||
|
const begriff = suche.value.trim().toLowerCase();
|
||||||
|
let sichtbar = 0;
|
||||||
|
zeilen.forEach((row) => {
|
||||||
|
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
|
||||||
|
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
|
||||||
|
// Kaestchen entfernt, loescht beim Speichern Rechte.
|
||||||
|
row.hidden = !passt;
|
||||||
|
if (passt) sichtbar += 1;
|
||||||
|
});
|
||||||
|
if (zaehler) {
|
||||||
|
zaehler.textContent = begriff
|
||||||
|
? `${sichtbar} von ${zeilen.length} Zeilen`
|
||||||
|
: `${zeilen.length} Zeilen`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
suche.addEventListener("input", filtern);
|
||||||
|
filtern();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,9 +297,9 @@ async function nachbearbeiten(
|
|||||||
const textbreite = Math.max(
|
const textbreite = Math.max(
|
||||||
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
||||||
);
|
);
|
||||||
let unten = eintrag.y - 2;
|
let unten = eintrag.y - UNTERSCHRIFT.textY;
|
||||||
for (const zeile of zeilen) {
|
for (const zeile of zeilen) {
|
||||||
unten -= groesse * 1.15;
|
unten -= groesse * UNTERSCHRIFT.zeile;
|
||||||
seite.drawText(zeile, {
|
seite.drawText(zeile, {
|
||||||
x: eintrag.x,
|
x: eintrag.x,
|
||||||
y: unten,
|
y: unten,
|
||||||
@@ -308,11 +308,14 @@ async function nachbearbeiten(
|
|||||||
color: PDFLib.rgb(0.1, 0.1, 0.1),
|
color: PDFLib.rgb(0.1, 0.1, 0.1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Kraeftig genug, um neben den Linien eines Formulars als eigener Strich
|
||||||
|
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
|
||||||
|
// den Tabellenlinien schlicht unter.
|
||||||
seite.drawLine({
|
seite.drawLine({
|
||||||
start: { x: eintrag.x, y: eintrag.y - 1 },
|
start: { x: eintrag.x, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
end: { x: eintrag.x + textbreite, y: eintrag.y - 1 },
|
end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
thickness: 0.5,
|
thickness: UNTERSCHRIFT.strich,
|
||||||
color: PDFLib.rgb(0.4, 0.4, 0.4),
|
color: PDFLib.rgb(0.15, 0.15, 0.15),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,12 +342,77 @@ async function nachbearbeiten(
|
|||||||
return dokument.save();
|
return dokument.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Masse des Textblocks unter einer Unterschrift -- in PDF-Punkten.
|
||||||
|
*
|
||||||
|
* Die Vorschau rechnet dieselben Zahlen in Pixel um. Standen sie zweimal im
|
||||||
|
* Code, liefen sie auseinander: die Marke haengt mit ihrer **Unterkante** am
|
||||||
|
* Ankerpunkt, das PDF setzt dort die **Unterkante des Bildes** und schreibt
|
||||||
|
* den Text darunter. Beim Setzen sass der Block deshalb um die Texthoehe
|
||||||
|
* hoeher als in der gespeicherten Datei.
|
||||||
|
*/
|
||||||
|
const UNTERSCHRIFT = {
|
||||||
|
//: Mitte des Strichs unter der Bildkante.
|
||||||
|
strichY: 1.5,
|
||||||
|
//: Staerke des Strichs.
|
||||||
|
strich: 0.9,
|
||||||
|
//: Bildkante bis zur ersten Grundlinie, vor dem Zeilenabstand.
|
||||||
|
textY: 2,
|
||||||
|
//: Zeilenabstand als Vielfaches der Schriftgroesse.
|
||||||
|
zeile: 1.15,
|
||||||
|
//: Oberkante der Zeilenbox bis zur Grundlinie, in "em" -- nur ein
|
||||||
|
//: Rueckfall: gewoehnlich misst "grundlinie()" das am echten Text nach.
|
||||||
|
//: (Helvetica/Arial: Ascender 0,905 plus halber Durchschuss.)
|
||||||
|
grundlinie: 0.9215,
|
||||||
|
};
|
||||||
|
|
||||||
|
//: Gemessene Grundlinien, je dargestellter Schriftgroesse in Pixeln.
|
||||||
|
const grundlinien = new Map();
|
||||||
|
|
||||||
|
/** Abstand von der Oberkante der Zeilenbox zur Grundlinie, in "em".
|
||||||
|
*
|
||||||
|
* Gemessen statt geraten, und bei genau der Pixelgroesse, die auch auf dem
|
||||||
|
* Schirm steht. Zwei Gruende: welche Datei hinter "Helvetica" steckt,
|
||||||
|
* entscheidet das Geraet -- auf einem iPad eine andere als auf einem
|
||||||
|
* Windows-Rechner, mit anderen Oberlaengen. Und der Browser rundet die
|
||||||
|
* Schriftmasse auf ganze Pixel: bei acht Punkt Schrift verschiebt das die
|
||||||
|
* Zeile um einen halben Pixel gegenueber der Rechnung. pdf-lib rundet nicht.
|
||||||
|
*
|
||||||
|
* Eine leere Inline-Box sitzt auf der Grundlinie -- ihre Lage verraet also,
|
||||||
|
* wo der Text steht.
|
||||||
|
*/
|
||||||
|
function grundlinie(px) {
|
||||||
|
const schluessel = px.toFixed(1);
|
||||||
|
const gemerkt = grundlinien.get(schluessel);
|
||||||
|
if (gemerkt !== undefined) return gemerkt;
|
||||||
|
let anteil = UNTERSCHRIFT.grundlinie;
|
||||||
|
try {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.className = "viewer__unterschrifttext";
|
||||||
|
probe.style.cssText =
|
||||||
|
"position:absolute;left:-9999px;top:0;visibility:hidden;border:0;padding:0;";
|
||||||
|
probe.style.fontSize = `${px}px`;
|
||||||
|
probe.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||||
|
const lot = document.createElement("span");
|
||||||
|
lot.style.cssText = "display:inline-block;width:0;height:0;";
|
||||||
|
probe.append(document.createTextNode("Hg"), lot);
|
||||||
|
document.body.append(probe);
|
||||||
|
const oben = probe.getBoundingClientRect().top;
|
||||||
|
const auf = lot.getBoundingClientRect().top;
|
||||||
|
probe.remove();
|
||||||
|
if (auf > oben) anteil = (auf - oben) / px;
|
||||||
|
} catch (fehler) {
|
||||||
|
console.warn("Grundlinie nicht messbar -- Rueckfall auf Helvetica", fehler);
|
||||||
|
}
|
||||||
|
grundlinien.set(schluessel, anteil);
|
||||||
|
return anteil;
|
||||||
|
}
|
||||||
|
|
||||||
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
||||||
*
|
*
|
||||||
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
||||||
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
||||||
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht,
|
* Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
|
||||||
* was spaeter im PDF steht.
|
* Schirm steht, was spaeter im PDF steht.
|
||||||
*/
|
*/
|
||||||
function schriftgroesse(breite) {
|
function schriftgroesse(breite) {
|
||||||
return Math.max(5, Math.min(10, breite / 18));
|
return Math.max(5, Math.min(10, breite / 18));
|
||||||
@@ -634,6 +702,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
const MAX_BREITE = 150;
|
const MAX_BREITE = 150;
|
||||||
const MAX_HOEHE = 50;
|
const MAX_HOEHE = 50;
|
||||||
const unterschriften = [];
|
const unterschriften = [];
|
||||||
|
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
|
||||||
|
const neuzeichnen = new Set();
|
||||||
let wartendeUnterschrift = null;
|
let wartendeUnterschrift = null;
|
||||||
let zeugenzeile = () => "";
|
let zeugenzeile = () => "";
|
||||||
|
|
||||||
@@ -681,11 +751,20 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
const bild = document.createElement("img");
|
const bild = document.createElement("img");
|
||||||
bild.src = eintrag.vorschau;
|
bild.src = eintrag.vorschau;
|
||||||
bild.alt = "";
|
bild.alt = "";
|
||||||
|
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
|
||||||
|
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
|
||||||
|
// Vorschau standen beide hintereinander, getrennt durch einen
|
||||||
|
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
|
||||||
|
// flacher aus als das Ergebnis.
|
||||||
const text = document.createElement("div");
|
const text = document.createElement("div");
|
||||||
text.className = "viewer__unterschrifttext";
|
text.className = "viewer__unterschrifttext";
|
||||||
text.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
const namenszeile = document.createElement("div");
|
||||||
|
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
||||||
|
text.append(namenszeile);
|
||||||
if (zeuge && eintrag === unterschriften[0]) {
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
text.textContent += ` / In TESM abgelegt durch ${zeuge}`;
|
const zeugenzeile = document.createElement("div");
|
||||||
|
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
|
||||||
|
text.append(zeugenzeile);
|
||||||
}
|
}
|
||||||
const griff = document.createElement("button");
|
const griff = document.createElement("button");
|
||||||
griff.type = "button";
|
griff.type = "button";
|
||||||
@@ -702,16 +781,52 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
seitenEl.append(marke);
|
seitenEl.append(marke);
|
||||||
|
|
||||||
const zeichnen = () => {
|
const zeichnen = () => {
|
||||||
|
// Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
// Ankerpunkt, so wie im PDF. Der Text haengt darunter (siehe
|
||||||
|
// Gestaltung: absolut, "top: 100%") und zaehlt nicht zur Hoehe --
|
||||||
|
// sonst saesse der Block in der Vorschau um die Texthoehe zu hoch.
|
||||||
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||||
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||||
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
||||||
|
// Alles Weitere wird gerechnet, nicht vom Stylesheet abgeleitet:
|
||||||
|
// Punkte mal Pixel-je-Punkt der gerade dargestellten Seite. Ueber
|
||||||
|
// Container-Einheiten ("cqw") ging das auch -- aber nur dort, wo der
|
||||||
|
// Browser sie kennt; wo nicht, erbte der Block die Schriftgroesse der
|
||||||
|
// Seite, und der Text war um die Haelfte zu breit.
|
||||||
|
const proPunkt = seitenEl.getBoundingClientRect().width / blick.width;
|
||||||
|
const groesse = schriftgroesse(eintrag.breite);
|
||||||
|
const inPixeln = groesse * proPunkt;
|
||||||
|
const oben = UNTERSCHRIFT.strichY - UNTERSCHRIFT.strich / 2;
|
||||||
|
text.style.fontSize = `${inPixeln}px`;
|
||||||
|
text.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||||
|
text.style.marginTop = `${oben * proPunkt}px`;
|
||||||
|
text.style.borderTopWidth = `${UNTERSCHRIFT.strich * proPunkt}px`;
|
||||||
|
// Rahmenbreiten rundet der Browser auf ganze Geraetepixel: aus 1,21
|
||||||
|
// wird 1. Mit dem gerechneten Wert sass die erste Zeile deshalb einen
|
||||||
|
// Fuenftel Punkt zu hoch -- also mit dem tatsaechlich benutzten rechnen.
|
||||||
|
const strich =
|
||||||
|
(parseFloat(getComputedStyle(text).borderTopWidth) ||
|
||||||
|
UNTERSCHRIFT.strich * proPunkt) / proPunkt;
|
||||||
|
// Oberkante des Strichs, die Strichstaerke, dann so viel Luft, dass die
|
||||||
|
// erste Grundlinie dort sitzt, wo pdf-lib sie setzt: textY plus einen
|
||||||
|
// Zeilenabstand unter der Bildkante.
|
||||||
|
const innen =
|
||||||
|
UNTERSCHRIFT.textY +
|
||||||
|
UNTERSCHRIFT.zeile * groesse -
|
||||||
|
oben -
|
||||||
|
strich -
|
||||||
|
grundlinie(inPixeln) * groesse;
|
||||||
|
text.style.paddingTop = `${Math.max(0, innen) * proPunkt}px`;
|
||||||
};
|
};
|
||||||
|
// Beim Zoomen aendert sich Pixel-je-Punkt -- dann neu rechnen.
|
||||||
|
neuzeichnen.add(zeichnen);
|
||||||
zeichnen();
|
zeichnen();
|
||||||
|
|
||||||
weg.addEventListener("click", (event) => {
|
weg.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const stelle = unterschriften.indexOf(eintrag);
|
const stelle = unterschriften.indexOf(eintrag);
|
||||||
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
||||||
|
neuzeichnen.delete(zeichnen);
|
||||||
marke.remove();
|
marke.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -739,8 +854,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
} else {
|
} else {
|
||||||
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
||||||
eintrag.y = Math.max(
|
eintrag.y = Math.max(
|
||||||
// Platz fuer die Textzeilen unter der Zeichnung.
|
// Platz fuer die beiden Textzeilen unter der Zeichnung.
|
||||||
schriftgroesse(eintrag.breite) * 2.5,
|
UNTERSCHRIFT.textY + 2 * UNTERSCHRIFT.zeile * schriftgroesse(eintrag.breite),
|
||||||
start.y - (zug.clientY - startY) / proPunkt
|
start.y - (zug.clientY - startY) / proPunkt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -763,6 +878,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach dem Zoomen stehen die Marken anteilig richtig, aber die Schrift
|
||||||
|
// haengt an der Pixelgroesse der Seite -- die ist jetzt eine andere.
|
||||||
|
eventBus.on("scalechanging", () => {
|
||||||
|
for (const zeichnen of neuzeichnen) {
|
||||||
|
try {
|
||||||
|
zeichnen();
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error("Unterschrift neu vermessen", fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
@@ -901,6 +1028,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
leiste.hidden = art === "text";
|
leiste.hidden = art === "text";
|
||||||
if (speichern) speichern.hidden = art !== "pdf";
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
||||||
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -942,6 +1070,15 @@ export function initFilePreview(wurzel = document) {
|
|||||||
if (!adresse) return;
|
if (!adresse) return;
|
||||||
try {
|
try {
|
||||||
const stand = await window.tesm.api(adresse);
|
const stand = await window.tesm.api(adresse);
|
||||||
|
if (stand.status === "unveraendert" || stand.status === "veraendert") {
|
||||||
|
// Ein unterschriebenes Dokument ist abgeschlossen. Wer daraus eine
|
||||||
|
// neue Fassung machen koennte, koennte auch eine Unterschrift unter
|
||||||
|
// einen veraenderten Text setzen -- also gibt es hier nichts mehr zu
|
||||||
|
// bearbeiten. Der Server weist es zusaetzlich ab.
|
||||||
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = true;
|
||||||
|
if (speichern) speichern.hidden = true;
|
||||||
|
if (zeile) zeile.hidden = true;
|
||||||
|
}
|
||||||
if (stand.status === "unveraendert") {
|
if (stand.status === "unveraendert") {
|
||||||
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
||||||
if (pruefzeile) {
|
if (pruefzeile) {
|
||||||
|
|||||||
@@ -47,9 +47,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
|
{# Auch fuer einen Administrator die Liste zeigen: sein Kennzeichen gibt
|
||||||
|
den Verwaltungsbereich, der Rest kommt aus Gruppen -- und genau das
|
||||||
|
soll er hier ablesen koennen. "Darf alles" war bis 2.7.x richtig und
|
||||||
|
haette danach das Gegenteil dessen behauptet, was gilt. #}
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }}
|
{{ alert('Als Administrator haben Sie den Verwaltungsbereich immer –
|
||||||
{% elif permissions %}
|
Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb, Diagnose.
|
||||||
|
Alles Weitere steht unten und kommt aus Ihren Gruppen.', 'info') }}
|
||||||
|
{% endif %}
|
||||||
|
{% if permissions %}
|
||||||
<div class="row row--tight">
|
<div class="row row--tight">
|
||||||
{% for permission in permissions %}
|
{% for permission in permissions %}
|
||||||
<span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span>
|
<span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
|
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
|
||||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||||
Anpassungen bleiben erhalten.
|
Anpassungen bleiben erhalten.
|
||||||
{% if current_user.has('groups.edit') %}
|
{% if current_user.has('groups.write') %}
|
||||||
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
|
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
|
||||||
{{ icon('unlock', size=14) }} Rechte freischalten
|
{{ icon('unlock', size=14) }} Rechte freischalten
|
||||||
</a>
|
</a>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<div class="card__title">{{ icon('users', size=17) }} Mitglieder</div>
|
<div class="card__title">{{ icon('users', size=17) }} Mitglieder</div>
|
||||||
<div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div>
|
<div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('users.view') %}
|
{% if current_user.has('users.read') %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a>
|
<a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,8 +44,11 @@
|
|||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{# "table--stapelbar": schmal wird aus jeder Zeile ein kleiner Block. Vorher
|
||||||
|
quetschte sich das Detail mit Pruefsumme in eine Spalte von zwei
|
||||||
|
Zentimetern und lief ueber zehn Zeilen -- lesbar war das nicht. #}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact" data-behavior="sortable">
|
<table class="table table--compact table--stapelbar" data-behavior="sortable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable">Zeit</th>
|
<th class="sortable">Zeit</th>
|
||||||
@@ -60,12 +63,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
<td class="fs-sm nowrap" data-spalte="Zeit" data-sort-value="{{ row.ts }}">
|
||||||
<td class="fs-sm fw-600">{{ row.actor }}</td>
|
{{ row.ts | dt }}
|
||||||
<td class="mono fs-xs">{{ row.action }}</td>
|
</td>
|
||||||
<td class="fs-sm">{{ row.target }}</td>
|
<td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
|
||||||
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
|
<td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
|
||||||
<td>
|
<td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
|
||||||
|
{# Ohne die Minuszeichen stuende bei "white-space: pre-line" die
|
||||||
|
Einrueckung der Vorlage als leere erste Zeile in der Zelle. #}
|
||||||
|
<td class="fs-sm muted break-all td--detail" data-spalte="Detail">
|
||||||
|
{{- row.detail | audit_detail -}}
|
||||||
|
</td>
|
||||||
|
<td data-spalte="Stufe">
|
||||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||||
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
||||||
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
<td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '–' | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -162,8 +162,25 @@
|
|||||||
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '–' | safe }}</dd>
|
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '–' | safe }}</dd>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt -#}
|
{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt.
|
||||||
|
|
||||||
|
Seit 2.8.0 hat jede Funktion ihre eigene Zeile -- 37 statt 21. Das ist der
|
||||||
|
Sinn der Sache, macht die Tabelle aber lang, und eine lange Tabelle
|
||||||
|
verleitet dazu, oben den Bereichsschalter zu setzen und fertig. Drei
|
||||||
|
Hilfen dagegen: Unterzeilen stehen eingerueckt unter ihrer Hauptzeile, die
|
||||||
|
Funktion steht in Kleinschrift daneben, und ein Filterfeld blendet aus,
|
||||||
|
wonach man nicht sucht. -#}
|
||||||
{% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%}
|
{% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%}
|
||||||
|
{% if not disabled %}
|
||||||
|
<div class="matrix__filter">
|
||||||
|
<label class="field field--inline">
|
||||||
|
<span class="field__label">Zeile suchen</span>
|
||||||
|
<input type="search" class="input input--sm" data-matrix-filter
|
||||||
|
placeholder="z. B. Neustart, DHCP, Sicherung" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<span class="matrix__filter-hint" data-matrix-count></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="matrix" data-behavior="permission-matrix">
|
<table class="matrix" data-behavior="permission-matrix">
|
||||||
{% for area in tree.areas %}
|
{% for area in tree.areas %}
|
||||||
@@ -172,8 +189,20 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="min-width:240px">{{ area.label }}</th>
|
<th style="min-width:240px">{{ area.label }}</th>
|
||||||
{% for action in columns %}
|
{% for action in columns %}
|
||||||
<th title="{{ action_meta[action][1] }} – {{ action_meta[action][2] }}">
|
<th title="{{ action_meta[action][1] }} ({{ action_meta[action][2] }}) –
|
||||||
{{ action_meta[action][0] }}
|
{{ action_meta[action][3] }}">
|
||||||
|
<span class="matrix__spalte">
|
||||||
|
<b>{{ action_meta[action][0] }}</b>
|
||||||
|
<span>{{ action_meta[action][1] }}</span>
|
||||||
|
<small>{{ action_meta[action][2] }}</small>
|
||||||
|
{% if not disabled %}
|
||||||
|
<button type="button" class="matrix__alle" data-matrix-column="{{ action.value }}"
|
||||||
|
data-area="{{ area.key }}" title="Ganze Spalte in diesem Bereich"
|
||||||
|
aria-label="Alle {{ action_meta[action][1] }}-Rechte dieses Bereichs umschalten">
|
||||||
|
alle
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</th>
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -182,9 +211,9 @@
|
|||||||
<tr class="matrix__area">
|
<tr class="matrix__area">
|
||||||
<td class="matrix__killswitch">
|
<td class="matrix__killswitch">
|
||||||
<label class="check">
|
<label class="check">
|
||||||
<input type="checkbox" name="{{ field_prefix }}" value="{{ area.view_key }}"
|
<input type="checkbox" name="{{ field_prefix }}" value="{{ area.read_key }}"
|
||||||
data-area-switch="{{ area.key }}"
|
data-area-switch="{{ area.key }}"
|
||||||
{% if state.get(area.key, {}).get('view') %}checked{% endif %}
|
{% if state.get(area.key, {}).get('read') %}checked{% endif %}
|
||||||
{% if disabled %}disabled{% endif %}>
|
{% if disabled %}disabled{% endif %}>
|
||||||
<span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span>
|
<span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -192,23 +221,48 @@
|
|||||||
{% for action in columns %}<td></td>{% endfor %}
|
{% for action in columns %}<td></td>{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% for resource in area.resources %}
|
{% for resource in area.resources %}
|
||||||
<tr data-area="{{ area.key }}">
|
<tr data-area="{{ area.key }}"
|
||||||
|
data-suchtext="{{ (resource.label ~ ' ' ~ resource.hint) | lower }}"
|
||||||
|
{% if resource.parent %}class="matrix__unterzeile"{% endif %}>
|
||||||
<td>
|
<td>
|
||||||
<span class="matrix__resource-label">
|
<span class="matrix__resource-label">
|
||||||
{{ resource.label }}
|
<span class="matrix__zeile">
|
||||||
|
<b>{{ resource.short_label }}</b>
|
||||||
|
{% if not disabled %}
|
||||||
|
{# Ganze Zeile: bei 37 Zeilen mit bis zu sieben Spalten
|
||||||
|
sind das ueber 200 Kaestchen. Wer einer Rolle eine
|
||||||
|
Funktion ganz geben will, soll nicht siebenmal
|
||||||
|
klicken. Hinter der Beschriftung, nicht davor -- vorn
|
||||||
|
gelesen ergab "alle Clients" einen falschen Satz. #}
|
||||||
|
<button type="button" class="matrix__alle" data-matrix-row
|
||||||
|
title="Ganze Zeile an- oder abwählen"
|
||||||
|
aria-label="Alle Rechte dieser Zeile umschalten">alle</button>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
{% if resource.hint %}<small>{{ resource.hint }}</small>{% endif %}
|
{% if resource.hint %}<small>{{ resource.hint }}</small>{% endif %}
|
||||||
|
{# Was eine Aktion in dieser Zeile bedeutet, steht in der
|
||||||
|
Zeile -- nicht in einem Titelattribut, das man nur mit der
|
||||||
|
Maus findet, und nicht in einer Legende unter einer
|
||||||
|
Tabelle, die man dafuer erst zu Ende scrollt. #}
|
||||||
|
{% for action in resource.actions if resource.action_hints.get(action) %}
|
||||||
|
<small class="matrix__bedeutung">
|
||||||
|
<b>{{ action_meta[action][0] }}</b>
|
||||||
|
{{ action_meta[action][1] }} –
|
||||||
|
{{ resource.action_hints.get(action) }}
|
||||||
|
</small>
|
||||||
|
{% endfor %}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{% for action in columns %}
|
{% for action in columns %}
|
||||||
<td>
|
<td>
|
||||||
{% if resource.has(action) %}
|
{% if resource.has(action) %}
|
||||||
<label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][2] }}">
|
<label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][3] }}">
|
||||||
<input type="checkbox" name="{{ field_prefix }}"
|
<input type="checkbox" name="{{ field_prefix }}"
|
||||||
value="{{ resource.permission(action) }}"
|
value="{{ resource.permission(action) }}"
|
||||||
data-action="{{ action.value }}"
|
data-action="{{ action.value }}"
|
||||||
{% if state.get(resource.key, {}).get(action.value) %}checked{% endif %}
|
{% if state.get(resource.key, {}).get(action.value) %}checked{% endif %}
|
||||||
{% if disabled %}disabled{% endif %}>
|
{% if disabled %}disabled{% endif %}>
|
||||||
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
<span class="sr-only">{{ resource.label }} {{ action_meta[action][2] }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
|
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
|
||||||
@@ -229,7 +283,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="matrix__legend mt-3">
|
<div class="matrix__legend mt-3">
|
||||||
{% for action, meta in action_meta.items() %}
|
{% for action, meta in action_meta.items() %}
|
||||||
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
<span><b>{{ meta[0] }}</b> {{ meta[1] }} ({{ meta[2] }}) – {{ meta[3] }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
||||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||||
|
|||||||
@@ -119,7 +119,13 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="fs-sm">{{ entry.ad_group_name }}
|
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||||
<td>{{ pill(entry.group_name or 'unbekannt', 'accent') }}</td>
|
<td>
|
||||||
|
{% if entry.grants_admin %}
|
||||||
|
{{ pill('Administrator (Kennzeichen)', 'warning') }}
|
||||||
|
{% else %}
|
||||||
|
{{ pill(entry.group_name or 'unbekannt', 'accent') }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" data-no-guard
|
<form method="post" data-no-guard
|
||||||
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
||||||
@@ -140,22 +146,35 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }}
|
'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('settings_directory.edit') %}
|
{% if current_user.has('settings_directory.write') %}
|
||||||
<form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}">
|
<form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }}
|
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }}
|
||||||
{# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. #}
|
{# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld.
|
||||||
|
|
||||||
|
Das Kennzeichen steht mit in derselben Liste, weil es dieselbe
|
||||||
|
Frage beantwortet: "was wird dieses Konto hier?". Vorher gab es
|
||||||
|
nur Gruppen, und "Domänen-Admins" landete deshalb in der Gruppe
|
||||||
|
*Administration* -- die Benutzer und Rechte pflegt, aber kein
|
||||||
|
Administratorkennzeichen hat und sich keines geben kann. #}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">
|
<span class="field__label">
|
||||||
Gruppe in dieser Anwendung<span class="req" aria-hidden="true">*</span>
|
Ziel in dieser Anwendung<span class="req" aria-hidden="true">*</span>
|
||||||
</span>
|
</span>
|
||||||
<select class="select" name="app_group_id" required>
|
<select class="select" name="ziel" required>
|
||||||
{% for group in groups %}
|
{% for group in groups %}
|
||||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
<option value="gruppe:{{ group.id }}">{{ group.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<option value="administrator">Administrator (Kennzeichen)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span class="field__hint">
|
||||||
|
„Administrator (Kennzeichen)“ macht Mitglieder dieser Verzeichnisgruppe zu
|
||||||
|
echten Administratoren: sie haben immer den ganzen Verwaltungsbereich.
|
||||||
|
Funktionsrechte kommen weiter über Gruppen. Wer aus der Verzeichnisgruppe
|
||||||
|
austritt, verliert das Kennzeichen bei der nächsten Anmeldung.
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.has('settings_network.edit') and interfaces %}
|
{% if current_user.has('settings_network.write') and interfaces %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Sei
|
|||||||
<td class="muted fs-sm">{{ entry.detail }}</td>
|
<td class="muted fs-sm">{{ entry.detail }}</td>
|
||||||
<td class="fs-sm"><span data-relative-time="{{ entry.deleted_at }}">{{ entry.deleted_at | dt }}</span></td>
|
<td class="fs-sm"><span data-relative-time="{{ entry.deleted_at }}">{{ entry.deleted_at | dt }}</span></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if current_user.has('trash.edit') %}
|
{% if current_user.has('trash.write') %}
|
||||||
<form method="post" style="display:inline" data-no-guard
|
<form method="post" style="display:inline" data-no-guard
|
||||||
action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}">
|
action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
{% else %}<span class="faint">noch nie</span>{% endif %}
|
{% else %}<span class="faint">noch nie</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if current_user.has('users.edit') %}
|
{% if current_user.has('users.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten">
|
data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten">
|
||||||
{{ icon('pencil', size=15) }}
|
{{ icon('pencil', size=15) }}
|
||||||
@@ -137,7 +137,18 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }}
|
{# Das Kennzeichen heisst seit 2.8.0 "Verwaltung" und nicht "alles":
|
||||||
|
Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen.
|
||||||
|
Stand hier weiter "darf alles", waere die Beschriftung eine
|
||||||
|
falsche Zusage -- und niemand wuerde die Gruppe dazugeben. #}
|
||||||
|
{{ checkbox('is_admin',
|
||||||
|
'Administrator – hat immer den ganzen Verwaltungsbereich') }}
|
||||||
|
<p class="fs-xs faint">
|
||||||
|
Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb und Diagnose gelten
|
||||||
|
für ein Administratorkonto immer, auch ohne Gruppe. Alles andere – Geräte,
|
||||||
|
Neustarts, DHCP, Dateifreigaben, Protokolle – kommt über Gruppen; die
|
||||||
|
Gruppe „Funktionen (Vollzugriff)“ gibt es dafür schon.
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +191,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('users.edit') %}
|
{% if current_user.has('users.write') %}
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
{% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username,
|
{% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username,
|
||||||
'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %}
|
'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %}
|
||||||
@@ -211,7 +222,8 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="stack stack--tight">
|
<div class="stack stack--tight">
|
||||||
{% if current_user.is_admin and user.id != current_user.id %}
|
{% if current_user.is_admin and user.id != current_user.id %}
|
||||||
{{ checkbox('is_admin', 'Administrator', checked=user.is_admin) }}
|
{{ checkbox('is_admin', 'Administrator – hat immer den ganzen Verwaltungsbereich',
|
||||||
|
checked=user.is_admin) }}
|
||||||
{% elif user.is_admin %}
|
{% elif user.is_admin %}
|
||||||
{{ pill('Administrator', 'accent') }}
|
{{ pill('Administrator', 'accent') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ def audit_detail(text: Any) -> Markup:
|
|||||||
if not roh or not MITSCHRIFT.search(roh):
|
if not roh or not MITSCHRIFT.search(roh):
|
||||||
return Markup(escape(roh))
|
return Markup(escape(roh))
|
||||||
try:
|
try:
|
||||||
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view"))
|
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.read"))
|
||||||
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
|
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
|
||||||
erlaubt = False
|
erlaubt = False
|
||||||
if not erlaubt:
|
if not erlaubt:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def _int_list(name: str) -> list[int]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/users")
|
@bp.get("/users")
|
||||||
@permission_required("users.view")
|
@permission_required("users.read")
|
||||||
def users():
|
def users():
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -95,7 +95,7 @@ def user_create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/users/<int:user_id>")
|
@bp.post("/users/<int:user_id>")
|
||||||
@permission_required("users.edit")
|
@permission_required("users.write")
|
||||||
def user_update(user_id: int):
|
def user_update(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -164,7 +164,7 @@ def user_delete(user_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/users/<int:user_id>/end-sessions")
|
@bp.post("/users/<int:user_id>/end-sessions")
|
||||||
@permission_required("users.edit")
|
@permission_required("users.write")
|
||||||
def user_revoke_sessions(user_id: int):
|
def user_revoke_sessions(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
target = rbac.get_user(conn, user_id)
|
target = rbac.get_user(conn, user_id)
|
||||||
@@ -239,7 +239,7 @@ def directory_adopt():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/groups")
|
@bp.get("/groups")
|
||||||
@permission_required("groups.view")
|
@permission_required("groups.read")
|
||||||
def groups():
|
def groups():
|
||||||
conn = db()
|
conn = db()
|
||||||
tree = core().permissions
|
tree = core().permissions
|
||||||
@@ -255,7 +255,7 @@ def groups():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/groups/<int:group_id>")
|
@bp.get("/groups/<int:group_id>")
|
||||||
@permission_required("groups.view")
|
@permission_required("groups.read")
|
||||||
def group_detail(group_id: int):
|
def group_detail(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
group = rbac.get_group(conn, group_id)
|
group = rbac.get_group(conn, group_id)
|
||||||
@@ -274,7 +274,7 @@ def group_detail(group_id: int):
|
|||||||
"WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE",
|
"WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE",
|
||||||
(group_id,),
|
(group_id,),
|
||||||
),
|
),
|
||||||
can_edit=current_user.has("groups.edit"),
|
can_edit=current_user.has("groups.write"),
|
||||||
unlocked=request.args.get("unlocked") == "1",
|
unlocked=request.args.get("unlocked") == "1",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ def group_create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/groups/<int:group_id>")
|
@bp.post("/groups/<int:group_id>")
|
||||||
@permission_required("groups.edit")
|
@permission_required("groups.write")
|
||||||
def group_update(group_id: int):
|
def group_update(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -427,9 +427,15 @@ def nav_order():
|
|||||||
if submitted:
|
if submitted:
|
||||||
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
|
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
vorher = extension.settings.get(conn, "nav_order") or extension.nav.default_order()
|
||||||
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
|
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
|
||||||
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
|
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
|
||||||
_audit(conn, "system.nav_order_changed", severity="info")
|
_audit(
|
||||||
|
conn,
|
||||||
|
"system.nav_order_changed",
|
||||||
|
detail=f"{', '.join(vorher)} -> {', '.join(order)}",
|
||||||
|
severity="info",
|
||||||
|
)
|
||||||
flash("Reihenfolge der Navigation gespeichert.", "success")
|
flash("Reihenfolge der Navigation gespeichert.", "success")
|
||||||
return redirect(request.referrer or url_for("index"))
|
return redirect(request.referrer or url_for("index"))
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics"
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("diagnostics.view")
|
@permission_required("diagnostics.read")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def _license_hint(feature: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/network")
|
@bp.get("/network")
|
||||||
@permission_required("settings_network.view")
|
@permission_required("settings_network.read")
|
||||||
def network():
|
def network():
|
||||||
extension = core()
|
extension = core()
|
||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
@@ -81,7 +81,7 @@ def network():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/network")
|
@bp.post("/network")
|
||||||
@permission_required("settings_network.edit")
|
@permission_required("settings_network.write")
|
||||||
def network_apply():
|
def network_apply():
|
||||||
extension = core()
|
extension = core()
|
||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
@@ -148,7 +148,7 @@ def network_apply():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/network/confirm")
|
@bp.post("/network/confirm")
|
||||||
@permission_required("settings_network.edit")
|
@permission_required("settings_network.write")
|
||||||
def network_confirm():
|
def network_confirm():
|
||||||
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
|
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
|
||||||
flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
|
flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
|
||||||
@@ -222,7 +222,7 @@ def _web_config() -> webserver.WebConfig:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/web")
|
@bp.get("/web")
|
||||||
@permission_required("settings_web.view")
|
@permission_required("settings_web.read")
|
||||||
def web():
|
def web():
|
||||||
extension = core()
|
extension = core()
|
||||||
config = _web_config()
|
config = _web_config()
|
||||||
@@ -241,7 +241,7 @@ def web():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/web")
|
@bp.post("/web")
|
||||||
@permission_required("settings_web.edit")
|
@permission_required("settings_web.write")
|
||||||
def web_save():
|
def web_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -415,7 +415,7 @@ def web_self_signed():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/directory")
|
@bp.get("/directory")
|
||||||
@permission_required("settings_directory.view")
|
@permission_required("settings_directory.read")
|
||||||
def directory_settings():
|
def directory_settings():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -436,7 +436,7 @@ def directory_settings():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/directory")
|
@bp.post("/directory")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.write")
|
||||||
def directory_save():
|
def directory_save():
|
||||||
problem = _license_hint("directory")
|
problem = _license_hint("directory")
|
||||||
if problem:
|
if problem:
|
||||||
@@ -490,7 +490,7 @@ def directory_test():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/directory/group-search")
|
@bp.get("/directory/group-search")
|
||||||
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
|
@permission_required("settings_directory.write", "fileshare.write", mode="any")
|
||||||
def directory_group_search():
|
def directory_group_search():
|
||||||
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
|
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
|
||||||
|
|
||||||
@@ -505,21 +505,30 @@ def directory_group_search():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/directory/mapping")
|
@bp.post("/directory/mapping")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.write")
|
||||||
def directory_mapping_add():
|
def directory_mapping_add():
|
||||||
conn = db()
|
conn = db()
|
||||||
dn = (request.form.get("ad_group_dn") or "").strip()
|
dn = (request.form.get("ad_group_dn") or "").strip()
|
||||||
name = (request.form.get("ad_group_name") or "").strip()
|
name = (request.form.get("ad_group_name") or "").strip()
|
||||||
group_id = request.form.get("app_group_id")
|
# Ein Feld, zwei Arten von Ziel: "gruppe:<id>" oder "administrator". Zwei
|
||||||
if not dn or not group_id:
|
# Felder waeren zwei Zustaende, von denen einer immer falsch ist.
|
||||||
flash("Verzeichnisgruppe und Zielgruppe sind beide erforderlich.", "danger")
|
ziel = (request.form.get("ziel") or request.form.get("app_group_id") or "").strip()
|
||||||
|
kennzeichen = ziel == "administrator"
|
||||||
|
gruppe: int | None = None
|
||||||
|
if not kennzeichen:
|
||||||
|
nummer = ziel.split(":", 1)[1] if ziel.startswith("gruppe:") else ziel
|
||||||
|
gruppe = int(nummer) if nummer.isdigit() else None
|
||||||
|
if not dn or (gruppe is None and not kennzeichen):
|
||||||
|
flash("Verzeichnisgruppe und Ziel sind beide erforderlich.", "danger")
|
||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id, created_at) "
|
"INSERT INTO ldap_group_mappings "
|
||||||
"VALUES (?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||||
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
|
"VALUES (?,?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||||
(dn, name or dn, int(group_id)),
|
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
|
||||||
|
"grants_admin=excluded.grants_admin",
|
||||||
|
(dn, name or dn, gruppe, int(kennzeichen)),
|
||||||
)
|
)
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
@@ -527,15 +536,22 @@ def directory_mapping_add():
|
|||||||
actor_id=current_user.id,
|
actor_id=current_user.id,
|
||||||
action="settings.directory_mapping_added",
|
action="settings.directory_mapping_added",
|
||||||
target=name or dn,
|
target=name or dn,
|
||||||
|
detail=(
|
||||||
|
"Ziel: Administrator (Kennzeichen)"
|
||||||
|
if kennzeichen
|
||||||
|
else f"Ziel: Gruppe {gruppe}"
|
||||||
|
),
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="notice",
|
# Eine Zuordnung, die Administratoren erzeugt, ist mehr als eine
|
||||||
|
# Notiz wert: sie entscheidet, wer die Anlage verwaltet.
|
||||||
|
severity="warning" if kennzeichen else "notice",
|
||||||
)
|
)
|
||||||
flash("Zuordnung gespeichert.", "success")
|
flash("Zuordnung gespeichert.", "success")
|
||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/directory/mapping/<int:mapping_id>/delete")
|
@bp.post("/directory/mapping/<int:mapping_id>/delete")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.write")
|
||||||
def directory_mapping_delete(mapping_id: int):
|
def directory_mapping_delete(mapping_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
|
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def _live_log():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/live")
|
@bp.get("/live")
|
||||||
@permission_required("logs_live.view")
|
@permission_required("logs_live.read")
|
||||||
def live():
|
def live():
|
||||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||||
needle = (request.args.get("q") or "").strip()
|
needle = (request.args.get("q") or "").strip()
|
||||||
@@ -53,7 +53,7 @@ def live():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/live/content")
|
@bp.get("/live/content")
|
||||||
@permission_required("logs_live.view")
|
@permission_required("logs_live.read")
|
||||||
@fragment
|
@fragment
|
||||||
def live_fragment():
|
def live_fragment():
|
||||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||||
@@ -64,7 +64,7 @@ def live_fragment():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/history")
|
@bp.get("/history")
|
||||||
@permission_required("logs_history.view", "logs_history.export", mode="any")
|
@permission_required("logs_history.read", "logs_history.export", mode="any")
|
||||||
def history():
|
def history():
|
||||||
extension = core()
|
extension = core()
|
||||||
rotated = []
|
rotated = []
|
||||||
@@ -83,7 +83,7 @@ def history():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/history/file/<path:name>")
|
@bp.get("/history/file/<path:name>")
|
||||||
@permission_required("logs_history.view")
|
@permission_required("logs_history.read")
|
||||||
def history_file(name: str):
|
def history_file(name: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
target = (extension.config.log_dir / name).resolve()
|
target = (extension.config.log_dir / name).resolve()
|
||||||
@@ -102,7 +102,7 @@ def history_file(name: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/history/archive/<day>")
|
@bp.get("/history/archive/<day>")
|
||||||
@permission_required("logs_history.view")
|
@permission_required("logs_history.read")
|
||||||
def history_archive(day: str):
|
def history_archive(day: str):
|
||||||
if len(day) != 10 or day.count("-") != 2:
|
if len(day) != 10 or day.count("-") != 2:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -177,7 +177,7 @@ def history_purge():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/audit")
|
@bp.get("/audit")
|
||||||
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
|
@permission_required("logs_audit.read", "logs_audit.export", mode="any")
|
||||||
def audit_view():
|
def audit_view():
|
||||||
conn = db()
|
conn = db()
|
||||||
page = max(1, int(request.args.get("page") or 1))
|
page = max(1, int(request.args.get("page") or 1))
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ def _purge_expired(conn: sqlite3.Connection) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("trash.view")
|
@permission_required("trash.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
removed = 0
|
removed = 0
|
||||||
@@ -192,7 +192,7 @@ def _provider_or_404(key: str) -> TrashProvider:
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<ident>/restore")
|
@bp.post("/<kind>/<ident>/restore")
|
||||||
@permission_required("trash.edit")
|
@permission_required("trash.write")
|
||||||
def restore(kind: str, ident: str):
|
def restore(kind: str, ident: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.7.3"
|
version = "2.7.7"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in new issue
Block a user