Impersonation Audit — /audit/impersonation (임퍼소네이션 감사 로그)
SPEC #025 정합. 세션 단위 v1. handoff ops-audit.jsx 시안 존재 (디자인 부채 없음).
Overview
운영자(OPERATOR)가 본사 계정으로 진입(임퍼소네이션)한 세션을 감사 로그로 기록·조회하는 백오피스 화면. 누가(operatorEmail)·어느 본사로(hqName)·언제 진입해(startedAt) 언제 종료했는지(endedAt)·얼마나 머물렀는지(durationSec)·현재 상태(status)를 추적한다. 보안·감사 필수 화면(정식 출시급). append-only — 편집/삭제 불가.
시안 출처: workspace parent dir
design_handoff_linkmusic/design/screens/ops-audit.jsx. 좌측 세션 테이블 + 우측 드릴다운 패널 레이아웃을 그대로 반영한다.
세션 라이프사이클 (감사 기록 시점)
| 이벤트 | 트리거 | 기록 |
|---|---|---|
| 시작 | POST /api/v1/auth/impersonate-exchange 성공 (= 토큰 교환 · 실제 진입) | 세션 row INSERT (startedAt·expiresAt·operatorEmail·hqName 스냅샷) |
| 종료 | 명시적 종료(impersonation-exit / logout) | endedAt + endReason=EXITED → status=COMPLETED 파생 |
| 만료 | 종료 없이 now > expiresAt (고정 60분) | 별도 job 없이 조회 시 status 파생 = EXPIRED |
issue(/impersonate)가 아니라 exchange 시점이 “시작”이다(확인 다이얼로그만 열고 닫으면 세션 미생성). exchange·exit 호출은 이제 도착지apps/space의 BFF route 에서 일어난다(도착지가 apps/space/admin/*진짜 본사 기능으로 바뀜 — backend 동작·감사 기록은 동일, 호출 origin 만 admin→space). 발급(/impersonate)은 여전히apps/admin의 [전환] 다이얼로그가 수행한다.- status 파생 규칙:
endedAt있으면COMPLETED· 없고now > expiresAt면EXPIRED· 그 외ACTIVE. durationSec파생: 종료 시각(없으면 now) −startedAt. ACTIVE 세션은 조회 시점 기준 경과.- append-only —
ImpersonationAuditSession은 수정/삭제 endpoint가 없다 (PRD 불변성).
status 배지 (3종)
| status | 배지 | 톤 | 의미 |
|---|---|---|---|
ACTIVE | 진행 중 | success + 펄스 도트 | 진입 중 (미종료·미만료) |
COMPLETED | 완료 | muted | 정상 종료(EXITED) |
EXPIRED | 만료 | warn | 종료 없이 60분 만료 |
시안 statusMap(active=success·pulse / completed=neutral / aborted=warn) 톤 정합.
UI
<PageHeader>제목 “감사 로그 · 임퍼소네이션” + 부제 “총 N건”.- 2-컬럼 레이아웃 (시안
1.4fr / 1fr):- 좌측 세션 테이블 — 운영자(
<OrgAvatar>+ email) · 본사(hqName) · 시작 · 종료 · 지속(durationSec→ 한국어N시간 M분/N분 M초/N초) · 상태 배지. 행 클릭 시 우측 드릴다운 갱신. - 우측 드릴다운 — 선택 세션 메타(id · operator · hq · 시작 · 종료 · 지속 · status). 그 아래 액션 타임라인 자리는 placeholder(아래).
- 좌측 세션 테이블 — 운영자(
- 필터 바 (server-driven, URL searchParams 단일 소스) — 시작 기간(from/to date) · 운영자 ID · 본사 ID · 상태 select + [적용]/[초기화]. 적용 시
router.push로 URL 갱신 → server component 재fetch(backend 가 필터·정렬·페이지네이션 책임). 1페이지로 리셋. - 페이지네이션 — page/size/total. [이전]/[다음] 버튼이 URL
page(1-based) 갱신 → backend 0-based 변환. - 빈 상태 — “기록된 임퍼소네이션 세션이 없습니다.”
BE v1 미제공 (시안에 있으나 데이터 없음)
| 시안 요소 | v1 처리 | 후속 |
|---|---|---|
| 운영자 표시명(이름) | operatorEmail 로 표시 | 운영자 프로필 도착 시 |
| ticket(연동 티켓) | 컬럼/필드 생략 | CS 티켓 도메인 후속 |
| reason(진입 사유) | 드릴다운 사유 블록 생략 | exchange 시 사유 입력 후속 |
| 액션 타임라인(actions[]) | 드릴다운 우측 “세부 활동 기록 준비 중 (HQ 모드 후속)” placeholder | HQ 모드(Surface 11)가 send/play/edit 이벤트 emit 시 |
| 진행 중 폴링·강제 종료·CSV/PDF | 미구현 | 후속 SPEC |
Implementation
Page (server component, refresh-aware)
apps/admin/src/app/(protected)/audit/impersonation/page.tsx —
GET /api/v1/admin/audit/impersonation 을 server-side 로 fetch(보호 endpoint, OPERATOR-only —
토큰 서버 전용). 다른 protected page(/hq·/stores)와 동일 refresh-aware 패턴:
refreshIfNeeded → fetch → 401 catch → forceRefresh 1회 재시도 → 그래도 실패 시
session.destroy() + /login redirect. 5xx/네트워크는 강제 로그아웃하지 않고 errorMessage
prop 으로 내려 client banner.
- searchParams(
from·to·operatorId·hqId·status·page)는 Next 15 Promise + 다중값 정규화(첫 element 채택).page1-based URL → backend 0-based. 비정상 status 값은 무시. - 응답 타입은 generated
ImpersonationAuditListResponse/ListImpersonationParams(@linkmusic/api-client) 단일 소스 — 수기 중복 정의 없음. server helperbackendListImpersonationAudit(lib/backend.ts)가 정의된 param 만 직렬화.
Client
audit-impersonation-client.tsx — 필터 입력은 URL 초기값에서 hydrate, 드릴다운 선택은
client-local(선택 세션 id). 필터/페이지는 client 재필터 없이 URL push → server refetch.
Sidebar · 탭
사이드바 “감사 로그” 항목은 이미 /audit/impersonation href 로 등록돼 있었다 (SPEC #025 전:
라우트 미구현). 본 SPEC 에서 라우트를 구현해 연결을 완성 — href 변경 없음.
SPEC #026 에서 감사 영역에 운영자 액션 뷰(/audit/actions)가 추가되며, 두 audit 페이지 상단에
공유 라우트 탭 <AuditTabs>(임퍼소네이션 | 운영자 액션, audit/audit-tabs.tsx)를 둔다. 사이드바
href 는 그대로 단일 항목(/audit/impersonation)으로 유지하고 뷰 전환은 탭이 담당한다.
States & Edge Cases
| 상태 | 처리 |
|---|---|
| 세션 0건 | ”기록된 임퍼소네이션 세션이 없습니다” 빈 상태 |
| 5xx / 네트워크 실패 | client banner (세션 유지, 강제 로그아웃 X) |
| 401 (refresh 실패) | session.destroy() + /login redirect |
| endedAt 없음 (ACTIVE/EXPIRED) | 종료 셀 — |
| ACTIVE durationSec | ”진행 중 · (지속시간)” (success 색) |
| 잘못된 status query | 무시(필터 미적용) |
CSV 내보내기 (SPEC #074)
임퍼소네이션 감사 세션을 현재 적용된 필터 전체 매칭 행으로 CSV 다운로드한다. 화면은 페이지네이션(20) 이지만 CSV 는 같은 필터의 전체 집합을 한 파일로 — 회계·감사·법무 실수요. #048(운영자 액션)·#070(본사 송출) 와 같은 패턴으로 도착(CSV export 3종 완결).
Endpoint
GET /api/v1/admin/audit/impersonation/export (exportImpersonationAudit, OPERATOR-only) — 200
text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''임퍼소네이션감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) +
X-Export-Truncated: true|false 헤더(상한 신호). query 는 listImpersonationAudit 와 동일하되
page/size 제외 — from?·to? ISO-8601 date-time · operatorId? · hqId? · status?. 정렬
started_at DESC 서버 고정.
컬럼 (7종, 한국어 헤더)
| # | 헤더 | 비고 |
|---|---|---|
| 1 | 시작시각(KST) | startedAt → Asia/Seoul (yyyy-MM-dd HH:mm:ss) |
| 2 | 종료시각(KST) | endedAt → Asia/Seoul; ACTIVE 면 빈 칸 |
| 3 | 소요시간(초) | durationSec; ACTIVE 면 빈 칸 |
| 4 | 운영자 이메일 | operatorEmail |
| 5 | 대상 본사명 | hqName 스냅샷 |
| 6 | 대상 본사 id | hqId UUID |
| 7 | 상태 | status(ACTIVE/COMPLETED/EXPIRED) 한국어 라벨 매핑(진행 중/완료/만료) |
상한 · CSV injection 방어
- 상한
EXPORT_MAX=50000행(베타 규모 충분). 초과 시 잘림 + 응답 헤더X-Export-Truncated: true→ 클라이언트가 잘림 경고 배너 노출 (“결과가 50,000행으로 잘렸습니다. 필터를 좁힌 뒤 다시 시도해 주세요.”). - CSV injection 방어 — 공용
AuditCsvWriter가=·+·-·@·탭·CR 시작 셀에'prefix (SPEC #048 운영자 액션 · SPEC #070 본사 audit 과 동일 모듈 재사용; new methodwriteImpersonationCsv).
FE 다운로드 패턴 (generated 훅 우회)
generated orval hook 미사용 — apiFetch mutator 가 모든 응답을 res.text() → JSON 파싱이라
text/csv 응답에 부적합. 공용 helper apps/admin/src/lib/csv-export.ts downloadCsvFromBackend(path, query, fallbackFilename) 가 우회한다(SPEC #048 도착 시점에 도입된 그대로).
- 브라우저에서 BFF (
/api/backend/v1/admin/audit/impersonation/export?...) 로 직접fetch(catch-all route handler 가arrayBuffer()passthrough +Content-Type/Content-Disposition헤더 보존). res.blob()→URL.createObjectURL→<a download>부착·클릭·제거 → 다음 tick 에revokeObjectURL(Safari 다운로드 보호).- 401 감지 시
/login?next={pathname+search}풀 리다이렉트(mutator 동작 미러, 필터 search 보존).
/audit/impersonation PageHeader.primary 슬롯에 [CSV 내보내기] 버튼(lucide:Download 아이콘, secondary
variant, sm). 진행 중 disabled + aria-busy="true" + 라벨 “다운로드 중…”. 잘림 시 Banner variant="warn"
[닫기], 실패 시Banner variant="danger"+[닫기]를AuditTabs와 본문 사이에 배치(#048 미러).
보낼 필터 (applied 값)
화면 테이블이 보여주는 것과 일치하도록 draft(폼 입력) 가 아닌 URL 의 applied 필터를 보낸다.
<input type=date> date-only 는 KST(+09:00) 경계로 정규화한다(from=00:00:00.000·to=23:59:59.999
inclusive — page.tsx normalizeBoundary 와 동일 규칙).
Roadmap (후속)
- 세션 내 액션 타임라인(send/play/edit/view) — HQ 모드(Surface 11) 이벤트 emit 시.
- 일반 OPERATOR 액션 감사(HQ 정지/복구·계정 발급·약관 게시 등) — 공통
AuditLog후속 SPEC. CSV 내보내기✅ (SPEC #074). PDF · 자동 스케줄 후속.- 진행 중 세션 실시간 폴링 (v1 은 새로고침/페이지 단위).
- 강제 종료 액션 · ticket/reason 연동 · 운영자 표시명.
- 보존 정책(90일 soft-delete) — 현재 append-only 만.
References
- SPEC #025 · SPEC #074 (CSV 내보내기) · #005·#013 (임퍼소네이션 인프라) · #020 (HQ 상세 활동 탭 placeholder) · #048 (운영자 액션 CSV — 패턴 원본) · #070 (본사 audit CSV)
linkmusic-frontend-space/apps/admin/src/app/(protected)/audit/impersonation/(page.tsx·audit-impersonation-client.tsx) ·apps/admin/src/lib/csv-export.ts- 시안: workspace parent dir
design_handoff_linkmusic/design/screens/ops-audit.jsx