diff --git a/.gitignore b/.gitignore index c8a1993..fddea96 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ __pycache__/ downloads/ migration-output/ .session-store/ +.audit-log.jsonl *.pdf private.key diff --git a/tests/test_api_audit.py b/tests/test_api_audit.py new file mode 100644 index 0000000..4953e59 --- /dev/null +++ b/tests/test_api_audit.py @@ -0,0 +1,122 @@ +""" +tests/test_api_audit.py +----------------------- +Tests for audit logging and the recent activity endpoint. +""" + +import json +import os +from unittest.mock import patch + +import httpx +import pytest +import respx +from fastapi.testclient import TestClient + +from web.app import app +from web.session import _COOKIE_NAME, create_test_session +import web.routers.migrate as migrate_module + +client = TestClient(app, raise_server_exceptions=True) + +ADOBE_BASE = "https://api.eu2.adobesign.com/api/rest/v6" +DS_BASE = "https://demo.docusign.net/restapi" +DS_ACCOUNT = "test-account-id" +ADOBE_ID = "adobe-123" +DS_NEW_ID = "ds-new-456" + + +@pytest.fixture(autouse=True) +def temp_audit_env(tmp_path, monkeypatch): + import web.config as cfg + + monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store")) + monkeypatch.setattr(cfg.settings, "audit_log_file", str(tmp_path / ".audit-log.jsonl")) + monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT) + monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE) + monkeypatch.setattr(cfg.settings, "adobe_sign_base_url", ADOBE_BASE) + monkeypatch.setattr(migrate_module, "_HISTORY_FILE", str(tmp_path / ".history.json")) + client.cookies.clear() + yield + client.cookies.clear() + + +def test_adobe_connect_writes_audit_event(monkeypatch): + monkeypatch.setenv("ADOBE_ACCESS_TOKEN", "existing-token") + monkeypatch.setenv("ADOBE_REFRESH_TOKEN", "existing-refresh") + + with patch("adobe_api._refresh_access_token", return_value="refreshed-token"), \ + patch("web.routers.auth._fetch_adobe_profile", return_value={ + "adobe_user_name": "Paul Adobe", + "adobe_user_email": "paul@example.com", + "adobe_account_name": "Paul Sandbox", + "adobe_account_id": "adobe-account-123", + }): + resp = client.get("/api/auth/adobe/connect") + + assert resp.status_code == 200 + + activity = client.get("/api/audit/recent") + assert activity.status_code == 200 + events = activity.json()["events"] + assert events + assert events[0]["action"] == "adobe_connected" + assert events[0]["adobe_account_name"] == "Paul Sandbox" + assert events[0]["details"]["auth_mode"] == "shared_env" + + +def _mock_compose(template_dir: str, output_path: str): + with open(output_path, "w", encoding="utf-8") as f: + json.dump({"name": "Test NDA", "description": "mocked"}, f) + + +def _mock_download(template_id, access_token, output_dir): + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, "metadata.json"), "w", encoding="utf-8") as f: + json.dump({"name": "Test NDA", "id": template_id}, f) + with open(os.path.join(output_dir, "form_fields.json"), "w", encoding="utf-8") as f: + json.dump({"fields": []}, f) + with open(os.path.join(output_dir, "documents.json"), "w", encoding="utf-8") as f: + json.dump({"documents": []}, f) + return True + + +async def _async_wrap(fn, *args, **kwargs): + return fn(*args, **kwargs) + + +@respx.mock +def test_migration_writes_audit_event(): + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={"envelopeTemplates": []}) + ) + respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(201, json={"templateId": DS_NEW_ID}) + ) + + session_cookie = create_test_session({ + "adobe_access_token": "adobe-tok", + "docusign_access_token": "ds-tok", + "adobe_account_name": "Adobe QA", + "docusign_user_name": "Paul Example", + }) + + with ( + patch.object(migrate_module, "_download_adobe_template", new=lambda *args, **kwargs: _async_wrap(_mock_download, *args, **kwargs)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + ): + resp = client.post( + "/api/migrate", + json={"adobe_template_ids": [ADOBE_ID]}, + cookies={_COOKIE_NAME: session_cookie}, + ) + + assert resp.status_code == 200 + + activity = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_cookie}) + assert activity.status_code == 200 + events = activity.json()["events"] + migrate_event = next(event for event in events if event["action"] == "migration_run") + assert migrate_event["adobe_account_name"] == "Adobe QA" + assert migrate_event["details"]["template_count"] == 1 + assert migrate_event["details"]["success_count"] == 1 diff --git a/web/app.py b/web/app.py index e561ac1..ebc66ec 100644 --- a/web/app.py +++ b/web/app.py @@ -15,7 +15,7 @@ from fastapi.responses import FileResponse import os from web.config import settings -from web.routers import auth, templates, migrate, verify +from web.routers import auth, templates, migrate, verify, audit app = FastAPI( title="Adobe Sign → DocuSign Migrator", @@ -28,6 +28,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(templates.router, prefix="/api/templates", tags=["templates"]) app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"]) app.include_router(verify.router, prefix="/api/verify", tags=["verify"]) +app.include_router(audit.router, prefix="/api/audit", tags=["audit"]) # Static files (frontend) _static_dir = os.path.join(os.path.dirname(__file__), "static") diff --git a/web/audit.py b/web/audit.py new file mode 100644 index 0000000..d3712f3 --- /dev/null +++ b/web/audit.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import os +from collections import deque +from datetime import datetime, timezone +from typing import Any + +from fastapi import Request + +from web.config import settings + + +def _audit_path() -> str: + return settings.audit_log_file + + +def _ensure_parent_dir() -> None: + parent = os.path.dirname(_audit_path()) + if parent: + os.makedirs(parent, exist_ok=True) + + +def _client_ip(request: Request) -> str | None: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return None + + +def _session_identity(session: dict[str, Any]) -> dict[str, Any]: + return { + "session_id": session.get("_session_id"), + "adobe_user_name": session.get("adobe_user_name"), + "adobe_user_email": session.get("adobe_user_email"), + "adobe_account_name": session.get("adobe_account_name"), + "adobe_account_id": session.get("adobe_account_id"), + "docusign_user_name": session.get("docusign_user_name"), + "docusign_user_email": session.get("docusign_user_email"), + "docusign_account_name": session.get("docusign_selected_account_name"), + "docusign_account_id": session.get("docusign_selected_account_id"), + } + + +def request_context(request: Request) -> dict[str, Any]: + return { + "path": str(request.url.path), + "method": request.method, + "ip": _client_ip(request), + "user_agent": request.headers.get("user-agent"), + } + + +def build_event_with_context( + context: dict[str, Any], + session: dict[str, Any], + action: str, + details: dict[str, Any] | None = None, +) -> dict[str, Any]: + event = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "action": action, + "path": context.get("path"), + "method": context.get("method"), + "ip": context.get("ip"), + "user_agent": context.get("user_agent"), + **_session_identity(session), + } + if details: + event["details"] = details + return event + + +def build_event(request: Request, session: dict[str, Any], action: str, details: dict[str, Any] | None = None) -> dict[str, Any]: + return build_event_with_context(request_context(request), session, action, details) + + +def log_event(request: Request, session: dict[str, Any], action: str, details: dict[str, Any] | None = None) -> dict[str, Any]: + event = build_event(request, session, action, details) + _ensure_parent_dir() + with open(_audit_path(), "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=True) + "\n") + return event + + +def log_context_event( + context: dict[str, Any], + session: dict[str, Any], + action: str, + details: dict[str, Any] | None = None, +) -> dict[str, Any]: + event = build_event_with_context(context, session, action, details) + _ensure_parent_dir() + with open(_audit_path(), "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=True) + "\n") + return event + + +def recent_events(limit: int = 100) -> list[dict[str, Any]]: + path = _audit_path() + if not os.path.exists(path): + return [] + + lines: deque[str] = deque(maxlen=max(1, min(limit, 500))) + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + lines.append(line) + + events: list[dict[str, Any]] = [] + for line in reversed(lines): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(item, dict): + events.append(item) + return events diff --git a/web/config.py b/web/config.py index 5c7d56a..e9dff44 100644 --- a/web/config.py +++ b/web/config.py @@ -32,6 +32,10 @@ class Settings: "SESSION_STORE_DIR", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".session-store")), ) + audit_log_file: str = os.getenv( + "AUDIT_LOG_FILE", + os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".audit-log.jsonl")), + ) # App version: str = "2.0" diff --git a/web/routers/audit.py b/web/routers/audit.py new file mode 100644 index 0000000..63d3fe3 --- /dev/null +++ b/web/routers/audit.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from web.audit import recent_events + +router = APIRouter() + + +@router.get("/recent") +def get_recent_events(limit: int = 100): + limit = max(1, min(limit, 500)) + return {"events": recent_events(limit)} diff --git a/web/routers/auth.py b/web/routers/auth.py index 372b543..3b48bad 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -20,6 +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.config import settings from web.docusign_context import ( DocusignContextError, @@ -191,6 +192,12 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request): session["adobe_refresh_token"] = token_data.get("refresh_token") session["adobe_auth_mode"] = "session_oauth" session = await _merge_adobe_profile(session, session["adobe_access_token"]) + log_event( + request, + session, + "adobe_connected", + {"auth_mode": "session_oauth", "source": "manual_exchange"}, + ) response = JSONResponse({"connected": True}) save_session(response, session) @@ -230,6 +237,12 @@ async def adobe_connect_env(request: Request): session["adobe_refresh_token"] = refresh_token session["adobe_auth_mode"] = "shared_env" session = await _merge_adobe_profile(session, token) + log_event( + request, + session, + "adobe_connected", + {"auth_mode": "shared_env", "source": "server_env"}, + ) response = JSONResponse({"connected": True}) save_session(response, session) @@ -239,6 +252,7 @@ async def adobe_connect_env(request: Request): @router.get("/adobe/disconnect") def adobe_disconnect(request: Request): session = get_session(request) + previous_account_name = session.get("adobe_account_name") session.pop("adobe_access_token", None) session.pop("adobe_refresh_token", None) session.pop("adobe_user_name", None) @@ -246,6 +260,12 @@ def adobe_disconnect(request: Request): session.pop("adobe_account_name", None) session.pop("adobe_account_id", None) session["adobe_auth_mode"] = "disconnected" + log_event( + request, + session, + "adobe_disconnected", + {"previous_account_name": previous_account_name}, + ) response = JSONResponse({"disconnected": "adobe"}) save_session(response, session) return response @@ -287,11 +307,18 @@ async def docusign_connect(request: Request): state = secrets.token_urlsafe(24) session["docusign_oauth_state"] = state session["docusign_auth_mode"] = "authorization_pending" + authorization_url = build_authorization_url(state=state) + log_event( + request, + session, + "docusign_authorization_requested", + {"auth_mode": "authorization_pending"}, + ) response = JSONResponse( { "connected": False, "authorization_required": True, - "authorization_url": build_authorization_url(state=state), + "authorization_url": authorization_url, }, status_code=200, ) @@ -307,6 +334,16 @@ async def docusign_connect(request: Request): session["docusign_access_token"] = token session["docusign_auth_mode"] = "session_oauth" + log_event( + request, + session, + "docusign_connected", + { + "auth_mode": "session_oauth", + "accounts_count": session.get("docusign_accounts_count", 0), + "selection_required": account_picker_required(session), + }, + ) response = JSONResponse({ "connected": True, "account_selection_required": account_picker_required(session), @@ -327,7 +364,14 @@ def docusign_start(request: Request): state = secrets.token_urlsafe(24) session["docusign_oauth_state"] = state session["docusign_auth_mode"] = "authorization_pending" - response = RedirectResponse(build_authorization_url(state=state)) + authorization_url = build_authorization_url(state=state) + log_event( + request, + session, + "docusign_authorization_started", + {"auth_mode": "authorization_pending"}, + ) + response = RedirectResponse(authorization_url) save_session(response, session) return response @@ -357,6 +401,16 @@ async def docusign_callback(request: Request, code: str = "", state: str = ""): session.pop("docusign_oauth_state", None) session["docusign_auth_mode"] = "session_oauth" + log_event( + request, + session, + "docusign_connected", + { + "auth_mode": "session_oauth", + "accounts_count": session.get("docusign_accounts_count", 0), + "selection_required": account_picker_required(session), + }, + ) response = RedirectResponse("/#/settings" if account_picker_required(session) else "/") save_session(response, session) @@ -384,6 +438,15 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request session = select_account(session, body.account_id) except DocusignContextError as e: return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code) + log_event( + request, + session, + "docusign_account_selected", + { + "selected_account_id": session.get("docusign_selected_account_id"), + "selected_account_name": session.get("docusign_selected_account_name"), + }, + ) response = JSONResponse( { @@ -398,6 +461,7 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request @router.get("/docusign/disconnect") def docusign_disconnect(request: Request): session = get_session(request) + previous_account_name = session.get("docusign_selected_account_name") session.pop("docusign_access_token", None) session.pop("docusign_refresh_token", None) session.pop("docusign_token_expiry", None) @@ -408,6 +472,12 @@ def docusign_disconnect(request: Request): session.pop("docusign_accounts_count", None) clear_selected_account(session) session["docusign_auth_mode"] = "disconnected" + log_event( + request, + session, + "docusign_disconnected", + {"previous_account_name": previous_account_name}, + ) response = JSONResponse({"disconnected": "docusign"}) save_session(response, session) return response diff --git a/web/routers/migrate.py b/web/routers/migrate.py index 0169727..963a600 100644 --- a/web/routers/migrate.py +++ b/web/routers/migrate.py @@ -23,6 +23,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from pydantic import BaseModel +from web.audit import log_context_event, log_event, request_context from web.config import settings from web.docusign_context import DocusignContextError, current_account from web.session import get_session @@ -381,6 +382,21 @@ async def run_migration(body: MigrateRequest, request: Request): history = _load_history() history.extend(scoped_results) _save_history(history) + log_event( + request, + session, + "migration_run", + { + "template_count": len(ids), + "dry_run": body.options.dry_run, + "overwrite_if_exists": body.options.overwrite_if_exists, + "include_documents": body.options.include_documents, + "success_count": sum(1 for result in results if result["status"] == "success"), + "failed_count": sum(1 for result in results if result["status"] in ("failed", "blocked")), + "skipped_count": sum(1 for result in results if result["status"] == "skipped"), + "dry_run_count": sum(1 for result in results if result["status"] == "dry_run"), + }, + ) return {"results": list(scoped_results)} @@ -399,6 +415,8 @@ def migration_history(request: Request): async def _run_batch_job( job_id: str, owner_session_id: str, + request_info: dict, + session_snapshot: dict, ids: List[str], adobe_token: str, ds_token: str, @@ -443,6 +461,19 @@ async def _run_batch_job( "skipped": skipped, "dry_run": dry_runs, } + log_context_event( + request_info, + session_snapshot, + "migration_batch_completed", + { + "job_id": job_id, + "template_count": len(ids), + "success": success, + "failed": failed, + "skipped": skipped, + "dry_run": dry_runs, + }, + ) @router.post("/batch") @@ -477,10 +508,22 @@ async def run_batch_migration(body: MigrateRequest, request: Request): "summary": None, "created_at": datetime.now(timezone.utc).isoformat(), } + log_event( + request, + session, + "migration_batch_started", + { + "job_id": job_id, + "template_count": len(ids), + "dry_run": body.options.dry_run, + "overwrite_if_exists": body.options.overwrite_if_exists, + "include_documents": body.options.include_documents, + }, + ) asyncio.create_task( _run_batch_job( - job_id, session_scope, ids, + job_id, session_scope, request_context(request), dict(session), ids, session["adobe_access_token"], session["docusign_access_token"], account["account_id"], diff --git a/web/routers/verify.py b/web/routers/verify.py index d4b9e9a..1a85fe4 100644 --- a/web/routers/verify.py +++ b/web/routers/verify.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from pydantic import BaseModel +from web.audit import log_event from web.docusign_context import DocusignContextError, current_account from web.session import get_session @@ -92,6 +93,17 @@ async def send_test_envelope(body: SendRequest, request: Request): ) data = resp.json() + log_event( + request, + session, + "verification_sent", + { + "template_id": body.template_id, + "recipient_email": body.recipient_email, + "recipient_name": body.recipient_name, + "envelope_id": data.get("envelopeId"), + }, + ) return {"envelope_id": data.get("envelopeId"), "roles": role_names} @@ -150,4 +162,10 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request): status_code=502, ) + log_event( + request, + session, + "verification_voided", + {"envelope_id": envelope_id, "reason": body.reason}, + ) return {"voided": True, "envelope_id": envelope_id} diff --git a/web/static/css/base.css b/web/static/css/base.css index dadda55..0547ee8 100644 --- a/web/static/css/base.css +++ b/web/static/css/base.css @@ -169,6 +169,7 @@ tr:hover td { background: #FAFBFC; } .tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; } .cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.activity-details { min-width: 320px; font-size: var(--font-size-sm); color: var(--text-muted); } /* ── Empty state ── */ .empty-state { diff --git a/web/static/index.html b/web/static/index.html index edfe378..0148c5d 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -91,6 +91,12 @@ History & Audit +
  • + + 🧾 + Activity + +
  • diff --git a/web/static/js/activity.js b/web/static/js/activity.js new file mode 100644 index 0000000..bbaf735 --- /dev/null +++ b/web/static/js/activity.js @@ -0,0 +1,117 @@ +// Recent activity view for tester/admin auditing + +import { api } from './api.js'; +import { escHtml, formatDateTime } from './utils.js'; + +const ACTION_LABELS = { + adobe_connected: 'Adobe connected', + adobe_disconnected: 'Adobe disconnected', + docusign_authorization_requested: 'DocuSign auth requested', + docusign_authorization_started: 'DocuSign auth started', + docusign_connected: 'DocuSign connected', + docusign_account_selected: 'DocuSign account selected', + docusign_disconnected: 'DocuSign disconnected', + migration_run: 'Migration run', + migration_batch_started: 'Batch migration started', + migration_batch_completed: 'Batch migration completed', + verification_sent: 'Verification sent', + verification_voided: 'Verification voided', +}; + +export async function renderActivity() { + const outlet = document.getElementById('router-outlet'); + outlet.innerHTML = `
    `; + + try { + const data = await api.audit.recent(150); + const events = data.events || []; + + outlet.innerHTML = ` + + + ${events.length === 0 ? ` +
    +
    🧾
    +
    No activity yet
    +
    Recent tester actions will appear here after people connect and use the app.
    +
    + ` : ` +
    +
    + + + + + + + + + + + + + + ${events.map(renderEventRow).join('')} + +
    TimeActionDocuSignAdobeSessionIPDetails
    +
    +
    + `} + `; + } catch (e) { + outlet.innerHTML = `
    Failed to load activity: ${escHtml(e.message)}
    `; + } +} + +function renderEventRow(event) { + const docusignLabel = event.docusign_account_name || event.docusign_user_name || event.docusign_user_email || '—'; + const adobeLabel = event.adobe_account_name || event.adobe_user_name || event.adobe_user_email || '—'; + const sessionId = event.session_id ? `${event.session_id.slice(0, 10)}…` : '—'; + const detailText = formatDetails(event.details); + + return ` + + ${escHtml(formatDateTime(event.timestamp))} + ${escHtml(ACTION_LABELS[event.action] || event.action || 'Activity')} + +
    ${escHtml(docusignLabel)}
    +
    ${escHtml(event.docusign_account_id || event.docusign_user_email || '')}
    + + +
    ${escHtml(adobeLabel)}
    +
    ${escHtml(event.adobe_account_id || event.adobe_user_email || '')}
    + + ${escHtml(sessionId)} + ${escHtml(event.ip || '—')} + ${escHtml(detailText)} + + `; +} + +function formatDetails(details) { + if (!details || typeof details !== 'object') { + return '—'; + } + + const parts = Object.entries(details) + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .map(([key, value]) => `${humanizeKey(key)}: ${formatValue(value)}`); + + return parts.length ? parts.join(' | ') : '—'; +} + +function humanizeKey(key) { + return key.replace(/_/g, ' '); +} + +function formatValue(value) { + if (typeof value === 'boolean') { + return value ? 'yes' : 'no'; + } + return String(value); +} diff --git a/web/static/js/api.js b/web/static/js/api.js index fe0659a..6b93ecb 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -95,4 +95,11 @@ export const api = { }, }, + // ── Activity Audit ─────────────────────────────────────────────────────── + audit: { + recent(limit = 100) { + return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}`); + }, + }, + }; diff --git a/web/static/js/app.js b/web/static/js/app.js index c0414c9..bc66d59 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -36,6 +36,11 @@ router.register('#/history', async () => { await renderHistory(); }); +router.register('#/activity', async () => { + const { renderActivity } = await import('./activity.js'); + await renderActivity(); +}); + router.register('#/settings', async () => { const { renderSettings } = await import('./settings.js'); renderSettings();