feat(spec113): harden system auth + session isolation

This commit is contained in:
Ahmed Darrazi 2026-02-26 02:28:43 +01:00
parent a069085814
commit 8ef221b48e
12 changed files with 297 additions and 19 deletions

View File

@ -9,6 +9,7 @@
use App\Services\Intune\AuditLogger;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login as BaseLogin;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class Login extends BaseLogin
@ -17,10 +18,25 @@ public function authenticate(): ?LoginResponse
{
$data = $this->form->getState();
$email = (string) ($data['email'] ?? '');
$throttleKey = $this->throttleKey($email);
if (RateLimiter::tooManyAttempts($throttleKey, 10)) {
$this->audit(status: 'failure', email: $email, actor: null, reason: 'throttled');
$seconds = RateLimiter::availableIn($throttleKey);
throw ValidationException::withMessages([
'data.email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => (int) ceil($seconds / 60),
]),
]);
}
try {
$response = parent::authenticate();
} catch (ValidationException $exception) {
RateLimiter::hit($throttleKey, 60);
$this->audit(status: 'failure', email: $email, actor: null, reason: 'invalid_credentials');
throw $exception;
@ -40,6 +56,7 @@ public function authenticate(): ?LoginResponse
if (! $user->is_active) {
auth('platform')->logout();
RateLimiter::hit($throttleKey, 60);
$this->audit(status: 'failure', email: $email, actor: null, reason: 'inactive');
throw ValidationException::withMessages([
@ -47,6 +64,7 @@ public function authenticate(): ?LoginResponse
]);
}
RateLimiter::clear($throttleKey);
$user->forceFill(['last_login_at' => now()])->saveQuietly();
$this->audit(status: 'success', email: $email, actor: $user);
@ -54,6 +72,14 @@ public function authenticate(): ?LoginResponse
return $response;
}
private function throttleKey(string $email): string
{
$ip = (string) request()->ip();
$normalizedEmail = mb_strtolower(trim($email));
return "system-login:{$ip}:{$normalizedEmail}";
}
private function audit(string $status, string $email, ?PlatformUser $actor, ?string $reason = null): void
{
$tenant = Tenant::query()->where('external_id', 'platform')->first();

View File

@ -29,7 +29,7 @@ public function handle(Request $request, Closure $next, string $capability): Res
}
if (! Gate::forUser($user)->allows($capability)) {
abort(404);
abort(403);
}
return $next($request);

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class UseSystemSessionCookie
{
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$originalCookieName = (string) config('session.cookie');
config(['session.cookie' => $this->systemCookieName()]);
try {
return $next($request);
} finally {
config(['session.cookie' => $originalCookieName]);
}
}
private function systemCookieName(): string
{
return Str::slug((string) config('app.name', 'laravel')).'-system-session';
}
}

View File

@ -2,8 +2,10 @@
namespace App\Providers\Filament;
use App\Http\Middleware\UseSystemSessionCookie;
use App\Filament\System\Pages\Auth\Login;
use App\Filament\System\Pages\Dashboard;
use App\Support\Auth\PlatformCapabilities;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -42,6 +44,7 @@ public function panel(Panel $panel): Panel
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
UseSystemSessionCookie::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
@ -53,7 +56,7 @@ public function panel(Panel $panel): Panel
])
->authMiddleware([
Authenticate::class,
'ensure-platform-capability:platform.access_system_panel',
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
]);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\System;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;
class AllowedTenantUniverse
{
public const PLATFORM_TENANT_EXTERNAL_ID = 'platform';
public function query(): Builder
{
return Tenant::query()
->where('external_id', '!=', self::PLATFORM_TENANT_EXTERNAL_ID);
}
public function isAllowed(Tenant $tenant): bool
{
return (string) $tenant->external_id !== self::PLATFORM_TENANT_EXTERNAL_ID;
}
public function ensureAllowed(Tenant $tenant): void
{
if ($this->isAllowed($tenant)) {
return;
}
throw ValidationException::withMessages([
'tenant_id' => 'This tenant is not eligible for System runbooks.',
]);
}
}

View File

@ -14,6 +14,14 @@ class PlatformCapabilities
public const USE_BREAK_GLASS = 'platform.use_break_glass';
public const OPS_VIEW = 'platform.ops.view';
public const RUNBOOKS_VIEW = 'platform.runbooks.view';
public const RUNBOOKS_RUN = 'platform.runbooks.run';
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
/**
* @return array<string>
*/

View File

