diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php index ce8e147..5ebb939 100644 --- a/app/Http/Middleware/RestrictAdminHost.php +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -49,6 +49,12 @@ class RestrictAdminHost private const SHARED = [ 'livewire/*', 'up', + // The console's own live-provisioning feed authorizes here (see + // routes/channels.php, admin.runs) — a route Laravel registers + // without any admin.* name, so isConsoleRoute() alone would not + // exempt it, and in exclusive mode it would 404 on the console host + // exactly like a stranger's request would. + 'broadcasting/auth', ]; public function handle(Request $request, Closure $next): Response diff --git a/routes/channels.php b/routes/channels.php index fbc4f3f..d1e7315 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -8,7 +8,19 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) { }); // Operator console live provisioning feed — operators only. -Broadcast::channel('admin.runs', fn () => (bool) Auth::guard('operator')->user()?->can('console.view')); +// +// 'guards' is not decoration: POST /broadcasting/auth carries only the 'web' +// middleware group (Laravel registers it that way, see withBroadcasting()), +// so without this, PusherBroadcaster::auth() resolves the user on the +// DEFAULT guard before this callback ever runs — finds nobody, since an +// operator is never signed in on 'web' — and throws AccessDeniedHttpException +// straight from retrieveUser(). The Auth::guard('operator') check below is +// correct but unreachable without this option. +Broadcast::channel( + 'admin.runs', + fn () => (bool) Auth::guard('operator')->user()?->can('console.view'), + ['guards' => ['operator', 'web']], +); // A customer's own provisioning feed — bridged from the auth user by email. Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) { diff --git a/tests/Feature/Admin/BroadcastAuthTest.php b/tests/Feature/Admin/BroadcastAuthTest.php new file mode 100644 index 0000000..da4985b --- /dev/null +++ b/tests/Feature/Admin/BroadcastAuthTest.php @@ -0,0 +1,86 @@ + driver(), which reads + * config('broadcasting.default') and builds its OWN instance — a container + * binding for BroadcasterContract is a separate, unrelated seam that + * driver() never consults. No live Reverb server is needed: auth() is pure + * PHP — channel authorization is an HMAC signature computed locally, never a + * call over the network. + */ +function bindRealBroadcaster(): void +{ + config()->set('broadcasting.default', 'reverb'); + config()->set('broadcasting.connections.reverb.key', 'test-key'); + config()->set('broadcasting.connections.reverb.secret', 'test-secret'); + config()->set('broadcasting.connections.reverb.app_id', 'test-app-id'); + config()->set('broadcasting.connections.reverb.options', [ + 'host' => '127.0.0.1', 'port' => 6001, 'scheme' => 'http', 'useTLS' => false, + ]); + + // routes/channels.php ran once already, at boot, registering admin.runs + // against whichever broadcaster 'null' (this suite's forced default) + // resolved to at the time. Switching the default config above builds a + // brand new driver instance on next use — it does not retroactively + // register anything on it. Re-requiring the routes file does. + require base_path('routes/channels.php'); +} + +function authorizeAdminRunsChannel() +{ + return test()->post('/broadcasting/auth', [ + 'channel_name' => 'private-admin.runs', + 'socket_id' => '1234.1234', + ]); +} + +it('lets an operator authorize the console live-feed channel on the operator guard', function () { + bindRealBroadcaster(); + $operator = Operator::factory()->role('Owner')->create(); + + $this->actingAs($operator, 'operator'); + // actingAs() also flips Auth's DEFAULT guard to 'operator' (Auth:: + // shouldUse()) — nothing does that for a real request to this route in + // production (/broadcasting/auth carries only the 'web' middleware + // group, never auth:operator,web), so left alone this would make the + // bare/default guard resolve the operator too and mask exactly the bug + // this test exists to catch. Reset so only the 'operator' GUARD INSTANCE + // (already given its user by actingAs() above, untouched by shouldUse()) + // has the operator signed in — matching a real session. + Auth::shouldUse('web'); + + authorizeAdminRunsChannel()->assertOk(); +}); + +it('refuses a guest on the console live-feed channel', function () { + bindRealBroadcaster(); + + authorizeAdminRunsChannel()->assertForbidden(); +}); + +it('refuses a signed-in portal customer who is not an operator', function () { + bindRealBroadcaster(); + $user = User::factory()->create(); + + $this->actingAs($user); + + // Signed in on 'web', not 'operator' — retrieveUser() now finds THIS + // user (guards => ['operator', 'web']) and reaches the channel's own + // callback, which must still refuse them: they hold no operator role. + authorizeAdminRunsChannel()->assertForbidden(); +}); diff --git a/tests/Feature/Admin/ConsoleHostSeparationTest.php b/tests/Feature/Admin/ConsoleHostSeparationTest.php index dcbf502..f26dd39 100644 --- a/tests/Feature/Admin/ConsoleHostSeparationTest.php +++ b/tests/Feature/Admin/ConsoleHostSeparationTest.php @@ -97,7 +97,7 @@ describe('exclusive host', function () { // isConsoleRoute(), not through this nameless allowlist. If this list // is wrong the symptom is "the console is broken", so it is written // out rather than inferred. - foreach (['/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) { + foreach (['/livewire/update', '/livewire/livewire.min.js', '/up', '/broadcasting/auth'] as $path) { expect(guard('admin.example.test', $path, null))->toBe('through', $path); } });