Stop a disabled operator from bypassing the hidden-site gate

PublicSiteGate let anyone through on a bare Auth::guard('operator')->check(),
which stays true for a disabled operator's live session — disabling
someone does not sign them out, so it never stopped this gate from
showing them the real site indefinitely, even though EnsureAdmin
already treats them as inactive. Now requires the same thing EnsureAdmin
does: an active operator holding a console role.

Chosen to merely ignore an invalid operator session here rather than log
it out. This gate only decides what one request sees; logging out would
ripple into whatever else that session is doing (a concurrent console
tab, mid-task), a bigger side effect than a visibility check exists to
have. EnsureAdmin sets the precedent — it also rejects a disabled
operator per request without touching their session.
feat/operator-identity
nexxo 2026-07-28 14:42:29 +02:00
parent 20329b176b
commit e8bd8268fd
2 changed files with 59 additions and 2 deletions

View File

@ -57,7 +57,7 @@ class PublicSiteGate
return $next($request); return $next($request);
} }
if ($this->fromManagementNetwork($request) || Auth::guard('operator')->check()) { if ($this->fromManagementNetwork($request) || $this->isActiveOperator()) {
return $next($request); return $next($request);
} }
@ -74,4 +74,24 @@ class PublicSiteGate
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges); return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
} }
/**
* Same standard as EnsureAdmin: an active operator holding a console role.
* A bare `check()` stays true for a disabled operator's live session
* disabling someone did not sign them out, so it never stopped this gate
* from waving them through indefinitely.
*
* Deliberately checked, not enforced by logging out: this gate only
* decides what ONE request sees, and destroying the session would ripple
* into whatever else that session is doing (a concurrent console tab,
* mid-task) a much bigger side effect than a visibility check exists to
* have. EnsureAdmin sets the precedent: it also 403s a disabled operator
* per request without touching their session.
*/
private function isActiveOperator(): bool
{
$operator = Auth::guard('operator')->user();
return $operator !== null && $operator->isActive() && $operator->can('console.view');
}
} }

View File

@ -1,8 +1,10 @@
<?php <?php
use App\Models\Operator;
use App\Models\User; use App\Models\User;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
it('serves the site normally while it is public', function () { it('serves the site normally while it is public', function () {
Settings::set('site.public', true); Settings::set('site.public', true);
@ -58,6 +60,41 @@ it('lets a signed-in operator see the real site from anywhere', function () {
->assertStatus(503); ->assertStatus(503);
}); });
it('does not let a disabled operator bypass the hidden site', function () {
// EnsureAdmin already refuses a disabled operator (isActive()); this gate
// used to check only Auth::guard('operator')->check(), which stays true
// for a disabled operator's live session — an indefinite bypass of
// maintenance/private-site mode for anyone disabled while signed in.
Settings::set('site.public', false);
$op = operator('Support');
$op->update(['disabled_at' => now()]);
$this->actingAs($op, 'operator')
->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])
->get('/')
->assertStatus(503);
// Chosen deliberately: merely ignored, not logged out — see
// PublicSiteGate's docblock for why (this gate only decides what ONE
// request sees; logging out would ripple into whatever else that
// session is doing, e.g. a concurrent console tab).
expect(Auth::guard('operator')->check())->toBeTrue();
});
it('does not let an operator holding no console role bypass the hidden site', function () {
// Mirrors EnsureAdmin's other half: isActive() alone is not enough,
// the operator must also hold a role that reaches the console.
Settings::set('site.public', false);
$op = Operator::factory()->create(); // no role assigned
$this->actingAs($op, 'operator')
->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])
->get('/')
->assertStatus(503);
});
it('keeps the console and the webhook reachable while hidden', function () { it('keeps the console and the webhook reachable while hidden', function () {
Settings::set('site.public', false); Settings::set('site.public', false);
@ -102,7 +139,7 @@ it('flips visibility from the console, and only with the capability', function (
it('keeps serving when the settings table is unavailable', function () { it('keeps serving when the settings table is unavailable', function () {
// A deploy that runs new code before migrations, or a database blip: the // A deploy that runs new code before migrations, or a database blip: the
// gate must fall back to "public", not take the whole site down with a 500. // gate must fall back to "public", not take the whole site down with a 500.
Illuminate\Support\Facades\Schema::drop('app_settings'); Schema::drop('app_settings');
$this->get('/')->assertOk(); $this->get('/')->assertOk();
}); });