TESM 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * 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. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * 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