diff --git a/cortex/routers/audit.py b/cortex/routers/audit.py index 55bfb4f..7518e65 100644 --- a/cortex/routers/audit.py +++ b/cortex/routers/audit.py @@ -1,36 +1,75 @@ """ -Tool audit log endpoints — admin only. +Tool audit log endpoints. +Self-service (any authenticated user, own data): + GET /api/audit/files → list of available date strings (newest first) + GET /api/audit/day?date=YYYY-MM-DD → entries for one day + +Admin-only (cross-user aggregation): GET /api/audit/recent?user=scott&days=7&limit=200 - Returns recent tool call entries for one user (or all users if user omitted). - GET /api/audit/stats?user=scott&days=7 - Returns aggregate counts by tool and status. """ import jwt from collections import Counter +from datetime import date, timedelta from fastapi import APIRouter, HTTPException, Query, Request from auth_utils import COOKIE_NAME, decode_token, get_user_role +from config import settings import tool_audit from persona import list_users router = APIRouter(prefix="/api/audit") -def _require_admin(request: Request) -> str: +def _session_user(request: Request) -> str: + """Return the authenticated username or raise 401.""" token = request.cookies.get(COOKIE_NAME) if not token: raise HTTPException(status_code=401, detail="Not authenticated") try: - username = decode_token(token) + return decode_token(token) except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid session") + + +def _require_admin(request: Request) -> str: + username = _session_user(request) if get_user_role(username) != "admin": raise HTTPException(status_code=403, detail="Admin access required") return username +@router.get("/files") +async def audit_files(request: Request) -> dict: + """List available audit log dates for the current user (newest first).""" + username = _session_user(request) + audit_dir = settings.home_root() / username / "tool_audit" + if not audit_dir.exists(): + return {"dates": []} + dates = sorted( + [p.stem for p in audit_dir.glob("*.jsonl") if p.stem], + reverse=True, + ) + return {"dates": dates} + + +@router.get("/day") +async def audit_day( + request: Request, + date: str = Query(..., description="YYYY-MM-DD"), +) -> dict: + """Return all entries for a specific day (current user only).""" + username = _session_user(request) + try: + from datetime import date as _date + d = _date.fromisoformat(date) + except ValueError: + raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD") + entries = tool_audit.read_day(username, date) + return {"date": date, "entries": entries} + + @router.get("/recent") async def audit_recent( request: Request, diff --git a/cortex/static/app.js b/cortex/static/app.js index eb162d9..996352f 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -1375,22 +1375,26 @@ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } - function renderFileSidebar(files) { + function _makeFileGroup(label, collapsed = false) { + const groupEl = document.createElement('div'); + groupEl.className = 'file-group'; + const header = document.createElement('div'); + header.className = 'fg-header' + (collapsed ? ' collapsed' : ''); + header.textContent = label; + header.addEventListener('click', () => header.classList.toggle('collapsed')); + groupEl.appendChild(header); + const items = document.createElement('div'); + items.className = 'fg-items'; + groupEl.appendChild(items); + return { groupEl, items }; + } + + function renderFileSidebar(files, auditDates = []) { const byName = Object.fromEntries(files.map(f => [f.name, f])); fileSidebar.innerHTML = ''; for (const group of FILE_GROUPS) { - const groupEl = document.createElement('div'); - groupEl.className = 'file-group'; - - const header = document.createElement('div'); - header.className = 'fg-header'; - header.textContent = group.label; - header.addEventListener('click', () => header.classList.toggle('collapsed')); - groupEl.appendChild(header); - - const items = document.createElement('div'); - items.className = 'fg-items'; + const { groupEl, items } = _makeFileGroup(group.label); for (const fname of group.files) { const f = byName[fname]; @@ -1416,7 +1420,35 @@ items.appendChild(item); } - groupEl.appendChild(items); + fileSidebar.appendChild(groupEl); + } + + // ── Audit Log section (dynamic, date-named files) ────────── + if (auditDates.length > 0) { + const { groupEl, items } = _makeFileGroup('Audit Log', true); + + for (const d of auditDates) { + const item = document.createElement('div'); + item.className = 'file-item'; + item.dataset.name = 'audit:' + d; + if (activeFileName === 'audit:' + d) item.classList.add('active'); + + const nameEl = document.createElement('div'); + nameEl.className = 'fi-name'; + nameEl.textContent = d + '.jsonl'; + item.appendChild(nameEl); + + const metaEl = document.createElement('div'); + metaEl.className = 'fi-meta'; + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + metaEl.innerHTML = `${d === today ? 'today' : d === yesterday ? 'yesterday' : d}`; + item.appendChild(metaEl); + + item.addEventListener('click', () => loadAuditLog(d)); + items.appendChild(item); + } + fileSidebar.appendChild(groupEl); } } @@ -1455,6 +1487,10 @@ async function loadFile(name) { setActiveFile(name); initMdEditor(); + // Restore editor/preview buttons hidden by audit view + fileRawBtn.style.display = ''; + filePreviewBtn.style.display = ''; + fileSaveBtn.style.display = ''; const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`); if (!res.ok) { mdEditor.setValue(`Error loading ${name}`); return; } const data = await res.json(); @@ -1463,13 +1499,90 @@ setFileMode(fileMode); } - async function openFileModal() { - const res = await fetch(`/files?${_fileParams}`); + function _auditStatusClass(status) { + if (status === 'ok') return 'at-status ok'; + if (status === 'error') return 'at-status error'; + if (status === 'denied') return 'at-status denied'; + return 'at-status'; + } + + function _fmtArgs(args) { + if (!args || typeof args !== 'object') return ''; + return Object.entries(args) + .map(([k, v]) => { + const s = typeof v === 'string' ? v : JSON.stringify(v); + return `${k}: ${s.length > 60 ? s.slice(0, 60) + '…' : s}`; + }) + .join(' · '); + } + + async function loadAuditLog(dateStr) { + setActiveFile('audit:' + dateStr); + document.getElementById('file-modal-title').textContent = dateStr + '.jsonl'; + // Hide edit controls — audit logs are read-only + fileRawBtn.style.display = 'none'; + filePreviewBtn.style.display = 'none'; + fileSaveBtn.style.display = 'none'; + fileEditorWrap.classList.add('hidden'); + filePreview.classList.add('active'); + filePreview.style.display = ''; + filePreview.innerHTML = '