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.
|
# Optional override for the server-side browser session store.
|
||||||
# Each tester gets an isolated session file here after they connect in the web UI.
|
# Each tester gets an isolated session file here after they connect in the web UI.
|
||||||
SESSION_STORE_DIR=
|
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
|
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
|
assert activity.status_code == 200
|
||||||
events = activity.json()["events"]
|
events = activity.json()["events"]
|
||||||
assert events
|
assert events
|
||||||
|
|
@ -120,3 +121,53 @@ def test_migration_writes_audit_event():
|
||||||
assert migrate_event["adobe_account_name"] == "Adobe QA"
|
assert migrate_event["adobe_account_name"] == "Adobe QA"
|
||||||
assert migrate_event["details"]["template_count"] == 1
|
assert migrate_event["details"]["template_count"] == 1
|
||||||
assert migrate_event["details"]["success_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 fastapi import Request
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
|
from web.session import ensure_session_id
|
||||||
|
|
||||||
|
|
||||||
def _audit_path() -> str:
|
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]:
|
def request_context(request: Request) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"path": str(request.url.path),
|
"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]:
|
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)
|
event = build_event(request, session, action, details)
|
||||||
_ensure_parent_dir()
|
_ensure_parent_dir()
|
||||||
with open(_audit_path(), "a", encoding="utf-8") as f:
|
with open(_audit_path(), "a", encoding="utf-8") as f:
|
||||||
|
|
@ -98,7 +109,7 @@ def log_context_event(
|
||||||
return 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()
|
path = _audit_path()
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return []
|
return []
|
||||||
|
|
@ -117,5 +128,7 @@ def recent_events(limit: int = 100) -> list[dict[str, Any]]:
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
|
if not include_all and session_id and item.get("session_id") != session_id:
|
||||||
|
continue
|
||||||
events.append(item)
|
events.append(item)
|
||||||
return events
|
return events
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,18 @@ class Settings:
|
||||||
"AUDIT_LOG_FILE",
|
"AUDIT_LOG_FILE",
|
||||||
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".audit-log.jsonl")),
|
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".audit-log.jsonl")),
|
||||||
)
|
)
|
||||||
|
admin_emails_raw: str = os.getenv("ADMIN_EMAILS", "")
|
||||||
|
|
||||||
# App
|
# App
|
||||||
version: str = "2.0"
|
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()
|
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recent")
|
@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))
|
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 fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from pydantic import BaseModel
|
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.config import settings
|
||||||
from web.docusign_context import (
|
from web.docusign_context import (
|
||||||
DocusignContextError,
|
DocusignContextError,
|
||||||
|
|
@ -117,6 +117,7 @@ def auth_status(request: Request):
|
||||||
"docusign_account_name": (docusign_account or {}).get("account_name"),
|
"docusign_account_name": (docusign_account or {}).get("account_name"),
|
||||||
"base_url": (docusign_account or {}).get("base_url", settings.docusign_base_url),
|
"base_url": (docusign_account or {}).get("base_url", settings.docusign_base_url),
|
||||||
"docusign_account_selection_required": account_picker_required(session),
|
"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)
|
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:
|
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.
|
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:
|
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."""
|
"""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 = dict(data)
|
||||||
payload.pop(_SESSION_ID_KEY, None)
|
payload.pop(_SESSION_ID_KEY, None)
|
||||||
_write_server_session(sid, payload)
|
_write_server_session(sid, payload)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Recent activity view for tester/admin auditing
|
// Recent activity view for tester/admin auditing
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
|
import { state } from './state.js';
|
||||||
import { escHtml, formatDateTime } from './utils.js';
|
import { escHtml, formatDateTime } from './utils.js';
|
||||||
|
|
||||||
const ACTION_LABELS = {
|
const ACTION_LABELS = {
|
||||||
|
|
@ -17,21 +18,32 @@ const ACTION_LABELS = {
|
||||||
verification_sent: 'Verification sent',
|
verification_sent: 'Verification sent',
|
||||||
verification_voided: 'Verification voided',
|
verification_voided: 'Verification voided',
|
||||||
};
|
};
|
||||||
|
const ACTIVITY_SCOPE_KEY = 'migrator_activity_scope';
|
||||||
|
|
||||||
export async function renderActivity() {
|
export async function renderActivity() {
|
||||||
const outlet = document.getElementById('router-outlet');
|
const outlet = document.getElementById('router-outlet');
|
||||||
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
|
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
|
||||||
|
const showAll = state.auth.isAdmin && localStorage.getItem(ACTIVITY_SCOPE_KEY) === 'all';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.audit.recent(150);
|
const data = await api.audit.recent(150, showAll);
|
||||||
const events = data.events || [];
|
const events = data.events || [];
|
||||||
|
const isAdmin = !!data.is_admin;
|
||||||
|
const scope = data.scope || 'session';
|
||||||
|
|
||||||
outlet.innerHTML = `
|
outlet.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="page-title">Recent Activity</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
${events.length === 0 ? `
|
${events.length === 0 ? `
|
||||||
|
|
@ -63,6 +75,11 @@ export async function renderActivity() {
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
document.getElementById('btn-toggle-activity-scope')?.addEventListener('click', () => {
|
||||||
|
localStorage.setItem(ACTIVITY_SCOPE_KEY, scope === 'all' ? 'session' : 'all');
|
||||||
|
renderActivity();
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load activity: ${escHtml(e.message)}</div>`;
|
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 ───────────────────────────────────────────────────────
|
// ── Activity Audit ───────────────────────────────────────────────────────
|
||||||
audit: {
|
audit: {
|
||||||
recent(limit = 100) {
|
recent(limit = 100, all = false) {
|
||||||
return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}`);
|
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,
|
docusignAccountName: data.docusign_account_name || null,
|
||||||
docusignAccountsCount: data.docusign_accounts_count || 0,
|
docusignAccountsCount: data.docusign_accounts_count || 0,
|
||||||
docusignAccountSelectionRequired: !!data.docusign_account_selection_required,
|
docusignAccountSelectionRequired: !!data.docusign_account_selection_required,
|
||||||
|
isAdmin: !!data.is_admin,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Auth status failed:', e.message);
|
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-value mono">${escHtml(data.session_id || '—')}</span>
|
||||||
<span class="conn-info-status"></span>
|
<span class="conn-info-status"></span>
|
||||||
</div>
|
</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">
|
<div class="conn-info-row">
|
||||||
<span class="conn-info-label">Switch Account Note</span>
|
<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>
|
<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,
|
docusignAccountName: null,
|
||||||
docusignAccountsCount: 0,
|
docusignAccountsCount: 0,
|
||||||
docusignAccountSelectionRequired: false,
|
docusignAccountSelectionRequired: false,
|
||||||
|
isAdmin: false,
|
||||||
},
|
},
|
||||||
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
|
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
|
||||||
templatesError: null, // Visible error state for template loading failures
|
templatesError: null, // Visible error state for template loading failures
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue