Skip to main content

Impersonate 확장

📝 초안 (Draft)

이 문서는 검토 중입니다. 내용이 변경될 수 있습니다.

사용자 전환(Impersonation) 기능을 제공하는 Core Extension입니다.

개요

Impersonate 확장은 상위 관리자가 하위 사용자로 전환하여 지원/디버깅을 수행할 수 있게 합니다. 모든 전환 세션은 감사 로그로 기록되어 보안과 추적성을 보장합니다.

┌─────────────────────────────────────────────────────────────┐
│ Impersonate Extension │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Impersonator │ ─────► │ Target │ │
│ │ (관리자) │ 전환 │ (사용자) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ 기능: │
│ • 권한 기반 전환: 상위 레벨만 하위 레벨 impersonate 가능 │
│ • 테넌트 격리: 같은 테넌트 내에서만 가능 (Platform 제외) │
│ • 감사 로깅: 모든 세션 자동 기록 │
│ • 세션 타임아웃: 설정된 시간 후 자동 만료 │
└─────────────────────────────────────────────────────────────┘

활성화

Impersonate는 선택적 확장 모듈로, 필요한 프로젝트에서만 활성화합니다.

# .env
CORE_IMPERSONATE_ENABLED=true
CORE_IMPERSONATE_TIMEOUT=60 # 세션 타임아웃 (분)
CORE_IMPERSONATE_LOG_RETENTION=365 # 로그 보존 기간 (일)
// config/core.php
'impersonate' => [
'enabled' => env('CORE_IMPERSONATE_ENABLED', false),
'session_timeout' => env('CORE_IMPERSONATE_TIMEOUT', 60),
'log_retention_days' => env('CORE_IMPERSONATE_LOG_RETENTION', 365),
'allowed_levels' => [0, 1, 2], // Impersonate 기능 사용 가능 레벨
'require_reason' => true, // 이유 입력 필수 여부
],

구조

packages/core/Extensions/Impersonate/
├── Contracts/
│ ├── ImpersonatableInterface.php # 전환 대상 인터페이스
│ ├── ImpersonateServiceInterface.php # 서비스 인터페이스
│ └── ImpersonatorInterface.php # 전환자 인터페이스
├── Enums/
│ ├── ImpersonationReason.php # support, debugging, training, ...
│ └── ImpersonationStatus.php # active, ended, expired
├── Events/
│ ├── ImpersonationStarted.php
│ └── ImpersonationEnded.php
├── Exceptions/
│ ├── CannotImpersonateException.php
│ └── NotImpersonatingException.php
├── Filament/
│ └── Actions/
│ ├── ImpersonateAction.php # 전환 시작 액션
│ └── LeaveImpersonationAction.php # 전환 종료 액션
├── Http/
│ └── Controllers/
│ └── ImpersonateController.php
├── Middleware/
│ └── ImpersonationMiddleware.php
├── Models/
│ └── ImpersonationLog.php # 감사 로그 모델
├── Services/
│ └── ImpersonateService.php # 비즈니스 로직
├── Traits/
│ ├── CanBeImpersonated.php # 전환 대상 Trait
│ └── CanImpersonate.php # 전환자 Trait
└── Providers/
└── ImpersonateServiceProvider.php

권한 규칙

Impersonator LevelCan Impersonate테넌트 제한
Platform Admin (0)Level 1-6모든 테넌트
SaaS Admin (1)Level 2-6모든 테넌트
Tenant Admin (2)Level 3-6같은 테넌트만
Organization Admin (3)Level 4-6같은 테넌트만
Workspace Admin (4)Level 5-6같은 테넌트만
Group Leader (5)Level 6같은 테넌트만
Member (6)불가-
핵심 규칙
  • 상위 레벨만 하위 레벨 사용자를 impersonate 가능
  • 같은 테넌트 내에서만 가능 (Platform/SaaS Admin 제외)
  • 자기 자신은 impersonate 불가

전환 이유 (ImpersonationReason)

이유설명사용 예
support고객 지원문의 해결, 사용자 문제 확인
debugging디버깅/문제해결버그 재현, 오류 조사
training교육/데모신규 직원 교육, 기능 시연
audit감사/검증사용자 데이터 확인, 권한 검증
other기타기타 관리 목적
use App\Core\Extensions\Impersonate\Enums\ImpersonationReason;

ImpersonationReason::SUPPORT->label(); // "Customer Support"
ImpersonationReason::SUPPORT->description(); // "Helping user with support request"
ImpersonationReason::options(); // 폼 선택용 배열

User 모델 설정

User 모델에 Trait과 Interface를 추가합니다.

