feat: Added write_mode support to impersonation tokens with conditional…
- "backend/auth.py" - "backend/models.py" - "backend/routers/admin.py" - "backend/tests/test_impersonation.py" GSD-Task: S07/T01
This commit is contained in:
parent
f822415f6f
commit
ab9dd2aa1b
14 changed files with 935 additions and 10 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
116
.gsd/milestones/M021/slices/S06/S06-SUMMARY.md
Normal file
116
.gsd/milestones/M021/slices/S06/S06-SUMMARY.md
Normal file
|
|
@ -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
|
||||
75
.gsd/milestones/M021/slices/S06/S06-UAT.md
Normal file
75
.gsd/milestones/M021/slices/S06/S06-UAT.md
Normal file
|
|
@ -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
|
||||
30
.gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json
Normal file
30
.gsd/milestones/M021/slices/S06/tasks/T03-VERIFY.json
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<ImpersonationLogEntry[]>` 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: `<Route path="/admin/audit-log" element={<Suspense fallback={<LoadingFallback />}><AdminAuditLog /></Suspense>} />` 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
|
||||
|
|
|
|||
85
.gsd/milestones/M021/slices/S07/S07-RESEARCH.md
Normal file
85
.gsd/milestones/M021/slices/S07/S07-RESEARCH.md
Normal file
|
|
@ -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`.
|
||||
56
.gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md
Normal file
56
.gsd/milestones/M021/slices/S07/tasks/T01-PLAN.md
Normal file
|
|
@ -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
|
||||
81
.gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md
Normal file
81
.gsd/milestones/M021/slices/S07/tasks/T01-SUMMARY.md
Normal file
|
|
@ -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.
|
||||
71
.gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md
Normal file
71
.gsd/milestones/M021/slices/S07/tasks/T02-PLAN.md
Normal file
|
|
@ -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<ImpersonationLogEntry[]>` 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
|
||||
55
.gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md
Normal file
55
.gsd/milestones/M021/slices/S07/tasks/T03-PLAN.md
Normal file
|
|
@ -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: `<Route path="/admin/audit-log" element={<Suspense fallback={<LoadingFallback />}><AdminAuditLog /></Suspense>} />` 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
174
backend/tests/test_impersonation.py
Normal file
174
backend/tests/test_impersonation.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue