From ab9dd2aa1b25b13873105efd6ea394b6727db666 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:24:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20write=5Fmode=20support=20to=20i?= =?UTF-8?q?mpersonation=20tokens=20with=20conditional=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/auth.py" - "backend/models.py" - "backend/routers/admin.py" - "backend/tests/test_impersonation.py" GSD-Task: S07/T01 --- .gsd/milestones/M021/M021-ROADMAP.md | 2 +- .../milestones/M021/slices/S06/S06-SUMMARY.md | 116 ++++++++++++ .gsd/milestones/M021/slices/S06/S06-UAT.md | 75 ++++++++ .../M021/slices/S06/tasks/T03-VERIFY.json | 30 +++ .gsd/milestones/M021/slices/S07/S07-PLAN.md | 110 ++++++++++- .../M021/slices/S07/S07-RESEARCH.md | 85 +++++++++ .../M021/slices/S07/tasks/T01-PLAN.md | 56 ++++++ .../M021/slices/S07/tasks/T01-SUMMARY.md | 81 ++++++++ .../M021/slices/S07/tasks/T02-PLAN.md | 71 +++++++ .../M021/slices/S07/tasks/T03-PLAN.md | 55 ++++++ backend/auth.py | 22 ++- backend/models.py | 4 + backend/routers/admin.py | 64 ++++++- backend/tests/test_impersonation.py | 174 ++++++++++++++++++ 14 files changed, 935 insertions(+), 10 deletions(-) create mode 100644 .gsd/milestones/M021/slices/S06/S06-SUMMARY.md create mode 100644 .gsd/milestones/M021/slices/S06/S06-UAT.md create mode 100644 .gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json create mode 100644 .gsd/milestones/M021/slices/S07/S07-RESEARCH.md create mode 100644 .gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md create mode 100644 .gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md create mode 100644 .gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md create mode 100644 backend/tests/test_impersonation.py diff --git a/.gsd/milestones/M021/M021-ROADMAP.md b/.gsd/milestones/M021/M021-ROADMAP.md index bb19a24..4dd40ca 100644 --- a/.gsd/milestones/M021/M021-ROADMAP.md +++ b/.gsd/milestones/M021/M021-ROADMAP.md @@ -11,6 +11,6 @@ LightRAG becomes the primary search engine. Chat engine goes live (encyclopedic | S03 | [B] Chat Engine MVP | high | S02 | ✅ | User asks a question, receives a streamed response with citations linking to source videos and technique pages | | S04 | [B] Highlight Detection v1 | medium | — | ✅ | Scored highlight candidates generated from existing pipeline data for a sample of videos | | S05 | [A] Audio Mode + Chapter Markers | medium | — | ✅ | Media player with waveform visualization in audio mode and chapter markers on the timeline | -| S06 | [A] Auto-Chapters Review UI | low | — | ⬜ | Creator reviews detected chapters: drag boundaries, rename, reorder, approve for publication | +| S06 | [A] Auto-Chapters Review UI | low | — | ✅ | Creator reviews detected chapters: drag boundaries, rename, reorder, approve for publication | | S07 | [A] Impersonation Polish + Write Mode | low | — | ⬜ | Impersonation write mode with confirmation modal. Audit log admin view shows all sessions. | | S08 | Forgejo KB Update — Chat, Retrieval, Highlights | low | S01, S02, S03, S04, S05, S06, S07 | ⬜ | Forgejo wiki updated with chat engine, retrieval routing, and highlight detection docs | diff --git a/.gsd/milestones/M021/slices/S06/S06-SUMMARY.md b/.gsd/milestones/M021/slices/S06/S06-SUMMARY.md new file mode 100644 index 0000000..d5978aa --- /dev/null +++ b/.gsd/milestones/M021/slices/S06/S06-SUMMARY.md @@ -0,0 +1,116 @@ +--- +id: S06 +parent: M021 +milestone: M021 +provides: + - Creator chapter management API (4 endpoints) + - ChapterReview UI page at /creator/chapters/:videoId + - ChapterStatus enum and sort_order on KeyMoment model + - Sidebar navigation link to Chapters in CreatorDashboard +requires: + [] +affects: + - S08 +key_files: + - backend/models.py + - backend/schemas.py + - alembic/versions/020_add_chapter_status_and_sort_order.py + - backend/routers/creator_chapters.py + - backend/routers/videos.py + - backend/main.py + - frontend/src/pages/ChapterReview.tsx + - frontend/src/pages/ChapterReview.module.css + - frontend/src/api/videos.ts + - frontend/src/App.tsx + - frontend/src/pages/CreatorDashboard.tsx +key_decisions: + - Public chapters endpoint falls back to all chapters when none are approved (backward compatibility) + - Status cycling: draft → approved → hidden → draft + - Region drag/resize persists via fire-and-forget updateChapter with error logging + - Video picker reuses consent API for creator video listing + - _verify_creator_owns_video helper pattern for ownership checks +patterns_established: + - Creator resource ownership verification via _verify_creator_owns_video helper + - Split route wrapper + detail component pattern for optional URL params + - WaveSurfer RegionsPlugin with drag:true/resize:true for interactive timeline editing +observability_surfaces: + - none +drill_down_paths: + - .gsd/milestones/M021/slices/S06/tasks/T01-SUMMARY.md + - .gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md + - .gsd/milestones/M021/slices/S06/tasks/T03-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-04-04T06:13:44.794Z +blocker_discovered: false +--- + +# S06: [A] Auto-Chapters Review UI + +**Built full creator chapter review UI: backend CRUD endpoints with auth-guarded chapter management, WaveSurfer waveform with draggable/resizable regions, inline editing, reorder, bulk approve, and routing with video picker.** + +## What Happened + +This slice delivered the complete chapter review workflow for creators across three tasks. + +**T01 — Backend chapter management.** Added `ChapterStatus` enum (draft/approved/hidden) and `sort_order` column to the KeyMoment model with Alembic migration 020. Created 4 Pydantic schemas (ChapterUpdate, ChapterReorderItem, ChapterReorderRequest, ChapterBulkApproveRequest) and extended ChapterMarkerRead. Built `creator_chapters.py` router with 4 auth-guarded endpoints: GET chapters for a video, PATCH single chapter, PUT reorder, POST bulk-approve. Updated the public chapters endpoint to prefer approved chapters with fallback to all when none are approved (backward compatibility). + +**T02 — Frontend chapter review page.** Extended the Chapter interface and videos.ts API module with 4 new functions (fetchCreatorChapters, updateChapter, reorderChapters, approveChapters). Created ChapterReview.tsx with WaveSurfer waveform (drag: true, resize: true on regions), region-updated event sync to API, inline title editing, up/down reorder arrows, status cycle button (draft→approved→hidden→draft), checkbox-based bulk approve, and loading/error/empty states. CSS module follows CreatorDashboard design patterns. + +**T03 — Routing and navigation.** Added two protected lazy-loaded routes in App.tsx (/creator/chapters and /creator/chapters/:videoId). Replaced the disabled Content placeholder in CreatorDashboard sidebar with an active Chapters NavLink. Added a VideoPicker component that lists creator videos when no videoId param is present, using the consent API for video listing. + +## Verification + +All verification checks pass: +- `python -c "from models import KeyMoment, ChapterStatus; print(ChapterStatus.draft)"` → ChapterStatus.draft ✅ +- `python -c "from schemas import ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest; print('schemas OK')"` → schemas OK ✅ +- `grep -q 'creator_chapters' backend/main.py` → found ✅ +- `test -f alembic/versions/020_add_chapter_status_and_sort_order.py` → exists ✅ +- `cd frontend && npx tsc --noEmit` → 0 errors ✅ +- `grep -q 'ChapterReview' frontend/src/App.tsx` → found ✅ +- `grep -q '/creator/chapters' frontend/src/pages/CreatorDashboard.tsx` → found ✅ +- `grep -q 'updateChapter' frontend/src/api/videos.ts` → found ✅ +- `test -f frontend/src/pages/ChapterReview.tsx` → exists ✅ +- `test -f frontend/src/pages/ChapterReview.module.css` → exists ✅ + +## Requirements Advanced + +None. + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +Video picker uses consent API (fetchCreatorConsent) for video listing instead of a dedicated creator videos endpoint — pragmatic reuse of existing data. ChapterReview split into wrapper + ChapterReviewDetail subcomponent for cleaner param handling. + +## Known Limitations + +Migration 020 needs to be applied on ub01 (`docker exec chrysopedia-api alembic upgrade head`) before the new endpoints work. No drag-and-drop reorder — uses simple up/down arrows (sufficient for typical 5-15 chapter lists). + +## Follow-ups + +Run migration 020 on ub01 deployment. End-to-end test with real video chapters in the browser. + +## Files Created/Modified + +- `backend/models.py` — Added ChapterStatus enum, chapter_status and sort_order columns to KeyMoment +- `backend/schemas.py` — Added ChapterUpdate, ChapterReorderItem, ChapterReorderRequest, ChapterBulkApproveRequest schemas; extended ChapterMarkerRead +- `alembic/versions/020_add_chapter_status_and_sort_order.py` — Migration adding chapter_status enum type and sort_order column +- `backend/routers/creator_chapters.py` — New router with 4 auth-guarded chapter management endpoints +- `backend/routers/videos.py` — Updated public chapters endpoint to prefer approved chapters +- `backend/main.py` — Registered creator_chapters router +- `frontend/src/pages/ChapterReview.tsx` — Full chapter review page with waveform, inline editing, reorder, bulk approve, and video picker +- `frontend/src/pages/ChapterReview.module.css` — Styles for chapter review UI +- `frontend/src/api/videos.ts` — Added chapter_status/sort_order to Chapter interface; 4 new API functions +- `frontend/src/App.tsx` — Added /creator/chapters and /creator/chapters/:videoId routes +- `frontend/src/pages/CreatorDashboard.tsx` — Replaced disabled Content span with active Chapters NavLink diff --git a/.gsd/milestones/M021/slices/S06/S06-UAT.md b/.gsd/milestones/M021/slices/S06/S06-UAT.md new file mode 100644 index 0000000..2776ed9 --- /dev/null +++ b/.gsd/milestones/M021/slices/S06/S06-UAT.md @@ -0,0 +1,75 @@ +# S06: [A] Auto-Chapters Review UI — UAT + +**Milestone:** M021 +**Written:** 2026-04-04T06:13:44.794Z + +## UAT: Auto-Chapters Review UI + +### Preconditions +- Migration 020 applied (`docker exec chrysopedia-api alembic upgrade head`) +- At least one creator account exists with processed videos that have key moments (chapters) +- User is logged in as a creator + +### Test 1: Sidebar Navigation +1. Navigate to `/creator/dashboard` +2. **Expected:** Sidebar shows "Chapters" link with document icon (not disabled "Content" placeholder) +3. Click "Chapters" link +4. **Expected:** Navigates to `/creator/chapters` + +### Test 2: Video Picker (No videoId) +1. Navigate to `/creator/chapters` (no videoId) +2. **Expected:** Page shows list of creator's videos with titles +3. Click a video title +4. **Expected:** Navigates to `/creator/chapters/:videoId` + +### Test 3: Chapter List Display +1. Navigate to `/creator/chapters/:videoId` for a video with chapters +2. **Expected:** Waveform renders with colored regions for each chapter. Chapter list below shows title, time range, status badge (draft/approved/hidden), reorder arrows, and checkbox + +### Test 4: Waveform Region Drag +1. On the chapter review page, drag a region boundary on the waveform +2. **Expected:** Region resizes. After release, chapter's start/end time updates in the list below. API PATCH call fires to persist. + +### Test 5: Inline Title Editing +1. Click the title text of a chapter in the list +2. Change the title text and blur/tab away +3. **Expected:** Title updates. API PATCH call persists the change. + +### Test 6: Status Cycling +1. Click the status badge on a chapter row +2. **Expected:** Status cycles: draft → approved → hidden → draft. Badge color changes accordingly. API PATCH persists. + +### Test 7: Reorder Chapters +1. Click the down arrow on the first chapter +2. **Expected:** Chapter moves to second position. All sort_order values update. API PUT /reorder fires. +3. Click the up arrow on the second chapter +4. **Expected:** Chapter moves back to first position. + +### Test 8: Bulk Approve +1. Check boxes on 2+ draft chapters +2. Click "Approve Selected" +3. **Expected:** Selected chapters change to approved status. API POST /approve fires with selected IDs. + +### Test 9: Approve All +1. Click "Approve All" button +2. **Expected:** All chapters become approved. API POST /approve fires with all chapter IDs. + +### Test 10: Public Chapter Filtering +1. Approve some chapters for a video (not all) +2. In a different browser/incognito, view the video's public page +3. **Expected:** Only approved chapters display in the player timeline +4. Now hide all approved chapters (set all to draft/hidden) +5. **Expected:** Public page falls back to showing all chapters (backward compatibility) + +### Test 11: Auth Guard +1. Log out and navigate directly to `/creator/chapters/:videoId` +2. **Expected:** Redirected to login page + +### Test 12: Empty State +1. Navigate to `/creator/chapters/:videoId` for a video with no chapters +2. **Expected:** Page shows empty state message (no crash, no blank page) + +### Edge Cases +- **Ownership check:** Attempt to access chapters for a video owned by a different creator → 403 Forbidden +- **Concurrent edits:** Drag a region while also editing title → both changes persist independently +- **Single chapter:** Reorder arrows disabled/hidden when only one chapter exists diff --git a/.gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json b/.gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json new file mode 100644 index 0000000..76d5979 --- /dev/null +++ b/.gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T03", + "unitId": "M021/S06/T03", + "timestamp": 1775283130846, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 9, + "verdict": "pass" + }, + { + "command": "grep -q 'ChapterReview' src/App.tsx", + "exitCode": 2, + "durationMs": 9, + "verdict": "fail" + }, + { + "command": "grep -q '/creator/chapters' src/pages/CreatorDashboard.tsx", + "exitCode": 2, + "durationMs": 10, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M021/slices/S07/S07-PLAN.md b/.gsd/milestones/M021/slices/S07/S07-PLAN.md index 1e19795..bb509e7 100644 --- a/.gsd/milestones/M021/slices/S07/S07-PLAN.md +++ b/.gsd/milestones/M021/slices/S07/S07-PLAN.md @@ -1,6 +1,114 @@ # S07: [A] Impersonation Polish + Write Mode -**Goal:** Add write mode toggle and audit log admin view to impersonation system +**Goal:** Impersonation write mode with confirmation modal. Audit log admin view shows all sessions. **Demo:** After this: Impersonation write mode with confirmation modal. Audit log admin view shows all sessions. ## Tasks +- [x] **T01: Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint** — Add write_mode support to impersonation tokens and a paginated audit log endpoint. + +## Steps + +1. In `backend/models.py`, add `write_mode: Mapped[bool]` column to `ImpersonationLog` with `default=False, server_default=text('false')`. Import `text` from sqlalchemy if needed. + +2. In `backend/auth.py`: + - Modify `create_impersonation_token()` to accept an optional `write_mode: bool = False` param. When True, add `"write_mode": True` to the JWT payload. + - Modify `get_current_user()` to also attach `_impersonation_write_mode` from the decoded payload: `user._impersonation_write_mode = payload.get("write_mode", False)`. + - Modify `reject_impersonation()` to check `_impersonation_write_mode` — if True, allow the request through (return user). Only block when impersonating WITHOUT write mode. + +3. In `backend/routers/admin.py`: + - Add a Pydantic request body model `StartImpersonationRequest` with `write_mode: bool = False`. + - Modify `start_impersonation()` to accept this body, pass `write_mode` to `create_impersonation_token()`, and log `write_mode` in the `ImpersonationLog` row. + - Add response schema `ImpersonationLogItem(BaseModel)` with fields: `id`, `admin_name`, `target_name`, `action`, `write_mode`, `ip_address`, `created_at`. + - Add `GET /impersonation-log` endpoint (admin-only) that queries `ImpersonationLog` joined with `User` (aliased for admin and target) for display names. Support `?page=1&page_size=50` query params. Return `list[ImpersonationLogItem]`. + +4. In `backend/tests/test_impersonation.py` (new file), write integration tests: + - Test: start impersonation without write_mode → write to `PUT /auth/me` is 403. + - Test: start impersonation with write_mode=true → write to `PUT /auth/me` succeeds (or at least is not 403 from reject_impersonation — may get validation error, that's fine). + - Test: `GET /admin/impersonation-log` returns log entries with admin/target names. + - Test: non-admin cannot access impersonation-log. + +## Must-Haves + +- [ ] `reject_impersonation` allows writes when token has `write_mode=true` +- [ ] `reject_impersonation` still blocks writes when `write_mode` absent or false +- [ ] `ImpersonationLog` records `write_mode` boolean +- [ ] `GET /admin/impersonation-log` returns paginated entries with display names +- [ ] All new tests pass + - Estimate: 1h + - Files: backend/auth.py, backend/models.py, backend/routers/admin.py, backend/tests/test_impersonation.py + - Verify: cd backend && python -m pytest tests/test_impersonation.py -v +- [ ] **T02: Frontend: confirmation modal, write-mode banner state, API write_mode param** — Add a confirmation modal for write-mode impersonation and update the banner to reflect mode. + +## Steps + +1. Create `frontend/src/components/ConfirmModal.tsx` — a reusable overlay modal: + - Props: `open: boolean`, `title: string`, `message: string`, `confirmLabel?: string`, `cancelLabel?: string`, `onConfirm: () => void`, `onCancel: () => void`, `variant?: 'warning' | 'danger'`. + - Renders a backdrop + centered card with title, message, cancel button, confirm button. + - Confirm button uses red/amber depending on variant. + - Closes on Escape key and backdrop click. + +2. Create `frontend/src/components/ConfirmModal.module.css` — modal styling matching the dark theme. + +3. Update `frontend/src/api/auth.ts`: + - Modify `impersonateUser()` to accept optional `writeMode?: boolean` param. When true, send `{ write_mode: true }` as JSON body with `Content-Type: application/json`. + - Add `ImpersonationLogEntry` interface: `{ id, admin_name, target_name, action, write_mode, ip_address, created_at }`. + - Add `fetchImpersonationLog(token: string, page?: number): Promise` function calling `GET /admin/impersonation-log?page=N`. + +4. Update `frontend/src/context/AuthContext.tsx`: + - Add `isWriteMode: boolean` to context value. + - Track write mode in state. When `startImpersonation` is called, accept optional `writeMode` param and pass to API. + - Expose `isWriteMode` in context. + +5. Update `frontend/src/pages/AdminUsers.tsx`: + - Replace the single "View As" button with two buttons: "View As" (read-only) and "Edit As" (write mode). + - "Edit As" opens the ConfirmModal with a warning: "You are about to edit as {name}. Changes will be attributed to this creator and logged. Continue?" + - On confirm, call `startImpersonation(userId, true)`. + - "View As" works as before (no modal, read-only mode). + +6. Update `frontend/src/components/ImpersonationBanner.tsx`: + - Pull `isWriteMode` from auth context. + - When write mode: banner background red (#dc2626), icon ✏️, text "Editing as {name}". + - When read mode: keep current amber (#b45309), icon 👁, text "Viewing as {name}". + - Add `body.impersonating-write` class in write mode for potential downstream styling. + +## Must-Haves + +- [ ] ConfirmModal component renders with backdrop, closes on Escape/backdrop click +- [ ] "Edit As" button shows confirmation modal before starting write-mode impersonation +- [ ] Banner shows red "Editing as" in write mode, amber "Viewing as" in read mode +- [ ] `impersonateUser` API function sends write_mode in request body +- [ ] `npm run build` passes with zero errors + - Estimate: 1h + - Files: frontend/src/components/ConfirmModal.tsx, frontend/src/components/ConfirmModal.module.css, frontend/src/api/auth.ts, frontend/src/context/AuthContext.tsx, frontend/src/pages/AdminUsers.tsx, frontend/src/components/ImpersonationBanner.tsx, frontend/src/components/ImpersonationBanner.module.css + - Verify: cd frontend && npm run build 2>&1 | tail -5 +- [ ] **T03: Frontend: audit log admin page, route, and nav link** — Add an admin page displaying paginated impersonation audit log entries. + +## Steps + +1. Create `frontend/src/pages/AdminAuditLog.tsx`: + - Use `useDocumentTitle('Audit Log — Admin')` like other admin pages. + - Fetch impersonation log via `fetchImpersonationLog(token)` from `api/auth.ts` (added in T02). + - Render a table with columns: Date/Time, Admin, Target User, Action (start/stop), Write Mode (yes/no badge), IP Address. + - Format `created_at` as locale datetime string. + - Show loading and error states matching existing admin page patterns (see AdminUsers.tsx). + - Add simple pagination: "Previous" / "Next" buttons, tracking current page in state. + +2. Create `frontend/src/pages/AdminAuditLog.module.css` — table styling matching AdminUsers.module.css patterns. + +3. Update `frontend/src/App.tsx`: + - Add lazy import: `const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));` + - Add route: `}>} />` alongside existing admin routes. + +4. Update `frontend/src/components/AdminDropdown.tsx`: + - Add an "Audit Log" link to `/admin/audit-log` in the dropdown menu, after the "Users" link. + +## Must-Haves + +- [ ] `/admin/audit-log` route renders the AdminAuditLog page +- [ ] Table displays impersonation log entries with correct columns +- [ ] Pagination controls work (page state updates, new data fetched) +- [ ] AdminDropdown includes Audit Log link +- [ ] `npm run build` passes with zero errors + - Estimate: 45m + - Files: frontend/src/pages/AdminAuditLog.tsx, frontend/src/pages/AdminAuditLog.module.css, frontend/src/App.tsx, frontend/src/components/AdminDropdown.tsx + - Verify: cd frontend && npm run build 2>&1 | tail -5 diff --git a/.gsd/milestones/M021/slices/S07/S07-RESEARCH.md b/.gsd/milestones/M021/slices/S07/S07-RESEARCH.md new file mode 100644 index 0000000..3a0b248 --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/S07-RESEARCH.md @@ -0,0 +1,85 @@ +# S07 Research — Impersonation Polish + Write Mode + +## Depth: Light Research + +This is straightforward UI+API work layering two features onto an already-solid impersonation system. No new tech, no risky integration. + +## Summary + +The slice delivers three things: +1. **Impersonation write mode** — currently `reject_impersonation` blocks ALL writes during impersonation. Add an opt-in "write mode" with a confirmation modal so admins can edit on behalf of a creator. +2. **Confirmation modal** — before entering write mode, show a modal warning the admin. +3. **Audit log admin view** — new page showing all impersonation sessions (start/stop, who, when, IP). + +## Requirement Coverage + +No specific active requirements are owned by this slice. It advances operational readiness and admin tooling (good governance for the impersonation feature established in earlier milestones). + +## Implementation Landscape + +### Backend — What Exists + +| File | What it does | Relevance | +|------|-------------|-----------| +| `backend/auth.py` | `create_impersonation_token()` — JWT with `original_user_id` + `type: "impersonation"`. `reject_impersonation()` — dependency that blocks ALL writes. | Core target. Write mode needs a token claim or request-level flag to bypass the blanket reject. | +| `backend/routers/admin.py` | `POST /admin/impersonate/{user_id}` (start), `POST /admin/impersonate/stop` (stop). `GET /admin/users` (list). | Add `GET /admin/impersonation-log` here. Modify start to accept `write_mode` param. | +| `backend/models.py:680` | `ImpersonationLog` model — `id, admin_user_id, target_user_id, action, ip_address, created_at`. Already in DB. | Query target for audit log endpoint. May want to add `write_mode` boolean column. | +| `backend/routers/auth.py:134` | `PUT /auth/me` uses `reject_impersonation` dependency. | Will need to conditionally allow writes when write mode is active. | +| `backend/routers/consent.py:178` | Consent PUT also uses `reject_impersonation`. | Same — conditionally allow. | + +### Frontend — What Exists + +| File | What it does | Relevance | +|------|-------------|-----------| +| `frontend/src/pages/AdminUsers.tsx` | Users table with "View As" button per creator. Calls `startImpersonation()`. | Add write-mode toggle or make the confirmation modal launch from here. | +| `frontend/src/components/ImpersonationBanner.tsx` | Fixed amber banner: "Viewing as {name}" + Exit button. Adds `body.impersonating` class. | Add write-mode indicator (e.g. "Editing as {name}" in red) and toggle button. | +| `frontend/src/context/AuthContext.tsx` | `startImpersonation()`, `exitImpersonation()`, `isImpersonating` state. Stores admin token in sessionStorage. | Add `isWriteMode` state. Possibly pass write_mode flag to start endpoint. | +| `frontend/src/api/auth.ts` | API functions: `impersonateUser()`, `stopImpersonation()`, `fetchUsers()`. Types: `ImpersonateResponse`, `UserResponse.impersonating`. | Add `fetchImpersonationLog()` function. Add `write_mode` param to `impersonateUser()`. | +| `frontend/src/components/AdminDropdown.tsx` | Admin nav dropdown with links to Reports, Pipeline, Techniques, Users. | Add "Audit Log" link. | +| `frontend/src/App.tsx:186-189` | Admin routes: `/admin/reports`, `/admin/pipeline`, `/admin/techniques`, `/admin/users`. | Add `/admin/audit-log` route. | + +### Key Design Decisions Needed + +1. **Write mode scope**: Two approaches: + - **Token-level**: Encode `write_mode: true` in the impersonation JWT at creation time. Simpler but requires re-issuing token to toggle. + - **Request-level**: Keep current token, add a separate `POST /admin/impersonate/write-mode` toggle that flips a server-side session flag or issues a new token. More flexible. + - **Recommendation**: Token-level. Issue a new impersonation token with `write_mode: true` claim when admin confirms the modal. `reject_impersonation` checks this claim. Simple, stateless, auditable. + +2. **Confirmation modal**: No existing modal component in the codebase. Need to create a reusable `ConfirmModal` or a one-off. Since there's no modal lib, a simple CSS-based overlay is the right call — matches the existing hand-rolled component style. + +3. **Audit log endpoint**: Simple paginated GET returning `ImpersonationLog` rows joined with user display names. + +## Natural Seams (Task Decomposition) + +1. **Backend: Write-mode token + audit log endpoint** (~independent) + - Add `write_mode` boolean claim to impersonation token + - Modify `reject_impersonation` to allow writes when `write_mode=true` in token + - Add `write_mode` column to `ImpersonationLog` model + - Add `GET /admin/impersonation-log` endpoint (paginated, admin-only) + - Add `write_mode` param to `POST /admin/impersonate/{user_id}` + - Files: `backend/auth.py`, `backend/routers/admin.py`, `backend/models.py`, `backend/schemas.py` + +2. **Frontend: Confirmation modal + write-mode banner** (~depends on T01 API) + - Create `ConfirmModal` component (reusable overlay) + - Wire confirmation flow into `AdminUsers.tsx` "View As" button — add "View As (Read)" vs "Edit As (Write)" options, or a checkbox before confirming + - Update `ImpersonationBanner.tsx` to show write-mode state (red vs amber) + - Update `AuthContext.tsx` to track and expose `isWriteMode` + - Update `api/auth.ts` to pass `write_mode` to start endpoint + - Files: `frontend/src/components/ConfirmModal.tsx`, `frontend/src/components/ConfirmModal.module.css`, `frontend/src/pages/AdminUsers.tsx`, `frontend/src/components/ImpersonationBanner.tsx`, `frontend/src/context/AuthContext.tsx`, `frontend/src/api/auth.ts` + +3. **Frontend: Audit log admin page** (~depends on T01 API) + - New `AdminAuditLog.tsx` page with table of sessions + - Add route in `App.tsx` + - Add link in `AdminDropdown.tsx` + - Add API function in `api/auth.ts` + - Files: `frontend/src/pages/AdminAuditLog.tsx`, `frontend/src/pages/AdminAuditLog.module.css`, `frontend/src/App.tsx`, `frontend/src/components/AdminDropdown.tsx`, `frontend/src/api/auth.ts` + +## Verification Strategy + +- Backend: Unit tests for `reject_impersonation` allowing writes with write_mode token, blocking without. Test audit log endpoint returns rows. +- Frontend: Build passes (`npm run build`). Visual check that confirmation modal appears, banner changes color in write mode, audit log page renders. +- Integration: Start impersonation with write_mode=true → perform a write → verify it succeeds and audit log records the session. + +## Risks + +None significant. This is additive feature work on established patterns. The only mild risk is DB migration for the `write_mode` column on `ImpersonationLog` — the project doesn't use Alembic migrations (tables are created via `metadata.create_all`), so the column needs a default or manual `ALTER TABLE`. diff --git a/.gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md b/.gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md new file mode 100644 index 0000000..19b877c --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md @@ -0,0 +1,56 @@ +--- +estimated_steps: 23 +estimated_files: 4 +skills_used: [] +--- + +# T01: Backend: write-mode token, conditional reject, audit log endpoint + tests + +Add write_mode support to impersonation tokens and a paginated audit log endpoint. + +## Steps + +1. In `backend/models.py`, add `write_mode: Mapped[bool]` column to `ImpersonationLog` with `default=False, server_default=text('false')`. Import `text` from sqlalchemy if needed. + +2. In `backend/auth.py`: + - Modify `create_impersonation_token()` to accept an optional `write_mode: bool = False` param. When True, add `"write_mode": True` to the JWT payload. + - Modify `get_current_user()` to also attach `_impersonation_write_mode` from the decoded payload: `user._impersonation_write_mode = payload.get("write_mode", False)`. + - Modify `reject_impersonation()` to check `_impersonation_write_mode` — if True, allow the request through (return user). Only block when impersonating WITHOUT write mode. + +3. In `backend/routers/admin.py`: + - Add a Pydantic request body model `StartImpersonationRequest` with `write_mode: bool = False`. + - Modify `start_impersonation()` to accept this body, pass `write_mode` to `create_impersonation_token()`, and log `write_mode` in the `ImpersonationLog` row. + - Add response schema `ImpersonationLogItem(BaseModel)` with fields: `id`, `admin_name`, `target_name`, `action`, `write_mode`, `ip_address`, `created_at`. + - Add `GET /impersonation-log` endpoint (admin-only) that queries `ImpersonationLog` joined with `User` (aliased for admin and target) for display names. Support `?page=1&page_size=50` query params. Return `list[ImpersonationLogItem]`. + +4. In `backend/tests/test_impersonation.py` (new file), write integration tests: + - Test: start impersonation without write_mode → write to `PUT /auth/me` is 403. + - Test: start impersonation with write_mode=true → write to `PUT /auth/me` succeeds (or at least is not 403 from reject_impersonation — may get validation error, that's fine). + - Test: `GET /admin/impersonation-log` returns log entries with admin/target names. + - Test: non-admin cannot access impersonation-log. + +## Must-Haves + +- [ ] `reject_impersonation` allows writes when token has `write_mode=true` +- [ ] `reject_impersonation` still blocks writes when `write_mode` absent or false +- [ ] `ImpersonationLog` records `write_mode` boolean +- [ ] `GET /admin/impersonation-log` returns paginated entries with display names +- [ ] All new tests pass + +## Inputs + +- ``backend/auth.py` — existing impersonation token creation and reject_impersonation dependency` +- ``backend/models.py` — existing ImpersonationLog model at line 680` +- ``backend/routers/admin.py` — existing start/stop impersonation endpoints and schemas` +- ``backend/tests/conftest.py` — test fixtures (client, db_engine, etc.)` + +## Expected Output + +- ``backend/auth.py` — modified with write_mode param in create_impersonation_token, _impersonation_write_mode on user, conditional reject_impersonation` +- ``backend/models.py` — ImpersonationLog.write_mode column added` +- ``backend/routers/admin.py` — StartImpersonationRequest body, write_mode passed through, GET /impersonation-log endpoint` +- ``backend/tests/test_impersonation.py` — new integration test file with 4+ test cases` + +## Verification + +cd backend && python -m pytest tests/test_impersonation.py -v diff --git a/.gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md b/.gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..31093fd --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md @@ -0,0 +1,81 @@ +--- +id: T01 +parent: S07 +milestone: M021 +provides: [] +requires: [] +affects: [] +key_files: ["backend/auth.py", "backend/models.py", "backend/routers/admin.py", "backend/tests/test_impersonation.py"] +key_decisions: ["StartImpersonationRequest body is optional for backward compatibility", "write_mode only added to JWT payload when True to keep read-only tokens minimal"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "All 5 integration tests pass: test_impersonation_without_write_mode_blocks_writes, test_impersonation_with_write_mode_allows_writes, test_impersonation_log_returns_entries, test_impersonation_log_non_admin_forbidden, test_impersonation_log_pagination. Run against real PostgreSQL test database via SSH tunnel." +completed_at: 2026-04-04T06:23:49.140Z +blocker_discovered: false +--- + +# T01: Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint + +> Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint + +## What Happened +--- +id: T01 +parent: S07 +milestone: M021 +key_files: + - backend/auth.py + - backend/models.py + - backend/routers/admin.py + - backend/tests/test_impersonation.py +key_decisions: + - StartImpersonationRequest body is optional for backward compatibility + - write_mode only added to JWT payload when True to keep read-only tokens minimal +duration: "" +verification_result: passed +completed_at: 2026-04-04T06:23:49.140Z +blocker_discovered: false +--- + +# T01: Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint + +**Added write_mode support to impersonation tokens with conditional write rejection and paginated admin audit log endpoint** + +## What Happened + +Added write_mode boolean column to ImpersonationLog model. Modified create_impersonation_token to accept write_mode param and embed it in JWT payload. Updated get_current_user to attach _impersonation_write_mode from token. Made reject_impersonation conditional — allows writes when write_mode is True, blocks when False or absent. Added StartImpersonationRequest body model, ImpersonationLogItem response schema, and GET /impersonation-log admin endpoint with aliased User joins for display names and pagination. Created 5 integration tests covering all must-haves. + +## Verification + +All 5 integration tests pass: test_impersonation_without_write_mode_blocks_writes, test_impersonation_with_write_mode_allows_writes, test_impersonation_log_returns_entries, test_impersonation_log_non_admin_forbidden, test_impersonation_log_pagination. Run against real PostgreSQL test database via SSH tunnel. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `python -m pytest tests/test_impersonation.py -v` | 0 | ✅ pass | 7430ms | + + +## Deviations + +StartImpersonationRequest body parameter made optional (defaults to None) for backward compatibility with existing callers that POST without a body. + +## Known Issues + +None. + +## Files Created/Modified + +- `backend/auth.py` +- `backend/models.py` +- `backend/routers/admin.py` +- `backend/tests/test_impersonation.py` + + +## Deviations +StartImpersonationRequest body parameter made optional (defaults to None) for backward compatibility with existing callers that POST without a body. + +## Known Issues +None. diff --git a/.gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md b/.gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md new file mode 100644 index 0000000..02d2d3c --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md @@ -0,0 +1,71 @@ +--- +estimated_steps: 32 +estimated_files: 7 +skills_used: [] +--- + +# T02: Frontend: confirmation modal, write-mode banner state, API write_mode param + +Add a confirmation modal for write-mode impersonation and update the banner to reflect mode. + +## Steps + +1. Create `frontend/src/components/ConfirmModal.tsx` — a reusable overlay modal: + - Props: `open: boolean`, `title: string`, `message: string`, `confirmLabel?: string`, `cancelLabel?: string`, `onConfirm: () => void`, `onCancel: () => void`, `variant?: 'warning' | 'danger'`. + - Renders a backdrop + centered card with title, message, cancel button, confirm button. + - Confirm button uses red/amber depending on variant. + - Closes on Escape key and backdrop click. + +2. Create `frontend/src/components/ConfirmModal.module.css` — modal styling matching the dark theme. + +3. Update `frontend/src/api/auth.ts`: + - Modify `impersonateUser()` to accept optional `writeMode?: boolean` param. When true, send `{ write_mode: true }` as JSON body with `Content-Type: application/json`. + - Add `ImpersonationLogEntry` interface: `{ id, admin_name, target_name, action, write_mode, ip_address, created_at }`. + - Add `fetchImpersonationLog(token: string, page?: number): Promise` function calling `GET /admin/impersonation-log?page=N`. + +4. Update `frontend/src/context/AuthContext.tsx`: + - Add `isWriteMode: boolean` to context value. + - Track write mode in state. When `startImpersonation` is called, accept optional `writeMode` param and pass to API. + - Expose `isWriteMode` in context. + +5. Update `frontend/src/pages/AdminUsers.tsx`: + - Replace the single "View As" button with two buttons: "View As" (read-only) and "Edit As" (write mode). + - "Edit As" opens the ConfirmModal with a warning: "You are about to edit as {name}. Changes will be attributed to this creator and logged. Continue?" + - On confirm, call `startImpersonation(userId, true)`. + - "View As" works as before (no modal, read-only mode). + +6. Update `frontend/src/components/ImpersonationBanner.tsx`: + - Pull `isWriteMode` from auth context. + - When write mode: banner background red (#dc2626), icon ✏️, text "Editing as {name}". + - When read mode: keep current amber (#b45309), icon 👁, text "Viewing as {name}". + - Add `body.impersonating-write` class in write mode for potential downstream styling. + +## Must-Haves + +- [ ] ConfirmModal component renders with backdrop, closes on Escape/backdrop click +- [ ] "Edit As" button shows confirmation modal before starting write-mode impersonation +- [ ] Banner shows red "Editing as" in write mode, amber "Viewing as" in read mode +- [ ] `impersonateUser` API function sends write_mode in request body +- [ ] `npm run build` passes with zero errors + +## Inputs + +- ``frontend/src/api/auth.ts` — existing impersonateUser and stopImpersonation functions` +- ``frontend/src/context/AuthContext.tsx` — existing auth context with startImpersonation` +- ``frontend/src/pages/AdminUsers.tsx` — existing admin users page with View As button` +- ``frontend/src/components/ImpersonationBanner.tsx` — existing banner component` +- ``frontend/src/components/ImpersonationBanner.module.css` — existing banner styles` + +## Expected Output + +- ``frontend/src/components/ConfirmModal.tsx` — new reusable modal component` +- ``frontend/src/components/ConfirmModal.module.css` — new modal styles` +- ``frontend/src/api/auth.ts` — modified with write_mode param, ImpersonationLogEntry type, fetchImpersonationLog function` +- ``frontend/src/context/AuthContext.tsx` — modified with isWriteMode state and writeMode param on startImpersonation` +- ``frontend/src/pages/AdminUsers.tsx` — modified with Edit As button and confirmation modal` +- ``frontend/src/components/ImpersonationBanner.tsx` — modified with write-mode visual state` +- ``frontend/src/components/ImpersonationBanner.module.css` — modified with write-mode red style` + +## Verification + +cd frontend && npm run build 2>&1 | tail -5 diff --git a/.gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md b/.gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md new file mode 100644 index 0000000..d6bf52d --- /dev/null +++ b/.gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md @@ -0,0 +1,55 @@ +--- +estimated_steps: 21 +estimated_files: 4 +skills_used: [] +--- + +# T03: Frontend: audit log admin page, route, and nav link + +Add an admin page displaying paginated impersonation audit log entries. + +## Steps + +1. Create `frontend/src/pages/AdminAuditLog.tsx`: + - Use `useDocumentTitle('Audit Log — Admin')` like other admin pages. + - Fetch impersonation log via `fetchImpersonationLog(token)` from `api/auth.ts` (added in T02). + - Render a table with columns: Date/Time, Admin, Target User, Action (start/stop), Write Mode (yes/no badge), IP Address. + - Format `created_at` as locale datetime string. + - Show loading and error states matching existing admin page patterns (see AdminUsers.tsx). + - Add simple pagination: "Previous" / "Next" buttons, tracking current page in state. + +2. Create `frontend/src/pages/AdminAuditLog.module.css` — table styling matching AdminUsers.module.css patterns. + +3. Update `frontend/src/App.tsx`: + - Add lazy import: `const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));` + - Add route: `}>} />` alongside existing admin routes. + +4. Update `frontend/src/components/AdminDropdown.tsx`: + - Add an "Audit Log" link to `/admin/audit-log` in the dropdown menu, after the "Users" link. + +## Must-Haves + +- [ ] `/admin/audit-log` route renders the AdminAuditLog page +- [ ] Table displays impersonation log entries with correct columns +- [ ] Pagination controls work (page state updates, new data fetched) +- [ ] AdminDropdown includes Audit Log link +- [ ] `npm run build` passes with zero errors + +## Inputs + +- ``frontend/src/api/auth.ts` — fetchImpersonationLog function and ImpersonationLogEntry type (added in T02)` +- ``frontend/src/App.tsx` — existing admin routes to add alongside` +- ``frontend/src/components/AdminDropdown.tsx` — existing dropdown to add link to` +- ``frontend/src/pages/AdminUsers.tsx` — reference for admin page patterns and CSS module conventions` +- ``frontend/src/pages/AdminUsers.module.css` — reference for table styling patterns` + +## Expected Output + +- ``frontend/src/pages/AdminAuditLog.tsx` — new audit log admin page` +- ``frontend/src/pages/AdminAuditLog.module.css` — new page styles` +- ``frontend/src/App.tsx` — modified with /admin/audit-log route` +- ``frontend/src/components/AdminDropdown.tsx` — modified with Audit Log link` + +## Verification + +cd frontend && npm run build 2>&1 | tail -5 diff --git a/backend/auth.py b/backend/auth.py index 23e6508..f6fd863 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -63,11 +63,14 @@ def create_impersonation_token( admin_user_id: uuid.UUID | str, target_user_id: uuid.UUID | str, target_role: str, + *, + write_mode: bool = False, ) -> str: """Create a scoped JWT for admin impersonation. The token has sub=target_user_id so get_current_user loads the target, plus original_user_id so the system knows it's impersonation. + When write_mode is True, the token allows write operations. """ settings = get_settings() now = datetime.now(timezone.utc) @@ -79,6 +82,8 @@ def create_impersonation_token( "iat": now, "exp": now + timedelta(minutes=_IMPERSONATION_EXPIRE_MINUTES), } + if write_mode: + payload["write_mode"] = True return jwt.encode(payload, settings.app_secret_key, algorithm=_ALGORITHM) @@ -127,6 +132,7 @@ async def get_current_user( ) # Attach impersonation metadata (non-column runtime attribute) user._impersonating_admin_id = payload.get("original_user_id") # type: ignore[attr-defined] + user._impersonation_write_mode = payload.get("write_mode", False) # type: ignore[attr-defined] return user @@ -149,11 +155,17 @@ def require_role(required_role: UserRole): async def reject_impersonation( current_user: Annotated[User, Depends(get_current_user)], ) -> User: - """Dependency that blocks write operations during impersonation.""" + """Dependency that blocks write operations during impersonation. + + If the impersonation token was issued with write_mode=True, + writes are permitted. + """ admin_id = getattr(current_user, "_impersonating_admin_id", None) if admin_id is not None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Write operations are not allowed during impersonation", - ) + write_mode = getattr(current_user, "_impersonation_write_mode", False) + if not write_mode: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Write operations are not allowed during impersonation", + ) return current_user diff --git a/backend/models.py b/backend/models.py index ca8d041..b580967 100644 --- a/backend/models.py +++ b/backend/models.py @@ -21,6 +21,7 @@ from sqlalchemy import ( Text, UniqueConstraint, func, + text, ) from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column @@ -691,6 +692,9 @@ class ImpersonationLog(Base): action: Mapped[str] = mapped_column( String(10), nullable=False, doc="'start' or 'stop'" ) + write_mode: Mapped[bool] = mapped_column( + default=False, server_default=text("false"), + ) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) created_at: Mapped[datetime] = mapped_column( default=_now, server_default=func.now() diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 37ccc02..b8058dc 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -3,13 +3,15 @@ from __future__ import annotations import logging +from datetime import datetime from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from auth import ( create_impersonation_token, @@ -52,6 +54,20 @@ class StopImpersonateResponse(BaseModel): message: str +class StartImpersonationRequest(BaseModel): + write_mode: bool = False + + +class ImpersonationLogItem(BaseModel): + id: str + admin_name: str + target_name: str + action: str + write_mode: bool + ip_address: str | None + created_at: datetime + + # ── Helpers ────────────────────────────────────────────────────────────────── @@ -97,8 +113,12 @@ async def start_impersonation( request: Request, admin: Annotated[User, Depends(_require_admin)], session: Annotated[AsyncSession, Depends(get_session)], + body: StartImpersonationRequest | None = None, ): """Start impersonating a user. Admin only. Returns a scoped JWT.""" + if body is None: + body = StartImpersonationRequest() + # Cannot impersonate yourself if admin.id == user_id: raise HTTPException( @@ -120,6 +140,7 @@ async def start_impersonation( admin_user_id=admin.id, target_user_id=target.id, target_role=target.role.value, + write_mode=body.write_mode, ) # Audit log @@ -127,13 +148,14 @@ async def start_impersonation( admin_user_id=admin.id, target_user_id=target.id, action="start", + write_mode=body.write_mode, ip_address=_client_ip(request), )) await session.commit() logger.info( - "Impersonation started: admin=%s target=%s", - admin.id, target.id, + "Impersonation started: admin=%s target=%s write_mode=%s", + admin.id, target.id, body.write_mode, ) return ImpersonateResponse( @@ -178,3 +200,39 @@ async def stop_impersonation( ) return StopImpersonateResponse(message="Impersonation ended") + + +@router.get("/impersonation-log", response_model=list[ImpersonationLogItem]) +async def get_impersonation_log( + _admin: Annotated[User, Depends(_require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +): + """Paginated impersonation audit log. Admin only.""" + AdminUser = aliased(User, name="admin_user") + TargetUser = aliased(User, name="target_user") + + stmt = ( + select(ImpersonationLog, AdminUser.display_name, TargetUser.display_name) + .join(AdminUser, ImpersonationLog.admin_user_id == AdminUser.id) + .join(TargetUser, ImpersonationLog.target_user_id == TargetUser.id) + .order_by(ImpersonationLog.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + result = await session.execute(stmt) + rows = result.all() + + return [ + ImpersonationLogItem( + id=str(log.id), + admin_name=admin_name, + target_name=target_name, + action=log.action, + write_mode=log.write_mode, + ip_address=log.ip_address, + created_at=log.created_at, + ) + for log, admin_name, target_name in rows + ] diff --git a/backend/tests/test_impersonation.py b/backend/tests/test_impersonation.py new file mode 100644 index 0000000..fdaa172 --- /dev/null +++ b/backend/tests/test_impersonation.py @@ -0,0 +1,174 @@ +"""Integration tests for impersonation write-mode and audit log.""" + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from models import InviteCode, User, UserRole + +# Re-use fixtures from conftest: db_engine, client, admin_auth + + +_TARGET_EMAIL = "impersonate-target@chrysopedia.com" +_TARGET_PASSWORD = "targetpass123" +_TARGET_INVITE = "IMP-TARGET-INV" + + +@pytest_asyncio.fixture() +async def target_user(client: AsyncClient, db_engine): + """Register a regular user to be the impersonation target.""" + factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + code = InviteCode(code=_TARGET_INVITE, uses_remaining=10) + session.add(code) + await session.commit() + + resp = await client.post("/api/v1/auth/register", json={ + "email": _TARGET_EMAIL, + "password": _TARGET_PASSWORD, + "display_name": "Target User", + "invite_code": _TARGET_INVITE, + }) + assert resp.status_code == 201 + return resp.json() + + +# ── Write-mode tests ──────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_impersonation_without_write_mode_blocks_writes( + client: AsyncClient, admin_auth, target_user, +): + """Read-only impersonation (default) should 403 on PUT /auth/me.""" + # Start impersonation without write_mode + resp = await client.post( + f"/api/v1/admin/impersonate/{target_user['id']}", + headers=admin_auth["headers"], + ) + assert resp.status_code == 200 + imp_token = resp.json()["access_token"] + imp_headers = {"Authorization": f"Bearer {imp_token}"} + + # Attempt a write operation — should be blocked + resp = await client.put( + "/api/v1/auth/me", + headers=imp_headers, + json={"display_name": "Hacked Name"}, + ) + assert resp.status_code == 403 + assert "impersonation" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_impersonation_with_write_mode_allows_writes( + client: AsyncClient, admin_auth, target_user, +): + """Write-mode impersonation should not 403 on PUT /auth/me.""" + # Start impersonation WITH write_mode + resp = await client.post( + f"/api/v1/admin/impersonate/{target_user['id']}", + headers=admin_auth["headers"], + json={"write_mode": True}, + ) + assert resp.status_code == 200 + imp_token = resp.json()["access_token"] + imp_headers = {"Authorization": f"Bearer {imp_token}"} + + # Attempt a write — should NOT get 403 from reject_impersonation + resp = await client.put( + "/api/v1/auth/me", + headers=imp_headers, + json={"display_name": "Updated Via WriteMode"}, + ) + # Should succeed (200) or at least not be a 403 + assert resp.status_code != 403 + # Verify the update actually took effect + assert resp.status_code == 200 + assert resp.json()["display_name"] == "Updated Via WriteMode" + + +# ── Audit log endpoint tests ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_impersonation_log_returns_entries( + client: AsyncClient, admin_auth, target_user, +): + """GET /admin/impersonation-log returns log entries with names.""" + # Create some log entries by starting impersonation + resp = await client.post( + f"/api/v1/admin/impersonate/{target_user['id']}", + headers=admin_auth["headers"], + json={"write_mode": True}, + ) + assert resp.status_code == 200 + + # Fetch the log + resp = await client.get( + "/api/v1/admin/impersonation-log", + headers=admin_auth["headers"], + ) + assert resp.status_code == 200 + logs = resp.json() + assert len(logs) >= 1 + + entry = logs[0] + assert entry["admin_name"] == "Admin User" + assert entry["target_name"] == "Target User" + assert entry["action"] == "start" + assert entry["write_mode"] is True + assert "id" in entry + assert "created_at" in entry + + +@pytest.mark.asyncio +async def test_impersonation_log_non_admin_forbidden( + client: AsyncClient, target_user, +): + """Non-admin users cannot access the impersonation log.""" + # Login as the target (regular) user + resp = await client.post("/api/v1/auth/login", json={ + "email": _TARGET_EMAIL, + "password": _TARGET_PASSWORD, + }) + assert resp.status_code == 200 + user_headers = {"Authorization": f"Bearer {resp.json()['access_token']}"} + + resp = await client.get( + "/api/v1/admin/impersonation-log", + headers=user_headers, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_impersonation_log_pagination( + client: AsyncClient, admin_auth, target_user, +): + """Verify pagination params work on impersonation-log.""" + # Create two entries + for _ in range(2): + await client.post( + f"/api/v1/admin/impersonate/{target_user['id']}", + headers=admin_auth["headers"], + ) + + # Fetch page 1, page_size=1 + resp = await client.get( + "/api/v1/admin/impersonation-log", + headers=admin_auth["headers"], + params={"page": 1, "page_size": 1}, + ) + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + # Fetch page 2 + resp = await client.get( + "/api/v1/admin/impersonation-log", + headers=admin_auth["headers"], + params={"page": 2, "page_size": 1}, + ) + assert resp.status_code == 200 + assert len(resp.json()) == 1