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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user