Authorize the console's live feed on the operator guard, not the default

POST /broadcasting/auth carries only the 'web' middleware group
(Laravel's own withBroadcasting() registers it that way), so
PusherBroadcaster::auth() resolved retrieveUser() on the DEFAULT guard
before the admin.runs channel's own Auth::guard('operator') check ever
ran — found nobody, since an operator is never signed in on 'web', and
threw straight from there. The guard was correct but unreachable.
Fixed by naming both guards on the channel registration itself.

Also adds broadcasting/auth to RestrictAdminHost::SHARED: in exclusive
mode it was neither admin.* by name nor on the shared list, so it 404'd
on the console host too — degrading silently to wire:poll.4s rather
than breaking outright, which is why nothing screamed.
feat/operator-identity
nexxo 2026-07-28 13:31:50 +02:00
parent 54fb6deb03
commit 47812cfca9
4 changed files with 106 additions and 2 deletions

View File

@ -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

View File

@ -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) {

View File

@ -0,0 +1,86 @@
<?php
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
/**
* routes/channels.php's admin.runs channel — the console's live provisioning
* feed. Its own callback checks Auth::guard('operator') correctly, but that
* is not what this file is about: it is about whether the callback ever runs
* at all.
*
* BROADCAST_CONNECTION=null (this suite's forced default, phpunit.xml) backs
* onto NullBroadcaster, whose auth() is a bare no-op it never calls a
* channel's own callback, so it cannot exercise this bug or its fix either
* way. A real Pusher-protocol broadcaster (Reverb uses PusherBroadcaster
* under the hood, BroadcastManager::createReverbDriver()) is switched in
* instead, via config rather than a container binding: Broadcast::auth()
* resolves through BroadcastManager::__call() -> 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();
});

View File

@ -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);
}
});