use App\Core\Extensions\Impersonate\Contracts\ImpersonatableInterface;
use App\Core\Extensions\Impersonate\Contracts\ImpersonatorInterface;
use App\Core\Extensions\Impersonate\Traits\CanBeImpersonated;
use App\Core\Extensions\Impersonate\Traits\CanImpersonate;

class User extends Authenticatable implements ImpersonatorInterface, ImpersonatableInterface
{
use CanImpersonate; // 다른 사용자로 전환 가능
use CanBeImpersonated; // 다른 사용자에 의해 전환 대상이 될 수 있음
}

ImpersonateService 사용법

Impersonation 시작

use App\Core\Extensions\Impersonate\Contracts\ImpersonateServiceInterface;
use App\Core\Extensions\Impersonate\Enums\ImpersonationReason;

$service = app(ImpersonateServiceInterface::class);

// 권한 확인
if ($service->canImpersonate($admin, $member)) {
// Impersonation 시작
$log = $service->impersonate(
impersonator: $admin,
target: $member,
reason: ImpersonationReason::SUPPORT,
notes: 'Support ticket #123'
);
}

상태 확인

// 현재 impersonating 중인지 확인
if ($service->isImpersonating()) {
// 원래 사용자 (impersonator) 조회
$impersonator = $service->getImpersonator();

// 현재 세션 로그 조회
$currentSession = $service->getCurrentImpersonation();
}

Impersonation 종료

// 정상 종료 (원래 사용자로 복귀)
$log = $service->leave();

이력 조회

// 활성 impersonation 목록
$activeImpersonations = $service->getActiveImpersonations();

// 사용자가 impersonator로 참여한 이력
$impersonatorHistory = $service->getImpersonatorHistory($user);

// 사용자가 대상이 된 이력
$impersonatedHistory = $service->getImpersonatedHistory($user);

강제 종료

// 특정 사용자의 모든 활성 impersonation 강제 종료
$count = $service->forceEndAllForUser($user);

Trait 메서드

CanImpersonate (전환자 Trait)

// 권한 확인
$canImpersonate = $admin->canImpersonate($member);

// Impersonation 시작
$log = $admin->impersonate($member, ImpersonationReason::DEBUGGING, 'Bug investigation');

// 이력 조회
$logs = $admin->getImpersonationLogs();

// 활성 세션 조회
$activeImpersonations = $admin->getActiveImpersonations();

CanBeImpersonated (전환 대상 Trait)

// 권한 확인
$canBeImpersonated = $member->canBeImpersonatedBy($admin);

// 이력 조회 (대상이 된 기록)
$logs = $member->getImpersonatedLogs();

// 현재 impersonating 당하고 있는지 확인
$isBeingImpersonated = $member->isBeingImpersonated();

// 모든 활성 impersonation 강제 종료
$member->forceEndImpersonations();

ImpersonationLog 모델

속성

속성설명
impersonator_id전환자 ID
impersonated_id대상 ID
tenant_id테넌트 ID
statusactive, ended, expired
reason전환 이유
notes메모/설명
started_at시작 시각
ended_at종료 시각
ip_addressIP 주소
user_agentUser Agent

상태 메서드

$log->isActive();   // 활성 상태?
$log->isEnded(); // 정상 종료?
$log->isExpired(); // 만료됨?

// 상태 변경
$log->end(); // 정상 종료
$log->expire(); // 만료 처리

Accessor

$log->statusEnum;  // ImpersonationStatus enum
$log->reasonEnum; // ImpersonationReason enum
$log->duration; // 지속 시간 (초)
$log->duration_for_humans; // "5 minutes", "1h 30m"

Query Scopes

use App\Core\Extensions\Impersonate\Models\ImpersonationLog;

// 활성 세션만
ImpersonationLog::active()->get();

// 종료된 세션만
ImpersonationLog::ended()->get();

// 특정 impersonator의 이력
ImpersonationLog::byImpersonator($userId)->get();

// 특정 대상의 이력
ImpersonationLog::ofImpersonated($userId)->get();

// 최근 N일 이내
ImpersonationLog::recent(30)->get();

// 특정 이유
ImpersonationLog::forReason(ImpersonationReason::SUPPORT)->get();

Filament 연동

Table Action 추가

use App\Core\Extensions\Impersonate\Filament\Actions\ImpersonateAction;

public static function table(Table $table): Table
{
return $table
->columns([
// ...
])
->actions([
ImpersonateAction::make(),
]);
}

Impersonation Banner

Impersonation 중일 때 상단에 배너를 표시합니다.

// PanelProvider에서
use Filament\View\PanelsRenderHook;

