FeaturesAudit (감사 로그)Impersonation Audit (/audit/impersonation)

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=EXITEDstatus=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 > expiresAtEXPIRED · 그 외 ACTIVE.
  • durationSec 파생: 종료 시각(없으면 now) − startedAt. ACTIVE 세션은 조회 시점 기준 경과.
  • append-onlyImpersonationAuditSession 은 수정/삭제 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 모드 후속)” placeholderHQ 모드(Surface 11)가 send/play/edit 이벤트 emit 시
진행 중 폴링·강제 종료·CSV/PDF미구현후속 SPEC

Implementation

Page (server component, refresh-aware)

apps/admin/src/app/(protected)/audit/impersonation/page.tsxGET /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 채택). page 1-based URL → backend 0-based. 비정상 status 값은 무시.
  • 응답 타입은 generated ImpersonationAuditListResponse/ListImpersonationParams(@linkmusic/api-client) 단일 소스 — 수기 중복 정의 없음. server helper backendListImpersonationAudit(lib/backend.ts)가 정의된 param 만 직렬화.

Client

audit-impersonation-client.tsx — 필터 입력은 URL 초기값에서 hydrate, 드릴다운 선택은 client-local(선택 세션 id). 필터/페이지는 client 재필터 없이 URL push → server refetch.

사이드바 “감사 로그” 항목은 이미 /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)startedAtAsia/Seoul (yyyy-MM-dd HH:mm:ss)
2종료시각(KST)endedAtAsia/Seoul; ACTIVE 면 빈 칸
3소요시간(초)durationSec; ACTIVE 면 빈 칸
4운영자 이메일operatorEmail
5대상 본사명hqName 스냅샷
6대상 본사 idhqId 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 method writeImpersonationCsv).

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 도착 시점에 도입된 그대로).

  1. 브라우저에서 BFF (/api/backend/v1/admin/audit/impersonation/export?...) 로 직접 fetch (catch-all route handler 가 arrayBuffer() passthrough + Content-Type/Content-Disposition 헤더 보존).
  2. res.blob()URL.createObjectURL<a download> 부착·클릭·제거 → 다음 tick 에 revokeObjectURL (Safari 다운로드 보호).
  3. 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