b28c653076
- Inject OperationsLogger $ops into regenerate() via Laravel IoC
- Record api_key.regenerated event with payloadAfter={key_prefix} — plain key never logged
- New Pest test: ApiKeyRegenerateAuditTest (RED→GREEN verified, 8 assertions)
- Existing ApiKeyControllerTest: 7/7 pass (no regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
87 lines
2.9 KiB
PHP
87 lines
2.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\ApiKey;
|
|
use App\Services\Audit\OperationsLogger;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* API-ключи тенанта (audit D2/D3/J5). Endpoints под auth:sanctum + tenant.
|
|
*
|
|
* Полный ключ показывается ОДИН раз — в ответе regenerate(). В БД хранится
|
|
* только bcrypt key_hash + key_prefix (первые 10 символов для UI). У тенанта
|
|
* поддерживается один активный ключ: regenerate деактивирует прежние.
|
|
*/
|
|
class ApiKeyController extends Controller
|
|
{
|
|
private const KEY_PREFIX = 'lpkapi_';
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
|
|
$keys = ApiKey::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->where('expires_at', '>', now())
|
|
->orderByDesc('created_at')
|
|
->get(['id', 'name', 'key_prefix', 'last_used_at', 'expires_at', 'created_at']);
|
|
|
|
return response()->json(['data' => $keys]);
|
|
}
|
|
|
|
public function regenerate(Request $request, OperationsLogger $ops): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
$userId = (int) $request->user()->id;
|
|
|
|
// Один активный ключ на тенанта — прежние деактивируются.
|
|
ApiKey::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->update(['is_active' => false]);
|
|
|
|
$plainKey = self::KEY_PREFIX.Str::random(48);
|
|
|
|
$key = ApiKey::query()->create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $userId,
|
|
'name' => 'API-ключ',
|
|
'key_hash' => Hash::make($plainKey),
|
|
'key_prefix' => substr($plainKey, 0, 10),
|
|
'scopes' => ['read'],
|
|
'expires_at' => now()->addYear(),
|
|
'is_active' => true,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$ops->record(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
entityType: 'api_key',
|
|
entityId: $key->id,
|
|
event: 'api_key.regenerated',
|
|
payloadBefore: null,
|
|
payloadAfter: ['key_prefix' => $key->key_prefix],
|
|
ip: $request->ip(),
|
|
userAgent: $request->userAgent(),
|
|
);
|
|
|
|
return response()->json([
|
|
'id' => $key->id,
|
|
'name' => $key->name,
|
|
'key' => $plainKey,
|
|
'key_prefix' => $key->key_prefix,
|
|
], Response::HTTP_CREATED);
|
|
}
|
|
}
|