feat: replace token-based impersonation with enterprise-grade header-based system
Replaces the insecure token-in-localStorage approach with a header-based impersonation system backed by cache sessions and MFA verification. Key changes: - New impersonation_sessions audit table (immutable, ULID PK) - MFA verification required to start impersonation (TOTP/email/backup) - X-Impersonate-User header + HandleImpersonation middleware - Per-request auth context swap (admin session never modified) - IP pinning, sensitive route blocking, no nesting, sliding 60-min TTL - Activity log auto-tagged with impersonated_by during sessions - Frontend: sessionStorage, BroadcastChannel sync, countdown timer - ImpersonateDialog with reason + MFA verification flow - 26 comprehensive tests covering core, middleware, audit, lifecycle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -926,17 +926,49 @@ Base path: `/api/v1/admin/`
|
||||
|
||||
### Admin Impersonation
|
||||
|
||||
- `POST /admin/impersonate/{user}` — start impersonating a user (requires `role:super_admin`)
|
||||
- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only, callable by impersonated user)
|
||||
Header-based impersonation with MFA verification. See `AUTH_ARCHITECTURE.md` section 10 for full details.
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `POST /admin/impersonate/{user}` — start impersonation (requires `role:super_admin` + MFA)
|
||||
- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only — admin calls without `X-Impersonate-User` header)
|
||||
- `GET /admin/impersonate/status` — check active session (requires `role:super_admin`)
|
||||
- `POST /admin/impersonate/send-mfa-code` — send email verification code to admin (requires `role:super_admin`)
|
||||
|
||||
#### Start Request
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "Investigating user issue with shift assignments",
|
||||
"mfa_code": "123456",
|
||||
"mfa_method": "totp"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Rules |
|
||||
|-------|------|-------|
|
||||
| `reason` | string | required, min:5, max:500 |
|
||||
| `mfa_code` | string | required |
|
||||
| `mfa_method` | string | required, in: `totp`, `email`, `backup_code` |
|
||||
|
||||
#### Start Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"token": "1|abc123...",
|
||||
"user": { "...AdminUserResource..." },
|
||||
"admin_id": "01JXYZ..."
|
||||
"session": {
|
||||
"id": "01JXYZ...",
|
||||
"admin_id": "01JXYZ...",
|
||||
"target_user_id": "01JXYZ...",
|
||||
"reason": "Investigating user issue",
|
||||
"mfa_method": "totp",
|
||||
"started_at": "2026-04-16T12:00:00+00:00",
|
||||
"expires_at": "2026-04-16T13:00:00+00:00",
|
||||
"ended_at": null,
|
||||
"end_reason": null,
|
||||
"actions_count": 0
|
||||
},
|
||||
"user": { "...AdminUserResource..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -951,12 +983,33 @@ Base path: `/api/v1/admin/`
|
||||
}
|
||||
```
|
||||
|
||||
#### Status Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"active": true,
|
||||
"session": { "...ImpersonationSessionResource..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Impersonation Header
|
||||
|
||||
During an active session, the frontend sends `X-Impersonate-User: {target_user_id}` on every request. The `HandleImpersonation` middleware validates the header against the cached session and swaps auth context.
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Admin must have MFA enabled (403 if not)
|
||||
- Cannot impersonate another super_admin (403)
|
||||
- Impersonation token has name `impersonation-by-{admin_id}`
|
||||
- Admin ID is cached for 4 hours at key `impersonation:{token_id}`
|
||||
- Activity log records both start (`admin.impersonation.started`) and stop (`admin.impersonation.stopped`)
|
||||
- Cannot nest impersonation sessions (403)
|
||||
- Cannot impersonate a user already being impersonated (403)
|
||||
- MFA code verified against admin's own MFA (TOTP, email, or backup code)
|
||||
- Sessions expire after 60 minutes (sliding TTL, extended on each request)
|
||||
- IP pinning: session terminated if admin's IP changes
|
||||
- Sensitive routes blocked during impersonation (auth/*, me/*, admin/impersonate/*)
|
||||
- All activity during session tagged with `impersonated_by` in properties
|
||||
- Immutable audit trail in `impersonation_sessions` table
|
||||
|
||||
## Email Settings (org admin)
|
||||
|
||||
|
||||
@@ -279,3 +279,124 @@ Platform admins (`super_admin`) can force-disable MFA for any user via `POST /ad
|
||||
| `app/Http/Controllers/Api/V1/Auth/MfaSetupController.php` | Authenticated MFA setup/disable/status endpoints |
|
||||
| `app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php` | Login-flow MFA verification (unauthenticated) |
|
||||
| `app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php` | Trusted device management |
|
||||
|
||||
---
|
||||
|
||||
## 10. Impersonation
|
||||
|
||||
### 10.1 Overview
|
||||
|
||||
Platform admins (`super_admin`) can impersonate other users to investigate issues. The system uses a **header-based approach** — the admin's own httpOnly cookie session is never modified. Instead, the frontend sends an `X-Impersonate-User` header, and a middleware swaps the auth context per-request.
|
||||
|
||||
### 10.2 Security Controls
|
||||
|
||||
| Control | Implementation |
|
||||
|---------|---------------|
|
||||
| **MFA at start** | Admin must provide a valid TOTP, email, or backup code to begin impersonation |
|
||||
| **Admin MFA required** | Admin's MFA must be enabled (`mfa_enabled = true`) |
|
||||
| **No super_admin targets** | Cannot impersonate another super_admin |
|
||||
| **No nesting** | Cannot start a second impersonation while one is active |
|
||||
| **No double-impersonation** | Cannot impersonate a user already being impersonated by another admin |
|
||||
| **IP pinning** | If the admin's IP changes mid-session, the session is terminated |
|
||||
| **Sensitive route blocking** | Auth, MFA, password, email, profile, and impersonation routes are blocked during impersonation |
|
||||
| **Sliding TTL** | Sessions auto-expire after 60 minutes; each request extends the window |
|
||||
| **Immutable audit** | Every session is recorded in `impersonation_sessions` with no soft deletes |
|
||||
|
||||
### 10.3 Flow
|
||||
|
||||
```
|
||||
Admin (with httpOnly cookie) API
|
||||
│ │
|
||||
│ POST /admin/impersonate/{user} │
|
||||
│ { reason, mfa_code, mfa_method } │
|
||||
│ ──────────────────────────────────────────►│
|
||||
│ │ ── Verify admin is super_admin
|
||||
│ │ ── Verify admin has MFA enabled
|
||||
│ │ ── Verify target is not super_admin
|
||||
│ │ ── Check no active session (admin or target)
|
||||
│ │ ── Verify MFA code
|
||||
│ │ ── Create ImpersonationSession (DB)
|
||||
│ │ ── Store session ID in cache (60 min TTL)
|
||||
│ ◄── session + user data ─────────────────│
|
||||
│ │
|
||||
│ GET /any-route │
|
||||
│ Cookie: crewli_app_token=... │
|
||||
│ X-Impersonate-User: {target_user_id} │
|
||||
│ ──────────────────────────────────────────►│
|
||||
│ │ ── CookieBearerToken: inject admin auth
|
||||
│ │ ── auth:sanctum: validate admin token
|
||||
│ │ ── HandleImpersonation middleware:
|
||||
│ │ 1. Read X-Impersonate-User header
|
||||
│ │ 2. Check sensitive route block list
|
||||
│ │ 3. Validate cache session (admin+target)
|
||||
│ │ 4. Check IP matches session
|
||||
│ │ 5. Extend sliding TTL
|
||||
│ │ 6. Store impersonator in request attributes
|
||||
│ │ 7. Swap auth: setUser(targetUser)
|
||||
│ │ 8. Increment actions_count
|
||||
│ │ ── Controller sees targetUser as auth user
|
||||
│ ◄── response (as target user) ───────────│
|
||||
│ │
|
||||
│ POST /admin/stop-impersonation │
|
||||
│ (NO X-Impersonate-User header) │
|
||||
│ ──────────────────────────────────────────►│
|
||||
│ │ ── Admin auth from cookie
|
||||
│ │ ── End session (DB + cache)
|
||||
│ ◄── admin user data ─────────────────────│
|
||||
```
|
||||
|
||||
### 10.4 Blocked Routes During Impersonation
|
||||
|
||||
The `HandleImpersonation` middleware blocks these route prefixes when the `X-Impersonate-User` header is present:
|
||||
|
||||
- `auth/password` — password management
|
||||
- `auth/logout` — would affect admin's session
|
||||
- `auth/mfa` — MFA setup/verify/status
|
||||
- `auth/trusted-devices` — device trust management
|
||||
- `me/profile` — profile updates
|
||||
- `me/change-password` — password changes
|
||||
- `me/change-email` — email changes
|
||||
- `admin/impersonate` — no nesting via API
|
||||
- `verify-email-change` — email verification
|
||||
|
||||
### 10.5 Activity Log Integration
|
||||
|
||||
During an active impersonation session, `AppServiceProvider` hooks into `Activity::saving()` and automatically adds:
|
||||
- `impersonated_by.user_id` — the admin's user ID
|
||||
- `impersonated_by.name` — the admin's full name
|
||||
- `impersonated_by.email` — the admin's email
|
||||
- `impersonation_session_id` — the session ID
|
||||
|
||||
This applies to **all** activity log entries, not just impersonation-specific events.
|
||||
|
||||
### 10.6 Database Table
|
||||
|
||||
**`impersonation_sessions`** — immutable audit table (no soft deletes)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | ULID | PK |
|
||||
| `admin_id` | ULID FK | → users |
|
||||
| `target_user_id` | ULID FK | → users |
|
||||
| `reason` | string | Admin-provided reason for impersonation |
|
||||
| `mfa_method` | string(20) | Which MFA method was used to verify |
|
||||
| `ip_address` | string(45) | Admin's IP at session start |
|
||||
| `user_agent` | text nullable | Admin's user agent |
|
||||
| `started_at` | timestamp | Session start time |
|
||||
| `ended_at` | timestamp nullable | NULL = still active |
|
||||
| `expires_at` | timestamp | Auto-expiry time (sliding, 60 min) |
|
||||
| `end_reason` | string(50) nullable | manual, expired, ip_changed, admin_kill_all |
|
||||
| `actions_count` | unsigned int | Number of API requests made during session |
|
||||
|
||||
**Indexes:** `(admin_id, ended_at)`, `(target_user_id, ended_at)`, `(started_at)`
|
||||
|
||||
### 10.7 Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/Services/ImpersonationService.php` | Session lifecycle, MFA verification, cache management |
|
||||
| `app/Http/Middleware/HandleImpersonation.php` | Per-request header validation, auth swap, route blocking |
|
||||
| `app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php` | Start, stop, status, send-mfa-code endpoints |
|
||||
| `app/Http/Requests/Admin/StartImpersonationRequest.php` | Validation for start request |
|
||||
| `app/Models/ImpersonationSession.php` | Eloquent model with `HasUlids`, `scopeActive()` |
|
||||
| `app/Http/Resources/Admin/ImpersonationSessionResource.php` | API resource for session data |
|
||||
|
||||
@@ -330,6 +330,29 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
||||
|
||||
---
|
||||
|
||||
### `impersonation_sessions`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | ------------------ | ---------------------------------------- |
|
||||
| `id` | ULID | PK |
|
||||
| `admin_id` | ULID FK | → users |
|
||||
| `target_user_id` | ULID FK | → users |
|
||||
| `reason` | string | Admin-provided reason |
|
||||
| `mfa_method` | string(20) | totp, email, or backup_code |
|
||||
| `ip_address` | string(45) | Admin's IP at start |
|
||||
| `user_agent` | text nullable | Admin's user agent |
|
||||
| `started_at` | timestamp | |
|
||||
| `ended_at` | timestamp nullable | NULL = still active |
|
||||
| `expires_at` | timestamp | Sliding 60-min TTL |
|
||||
| `end_reason` | string(50) nullable| manual, expired, ip_changed, admin_kill_all |
|
||||
| `actions_count` | unsigned int | API requests made during session |
|
||||
|
||||
**Relations:** `belongsTo` User (admin), `belongsTo` User (target)
|
||||
**Indexes:** `(admin_id, ended_at)`, `(target_user_id, ended_at)`, `(started_at)`
|
||||
**Soft delete:** no — immutable audit table
|
||||
|
||||
---
|
||||
|
||||
## 3.5.2 Locations
|
||||
|
||||
> Locations are event-scoped and reusable across sections within an event.
|
||||
|
||||
Reference in New Issue
Block a user