feat(api): api_keys model + GET/regenerate endpoints (closes J5 part 1)

Audit J5/D3: the api_keys table existed in schema but had zero code.
Adds the ApiKey model + factory, and ApiKeyController with GET
/api/api-keys (list active keys, key_hash hidden) and POST
/api/api-keys/regenerate (deactivate prior + create new, full key
returned once, bcrypt-hashed in DB). Tenant-scoped via auth:sanctum +
tenant middleware (RLS on api_keys). phpstan-baseline.neon updated for
Pest PendingCalls false-positives in the new test file; also removes
8 pre-existing stale ignore.unmatched entries (properties now resolved
by existing @mixin IdeHelper* docblocks — confirmed pre-existing via
git stash test before Task 3 changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 21:53:35 +03:00
parent 2c59a00714
commit a5e2bbbbe8
6 changed files with 287 additions and 48 deletions
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ApiKey;
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)
->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): 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(),
]);
return response()->json([
'id' => $key->id,
'name' => $key->name,
'key' => $plainKey,
'key_prefix' => $key->key_prefix,
], Response::HTTP_CREATED);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ApiKeyFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* API-ключ тенанта (таблица api_keys). Tenant-aware, RLS на уровне БД.
*
* key_hash bcrypt-хэш; оригинал ключа показывается ОДИН раз при генерации
* (ApiKeyController::regenerate). key_prefix (10 символов) для отображения
* в UI. Таблица имеет только created_at (без updated_at).
*
* @mixin IdeHelperApiKey
*/
class ApiKey extends Model
{
/** @use HasFactory<ApiKeyFactory> */
use HasFactory;
public $timestamps = false;
protected $fillable = [
'tenant_id',
'user_id',
'name',
'key_hash',
'key_prefix',
'scopes',
'last_used_at',
'last_used_ip',
'expires_at',
'is_active',
'created_at',
];
protected $hidden = ['key_hash'];
protected function casts(): array
{
return [
'scopes' => 'array',
'is_active' => 'boolean',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<ApiKey>
*/
class ApiKeyFactory extends Factory
{
protected $model = ApiKey::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'name' => 'API-ключ',
'key_hash' => Hash::make(Str::random(48)),
'key_prefix' => 'lpkapi_'.Str::lower(Str::random(3)),
'scopes' => ['read'],
'last_used_at' => null,
'expires_at' => now()->addYear(),
'is_active' => true,
'created_at' => now(),
];
}
}
+30 -48
View File
@@ -78,12 +78,6 @@ parameters:
count: 1
path: app/Http/Middleware/SetTenantContext.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ProjectResource.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -102,12 +96,6 @@ parameters:
count: 1
path: app/Services/NotificationService.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Match expression does not handle remaining value\: string$#'
identifier: match.unhandled
@@ -252,6 +240,36 @@ parameters:
count: 14
path: tests/Feature/Api/ProjectBulkActionsTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -492,18 +510,6 @@ parameters:
count: 6
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Billing/LedgerServiceTest.php
-
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Billing/LedgerServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
identifier: property.notFound
@@ -570,12 +576,6 @@ parameters:
count: 2
path: tests/Feature/Console/ResetDeliveredTodayCommandTest.php
-
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -912,12 +912,6 @@ parameters:
count: 6
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
@@ -1146,18 +1140,6 @@ parameters:
count: 7
path: tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php
-
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
-
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
+6
View File
@@ -125,6 +125,12 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g
Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
});
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate');
});
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
//
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('GET /api/api-keys возвращает активные ключи тенанта', function () {
ApiKey::factory()->create(['tenant_id' => $this->tenant->id, 'user_id' => $this->user->id]);
$response = $this->getJson('/api/api-keys');
$response->assertOk();
expect($response->json('data'))->toHaveCount(1);
expect($response->json('data.0'))->toHaveKeys(['id', 'name', 'key_prefix', 'last_used_at', 'created_at']);
expect($response->json('data.0'))->not->toHaveKey('key_hash');
});
test('GET /api/api-keys без auth: 401', function () {
auth()->logout();
$this->getJson('/api/api-keys')->assertStatus(401);
});
test('GET /api/api-keys изолирован по тенанту', function () {
$otherTenant = Tenant::factory()->create();
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
ApiKey::factory()->create(['tenant_id' => $otherTenant->id, 'user_id' => $otherUser->id]);
$response = $this->getJson('/api/api-keys');
$response->assertOk();
expect($response->json('data'))->toHaveCount(0);
});
test('POST /api/api-keys/regenerate создаёт ключ и возвращает plaintext один раз', function () {
$response = $this->postJson('/api/api-keys/regenerate');
$response->assertStatus(201);
expect($response->json('key'))->toStartWith('lpkapi_');
expect($response->json('key_prefix'))->toBe(substr($response->json('key'), 0, 10));
expect($response->json())->toHaveKeys(['id', 'name', 'key', 'key_prefix']);
$row = ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->first();
expect($row)->not->toBeNull();
expect($row->key_hash)->not->toBe($response->json('key'));
expect(Hash::check($response->json('key'), $row->key_hash))->toBeTrue();
});
test('POST /api/api-keys/regenerate деактивирует предыдущий активный ключ', function () {
$old = ApiKey::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'is_active' => true,
]);
$this->postJson('/api/api-keys/regenerate')->assertStatus(201);
$old->refresh();
expect($old->is_active)->toBeFalse();
expect(ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->count())->toBe(1);
});
test('POST /api/api-keys/regenerate без auth: 401', function () {
auth()->logout();
$this->postJson('/api/api-keys/regenerate')->assertStatus(401);
});