Scope audit activity by session
This commit is contained in:
parent
9f27b95f07
commit
8f0b14bc62
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
15
web/audit.py
15
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'}`);
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue