Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **SecretController**: Removed hardcoded tenant ID resolution (#190)
- Replaced `TenantKey::first()` workaround with proper `InjectTenantId` middleware
- New middleware automatically injects `tenant_id` into request for Secret routes
- Single-tenant development mode: Uses first available TenantKey
- Production-ready pattern: Middleware can be extended for user-based tenant resolution
- Middleware registered as `tenant.inject` alias in `bootstrap/app.php`
- Applied to all `/v1/secrets` and `/v1/attachments` routes
- 5 comprehensive middleware tests added
- Resolves TODO comment in `SecretController::store()`
- Maintains backward compatibility: Respects pre-existing `tenant_id` in request
- **CI/CD**: Codecov upload failures no longer block Dependabot PRs
- Set `fail_ci_if_error` conditionally: `false` for dependabot/renovate bots, `true` for normal PRs
- Allows automated dependency updates without CODECOV_TOKEN access issues
Expand Down
15 changes: 8 additions & 7 deletions app/Http/Controllers/Api/V1/SecretController.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,17 +157,18 @@ public function store(StoreSecretRequest $request): JsonResponse
/** @var \App\Models\User $user */
$user = $request->user();

// TODO: Replace with TenantMiddleware that injects tenant_id into request
// For now, use first available tenant (testing only - NOT production-ready)
$tenantId = \App\Models\TenantKey::first()?->id;
if (! $tenantId) {
/** @var int|null $tenantId */
$tenantId = $request->input('tenant_id');

// Validate tenant_id injected by middleware
if ($tenantId === null || ! is_numeric($tenantId) || (int) $tenantId <= 0) {
return response()->json([
'error' => 'Tenant resolution not yet implemented. Please contact system administrator.',
], Response::HTTP_SERVICE_UNAVAILABLE);
'message' => 'Invalid or missing tenant_id.',
], Response::HTTP_BAD_REQUEST);
}

$secret = new Secret;
$secret->tenant_id = $tenantId;
$secret->tenant_id = (int) $tenantId;
$secret->owner_id = $user->id;
$secret->version = 1;

Expand Down
57 changes: 57 additions & 0 deletions app/Http/Middleware/InjectTenantId.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Middleware;

use App\Models\TenantKey;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* InjectTenantId middleware injects tenant_id into the request.
*
* SINGLE-TENANT DEVELOPMENT MODE:
* Currently uses the first available TenantKey. This is NOT production-ready
* for multi-tenant deployments.
*
* FUTURE PRODUCTION IMPLEMENTATION:
* - Extract tenant_id from authenticated user's tenant relationship
* - Support subdomain-based tenant resolution
* - Support JWT claim-based tenant resolution
*
* @see https://github.com/SecPal/api/issues/190
*/
class InjectTenantId
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Skip if tenant_id already set (e.g., by SetTenant middleware)
if ($request->has('tenant_id')) {
return $next($request);
}

// SINGLE-TENANT MODE: Use first available tenant
// TODO: Replace with user-based tenant resolution for multi-tenant production
$tenantId = TenantKey::oldest('id')->value('id');

if ($tenantId === null) {
return response()->json([
'error' => 'No tenant keys available. Please ensure at least one tenant key is configured.',
], Response::HTTP_SERVICE_UNAVAILABLE);
}

// Inject tenant_id into request
$request->merge(['tenant_id' => $tenantId]);

return $next($request);
}
}
1 change: 1 addition & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'tenant' => \App\Http\Middleware\SetTenant::class,
'tenant.inject' => \App\Http\Middleware\InjectTenantId::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
]);
Expand Down
30 changes: 18 additions & 12 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,27 @@
});

// Secret Attachment endpoints (File Attachments API - Phase 2)
Route::post('/secrets/{secret}/attachments', [SecretAttachmentController::class, 'store']);
Route::get('/secrets/{secret}/attachments', [SecretAttachmentController::class, 'index']);
Route::get('/attachments/{attachment}/download', [SecretAttachmentController::class, 'download']);
Route::delete('/attachments/{attachment}', [SecretAttachmentController::class, 'destroy']);
Route::middleware('tenant.inject')->group(function () {
Route::post('/secrets/{secret}/attachments', [SecretAttachmentController::class, 'store']);
Route::get('/secrets/{secret}/attachments', [SecretAttachmentController::class, 'index']);
Route::get('/attachments/{attachment}/download', [SecretAttachmentController::class, 'download']);
Route::delete('/attachments/{attachment}', [SecretAttachmentController::class, 'destroy']);
});

