Files
portal/app/app/Http/Controllers/Api/ImpersonationController.php
T

244 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Services\Pd\ImpersonationAuditService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
/**
* SaaS-admin impersonation flow (ТЗ §22.7 / Ю-1).
*
* Flow:
* 1. Admin → POST /api/admin/impersonation/init {tenant_id, reason}.
* Backend генерирует 6-значный код, сохраняет hash в impersonation_tokens,
* отправляет plain код на tenant.contact_email. TTL 15 минут.
* 2. Admin вводит код в админке → POST /api/admin/impersonation/verify {token_id, code}.
* Backend проверяет hash, expiry, attempts. На success — token.used_at = NOW(),
* возвращает session_id (создаётся отдельно — отдельный коммит).
* 3. Admin завершает сессию → POST /api/admin/impersonation/end {token_id}.
* Backend ставит token.session_ended_at, отправляет email клиенту.
*
* NB: на MVP saas_admin_users auth не реализован, `requested_by` принимается
* параметром. Production: middleware('auth:saas-admin') + auth()->id().
*
* Two-person approval (CTO-15 / Ю-9): для тенантов с
* `pd_subject_request.processing_restricted=TRUE` ИЛИ
* `chargeback_unrecovered_rub > 0` требуется second_approver_id роли compliance.
* На MVP не реализовано — отдельный коммит после SaaS-admin auth.
*/
class ImpersonationController extends Controller
{
private const TOKEN_TTL_MINUTES = 15;
private const MAX_FAILED_ATTEMPTS = 5;
/**
* SaaS-admin — кросс-тенантная зона: запросы к impersonation_tokens / tenants
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
* Иначе на проде (роль crm_app_user, RLS on) без выставленного GUC
* app.current_tenant_id запрос падает SQLSTATE 42704 — у saas-admin нет
* tenant-контекста (middleware 'tenant' на /api/admin/* не висит). На dev
* pgsql_supplier = fallback на postgres-superuser, поведение идентично.
*/
private const DB_CONNECTION = 'pgsql_supplier';
/** GET /api/admin/impersonation/active — активные сессии (used_at != null AND session_ended_at == null) */
public function active(): JsonResponse
{
$rows = ImpersonationToken::on(self::DB_CONNECTION)
->whereNotNull('used_at')
->whereNull('session_ended_at')
->with(['tenant'])
->orderByDesc('used_at')
->limit(100)
->get(['id', 'tenant_id', 'requested_by', 'reason', 'sent_to_email', 'used_at', 'expires_at']);
return response()->json([
'sessions' => $rows->map(fn (ImpersonationToken $t) => [
'token_id' => $t->id,
'tenant_id' => $t->tenant_id,
'tenant_name' => $t->tenant?->organization_name,
'requested_by' => $t->requested_by,
'reason' => $t->reason,
'sent_to_email' => $t->sent_to_email,
'used_at' => $t->used_at?->toIso8601String(),
'expires_at' => $t->expires_at->toIso8601String(),
]),
]);
}
/** GET /api/admin/impersonation/recent — последние 20 завершённых */
public function recent(): JsonResponse
{
$rows = ImpersonationToken::on(self::DB_CONNECTION)
->whereNotNull('used_at')
->whereNotNull('session_ended_at')
->with(['tenant'])
->orderByDesc('session_ended_at')
->limit(20)
->get(['id', 'tenant_id', 'requested_by', 'reason', 'used_at', 'session_ended_at']);
return response()->json([
'sessions' => $rows->map(fn (ImpersonationToken $t) => [
'token_id' => $t->id,
'tenant_id' => $t->tenant_id,
'tenant_name' => $t->tenant?->organization_name,
'requested_by' => $t->requested_by,
'reason' => $t->reason,
'used_at' => $t->used_at?->toIso8601String(),
'session_ended_at' => $t->session_ended_at?->toIso8601String(),
'duration_seconds' => $t->used_at && $t->session_ended_at
? abs($t->session_ended_at->diffInSeconds($t->used_at))
: null,
]),
]);
}
/** POST /api/admin/impersonation/init */
public function init(Request $request, ImpersonationAuditService $audit): JsonResponse
{
$tenantId = (int) $request->input('tenant_id');
$requestedBy = (int) $request->input('requested_by'); // TODO: $request->user()->id когда saas-admin auth готов
$reason = $request->string('reason')->toString();
if (mb_strlen($reason) < 30) {
return response()->json([
'message' => 'Основание (reason) должно быть не короче 30 символов.',
'errors' => ['reason' => ['Минимум 30 символов.']],
], 422);
}
$tenant = Tenant::on(self::DB_CONNECTION)->find($tenantId);
if (! $tenant) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
// 6-значный код. Числа от 100000 до 999999.
$plainCode = (string) random_int(100_000, 999_999);
$token = ImpersonationToken::on(self::DB_CONNECTION)->create([
'tenant_id' => $tenant->id,
'requested_by' => $requestedBy,
'code_hash' => Hash::make($plainCode),
'reason' => $reason,
'sent_to_email' => (string) $tenant->contact_email,
'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES),
]);
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
// TODO: отправить email на $tenant->contact_email с $plainCode.
$payload = [
'token_id' => $token->id,
'expires_at' => $token->expires_at->toIso8601String(),
'sent_to_email' => $token->sent_to_email,
];
// Audit-fix A2: plain-код возвращается в API-ответе ТОЛЬКО на dev/testing
// (для тестов и локальной разработки). На prod код уходит исключительно
// в email клиента — env-guard исключает захват impersonation-сессии
// через чтение ответа init.
if (app()->environment('local', 'testing')) {
$payload['_dev_plain_code'] = $plainCode;
}
return response()->json($payload);
}
/** POST /api/admin/impersonation/verify */
public function verify(Request $request, ImpersonationAuditService $audit): JsonResponse
{
$tokenId = (int) $request->input('token_id');
$code = $request->string('code')->toString();
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
if (! $token) {
return response()->json(['message' => 'Токен не найден.'], 404);
}
if (! $token->isUsable()) {
$reason = $token->isExpired() ? 'expired'
: ($token->used_at !== null ? 'used'
: ($token->invalidated_at !== null ? 'invalidated'
: 'too_many_attempts'));
return response()->json([
'message' => 'Токен недействителен.',
'reason' => $reason,
], 422);
}
if (! Hash::check($code, $token->code_hash)) {
// increment атомарен на уровне SQL, а isUsable() независимо гейтит
// failed_attempts >= 5 — поэтому отдельная транзакция не нужна
// (и ломала бы общий PDO в тестах под SharesSupplierPdo).
$token->increment('failed_attempts');
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
$token->update(['invalidated_at' => now()]);
}
return response()->json([
'message' => 'Неверный код.',
'attempts_remaining' => max(0, self::MAX_FAILED_ATTEMPTS - $token->failed_attempts),
], 422);
}
// Success: mark used. Создание saas_admin_session с
// impersonating_token_id — отдельный коммит после saas-admin auth.
$token->update([
'used_at' => now(),
]);
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
return response()->json([
'token_id' => $token->id,
'tenant_id' => $token->tenant_id,
'used_at' => $token->used_at->toIso8601String(),
'message' => 'Impersonation начат. Сессия активна 1 час.',
]);
}
/** POST /api/admin/impersonation/end */
public function end(Request $request, ImpersonationAuditService $audit): JsonResponse
{
$tokenId = (int) $request->input('token_id');
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
if (! $token) {
return response()->json(['message' => 'Токен не найден.'], 404);
}
if ($token->used_at === null) {
return response()->json([
'message' => 'Сессия impersonation не была активирована.',
], 422);
}
if ($token->session_ended_at !== null) {
return response()->json([
'message' => 'Сессия уже завершена.',
], 422);
}
$token->update(['session_ended_at' => now()]);
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
// TODO: уведомление клиенту по email о завершении (как и в init flow).
return response()->json([
'token_id' => $token->id,
'session_ended_at' => $token->session_ended_at->toIso8601String(),
'message' => 'Impersonation завершён.',
]);
}
}