diff --git a/hermes_cli/dashboard_auth/audit.py b/hermes_cli/dashboard_auth/audit.py new file mode 100644 index 00000000000..d20cdb7def8 --- /dev/null +++ b/hermes_cli/dashboard_auth/audit.py @@ -0,0 +1,86 @@ +"""Audit log for dashboard-auth events. + +Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``. +Format: one JSON object per line. Token-like fields are stripped before +serialisation to avoid leaking refresh tokens or JWTs to disk. + +This module deliberately keeps a minimal dependency surface — no imports +from ``hermes_constants`` or other hermes_cli modules — so it can be +imported safely from middleware code that loads early in the startup +sequence. +""" +from __future__ import annotations + +import datetime as _dt +import enum +import json +import logging +import os +import threading +from pathlib import Path +from typing import Any + +_log = logging.getLogger(__name__) +_write_lock = threading.Lock() + +# Field names that must never appear in the log raw. Any kwarg matching +# these is silently dropped. +_REDACTED_FIELDS: frozenset = frozenset({ + "access_token", "refresh_token", "code", "code_verifier", + "state", "ticket", "cookie", "Authorization", "authorization", +}) + + +class AuditEvent(enum.Enum): + """Event types written to dashboard-auth.log. + + Values are the literal ``event`` field on the JSON line. + """ + + LOGIN_START = "login_start" + LOGIN_SUCCESS = "login_success" + LOGIN_FAILURE = "login_failure" + LOGOUT = "logout" + REFRESH_SUCCESS = "refresh_success" + REFRESH_FAILURE = "refresh_failure" + REVOKE = "revoke" + SESSION_VERIFY_FAILURE = "session_verify_failure" + WS_TICKET_MINTED = "ws_ticket_minted" + + +def _resolve_log_path() -> Path: + """``$HERMES_HOME/logs/dashboard-auth.log`` with the standard fallback. + + Mirrors ``hermes_constants.get_hermes_home`` semantics: env var wins, + else ``~/.hermes``. A local copy avoids an import cycle with the + middleware which lives below ``hermes_cli``. + """ + home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes") + return Path(home) / "logs" / "dashboard-auth.log" + + +def audit_log(event: AuditEvent, **fields: Any) -> None: + """Append one event to the audit log. + + Token-like fields are dropped. Missing log directory is created. + Write failures are logged at WARNING but never raise — auth must not + fail because the audit logger broke. + """ + safe_fields = { + k: v for k, v in fields.items() + if k not in _REDACTED_FIELDS + } + entry = { + "ts": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "event": event.value, + **safe_fields, + } + line = json.dumps(entry, separators=(",", ":")) + "\n" + path = _resolve_log_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with _write_lock: + with open(path, "a", encoding="utf-8") as f: + f.write(line) + except Exception as e: + _log.warning("dashboard-auth audit log write failed: %s", e) diff --git a/tests/hermes_cli/test_dashboard_auth_audit.py b/tests/hermes_cli/test_dashboard_auth_audit.py new file mode 100644 index 00000000000..1de51e17bb2 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_auth_audit.py @@ -0,0 +1,81 @@ +"""Audit log for dashboard-auth events. + +Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``. +Format: one JSON object per line. Token-like kwargs are dropped before +serialisation so we never leak refresh tokens or JWTs to disk. +""" +from __future__ import annotations + +import json +import pytest + +from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent + + +@pytest.fixture +def profile_home(tmp_path, monkeypatch): + """Redirect $HERMES_HOME and ~ to a tmp dir for the duration of the test.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # Some code paths fall back to Path.home() — patch that too. + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + return home + + +def test_audit_writes_jsonlines(profile_home): + audit_log(AuditEvent.LOGIN_START, provider="nous", ip="1.2.3.4") + audit_log( + AuditEvent.LOGIN_SUCCESS, + provider="nous", user_id="u1", + email="a@b.com", ip="1.2.3.4", + ) + + path = profile_home / "logs" / "dashboard-auth.log" + assert path.exists(), f"audit log not created at {path}" + lines = path.read_text().strip().splitlines() + assert len(lines) == 2 + + second = json.loads(lines[1]) + assert second["event"] == "login_success" + assert second["provider"] == "nous" + assert second["user_id"] == "u1" + assert second["email"] == "a@b.com" + assert "ts" in second # ISO-8601 timestamp + + +def test_audit_redacts_token_like_fields(profile_home): + audit_log( + AuditEvent.LOGIN_SUCCESS, + provider="nous", access_token="should-not-appear", + refresh_token="also-not", code="not-this", state="nope", + ) + raw = (profile_home / "logs" / "dashboard-auth.log").read_text() + for forbidden in ("should-not-appear", "also-not", "not-this", "nope"): + assert forbidden not in raw, f"token-like value leaked into audit log: {forbidden}" + + +def test_audit_all_event_types_have_string_values(): + for ev in AuditEvent: + assert isinstance(ev.value, str) + assert ev.value + + +def test_audit_write_failure_does_not_raise(monkeypatch, tmp_path): + """A broken audit log must not crash auth.""" + # Point HERMES_HOME at a file (not a dir) so mkdir/open will fail. + broken = tmp_path / "not-a-dir" + broken.write_text("blocking file") + monkeypatch.setenv("HERMES_HOME", str(broken)) + # Should NOT raise. + audit_log(AuditEvent.LOGIN_FAILURE, provider="nous", reason="x") + + +def test_audit_creates_logs_dir_if_missing(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # logs/ deliberately does not exist + audit_log(AuditEvent.LOGIN_START, provider="nous") + assert (home / "logs").is_dir() + assert (home / "logs" / "dashboard-auth.log").exists()