diff --git a/app/app/Http/Controllers/Api/ApiKeyController.php b/app/app/Http/Controllers/Api/ApiKeyController.php new file mode 100644 index 00000000..18f24f21 --- /dev/null +++ b/app/app/Http/Controllers/Api/ApiKeyController.php @@ -0,0 +1,72 @@ +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); + } +} diff --git a/app/app/Models/ApiKey.php b/app/app/Models/ApiKey.php new file mode 100644 index 00000000..156f3251 --- /dev/null +++ b/app/app/Models/ApiKey.php @@ -0,0 +1,66 @@ + */ + 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 */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/database/factories/ApiKeyFactory.php b/app/database/factories/ApiKeyFactory.php new file mode 100644 index 00000000..0a41d3c5 --- /dev/null +++ b/app/database/factories/ApiKeyFactory.php @@ -0,0 +1,36 @@ + + */ +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(), + ]; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index da81df11..414dbc69 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 diff --git a/app/routes/web.php b/app/routes/web.php index 358353ab..a8100336 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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 — параметром. // diff --git a/app/tests/Feature/ApiKeyControllerTest.php b/app/tests/Feature/ApiKeyControllerTest.php new file mode 100644 index 00000000..c6465b6d --- /dev/null +++ b/app/tests/Feature/ApiKeyControllerTest.php @@ -0,0 +1,77 @@ +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); +});