Add tester activity audit log
This commit is contained in:
parent
aaa72be54e
commit
fb54426bea
|
|
@ -10,5 +10,6 @@ __pycache__/
|
||||||
downloads/
|
downloads/
|
||||||
migration-output/
|
migration-output/
|
||||||
.session-store/
|
.session-store/
|
||||||
|
.audit-log.jsonl
|
||||||
*.pdf
|
*.pdf
|
||||||
private.key
|
private.key
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.routers import auth, templates, migrate, verify
|
from web.routers import auth, templates, migrate, verify, audit
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Adobe Sign → DocuSign Migrator",
|
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(templates.router, prefix="/api/templates", tags=["templates"])
|
||||||
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
|
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
|
||||||
app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
|
app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
|
||||||
|
app.include_router(audit.router, prefix="/api/audit", tags=["audit"])
|
||||||
|
|
||||||
# Static files (frontend)
|
# Static files (frontend)
|
||||||
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -32,6 +32,10 @@ class Settings:
|
||||||
"SESSION_STORE_DIR",
|
"SESSION_STORE_DIR",
|
||||||
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".session-store")),
|
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
|
# App
|
||||||
version: str = "2.0"
|
version: str = "2.0"
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
@ -20,6 +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.config import settings
|
from web.config import settings
|
||||||
from web.docusign_context import (
|
from web.docusign_context import (
|
||||||
DocusignContextError,
|
DocusignContextError,
|
||||||
|
|
@ -191,6 +192,12 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
||||||
session["adobe_auth_mode"] = "session_oauth"
|
session["adobe_auth_mode"] = "session_oauth"
|
||||||
session = await _merge_adobe_profile(session, session["adobe_access_token"])
|
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})
|
response = JSONResponse({"connected": True})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
|
|
@ -230,6 +237,12 @@ async def adobe_connect_env(request: Request):
|
||||||
session["adobe_refresh_token"] = refresh_token
|
session["adobe_refresh_token"] = refresh_token
|
||||||
session["adobe_auth_mode"] = "shared_env"
|
session["adobe_auth_mode"] = "shared_env"
|
||||||
session = await _merge_adobe_profile(session, token)
|
session = await _merge_adobe_profile(session, token)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_connected",
|
||||||
|
{"auth_mode": "shared_env", "source": "server_env"},
|
||||||
|
)
|
||||||
|
|
||||||
response = JSONResponse({"connected": True})
|
response = JSONResponse({"connected": True})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
|
|
@ -239,6 +252,7 @@ async def adobe_connect_env(request: Request):
|
||||||
@router.get("/adobe/disconnect")
|
@router.get("/adobe/disconnect")
|
||||||
def adobe_disconnect(request: Request):
|
def adobe_disconnect(request: Request):
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
|
previous_account_name = session.get("adobe_account_name")
|
||||||
session.pop("adobe_access_token", None)
|
session.pop("adobe_access_token", None)
|
||||||
session.pop("adobe_refresh_token", None)
|
session.pop("adobe_refresh_token", None)
|
||||||
session.pop("adobe_user_name", 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_name", None)
|
||||||
session.pop("adobe_account_id", None)
|
session.pop("adobe_account_id", None)
|
||||||
session["adobe_auth_mode"] = "disconnected"
|
session["adobe_auth_mode"] = "disconnected"
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_disconnected",
|
||||||
|
{"previous_account_name": previous_account_name},
|
||||||
|
)
|
||||||
response = JSONResponse({"disconnected": "adobe"})
|
response = JSONResponse({"disconnected": "adobe"})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
@ -287,11 +307,18 @@ async def docusign_connect(request: Request):
|
||||||
state = secrets.token_urlsafe(24)
|
state = secrets.token_urlsafe(24)
|
||||||
session["docusign_oauth_state"] = state
|
session["docusign_oauth_state"] = state
|
||||||
session["docusign_auth_mode"] = "authorization_pending"
|
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(
|
response = JSONResponse(
|
||||||
{
|
{
|
||||||
"connected": False,
|
"connected": False,
|
||||||
"authorization_required": True,
|
"authorization_required": True,
|
||||||
"authorization_url": build_authorization_url(state=state),
|
"authorization_url": authorization_url,
|
||||||
},
|
},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
@ -307,6 +334,16 @@ async def docusign_connect(request: Request):
|
||||||
|
|
||||||
session["docusign_access_token"] = token
|
session["docusign_access_token"] = token
|
||||||
session["docusign_auth_mode"] = "session_oauth"
|
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({
|
response = JSONResponse({
|
||||||
"connected": True,
|
"connected": True,
|
||||||
"account_selection_required": account_picker_required(session),
|
"account_selection_required": account_picker_required(session),
|
||||||
|
|
@ -327,7 +364,14 @@ def docusign_start(request: Request):
|
||||||
state = secrets.token_urlsafe(24)
|
state = secrets.token_urlsafe(24)
|
||||||
session["docusign_oauth_state"] = state
|
session["docusign_oauth_state"] = state
|
||||||
session["docusign_auth_mode"] = "authorization_pending"
|
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)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -357,6 +401,16 @@ async def docusign_callback(request: Request, code: str = "", state: str = ""):
|
||||||
|
|
||||||
session.pop("docusign_oauth_state", None)
|
session.pop("docusign_oauth_state", None)
|
||||||
session["docusign_auth_mode"] = "session_oauth"
|
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 "/")
|
response = RedirectResponse("/#/settings" if account_picker_required(session) else "/")
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
|
|
@ -384,6 +438,15 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request
|
||||||
session = select_account(session, body.account_id)
|
session = select_account(session, body.account_id)
|
||||||
except DocusignContextError as e:
|
except DocusignContextError as e:
|
||||||
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
|
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(
|
response = JSONResponse(
|
||||||
{
|
{
|
||||||
|
|
@ -398,6 +461,7 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request
|
||||||
@router.get("/docusign/disconnect")
|
@router.get("/docusign/disconnect")
|
||||||
def docusign_disconnect(request: Request):
|
def docusign_disconnect(request: Request):
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
|
previous_account_name = session.get("docusign_selected_account_name")
|
||||||
session.pop("docusign_access_token", None)
|
session.pop("docusign_access_token", None)
|
||||||
session.pop("docusign_refresh_token", None)
|
session.pop("docusign_refresh_token", None)
|
||||||
session.pop("docusign_token_expiry", None)
|
session.pop("docusign_token_expiry", None)
|
||||||
|
|
@ -408,6 +472,12 @@ def docusign_disconnect(request: Request):
|
||||||
session.pop("docusign_accounts_count", None)
|
session.pop("docusign_accounts_count", None)
|
||||||
clear_selected_account(session)
|
clear_selected_account(session)
|
||||||
session["docusign_auth_mode"] = "disconnected"
|
session["docusign_auth_mode"] = "disconnected"
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"docusign_disconnected",
|
||||||
|
{"previous_account_name": previous_account_name},
|
||||||
|
)
|
||||||
response = JSONResponse({"disconnected": "docusign"})
|
response = JSONResponse({"disconnected": "docusign"})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from web.audit import log_context_event, log_event, request_context
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.docusign_context import DocusignContextError, current_account
|
from web.docusign_context import DocusignContextError, current_account
|
||||||
from web.session import get_session
|
from web.session import get_session
|
||||||
|
|
@ -381,6 +382,21 @@ async def run_migration(body: MigrateRequest, request: Request):
|
||||||
history = _load_history()
|
history = _load_history()
|
||||||
history.extend(scoped_results)
|
history.extend(scoped_results)
|
||||||
_save_history(history)
|
_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)}
|
return {"results": list(scoped_results)}
|
||||||
|
|
||||||
|
|
@ -399,6 +415,8 @@ def migration_history(request: Request):
|
||||||
async def _run_batch_job(
|
async def _run_batch_job(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
owner_session_id: str,
|
owner_session_id: str,
|
||||||
|
request_info: dict,
|
||||||
|
session_snapshot: dict,
|
||||||
ids: List[str],
|
ids: List[str],
|
||||||
adobe_token: str,
|
adobe_token: str,
|
||||||
ds_token: str,
|
ds_token: str,
|
||||||
|
|
@ -443,6 +461,19 @@ async def _run_batch_job(
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"dry_run": dry_runs,
|
"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")
|
@router.post("/batch")
|
||||||
|
|
@ -477,10 +508,22 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
||||||
"summary": None,
|
"summary": None,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"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(
|
asyncio.create_task(
|
||||||
_run_batch_job(
|
_run_batch_job(
|
||||||
job_id, session_scope, ids,
|
job_id, session_scope, request_context(request), dict(session), ids,
|
||||||
session["adobe_access_token"],
|
session["adobe_access_token"],
|
||||||
session["docusign_access_token"],
|
session["docusign_access_token"],
|
||||||
account["account_id"],
|
account["account_id"],
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from web.audit import log_event
|
||||||
from web.docusign_context import DocusignContextError, current_account
|
from web.docusign_context import DocusignContextError, current_account
|
||||||
from web.session import get_session
|
from web.session import get_session
|
||||||
|
|
||||||
|
|
@ -92,6 +93,17 @@ async def send_test_envelope(body: SendRequest, request: Request):
|
||||||
)
|
)
|
||||||
|
|
||||||
data = resp.json()
|
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}
|
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,
|
status_code=502,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"verification_voided",
|
||||||
|
{"envelope_id": envelope_id, "reason": body.reason},
|
||||||
|
)
|
||||||
return {"voided": True, "envelope_id": envelope_id}
|
return {"voided": True, "envelope_id": envelope_id}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.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; }
|
.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; }
|
.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 ── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,12 @@
|
||||||
<span class="nav-label">History & Audit</span>
|
<span class="nav-label">History & Audit</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/activity" href="#/activity">
|
||||||
|
<span class="nav-icon">🧾</span>
|
||||||
|
<span class="nav-label">Activity</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-section-label">Admin</li>
|
<li class="nav-section-label">Admin</li>
|
||||||
<li>
|
<li>
|
||||||
|
|
|
||||||
|
|
@ -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 = `<div class="empty-state"><div class="spinner"></div></div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.audit.recent(150);
|
||||||
|
const events = data.events || [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${events.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🧾</div>
|
||||||
|
<div class="empty-state-title">No activity yet</div>
|
||||||
|
<div class="empty-state-sub">Recent tester actions will appear here after people connect and use the app.</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>DocuSign</th>
|
||||||
|
<th>Adobe</th>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${events.map(renderEventRow).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load activity: ${escHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space: nowrap">${escHtml(formatDateTime(event.timestamp))}</td>
|
||||||
|
<td><span class="badge badge-blue">${escHtml(ACTION_LABELS[event.action] || event.action || 'Activity')}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="table-name">${escHtml(docusignLabel)}</div>
|
||||||
|
<div class="table-sub">${escHtml(event.docusign_account_id || event.docusign_user_email || '')}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-name">${escHtml(adobeLabel)}</div>
|
||||||
|
<div class="table-sub">${escHtml(event.adobe_account_id || event.adobe_user_email || '')}</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">${escHtml(sessionId)}</td>
|
||||||
|
<td>${escHtml(event.ip || '—')}</td>
|
||||||
|
<td class="activity-details">${escHtml(detailText)}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -95,4 +95,11 @@ export const api = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Activity Audit ───────────────────────────────────────────────────────
|
||||||
|
audit: {
|
||||||
|
recent(limit = 100) {
|
||||||
|
return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ router.register('#/history', async () => {
|
||||||
await renderHistory();
|
await renderHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.register('#/activity', async () => {
|
||||||
|
const { renderActivity } = await import('./activity.js');
|
||||||
|
await renderActivity();
|
||||||
|
});
|
||||||
|
|
||||||
router.register('#/settings', async () => {
|
router.register('#/settings', async () => {
|
||||||
const { renderSettings } = await import('./settings.js');
|
const { renderSettings } = await import('./settings.js');
|
||||||
renderSettings();
|
renderSettings();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue