Scope audit activity by session

This commit is contained in:
Paul Huliganga 2026-04-22 11:29:37 -04:00
parent 9f27b95f07
commit 8f0b14bc62
12 changed files with 134 additions and 12 deletions

View File

@ -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=

View File

@ -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"]

View File

@ -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

View File

@ -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()

View File

@ -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),
}

View File

@ -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),
}

View File

@ -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)

View File

@ -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 = `<div class="empty-state"><div class="spinner"></div></div>`;
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 = `
<div class="page-header">
<div>
<div class="page-title">Recent Activity</div>
<div class="page-subtitle">Who connected, selected accounts, migrated templates, and sent verification envelopes.</div>
<div class="page-subtitle">${scope === 'all'
? 'Admin view of all tester activity.'
: 'Your activity in this browser session.'}</div>
</div>
${isAdmin ? `
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-toggle-activity-scope">${scope === 'all' ? 'Show My Activity' : 'Show All Activity'}</button>
</div>
` : ''}
</div>
${events.length === 0 ? `
@ -63,6 +75,11 @@ export async function renderActivity() {
</div>
`}
`;
document.getElementById('btn-toggle-activity-scope')?.addEventListener('click', () => {
localStorage.setItem(ACTIVITY_SCOPE_KEY, scope === 'all' ? 'session' : 'all');
renderActivity();
});
} catch (e) {
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load activity: ${escHtml(e.message)}</div>`;
}

View File

@ -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'}`);
},
},

View File

@ -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);

View File

@ -236,6 +236,11 @@ async function _loadConnInfo() {
<span class="conn-info-value mono">${escHtml(data.session_id || '—')}</span>
<span class="conn-info-status"></span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Admin Access</span>
<span class="conn-info-value">${data.is_admin ? 'Yes — this session can view all audit logs.' : 'No — this session can only view its own audit logs.'}</span>
<span class="conn-info-status"></span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Switch Account Note</span>
<span class="conn-info-value">Use <strong>Choose Account</strong> or <strong>Switch Docusign Account</strong> to select from the DocuSign accounts available to this login. The picker is sorted alphabetically and supports search.</span>

View File

@ -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