$panel->renderHook(
PanelsRenderHook::BODY_START,
fn () => view('components.impersonate.banner')
);
{{-- resources/views/components/impersonate/banner.blade.php --}}
@if(app(\App\Core\Extensions\Impersonate\Contracts\ImpersonateServiceInterface::class)->isImpersonating())
@php
$impersonator = app(\App\Core\Extensions\Impersonate\Contracts\ImpersonateServiceInterface::class)->getImpersonator();
@endphp
<div class="bg-yellow-100 border-b border-yellow-200 p-2 text-center">
<span class="text-yellow-800">
⚠️ {{ $impersonator->name }}님으로부터 Impersonation 중입니다.
</span>
<form action="{{ route('impersonate.leave') }}" method="POST" class="inline">
@csrf
<button type="submit" class="ml-2 text-yellow-600 underline">
종료하기
</button>
</form>
</div>
@endif

Leave Impersonation Action

use App\Core\Extensions\Impersonate\Filament\Actions\LeaveImpersonationAction;

// 패널 설정에서
$panel->navigationItems([
LeaveImpersonationAction::make(),
]);

이벤트

이벤트Payload설명
ImpersonationStarted$log, $impersonator, $impersonated전환 시작됨
ImpersonationEnded$log, $impersonator, $impersonated전환 종료됨
use App\Core\Extensions\Impersonate\Events\ImpersonationStarted;
use App\Core\Extensions\Impersonate\Events\ImpersonationEnded;

// 이벤트 리스너 등록
Event::listen(ImpersonationStarted::class, function ($event) {
// 보안 알림 발송
Log::warning('Impersonation started', [
'impersonator' => $event->impersonator->email,
'target' => $event->impersonated->email,
'reason' => $event->log->reason,
]);
});

Event::listen(ImpersonationEnded::class, function ($event) {
// 종료 기록
Log::info('Impersonation ended', [
'duration' => $event->log->duration_for_humans,
]);
});

예외 처리

예외발생 시점
CannotImpersonateException권한 부족, 동일 사용자, 테넌트 불일치
NotImpersonatingException비활성 상태에서 종료 시도
use App\Core\Extensions\Impersonate\Exceptions\CannotImpersonateException;

try {
$service->impersonate($admin, $target);
} catch (CannotImpersonateException $e) {
// 에러 처리
match ($e->getReason()) {
'disabled' => '기능이 비활성화되어 있습니다.',
'already_impersonating' => '이미 다른 사용자로 전환 중입니다.',
'same_user' => '자기 자신으로 전환할 수 없습니다.',
'higher_level' => '상위 레벨 사용자만 전환 가능합니다.',
'tenant_mismatch' => '같은 테넌트 내에서만 전환 가능합니다.',
};
}

보안 고려사항

권장 사항

  1. 로그 모니터링: Impersonation 로그를 주기적으로 검토
  2. 알림 설정: 민감한 계정 impersonation 시 알림 발송
  3. 세션 타임아웃: 적절한 타임아웃 설정 (기본 60분)
  4. 로그 보존: 감사 목적으로 충분한 기간 보존 (기본 365일)

자동 만료 처리

// 스케줄러에 등록 (app/Console/Kernel.php)
$schedule->command('impersonate:cleanup')
->hourly()
->description('Expire timed-out impersonation sessions');

테스트

# Unit 테스트
php artisan test tests/Unit/Core/Impersonate/

# Feature 테스트 (Filament 액션)
php artisan test tests/Feature/Filament/Impersonate/

# HTTP 테스트
php artisan test tests/Feature/Http/Impersonate/

테스트 예제

// tests/Unit/Core/Impersonate/ImpersonateServiceTest.php

it('allows higher level to impersonate lower level', function () {
$admin = User::factory()->create(['level' => 2]); // Tenant Admin
$member = User::factory()->create(['level' => 6, 'tenant_id' => $admin->tenant_id]);

$service = app(ImpersonateServiceInterface::class);

expect($service->canImpersonate($admin, $member))->toBeTrue();
});

it('prevents lower level from impersonating higher level', function () {
$member = User::factory()->create(['level' => 6]);
$admin = User::factory()->create(['level' => 2]);

$service = app(ImpersonateServiceInterface::class);

expect($service->canImpersonate($member, $admin))->toBeFalse();
});

it('logs impersonation session', function () {
$admin = User::factory()->create(['level' => 0]);
$member = User::factory()->create(['level' => 6]);

$service = app(ImpersonateServiceInterface::class);
$log = $service->impersonate($admin, $member, ImpersonationReason::SUPPORT);

expect($log)
->impersonator_id->toBe($admin->id)
->impersonated_id->toBe($member->id)
->status->toBe(ImpersonationStatus::ACTIVE->value);
});

관련 문서