diff --git a/app/Filament/System/Pages/Auth/Login.php b/app/Filament/System/Pages/Auth/Login.php index e4c2c31..333cd25 100644 --- a/app/Filament/System/Pages/Auth/Login.php +++ b/app/Filament/System/Pages/Auth/Login.php @@ -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(); diff --git a/app/Http/Middleware/EnsurePlatformCapability.php b/app/Http/Middleware/EnsurePlatformCapability.php index 29efd6f..0547fba 100644 --- a/app/Http/Middleware/EnsurePlatformCapability.php +++ b/app/Http/Middleware/EnsurePlatformCapability.php @@ -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); diff --git a/app/Http/Middleware/UseSystemSessionCookie.php b/app/Http/Middleware/UseSystemSessionCookie.php new file mode 100644 index 0000000..7d328c3 --- /dev/null +++ b/app/Http/Middleware/UseSystemSessionCookie.php @@ -0,0 +1,35 @@ + $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'; + } +} + diff --git a/app/Providers/Filament/SystemPanelProvider.php b/app/Providers/Filament/SystemPanelProvider.php index 98a42bc..27a4997 100644 --- a/app/Providers/Filament/SystemPanelProvider.php +++ b/app/Providers/Filament/SystemPanelProvider.php @@ -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, ]); } } diff --git a/app/Services/System/AllowedTenantUniverse.php b/app/Services/System/AllowedTenantUniverse.php new file mode 100644 index 0000000..2758a6b --- /dev/null +++ b/app/Services/System/AllowedTenantUniverse.php @@ -0,0 +1,37 @@ +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.', + ]); + } +} + diff --git a/app/Support/Auth/PlatformCapabilities.php b/app/Support/Auth/PlatformCapabilities.php index f409c1d..3e032c5 100644 --- a/app/Support/Auth/PlatformCapabilities.php +++ b/app/Support/Auth/PlatformCapabilities.php @@ -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 */ diff --git a/specs/113-platform-ops-runbooks/tasks.md b/specs/113-platform-ops-runbooks/tasks.md index 544f045..aa5235d 100644 --- a/specs/113-platform-ops-runbooks/tasks.md +++ b/specs/113-platform-ops-runbooks/tasks.md @@ -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) --- diff --git a/tests/Feature/Auth/SystemPanelAuthTest.php b/tests/Feature/Auth/SystemPanelAuthTest.php index 155220a..754d3d6 100644 --- a/tests/Feature/Auth/SystemPanelAuthTest.php +++ b/tests/Feature/Auth/SystemPanelAuthTest.php @@ -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 () { diff --git a/tests/Feature/System/Spec113/AllowedTenantUniverseTest.php b/tests/Feature/System/Spec113/AllowedTenantUniverseTest.php new file mode 100644 index 0000000..95be7cb --- /dev/null +++ b/tests/Feature/System/Spec113/AllowedTenantUniverseTest.php @@ -0,0 +1,47 @@ +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); +}); + diff --git a/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php b/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php new file mode 100644 index 0000000..c8ee510 --- /dev/null +++ b/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php @@ -0,0 +1,43 @@ +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(); +}); + diff --git a/tests/Feature/System/Spec113/SystemLoginThrottleTest.php b/tests/Feature/System/Spec113/SystemLoginThrottleTest.php new file mode 100644 index 0000000..e89680a --- /dev/null +++ b/tests/Feature/System/Spec113/SystemLoginThrottleTest.php @@ -0,0 +1,62 @@ +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'); +}); + diff --git a/tests/Feature/System/Spec113/SystemSessionIsolationTest.php b/tests/Feature/System/Spec113/SystemSessionIsolationTest.php new file mode 100644 index 0000000..d572f79 --- /dev/null +++ b/tests/Feature/System/Spec113/SystemSessionIsolationTest.php @@ -0,0 +1,17 @@ +get('/system/login') + ->assertSuccessful() + ->assertCookie($expectedCookieName); +}); +