// Secret Sharing endpoints (Phase 3) - must be before single {secret} routes
Route::get('/secrets/{secret}/shares', [SecretShareController::class, 'index']);
Route::post('/secrets/{secret}/shares', [SecretShareController::class, 'store']);
Route::delete('/secrets/{secret}/shares/{share}', [SecretShareController::class, 'destroy']);
Route::middleware('tenant.inject')->group(function () {
Route::get('/secrets/{secret}/shares', [SecretShareController::class, 'index']);
Route::post('/secrets/{secret}/shares', [SecretShareController::class, 'store']);
Route::delete('/secrets/{secret}/shares/{share}', [SecretShareController::class, 'destroy']);
});

// Secret CRUD endpoints (Phase 3)
Route::get('/secrets', [SecretController::class, 'index']);
Route::post('/secrets', [SecretController::class, 'store']);
Route::get('/secrets/{secret}', [SecretController::class, 'show']);
Route::patch('/secrets/{secret}', [SecretController::class, 'update']);
Route::delete('/secrets/{secret}', [SecretController::class, 'destroy']);
Route::middleware('tenant.inject')->group(function () {
Route::get('/secrets', [SecretController::class, 'index']);
Route::post('/secrets', [SecretController::class, 'store']);
Route::get('/secrets/{secret}', [SecretController::class, 'show']);
Route::patch('/secrets/{secret}', [SecretController::class, 'update']);
Route::delete('/secrets/{secret}', [SecretController::class, 'destroy']);
});
});
});
111 changes: 111 additions & 0 deletions tests/Feature/Middleware/InjectTenantIdMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later

use App\Http\Middleware\InjectTenantId;
use App\Models\TenantKey;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;

use function Pest\Laravel\actingAs;

uses(RefreshDatabase::class);

beforeEach(function (): void {
// Use process-specific KEK file for parallel test isolation
TenantKey::setKekPath(getTestKekPath());
TenantKey::generateKek();

// Create tenant
$keys = TenantKey::generateEnvelopeKeys();
$this->tenant = TenantKey::create($keys);

// Create authenticated user
$this->user = User::factory()->create();

// Register test route with middleware
Route::middleware(['auth:sanctum', InjectTenantId::class])->group(function () {
Route::post('/test/inject-tenant', function (Request $request) {
return response()->json([
'tenant_id' => $request->input('tenant_id'),
]);
});
});
});

afterEach(function (): void {
cleanupTestKekFile();
TenantKey::setKekPath(null);
});

describe('InjectTenantId Middleware', function () {
test('injects tenant_id from first available TenantKey', function () {
$response = actingAs($this->user, 'sanctum')
->postJson('/test/inject-tenant');

$response->assertOk()
->assertJson([
'tenant_id' => $this->tenant->id,
]);
});

test('returns 503 when no TenantKey exists', function () {
// Delete the tenant
$this->tenant->delete();

$response = actingAs($this->user, 'sanctum')
->postJson('/test/inject-tenant');

$response->assertStatus(Response::HTTP_SERVICE_UNAVAILABLE)
->assertJson([
'error' => 'No tenant keys available. Please ensure at least one tenant key is configured.',
]);
});

test('middleware does not overwrite existing tenant_id in request', function () {
// Test that middleware respects pre-existing tenant_id
// by directly calling the middleware handle method
$middleware = new InjectTenantId;

$request = Request::create('/test', 'POST');
$request->merge(['tenant_id' => 999]); // Pre-set tenant_id
$request->setUserResolver(fn () => $this->user);

$response = $middleware->handle($request, function ($req) {
return response()->json([
'tenant_id' => $req->input('tenant_id'),
]);
});

expect($response->getStatusCode())->toBe(200);
expect(json_decode($response->getContent(), true)['tenant_id'])->toBe(999);
});

test('injects tenant_id when user is authenticated', function () {
$response = actingAs($this->user, 'sanctum')
->postJson('/test/inject-tenant', ['foo' => 'bar']);

$response->assertOk();
expect($response->json('tenant_id'))->toBe($this->tenant->id);
});

test('middleware works with multiple TenantKeys (uses first)', function () {
// Create a second tenant
$keys2 = TenantKey::generateEnvelopeKeys();
$tenant2 = TenantKey::create($keys2);

$response = actingAs($this->user, 'sanctum')
->postJson('/test/inject-tenant');

$response->assertOk();

// Should use first tenant (oldest by ID)
$firstTenantId = TenantKey::oldest('id')->value('id');
expect($response->json('tenant_id'))->toBe($firstTenantId);
});
});