TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,721 @@
|
||||
"""Tests der Sicherheitsgrenzen des Kerns.
|
||||
|
||||
Jeder Test hier deckt eine Grenze ab, die im Vorgaengerprojekt entweder fehlte
|
||||
oder nur im Template durchgesetzt wurde.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from tesm_core import audit
|
||||
from tesm_core.db import Database, NestedConnectionError
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.keystore import DecryptionError, Keystore
|
||||
from tesm_core.migrations import split_statements
|
||||
from tesm_core.rbac import service as rbac
|
||||
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
|
||||
from tesm_core.security import passwords, totp
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Rechtebaum
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _tree() -> PermissionTree:
|
||||
return PermissionTree(
|
||||
[
|
||||
Area(
|
||||
key="ops",
|
||||
label="Betrieb",
|
||||
icon="cpu",
|
||||
resources=(
|
||||
Resource(
|
||||
"devices",
|
||||
"Geraete",
|
||||
(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.EXECUTE),
|
||||
),
|
||||
Resource("switches", "Switche", (Action.VIEW, Action.EDIT)),
|
||||
),
|
||||
),
|
||||
Area(
|
||||
key="admin",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
resources=(Resource("users", "Benutzer", (Action.VIEW, Action.CREATE)),),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_area_columns_only_contain_used_actions():
|
||||
tree = _tree()
|
||||
ops = tree.area("ops")
|
||||
admin = tree.area("admin")
|
||||
assert Action.EXECUTE in ops.columns()
|
||||
assert Action.EXECUTE not in admin.columns()
|
||||
assert Action.DELETE not in admin.columns()
|
||||
|
||||
|
||||
def test_sanitize_adds_view_and_area_switch():
|
||||
tree = _tree()
|
||||
result = tree.sanitize(["devices.delete"])
|
||||
assert result == ["devices.delete", "devices.view", "ops.view"]
|
||||
|
||||
|
||||
def test_sanitize_drops_unknown_permissions():
|
||||
tree = _tree()
|
||||
assert tree.sanitize(["gibtsnicht.view", "devices.view"]) == ["devices.view", "ops.view"]
|
||||
|
||||
|
||||
def test_kill_switch_disables_all_children():
|
||||
tree = _tree()
|
||||
granted = {"devices.view", "devices.edit", "switches.view"}
|
||||
assert tree.effective(granted) == set() # ohne ops.view greift nichts
|
||||
assert tree.effective(granted | {"ops.view"}) == granted | {"ops.view"}
|
||||
|
||||
|
||||
def test_duplicate_resource_keys_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
PermissionTree(
|
||||
[
|
||||
Area("a", "A", "grid", (Resource("x", "X", (Action.VIEW,)),)),
|
||||
Area("b", "B", "grid", (Resource("x", "X", (Action.VIEW,)),)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_resource_without_view_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
PermissionTree([Area("a", "A", "grid", (Resource("x", "X", (Action.EDIT,)),))])
|
||||
|
||||
|
||||
def test_preset_expansion():
|
||||
tree = _tree()
|
||||
preset = RolePreset(
|
||||
key="ops-read",
|
||||
name="Betrieb lesen",
|
||||
description="",
|
||||
grants=(("devices", (Action.VIEW,)), ("switches", "*")),
|
||||
)
|
||||
assert tree.expand_preset(preset) == [
|
||||
"devices.view",
|
||||
"ops.view",
|
||||
"switches.edit",
|
||||
"switches.view",
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Eskalationsschutz
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _Actor:
|
||||
def __init__(self, is_admin: bool, permissions: set[str]):
|
||||
self.is_admin = is_admin
|
||||
self.id = 42
|
||||
self._permissions = permissions
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
return self.is_admin or all(p in self._permissions for p in permissions)
|
||||
|
||||
|
||||
def test_no_amplification_blocks_unknown_permission():
|
||||
tree = _tree()
|
||||
actor = _Actor(False, {"ops.view", "devices.view"})
|
||||
with pytest.raises(rbac.PermissionDenied) as exc:
|
||||
rbac.guard_no_amplification(actor, tree, ["devices.delete"])
|
||||
assert "nicht besitzen" in str(exc.value)
|
||||
|
||||
|
||||
def test_no_amplification_allows_own_permissions():
|
||||
tree = _tree()
|
||||
actor = _Actor(False, {"ops.view", "devices.view"})
|
||||
rbac.guard_no_amplification(actor, tree, ["devices.view", "ops.view"])
|
||||
|
||||
|
||||
def test_admin_may_grant_anything():
|
||||
tree = _tree()
|
||||
rbac.guard_no_amplification(_Actor(True, set()), tree, ["devices.delete"])
|
||||
|
||||
|
||||
def test_group_editor_cannot_escalate_via_new_group(core_app, make_user):
|
||||
"""Der zentrale Fall: ein Gruppenverwalter darf sich nicht selbst hoeherstufen."""
|
||||
from tesm_core.auth.service import load_user
|
||||
|
||||
extension = core(core_app)
|
||||
row = make_user("gruppenchef", "Ein-gutes-Passwort-1", permissions=("groups.create", "groups.view"))
|
||||
with core_app.app_context(), extension.database.session() as conn:
|
||||
actor = load_user(conn, int(row["id"]))
|
||||
assert actor is not None
|
||||
with extension.database.transaction(conn):
|
||||
with pytest.raises(rbac.PermissionDenied):
|
||||
rbac.create_group(
|
||||
conn,
|
||||
name="Hintertuer",
|
||||
description="",
|
||||
permissions=["users.create", "administration.view"],
|
||||
tree=extension.permissions,
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
|
||||
def test_last_admin_cannot_be_demoted(core_app, make_user):
|
||||
from tesm_core.auth.service import load_user
|
||||
|
||||
extension = core(core_app)
|
||||
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
||||
with core_app.app_context(), extension.database.session() as conn:
|
||||
actor = load_user(conn, int(admin_row["id"]))
|
||||
second = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, updated_at) "
|
||||
"VALUES ('zweiter','x',1,'local',datetime('now'),datetime('now'))"
|
||||
)
|
||||
second_id = int(second.lastrowid or 0)
|
||||
with extension.database.transaction(conn):
|
||||
# Solange zwei Admins existieren, ist das Herabstufen erlaubt.
|
||||
rbac.update_user(
|
||||
conn, second_id, actor=actor, tree=extension.permissions, is_admin=False
|
||||
)
|
||||
with extension.database.transaction(conn):
|
||||
with pytest.raises(rbac.PermissionDenied):
|
||||
rbac.guard_last_admin(conn, int(admin_row["id"]), action="Herabstufen")
|
||||
|
||||
|
||||
def test_cannot_change_own_admin_flag(core_app, make_user):
|
||||
from tesm_core.auth.service import load_user
|
||||
|
||||
extension = core(core_app)
|
||||
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
||||
with core_app.app_context(), extension.database.session() as conn:
|
||||
actor = load_user(conn, int(row["id"]))
|
||||
with extension.database.transaction(conn):
|
||||
with pytest.raises(rbac.PermissionDenied):
|
||||
rbac.update_user(
|
||||
conn, int(row["id"]), actor=actor, tree=extension.permissions, is_admin=False
|
||||
)
|
||||
|
||||
|
||||
def test_non_admin_cannot_touch_admin_account(core_app, make_user):
|
||||
from tesm_core.auth.service import load_user
|
||||
|
||||
extension = core(core_app)
|
||||
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
||||
editor_row = make_user("helfer", "Ein-gutes-Passwort-1", permissions=("users.edit", "users.view"))
|
||||
with core_app.app_context(), extension.database.session() as conn:
|
||||
actor = load_user(conn, int(editor_row["id"]))
|
||||
with extension.database.transaction(conn):
|
||||
with pytest.raises(rbac.PermissionDenied):
|
||||
rbac.update_user(
|
||||
conn,
|
||||
int(admin_row["id"]),
|
||||
actor=actor,
|
||||
tree=extension.permissions,
|
||||
is_locked=True,
|
||||
)
|
||||
|
||||
|
||||
def test_system_group_permissions_locked(core_app, make_user):
|
||||
from tesm_core.auth.service import load_user
|
||||
|
||||
extension = core(core_app)
|
||||
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
||||
with core_app.app_context(), extension.database.session() as conn:
|
||||
actor = load_user(conn, int(row["id"]))
|
||||
default = next(g for g in rbac.list_groups(conn) if g.is_default)
|
||||
with extension.database.transaction(conn):
|
||||
with pytest.raises(rbac.PermissionDenied):
|
||||
rbac.update_group(
|
||||
conn,
|
||||
default.id,
|
||||
permissions=[],
|
||||
tree=extension.permissions,
|
||||
actor=actor,
|
||||
)
|
||||
# Mit ausdruecklicher Freischaltung geht es.
|
||||
rbac.update_group(
|
||||
conn,
|
||||
default.id,
|
||||
permissions=[],
|
||||
tree=extension.permissions,
|
||||
actor=actor,
|
||||
unlock_system=True,
|
||||
)
|
||||
|
||||
|
||||
def test_presets_are_not_reset_on_restart(core_app):
|
||||
"""Anders als im Vorgaenger bleiben Anpassungen an Systemgruppen erhalten."""
|
||||
extension = core(core_app)
|
||||
with extension.database.session() as conn:
|
||||
default = next(g for g in rbac.list_groups(conn) if g.is_default)
|
||||
with extension.database.transaction(conn):
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default.id,))
|
||||
created = rbac.ensure_presets(conn, extension.permissions, extension.role_presets)
|
||||
assert created == []
|
||||
assert rbac.group_permissions(conn, default.id) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Datenbank
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_second_connection_during_write_transaction_raises(tmp_path):
|
||||
database = Database(tmp_path / "test.db")
|
||||
with database.session() as conn:
|
||||
conn.execute("CREATE TABLE t (a INTEGER)")
|
||||
with database.transaction(conn):
|
||||
conn.execute("INSERT INTO t VALUES (1)")
|
||||
with pytest.raises(NestedConnectionError) as exc:
|
||||
database.connect()
|
||||
assert "Schreibtransaktion" in str(exc.value)
|
||||
|
||||
|
||||
def test_nested_transactions_commit_once(tmp_path):
|
||||
database = Database(tmp_path / "test.db")
|
||||
with database.session() as conn:
|
||||
conn.execute("CREATE TABLE t (a INTEGER)")
|
||||
with database.transaction(conn):
|
||||
conn.execute("INSERT INTO t VALUES (1)")
|
||||
with database.transaction(conn):
|
||||
conn.execute("INSERT INTO t VALUES (2)")
|
||||
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 2
|
||||
|
||||
|
||||
def test_inner_rollback_keeps_outer(tmp_path):
|
||||
database = Database(tmp_path / "test.db")
|
||||
with database.session() as conn:
|
||||
conn.execute("CREATE TABLE t (a INTEGER)")
|
||||
with database.transaction(conn):
|
||||
conn.execute("INSERT INTO t VALUES (1)")
|
||||
try:
|
||||
with database.transaction(conn):
|
||||
conn.execute("INSERT INTO t VALUES (2)")
|
||||
raise RuntimeError("abbruch")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 1
|
||||
|
||||
|
||||
def test_foreign_keys_enforced(tmp_path):
|
||||
database = Database(tmp_path / "test.db")
|
||||
with database.session() as conn:
|
||||
conn.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
||||
conn.execute("CREATE TABLE child (p INTEGER REFERENCES parent(id))")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO child VALUES (99)")
|
||||
|
||||
|
||||
def test_wal_mode_active(tmp_path):
|
||||
database = Database(tmp_path / "test.db")
|
||||
with database.session() as conn:
|
||||
assert Database.value(conn, "PRAGMA journal_mode").lower() == "wal"
|
||||
|
||||
|
||||
def test_split_statements_handles_comments_and_strings():
|
||||
script = """
|
||||
-- ein Kommentar mit ; Semikolon
|
||||
CREATE TABLE t (a TEXT DEFAULT 'x;y');
|
||||
INSERT INTO t VALUES ('a;b'); /* Block; Kommentar */
|
||||
"""
|
||||
statements = split_statements(script)
|
||||
assert len(statements) == 2
|
||||
assert "x;y" in statements[0]
|
||||
assert "a;b" in statements[1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schluessel und Verschluesselung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_keystore_roundtrip_and_aad_binding(tmp_path):
|
||||
keystore = Keystore(tmp_path / "data.keys")
|
||||
token = keystore.encrypt("geheim", aad="credentials.password:1")
|
||||
assert keystore.decrypt(token, aad="credentials.password:1") == "geheim"
|
||||
# Dasselbe Chiffrat an anderer Stelle eingespielt -> Fehler.
|
||||
with pytest.raises(DecryptionError):
|
||||
keystore.decrypt(token, aad="credentials.password:2")
|
||||
|
||||
|
||||
def test_keystore_rotation_keeps_old_values_readable(tmp_path):
|
||||
keystore = Keystore(tmp_path / "data.keys")
|
||||
old_token = keystore.encrypt("alt", aad="x")
|
||||
new_id = keystore.rotate()
|
||||
assert new_id == "2"
|
||||
assert keystore.decrypt(old_token, aad="x") == "alt"
|
||||
assert keystore.needs_reencrypt(old_token) is True
|
||||
new_token = keystore.encrypt("neu", aad="x")
|
||||
assert keystore.needs_reencrypt(new_token) is False
|
||||
|
||||
|
||||
def test_keystore_file_is_private(tmp_path):
|
||||
import os
|
||||
import stat
|
||||
|
||||
path = tmp_path / "data.keys"
|
||||
Keystore(path)
|
||||
if os.name == "posix":
|
||||
mode = path.stat().st_mode
|
||||
assert not mode & (stat.S_IRGRP | stat.S_IROTH)
|
||||
|
||||
|
||||
def test_tampered_ciphertext_rejected(tmp_path):
|
||||
keystore = Keystore(tmp_path / "data.keys")
|
||||
token = keystore.encrypt("geheim", aad="x")
|
||||
prefix, key_id, nonce, ciphertext = token.split(".", 3)
|
||||
broken = f"{prefix}.{key_id}.{nonce}.{ciphertext[:-4]}AAAA"
|
||||
with pytest.raises(DecryptionError):
|
||||
keystore.decrypt(broken, aad="x")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Passwoerter und TOTP
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_argon2_hash_and_verify():
|
||||
stored = passwords.hash_password("Ein-gutes-Passwort-1")
|
||||
assert stored.startswith("$argon2id$")
|
||||
assert passwords.verify_password(stored, "Ein-gutes-Passwort-1") is True
|
||||
assert passwords.verify_password(stored, "falsch") is False
|
||||
assert passwords.needs_rehash(stored) is False
|
||||
|
||||
|
||||
def test_bcrypt_legacy_hash_accepted_and_flagged_for_rehash():
|
||||
bcrypt = pytest.importorskip("bcrypt")
|
||||
legacy = bcrypt.hashpw(b"Altes-Passwort-1", bcrypt.gensalt(rounds=4)).decode()
|
||||
assert passwords.is_legacy_hash(legacy) is True
|
||||
assert passwords.verify_password(legacy, "Altes-Passwort-1") is True
|
||||
assert passwords.needs_rehash(legacy) is True
|
||||
|
||||
|
||||
def test_password_normalisation_across_platforms():
|
||||
import unicodedata
|
||||
|
||||
nfd = unicodedata.normalize("NFD", "Grüße-aus-München-1")
|
||||
nfc = unicodedata.normalize("NFC", "Grüße-aus-München-1")
|
||||
assert nfd != nfc
|
||||
stored = passwords.hash_password(nfd)
|
||||
assert passwords.verify_password(stored, nfc) is True
|
||||
|
||||
|
||||
def test_password_policy_rules():
|
||||
policy = passwords.PasswordPolicy(min_length=12)
|
||||
assert policy.check("Ein-gutes-Passwort-1") == []
|
||||
assert any("Zeichen erforderlich" in p for p in policy.check("kurz"))
|
||||
assert any("Zeichenarten" in p for p in policy.check("aaaaaaaaaaaaaaa"))
|
||||
assert any("Benutzernamen" in p for p in policy.check("Tester-Passwort-1", username="tester"))
|
||||
assert any("haeufig" in p for p in policy.check("passwort1234"))
|
||||
|
||||
|
||||
def test_totp_generates_and_verifies():
|
||||
secret = totp.generate_secret()
|
||||
code = totp.current_code(secret)
|
||||
assert totp.verify_code(secret, code) is True
|
||||
assert totp.verify_code(secret, "000000") is False
|
||||
assert totp.verify_code(secret, code, now=0) is False
|
||||
|
||||
|
||||
def test_totp_drift_tolerance():
|
||||
secret = totp.generate_secret()
|
||||
now = 1_700_000_000
|
||||
previous = totp.code_at(secret, int(now // totp.PERIOD) - 1)
|
||||
assert totp.verify_code(secret, previous, now=now) is True
|
||||
far = totp.code_at(secret, int(now // totp.PERIOD) - 5)
|
||||
assert totp.verify_code(secret, far, now=now) is False
|
||||
|
||||
|
||||
def test_totp_uri_contains_issuer_and_secret():
|
||||
secret = totp.generate_secret()
|
||||
uri = totp.provisioning_uri(secret, account="anna", issuer="TESM Test")
|
||||
assert uri.startswith("otpauth://totp/")
|
||||
assert secret in uri
|
||||
assert "issuer=TESM%20Test" in uri
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Aenderungsprotokoll
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_audit_chain_detects_modification(core_app):
|
||||
extension = core(core_app)
|
||||
with extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
for index in range(5):
|
||||
audit.log(conn, actor="tester", action="test.event", target=f"ziel-{index}")
|
||||
assert audit.verify_chain(conn)["ok"] is True
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
conn.execute("UPDATE audit_log SET detail='manipuliert' WHERE id=3")
|
||||
result = audit.verify_chain(conn)
|
||||
assert result["ok"] is False
|
||||
assert result["broken_at"] == 3
|
||||
assert "Hash" in result["reason"]
|
||||
|
||||
|
||||
def test_audit_chain_detects_deletion(core_app):
|
||||
extension = core(core_app)
|
||||
with extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
for index in range(5):
|
||||
audit.log(conn, actor="tester", action="test.event", target=str(index))
|
||||
with extension.database.transaction(conn):
|
||||
conn.execute("DELETE FROM audit_log WHERE id=3")
|
||||
result = audit.verify_chain(conn)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
def test_audit_archiving_moves_full_days(core_app, tmp_path):
|
||||
extension = core(core_app)
|
||||
old_day = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0)
|
||||
with extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
for index in range(40):
|
||||
audit.log(
|
||||
conn,
|
||||
actor="tester",
|
||||
action="test.old",
|
||||
target=str(index),
|
||||
ts=old_day + timedelta(seconds=index),
|
||||
)
|
||||
for index in range(5):
|
||||
audit.log(conn, actor="tester", action="test.new", target=str(index))
|
||||
with extension.database.transaction(conn):
|
||||
written = audit.archive_old_rows(conn, tmp_path / "archiv", threshold=10, target=5)
|
||||
assert written
|
||||
# Uebrig bleiben ausschliesslich Zeilen des laufenden Tages (inkl. der
|
||||
# Startzeile, die die App beim Migrieren selbst geschrieben hat).
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
remaining = Database.all(conn, "SELECT DISTINCT substr(ts,1,10) AS day FROM audit_log")
|
||||
assert [row["day"] for row in remaining] == [today]
|
||||
lines = (tmp_path / "archiv" / written[0].name).read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 40
|
||||
assert json.loads(lines[0])["action"] == "test.old"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sitzungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_session_id_rotates_on_login(client, make_user, login):
|
||||
make_user("anna", "Ein-gutes-Passwort-1")
|
||||
client.get("/login")
|
||||
before = client.get_cookie("tesm_session")
|
||||
login("anna", "Ein-gutes-Passwort-1")
|
||||
after = client.get_cookie("tesm_session")
|
||||
assert after is not None
|
||||
if before is not None:
|
||||
assert before.value != after.value
|
||||
|
||||
|
||||
def test_session_revocation_takes_effect_immediately(client, core_app, make_user, login):
|
||||
from tesm_core.security import sessions as session_store
|
||||
|
||||
row = make_user("anna", "Ein-gutes-Passwort-1")
|
||||
login("anna", "Ein-gutes-Passwort-1")
|
||||
assert client.get("/konto/").status_code == 200
|
||||
|
||||
extension = core(core_app)
|
||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||
session_store.revoke_all_for_user(conn, int(row["id"]), reason="test")
|
||||
|
||||
response = client.get("/konto/")
|
||||
assert response.status_code == 302
|
||||
assert "/login" in response.headers["Location"]
|
||||
|
||||
|
||||
def test_password_change_revokes_other_sessions(core_app, make_user):
|
||||
from tesm_core.security import sessions as session_store
|
||||
|
||||
extension = core(core_app)
|
||||
row = make_user("anna", "Ein-gutes-Passwort-1")
|
||||
first = core_app.test_client()
|
||||
second = core_app.test_client()
|
||||
for client_instance in (first, second):
|
||||
page = client_instance.get("/login")
|
||||
body = page.get_data(as_text=True)
|
||||
marker = 'name="csrf_token" value="'
|
||||
start = body.index(marker) + len(marker)
|
||||
token = body[start : body.index('"', start)]
|
||||
client_instance.post(
|
||||
"/login",
|
||||
data={"username": "anna", "password": "Ein-gutes-Passwort-1", "csrf_token": token},
|
||||
follow_redirects=True,
|
||||
)
|
||||
with extension.database.session() as conn:
|
||||
assert len(session_store.active_for_user(conn, int(row["id"]))) == 2
|
||||
|
||||
page = first.get("/konto/sicherheit")
|
||||
body = page.get_data(as_text=True)
|
||||
marker = 'name="csrf_token" value="'
|
||||
start = body.index(marker) + len(marker)
|
||||
token = body[start : body.index('"', start)]
|
||||
first.post(
|
||||
"/konto/passwort",
|
||||
data={
|
||||
"current_password": "Ein-gutes-Passwort-1",
|
||||
"new_password": "Neues-Passwort-2026",
|
||||
"repeat_password": "Neues-Passwort-2026",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert second.get("/konto/").status_code == 302
|
||||
assert first.get("/konto/").status_code == 200
|
||||
|
||||
|
||||
def test_reauth_required_for_sensitive_action(client, make_user, login, csrf_token):
|
||||
make_user("anna", "Ein-gutes-Passwort-1")
|
||||
login("anna", "Ein-gutes-Passwort-1")
|
||||
|
||||
# Frisch angemeldet gilt die Bestaetigung -- danach kuenstlich altern lassen.
|
||||
from flask import session as flask_session
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess["_reauth_at"] = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
token = csrf_token("/konto/sicherheit")
|
||||
response = client.post("/konto/zwei-faktor/starten", data={"csrf_token": token})
|
||||
assert response.status_code == 302
|
||||
assert "/bestaetigen" in response.headers["Location"]
|
||||
assert flask_session is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Der Rechteweg muss tatsaechlich begehbar sein
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_units_do_not_disable_the_privilege_path():
|
||||
"""``NoNewPrivileges`` schaltet setuid ab -- und damit sudo, und damit den Helfer.
|
||||
|
||||
Auf beiden Testhosts stand "Helfer erreichbar", waehrend in Wahrheit jede
|
||||
Systemaktion an ``sudo: the "no new privileges" flag is set`` scheiterte:
|
||||
nginx uebernehmen, Netzwerk aendern, Zertifikat anfordern, Kea schreiben.
|
||||
Die Haertung hatte genau den Mechanismus abgeschaltet, den sie schuetzen
|
||||
sollte.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent / "deploy" / "systemd"
|
||||
units = list(root.glob("*.service"))
|
||||
assert units
|
||||
for unit in units:
|
||||
text = unit.read_text(encoding="utf-8")
|
||||
assert "NoNewPrivileges=yes" not in text, (
|
||||
f"{unit.name}: verhindert sudo und damit jede Systemaktion."
|
||||
)
|
||||
# Gemessen: auch ein eingeschraenktes Bounding Set bricht sudo mit
|
||||
# "unable to change to root gid" -- inklusive CapabilityBoundingSet=CAP_NET_RAW.
|
||||
for line in text.splitlines():
|
||||
if line.startswith("CapabilityBoundingSet="):
|
||||
raise AssertionError(
|
||||
f"{unit.name}: {line!r} nimmt sudo CAP_SETUID/CAP_SETGID."
|
||||
)
|
||||
# Die eigentliche Grenze bleibt bestehen.
|
||||
# "strict" sperrt auch den Helfer aus /etc aus -- gemessen.
|
||||
assert "ProtectSystem=full" in text, unit.name
|
||||
assert "ProtectSystem=strict" not in text, (
|
||||
f"{unit.name}: strict sperrt den Helfer aus /etc aus."
|
||||
)
|
||||
assert "PrivateTmp=yes" in text, unit.name
|
||||
assert "User=root" not in text, unit.name
|
||||
|
||||
|
||||
def test_the_helper_is_probed_not_just_looked_for():
|
||||
""""Datei liegt da" ist keine Auskunft darueber, ob der Aufruf gelingt."""
|
||||
import inspect
|
||||
|
||||
from tesm_core.sysops import SysOps
|
||||
|
||||
assert hasattr(SysOps, "selftest")
|
||||
source = inspect.getsource(SysOps.selftest)
|
||||
assert 'self.run("service-status", "nginx")' in source
|
||||
assert 'self.run("nginx-test")' not in source, (
|
||||
"Ein fehlgeschlagenes nginx -t heisst 'Konfiguration kaputt', nicht 'Helfer kaputt'."
|
||||
)
|
||||
# Die beiden Fehlerbilder, die im Betrieb wirklich vorkommen, werden benannt.
|
||||
assert "no new privileges" in source
|
||||
assert "a password is required" in source
|
||||
|
||||
|
||||
def test_selftest_explains_a_blocked_sudo(monkeypatch):
|
||||
from tesm_core.sysops import Result, SysOps
|
||||
|
||||
class Blocked(SysOps):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
def run(self, verb, *args, stdin="", timeout=None):
|
||||
return Result(
|
||||
False, "", 'sudo: The "no new privileges" flag is set, which prevents sudo', 1
|
||||
)
|
||||
|
||||
result = Blocked(app_key="tesm").selftest()
|
||||
assert not result.ok
|
||||
assert "NoNewPrivileges" in result.message
|
||||
assert "systemctl" in result.message
|
||||
|
||||
|
||||
def test_selftest_reports_a_missing_sudoers_rule():
|
||||
from tesm_core.sysops import Result, SysOps
|
||||
|
||||
class NeedsPassword(SysOps):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
def run(self, verb, *args, stdin="", timeout=None):
|
||||
return Result(False, "", "sudo: a password is required", 1)
|
||||
|
||||
result = NeedsPassword(app_key="tesm").selftest()
|
||||
assert not result.ok
|
||||
assert "sudoers" in result.message
|
||||
|
||||
|
||||
def test_selftest_says_so_when_the_helper_is_absent(tmp_path):
|
||||
from tesm_core.sysops import SysOps
|
||||
|
||||
result = SysOps(app_key="tesm", helper=tmp_path / "gibt-es-nicht").selftest()
|
||||
assert not result.ok
|
||||
assert "fehlt" in result.message
|
||||
|
||||
|
||||
def test_authenticate_keeps_directory_groups(core_app, monkeypatch):
|
||||
"""Die Verzeichnisgruppen duerfen auf dem Weg nach oben nicht verlorengehen.
|
||||
|
||||
``_verify_credentials`` hat sie korrekt geliefert, ``authenticate`` hat aus
|
||||
dem Ergebnis aber ein neues Objekt gebaut und das Feld dabei fallen lassen.
|
||||
Sichtbar war davon nichts: die Anmeldung gelang, nur band sich keine
|
||||
Freigabe mehr ein. Genau solche stillen Verluste faengt dieser Test.
|
||||
"""
|
||||
from tesm_core.auth import directory, service
|
||||
from tesm_core.extension import core
|
||||
|
||||
profil = directory.DirectoryProfile(
|
||||
username="mitarbeiter",
|
||||
dn="CN=mitarbeiter,OU=Benutzer,DC=firma,DC=local",
|
||||
group_dns=("CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",),
|
||||
)
|
||||
monkeypatch.setattr(directory, "is_enabled", lambda conn: True)
|
||||
monkeypatch.setattr(
|
||||
directory, "authenticate", lambda conn, name, pw: directory.DirectoryResult("ok", profile=profil)
|
||||
)
|
||||
|
||||
with core_app.test_request_context("/login"):
|
||||
with core(core_app).database.session() as conn:
|
||||
outcome = service.authenticate(conn, "mitarbeiter", "egal", ip="127.0.0.1")
|
||||
|
||||
assert outcome.ok, outcome.message
|
||||
assert outcome.group_dns == profil.group_dns, "die Gruppen sind unterwegs verlorengegangen"
|
||||
Reference in New Issue
Block a user