feat(spec113): harden system auth + session isolation
This commit is contained in:
parent
a069085814
commit
8ef221b48e
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
35
app/Http/Middleware/UseSystemSessionCookie.php
Normal file
35
app/Http/Middleware/UseSystemSessionCookie.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Services/System/AllowedTenantUniverse.php
Normal file
37
app/Services/System/AllowedTenantUniverse.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 () {
|
||||
|
||||
47
tests/Feature/System/Spec113/AllowedTenantUniverseTest.php
Normal file
47
tests/Feature/System/Spec113/AllowedTenantUniverseTest.php
Normal 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);
|
||||
});
|
||||
|
||||
43
tests/Feature/System/Spec113/AuthorizationSemanticsTest.php
Normal file
43
tests/Feature/System/Spec113/AuthorizationSemanticsTest.php
Normal 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();
|
||||
});
|
||||
|
||||
62
tests/Feature/System/Spec113/SystemLoginThrottleTest.php
Normal file
62
tests/Feature/System/Spec113/SystemLoginThrottleTest.php
Normal 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');
|
||||
});
|
||||
|
||||
17
tests/Feature/System/Spec113/SystemSessionIsolationTest.php
Normal file
17
tests/Feature/System/Spec113/SystemSessionIsolationTest.php
Normal 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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user