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 Level | Can 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 |
status | active, ended, expired |
reason | 전환 이유 |
notes | 메모/설명 |
started_at | 시작 시각 |
ended_at | 종료 시각 |
ip_address | IP 주소 |
user_agent | User 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' => '같은 테넌트 내에서만 전환 가능합니다.',
};
}
보안 고려사항
권장 사항
- 로그 모니터링: Impersonation 로그를 주기적으로 검토
- 알림 설정: 민감한 계정 impersonation 시 알림 발송
- 세션 타임아웃: 적절한 타임아웃 설정 (기본 60분)
- 로그 보존: 감사 목적으로 충분한 기간 보존 (기본 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);
});