@ -16,10 +16,10 @@ ## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Confirm touch points and keep spec artifacts aligned.
- [ ] T001 Confirm spec UI Action Matrix is complete in specs/113-platform-ops-runbooks/spec.md
- [ ] T002 Confirm System panel provider registration in bootstrap/providers.php (Laravel 11+/12 provider registration)
- [ ] T003 [P] Capture current legacy /admin trigger location in app/Filament/Resources/FindingResource/Pages/ListFindings.php ("Backfill findings lifecycle" header action)
- [ ] T004 [P] Review existing single-tenant backfill pipeline entry points in app/Console/Commands/TenantpilotBackfillFindingLifecycle.php and app/Jobs/BackfillFindingLifecycleJob.php
- [X] T001 Confirm spec UI Action Matrix is complete in specs/113-platform-ops-runbooks/spec.md
- [X] T002 Confirm System panel provider registration in bootstrap/providers.php (Laravel 11+/12 provider registration)
- [X] T003 [P] Capture current legacy /admin trigger location in app/Filament/Resources/FindingResource/Pages/ListFindings.php ("Backfill findings lifecycle" header action)
- [X] T004 [P] Review existing single-tenant backfill pipeline entry points in app/Console/Commands/TenantpilotBackfillFindingLifecycle.php and app/Jobs/BackfillFindingLifecycleJob.php
---
@ -27,20 +27,20 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Security semantics, session isolation, and auth hardening that block all user stories.
- [ ] T005 Add platform runbook capability constants to app/Support/Auth/PlatformCapabilities.php (e.g., platform.ops.view, platform.runbooks.view, platform.runbooks.run, platform.runbooks.findings.lifecycle_backfill)
- [ ] T006 Update System panel access control to use capability registry constants in app/Providers/Filament/SystemPanelProvider.php (keep ACCESS_SYSTEM_PANEL gate, add per-page capability checks)
- [ ] T007 Change platform capability denial semantics to 403 (member-but-missing-capability) in app/Http/Middleware/EnsurePlatformCapability.php (keep wrong-plane 404 handled by ensure-correct-guard)
- [ ] T008 [P] Add SR-002 regression tests for 404 vs 403 semantics in tests/Feature/System/Spec113/AuthorizationSemanticsTest.php (tenant user -> 404 on /system/*, platform user without capability -> 403, platform user with capability -> 200)
- [X] T005 Add platform runbook capability constants to app/Support/Auth/PlatformCapabilities.php (e.g., platform.ops.view, platform.runbooks.view, platform.runbooks.run, platform.runbooks.findings.lifecycle_backfill)
- [X] T006 Update System panel access control to use capability registry constants in app/Providers/Filament/SystemPanelProvider.php (keep ACCESS_SYSTEM_PANEL gate, add per-page capability checks)
- [X] T007 Change platform capability denial semantics to 403 (member-but-missing-capability) in app/Http/Middleware/EnsurePlatformCapability.php (keep wrong-plane 404 handled by ensure-correct-guard)
- [X] T008 [P] Add SR-002 regression tests for 404 vs 403 semantics in tests/Feature/System/Spec113/AuthorizationSemanticsTest.php (tenant user -> 404 on /system/*, platform user without capability -> 403, platform user with capability -> 200)
- [ ] T009 Define and enforce the “allowed tenant universe” for System runbooks in app/Services/System/AllowedTenantUniverse.php (v1: exclude platform tenant; provide tenant query for pickers and runtime guard)
- [ ] T010 [P] Add allowed tenant universe tests in tests/Feature/System/Spec113/AllowedTenantUniverseTest.php (picker excludes platform tenant; attempts to target excluded tenant are rejected; no OperationRun created)
- [X] T009 Define and enforce the “allowed tenant universe” for System runbooks in app/Services/System/AllowedTenantUniverse.php (v1: exclude platform tenant; provide tenant query for pickers and runtime guard)
- [X] T010 [P] Add allowed tenant universe tests in tests/Feature/System/Spec113/AllowedTenantUniverseTest.php (picker excludes platform tenant; attempts to target excluded tenant are rejected; no OperationRun created)
- [ ] T011 Create System session cookie isolation middleware in app/Http/Middleware/UseSystemSessionCookie.php (set dedicated session cookie name before StartSession)
- [ ] T012 Wire System session cookie middleware before StartSession in app/Providers/Filament/SystemPanelProvider.php (SR-004)
- [ ] T013 [P] Add System session isolation test in tests/Feature/System/Spec113/SystemSessionIsolationTest.php (assert response sets the System session cookie name for /system)
- [X] T011 Create System session cookie isolation middleware in app/Http/Middleware/UseSystemSessionCookie.php (set dedicated session cookie name before StartSession)
- [X] T012 Wire System session cookie middleware before StartSession in app/Providers/Filament/SystemPanelProvider.php (SR-004)
- [X] T013 [P] Add System session isolation test in tests/Feature/System/Spec113/SystemSessionIsolationTest.php (assert response sets the System session cookie name for /system)
- [ ] T014 Implement /system/login throttling (10/min per IP + username key) in app/Filament/System/Pages/Auth/Login.php (SR-003; use RateLimiter and clear on success)
- [ ] T015 [P] Add /system/login throttling tests in tests/Feature/System/Spec113/SystemLoginThrottleTest.php (assert throttled after N failures; ensure failures still emit audit via AuditLogger)
- [X] T014 Implement /system/login throttling (10/min per IP + username key) in app/Filament/System/Pages/Auth/Login.php (SR-003; use RateLimiter and clear on success)
- [X] T015 [P] Add /system/login throttling tests in tests/Feature/System/Spec113/SystemLoginThrottleTest.php (assert throttled after N failures; ensure failures still emit audit via AuditLogger)
---

View File

@ -120,7 +120,7 @@
expect($audit->metadata['reason'] ?? null)->toBe('inactive');
});
it('denies system panel access (404) for platform users without the required capability', function () {
it('denies system panel access (403) for platform users without the required capability', function () {
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
@ -139,7 +139,7 @@
expect(auth('platform')->check())->toBeTrue();
$this->get('/system')->assertNotFound();
$this->get('/system')->assertForbidden();
});
it('allows system panel access for platform users with the required capability', function () {

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\System\AllowedTenantUniverse;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
it('excludes the platform tenant from the allowed universe query (picker)', function () {
$platformTenant = Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
$customerTenant = Tenant::factory()->create([
'external_id' => 'tenant-1',
'name' => 'Tenant One',
]);
$universe = app(AllowedTenantUniverse::class);
$ids = $universe->query()->orderBy('id')->pluck('id')->all();
expect($ids)->toContain((int) $customerTenant->getKey());
expect($ids)->not->toContain((int) $platformTenant->getKey());
});
it('rejects attempts to target the platform tenant', function () {
$platformTenant = Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
$universe = app(AllowedTenantUniverse::class);
expect(fn () => $universe->ensureAllowed($platformTenant))
->toThrow(ValidationException::class);
expect(OperationRun::query()->count())->toBe(0);
});

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Models\PlatformUser;
use App\Models\User;
use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns 404 when a tenant session accesses the system panel', function () {
$user = User::factory()->create();
$this->actingAs($user)->get('/system/login')->assertNotFound();
// Filament may switch the active guard within the test process,
// so ensure the tenant session is set for each request we assert.
$this->actingAs($user)->get('/system')->assertNotFound();
});
it('returns 403 when a platform user lacks the required capability', function () {
$platformUser = PlatformUser::factory()->create([
'capabilities' => [],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get('/system')
->assertForbidden();
});
it('returns 200 when a platform user has the required capability', function () {
$platformUser = PlatformUser::factory()->create([
'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get('/system')
->assertSuccessful();
});

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Auth\Login;
use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('throttles system login after repeated failures and still audits attempts', function () {
$platformTenant = Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
$user = PlatformUser::factory()->create([
'email' => 'operator@tenantpilot.io',
'is_active' => true,
]);
for ($i = 0; $i < 10; $i++) {
Livewire::test(Login::class)
->set('data.email', $user->email)
->set('data.password', 'wrong-password')
->call('authenticate')
->assertHasErrors(['data.email']);
}
Livewire::test(Login::class)
->set('data.email', $user->email)
->set('data.password', 'wrong-password')
->call('authenticate')
->assertHasErrors(['data.email']);
$auditCount = AuditLog::query()
->where('tenant_id', $platformTenant->getKey())
->where('action', 'platform.auth.login')
->count();
expect($auditCount)->toBe(11);
$latestAudit = AuditLog::query()
->where('tenant_id', $platformTenant->getKey())
->where('action', 'platform.auth.login')
->latest('id')
->first();
expect($latestAudit)->not->toBeNull();
expect($latestAudit->metadata['reason'] ?? null)->toBe('throttled');
});

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('sets a dedicated session cookie name for /system', function () {
$expectedCookieName = Str::slug((string) config('app.name', 'laravel')).'-system-session';
$this->get('/system/login')
->assertSuccessful()
->assertCookie($expectedCookieName);
});