From 8f0b14bc621912d325e6d32bd2b0e0773e387bb5 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Wed, 22 Apr 2026 11:29:37 -0400 Subject: [PATCH] Scope audit activity by session --- .env-sample | 4 +++ tests/test_api_audit.py | 53 ++++++++++++++++++++++++++++++++++++++- web/audit.py | 15 ++++++++++- web/config.py | 9 +++++++ web/routers/audit.py | 19 +++++++++++--- web/routers/auth.py | 3 ++- web/session.py | 11 +++++++- web/static/js/activity.js | 21 ++++++++++++++-- web/static/js/api.js | 4 +-- web/static/js/auth.js | 1 + web/static/js/settings.js | 5 ++++ web/static/js/state.js | 1 + 12 files changed, 134 insertions(+), 12 deletions(-) diff --git a/.env-sample b/.env-sample index 50404ea..faee090 100644 --- a/.env-sample +++ b/.env-sample @@ -51,3 +51,7 @@ SESSION_SECRET_KEY=change-me # Optional override for the server-side browser session store. # Each tester gets an isolated session file here after they connect in the web UI. SESSION_STORE_DIR= + +# Optional comma-separated admin emails. +# Matching Adobe or DocuSign user emails can view all audit activity; everyone else only sees their own session activity. +ADMIN_EMAILS= diff --git a/tests/test_api_audit.py b/tests/test_api_audit.py index 4953e59..5077e27 100644 --- a/tests/test_api_audit.py +++ b/tests/test_api_audit.py @@ -56,7 +56,8 @@ def test_adobe_connect_writes_audit_event(monkeypatch): assert resp.status_code == 200 - activity = client.get("/api/audit/recent") + session_cookie = resp.cookies.get("migrator_session") + activity = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_cookie}) assert activity.status_code == 200 events = activity.json()["events"] assert events @@ -120,3 +121,53 @@ def test_migration_writes_audit_event(): assert migrate_event["adobe_account_name"] == "Adobe QA" assert migrate_event["details"]["template_count"] == 1 assert migrate_event["details"]["success_count"] == 1 + + +def test_audit_recent_is_session_scoped_by_default(): + session_a = create_test_session({ + "docusign_user_email": "a@example.com", + "docusign_user_name": "Tester A", + }, session_id="session-a") + session_b = create_test_session({ + "docusign_user_email": "b@example.com", + "docusign_user_name": "Tester B", + }, session_id="session-b") + + import web.config as cfg + with open(cfg.settings.audit_log_file, "w", encoding="utf-8") as f: + f.write(json.dumps({"timestamp": "2026-04-22T15:00:00Z", "action": "docusign_connected", "session_id": "session-a"}) + "\n") + f.write(json.dumps({"timestamp": "2026-04-22T15:01:00Z", "action": "migration_run", "session_id": "session-b"}) + "\n") + + resp_a = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_a}) + resp_b = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_b}) + + assert resp_a.status_code == 200 + assert resp_b.status_code == 200 + assert [event["session_id"] for event in resp_a.json()["events"]] == ["session-a"] + assert [event["session_id"] for event in resp_b.json()["events"]] == ["session-b"] + + +def test_admin_can_request_all_audit_events(monkeypatch): + import web.config as cfg + + monkeypatch.setattr(cfg.settings, "admin_emails_raw", "admin@example.com") + admin_cookie = create_test_session({ + "docusign_user_email": "admin@example.com", + }, session_id="admin-session") + user_cookie = create_test_session({ + "docusign_user_email": "user@example.com", + }, session_id="user-session") + + with open(cfg.settings.audit_log_file, "w", encoding="utf-8") as f: + f.write(json.dumps({"timestamp": "2026-04-22T15:00:00Z", "action": "docusign_connected", "session_id": "admin-session"}) + "\n") + f.write(json.dumps({"timestamp": "2026-04-22T15:01:00Z", "action": "migration_run", "session_id": "user-session"}) + "\n") + + admin_resp = client.get("/api/audit/recent?all=true", cookies={_COOKIE_NAME: admin_cookie}) + user_resp = client.get("/api/audit/recent?all=true", cookies={_COOKIE_NAME: user_cookie}) + + assert admin_resp.status_code == 200 + assert user_resp.status_code == 200 + assert admin_resp.json()["scope"] == "all" + assert len(admin_resp.json()["events"]) == 2 + assert user_resp.json()["scope"] == "session" + assert [event["session_id"] for event in user_resp.json()["events"]] == ["user-session"] diff --git a/web/audit.py b/web/audit.py index d3712f3..d7f44a6 100644 --- a/web/audit.py +++ b/web/audit.py @@ -9,6 +9,7 @@ from typing import Any from fastapi import Request from web.config import settings +from web.session import ensure_session_id def _audit_path() -> str: @@ -44,6 +45,15 @@ def _session_identity(session: dict[str, Any]) -> dict[str, Any]: } +def is_admin_session(session: dict[str, Any]) -> bool: + emails = { + (session.get("docusign_user_email") or "").strip().lower(), + (session.get("adobe_user_email") or "").strip().lower(), + } + emails.discard("") + return bool(emails & settings.admin_emails) + + def request_context(request: Request) -> dict[str, Any]: return { "path": str(request.url.path), @@ -78,6 +88,7 @@ def build_event(request: Request, session: dict[str, Any], action: str, details: def log_event(request: Request, session: dict[str, Any], action: str, details: dict[str, Any] | None = None) -> dict[str, Any]: + ensure_session_id(session) event = build_event(request, session, action, details) _ensure_parent_dir() with open(_audit_path(), "a", encoding="utf-8") as f: @@ -98,7 +109,7 @@ def log_context_event( return event -def recent_events(limit: int = 100) -> list[dict[str, Any]]: +def recent_events(limit: int = 100, *, session_id: str | None = None, include_all: bool = False) -> list[dict[str, Any]]: path = _audit_path() if not os.path.exists(path): return [] @@ -117,5 +128,7 @@ def recent_events(limit: int = 100) -> list[dict[str, Any]]: except json.JSONDecodeError: continue if isinstance(item, dict): + if not include_all and session_id and item.get("session_id") != session_id: + continue events.append(item) return events diff --git a/web/config.py b/web/config.py index e9dff44..78a677b 100644 --- a/web/config.py +++ b/web/config.py @@ -36,9 +36,18 @@ class Settings: "AUDIT_LOG_FILE", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".audit-log.jsonl")), ) + admin_emails_raw: str = os.getenv("ADMIN_EMAILS", "") # App version: str = "2.0" + @property + def admin_emails(self) -> set[str]: + return { + email.strip().lower() + for email in self.admin_emails_raw.split(",") + if email.strip() + } + settings = Settings() diff --git a/web/routers/audit.py b/web/routers/audit.py index 63d3fe3..8df98a2 100644 --- a/web/routers/audit.py +++ b/web/routers/audit.py @@ -1,11 +1,22 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request -from web.audit import recent_events +from web.audit import is_admin_session, recent_events +from web.session import get_session router = APIRouter() @router.get("/recent") -def get_recent_events(limit: int = 100): +def get_recent_events(request: Request, limit: int = 100, all: bool = False): limit = max(1, min(limit, 500)) - return {"events": recent_events(limit)} + session = get_session(request) + include_all = all and is_admin_session(session) + return { + "events": recent_events( + limit, + session_id=session.get("_session_id"), + include_all=include_all, + ), + "scope": "all" if include_all else "session", + "is_admin": is_admin_session(session), + } diff --git a/web/routers/auth.py b/web/routers/auth.py index 3b48bad..7e96838 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -20,7 +20,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, RedirectResponse from pydantic import BaseModel -from web.audit import log_event +from web.audit import is_admin_session, log_event from web.config import settings from web.docusign_context import ( DocusignContextError, @@ -117,6 +117,7 @@ def auth_status(request: Request): "docusign_account_name": (docusign_account or {}).get("account_name"), "base_url": (docusign_account or {}).get("base_url", settings.docusign_base_url), "docusign_account_selection_required": account_picker_required(session), + "is_admin": is_admin_session(session), } diff --git a/web/session.py b/web/session.py index 5a9cd34..5ef5d11 100644 --- a/web/session.py +++ b/web/session.py @@ -110,6 +110,15 @@ def get_session_id(request: Request) -> str | None: return session.get(_SESSION_ID_KEY) +def ensure_session_id(data: dict) -> str: + sid = data.get(_SESSION_ID_KEY) + if sid: + return sid + sid = _new_session_id() + data[_SESSION_ID_KEY] = sid + return sid + + def create_test_session(data: dict, session_id: str | None = None) -> str: """ Test helper: create a server-side session and return a valid cookie value. @@ -123,7 +132,7 @@ def create_test_session(data: dict, session_id: str | None = None) -> str: def save_session(response: Response, data: dict, session_id: str | None = None) -> str: """Persist session data server-side and set the signed session-id cookie.""" - sid = session_id or data.get(_SESSION_ID_KEY) or _new_session_id() + sid = session_id or ensure_session_id(data) payload = dict(data) payload.pop(_SESSION_ID_KEY, None) _write_server_session(sid, payload) diff --git a/web/static/js/activity.js b/web/static/js/activity.js index bbaf735..2f7f2ac 100644 --- a/web/static/js/activity.js +++ b/web/static/js/activity.js @@ -1,6 +1,7 @@ // Recent activity view for tester/admin auditing import { api } from './api.js'; +import { state } from './state.js'; import { escHtml, formatDateTime } from './utils.js'; const ACTION_LABELS = { @@ -17,21 +18,32 @@ const ACTION_LABELS = { verification_sent: 'Verification sent', verification_voided: 'Verification voided', }; +const ACTIVITY_SCOPE_KEY = 'migrator_activity_scope'; export async function renderActivity() { const outlet = document.getElementById('router-outlet'); outlet.innerHTML = `
`; + const showAll = state.auth.isAdmin && localStorage.getItem(ACTIVITY_SCOPE_KEY) === 'all'; try { - const data = await api.audit.recent(150); + const data = await api.audit.recent(150, showAll); const events = data.events || []; + const isAdmin = !!data.is_admin; + const scope = data.scope || 'session'; outlet.innerHTML = ` ${events.length === 0 ? ` @@ -63,6 +75,11 @@ export async function renderActivity() { `} `; + + document.getElementById('btn-toggle-activity-scope')?.addEventListener('click', () => { + localStorage.setItem(ACTIVITY_SCOPE_KEY, scope === 'all' ? 'session' : 'all'); + renderActivity(); + }); } catch (e) { outlet.innerHTML = `
Failed to load activity: ${escHtml(e.message)}
`; } diff --git a/web/static/js/api.js b/web/static/js/api.js index 6b93ecb..e0487b1 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -97,8 +97,8 @@ export const api = { // ── Activity Audit ─────────────────────────────────────────────────────── audit: { - recent(limit = 100) { - return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}`); + recent(limit = 100, all = false) { + return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}&all=${all ? 'true' : 'false'}`); }, }, diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 5e4c782..732c79a 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -20,6 +20,7 @@ export async function refreshAuth() { docusignAccountName: data.docusign_account_name || null, docusignAccountsCount: data.docusign_accounts_count || 0, docusignAccountSelectionRequired: !!data.docusign_account_selection_required, + isAdmin: !!data.is_admin, }); } catch (e) { console.warn('Auth status failed:', e.message); diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 54fc574..bafd6a4 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -236,6 +236,11 @@ async function _loadConnInfo() { ${escHtml(data.session_id || '—')} +
+ Admin Access + ${data.is_admin ? 'Yes — this session can view all audit logs.' : 'No — this session can only view its own audit logs.'} + +
Switch Account Note Use Choose Account or Switch Docusign Account to select from the DocuSign accounts available to this login. The picker is sorted alphabetically and supports search. diff --git a/web/static/js/state.js b/web/static/js/state.js index a7afd86..198cac1 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -15,6 +15,7 @@ export const state = { docusignAccountName: null, docusignAccountsCount: 0, docusignAccountSelectionRequired: false, + isAdmin: false, }, templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }] templatesError: null, // Visible error state for template loading failures