253 lines
10 KiB
PHP
253 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\SaasAdminAuditLog;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* SaaS-admin incidents-log lookup для AdminIncidentsView.
|
|
*
|
|
* Чтение `incidents_log` (schema §9). На MVP без auth (saas-admin SSO ⏸ Б-1).
|
|
*
|
|
* Возвращает list инцидентов с дренажным фильтром по type/severity и summary
|
|
* статистикой (open/investigating/RKN-pending). `incident_id` — derived from
|
|
* `id` в формате `INC-YYYY-MMDD-NNNN` (frontend ожидает строковый код).
|
|
*/
|
|
class AdminIncidentsController extends Controller
|
|
{
|
|
use ResolvesAdminUserId;
|
|
|
|
/** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$type = (string) $request->query('type', '');
|
|
$severity = (string) $request->query('severity', '');
|
|
$unresolvedOnly = $request->boolean('unresolved_only');
|
|
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
|
$offset = max(0, (int) $request->query('offset', '0'));
|
|
|
|
$query = DB::table('incidents_log');
|
|
|
|
if ($type !== '') {
|
|
$query->where('type', $type);
|
|
}
|
|
if ($severity !== '') {
|
|
$query->where('severity', $severity);
|
|
}
|
|
if ($unresolvedOnly) {
|
|
$query->whereNull('resolved_at');
|
|
}
|
|
|
|
$total = (clone $query)->count();
|
|
|
|
$rows = $query
|
|
->orderByDesc('started_at')
|
|
->orderByDesc('id')
|
|
->limit($limit)
|
|
->offset($offset)
|
|
->get();
|
|
|
|
return response()->json([
|
|
'incidents' => $rows->map(fn ($r) => [
|
|
'id' => (int) $r->id,
|
|
'incident_id' => $this->formatIncidentId($r),
|
|
'type' => $r->type,
|
|
'severity' => $r->severity,
|
|
'summary' => $r->summary,
|
|
'started_at' => CarbonImmutable::parse($r->started_at)->toIso8601String(),
|
|
'detected_at' => CarbonImmutable::parse($r->detected_at)->toIso8601String(),
|
|
'resolved_at' => $r->resolved_at !== null
|
|
? CarbonImmutable::parse($r->resolved_at)->toIso8601String()
|
|
: null,
|
|
'status' => $this->deriveStatus($r),
|
|
'affected_tenants_count' => is_array($r->affected_tenant_ids)
|
|
? count($r->affected_tenant_ids)
|
|
: (is_string($r->affected_tenant_ids) ? $this->parsePgArray($r->affected_tenant_ids) : 0),
|
|
'affected_users_count' => $r->affected_users_count !== null ? (int) $r->affected_users_count : null,
|
|
'rkn_notified' => $r->rkn_notified_at !== null,
|
|
'rkn_notified_at' => $r->rkn_notified_at !== null
|
|
? CarbonImmutable::parse($r->rkn_notified_at)->toIso8601String()
|
|
: null,
|
|
'rkn_deadline_at' => $r->type === 'data_breach' && $r->rkn_notified_at === null
|
|
? CarbonImmutable::parse($r->detected_at)->addHours(24)->toIso8601String()
|
|
: null,
|
|
]),
|
|
'total' => $total,
|
|
'limit' => $limit,
|
|
'offset' => $offset,
|
|
'summary' => $this->computeSummary(),
|
|
]);
|
|
}
|
|
|
|
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
|
|
public function notifyRkn(Request $request, int $id): JsonResponse
|
|
{
|
|
$row = DB::table('incidents_log')->where('id', $id)->first();
|
|
if ($row === null) {
|
|
abort(404, 'incident not found');
|
|
}
|
|
if ($row->type !== 'data_breach') {
|
|
abort(422, 'РКН-уведомление применимо только к инцидентам типа data_breach');
|
|
}
|
|
if ($row->rkn_notified_at !== null) {
|
|
abort(409, 'РКН уже уведомлён по этому инциденту');
|
|
}
|
|
|
|
$adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');
|
|
|
|
DB::transaction(function () use ($row, $adminUserId, $request): void {
|
|
DB::table('incidents_log')->where('id', $row->id)->update([
|
|
'rkn_notified_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
SaasAdminAuditLog::create([
|
|
'admin_user_id' => $adminUserId,
|
|
'action' => 'incident.rkn_notify',
|
|
'target_type' => 'incident',
|
|
'target_id' => $row->id,
|
|
'payload_before' => ['rkn_notified_at' => null],
|
|
'payload_after' => ['rkn_notified_at' => now()->toIso8601String()],
|
|
'reason' => 'Роскомнадзор уведомлён об утечке ПДн через админ-интерфейс (152-ФЗ).',
|
|
'ip_address' => $request->ip() ?? '127.0.0.1',
|
|
'user_agent' => $request->userAgent(),
|
|
]);
|
|
});
|
|
|
|
return $this->show($id);
|
|
}
|
|
|
|
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
|
|
public function show(int $id): JsonResponse
|
|
{
|
|
$row = DB::table('incidents_log')->where('id', $id)->first();
|
|
if ($row === null) {
|
|
abort(404, 'incident not found');
|
|
}
|
|
|
|
$tenantIds = is_array($row->affected_tenant_ids)
|
|
? $row->affected_tenant_ids
|
|
: ($row->affected_tenant_ids !== null ? $this->parsePgArrayValues((string) $row->affected_tenant_ids) : []);
|
|
|
|
$tenants = $tenantIds === []
|
|
? collect()
|
|
: DB::table('tenants')->whereIn('id', $tenantIds)
|
|
->select(['id', 'organization_name'])->get();
|
|
|
|
$admins = DB::table('saas_admin_users')
|
|
->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
|
|
->pluck('full_name', 'id');
|
|
|
|
return response()->json([
|
|
'incident' => [
|
|
'id' => (int) $row->id,
|
|
'incident_id' => $this->formatIncidentId($row),
|
|
'type' => $row->type,
|
|
'severity' => $row->severity,
|
|
'summary' => $row->summary,
|
|
'root_cause' => $row->root_cause,
|
|
'postmortem_url' => $row->postmortem_url,
|
|
'started_at' => CarbonImmutable::parse($row->started_at)->toIso8601String(),
|
|
'detected_at' => CarbonImmutable::parse($row->detected_at)->toIso8601String(),
|
|
'resolved_at' => $row->resolved_at !== null
|
|
? CarbonImmutable::parse($row->resolved_at)->toIso8601String() : null,
|
|
'status' => $this->deriveStatus($row),
|
|
'affected_tenants' => $tenants->map(fn ($t) => [
|
|
'id' => (int) $t->id,
|
|
'organization_name' => $t->organization_name,
|
|
])->values(),
|
|
'affected_users_count' => $row->affected_users_count !== null ? (int) $row->affected_users_count : null,
|
|
'notification_sent_at' => $row->notification_sent_at !== null
|
|
? CarbonImmutable::parse($row->notification_sent_at)->toIso8601String() : null,
|
|
'rkn_notified' => $row->rkn_notified_at !== null,
|
|
'rkn_notified_at' => $row->rkn_notified_at !== null
|
|
? CarbonImmutable::parse($row->rkn_notified_at)->toIso8601String() : null,
|
|
'rkn_deadline_at' => $row->type === 'data_breach' && $row->rkn_notified_at === null
|
|
? CarbonImmutable::parse($row->detected_at)->addHours(24)->toIso8601String() : null,
|
|
'created_by_admin' => $admins->get($row->created_by_admin_id),
|
|
'closed_by_admin' => $row->closed_by_admin_id !== null ? $admins->get($row->closed_by_admin_id) : null,
|
|
'created_at' => $row->created_at !== null
|
|
? CarbonImmutable::parse($row->created_at)->toIso8601String() : null,
|
|
'updated_at' => $row->updated_at !== null
|
|
? CarbonImmutable::parse($row->updated_at)->toIso8601String() : null,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* PG-array literal '{1,2,3}' → массив int.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
private function parsePgArrayValues(string $literal): array
|
|
{
|
|
$trimmed = trim($literal, '{}');
|
|
if ($trimmed === '') {
|
|
return [];
|
|
}
|
|
|
|
return array_map('intval', explode(',', $trimmed));
|
|
}
|
|
|
|
/** Уникальный человеко-читаемый ID: INC-YYYY-MMDD-NNNN, NNNN = id padded. */
|
|
private function formatIncidentId(object $row): string
|
|
{
|
|
$started = CarbonImmutable::parse($row->started_at);
|
|
|
|
return sprintf('INC-%s-%s-%04d', $started->format('Y'), $started->format('md'), (int) $row->id);
|
|
}
|
|
|
|
/** Status derive: resolved_at!=null → resolved; иначе investigating если detected_at есть; иначе open. */
|
|
private function deriveStatus(object $row): string
|
|
{
|
|
if ($row->resolved_at !== null) {
|
|
return 'resolved';
|
|
}
|
|
if ($row->detected_at !== null) {
|
|
return 'investigating';
|
|
}
|
|
|
|
return 'open';
|
|
}
|
|
|
|
/** PG-array literal '{1,2,3}' → count элементов. */
|
|
private function parsePgArray(string $literal): int
|
|
{
|
|
$trimmed = trim($literal, '{}');
|
|
if ($trimmed === '') {
|
|
return 0;
|
|
}
|
|
|
|
return count(explode(',', $trimmed));
|
|
}
|
|
|
|
/**
|
|
* Open / investigating / РКН-pending counts для page-head.
|
|
*
|
|
* @return array<string, int>
|
|
*/
|
|
private function computeSummary(): array
|
|
{
|
|
$base = DB::table('incidents_log');
|
|
|
|
return [
|
|
'open' => (clone $base)->whereNull('resolved_at')->whereNull('detected_at')->count(),
|
|
'investigating' => (clone $base)->whereNull('resolved_at')->whereNotNull('detected_at')->count(),
|
|
'rkn_pending' => (clone $base)
|
|
->where('type', 'data_breach')
|
|
->whereNull('rkn_notified_at')
|
|
->whereNull('resolved_at')
|
|
->count(),
|
|
'total_unresolved' => (clone $base)->whereNull('resolved_at')->count(),
|
|
];
|
|
}
|
|
}
|