Close the domain page to packages that may not have one
The rule was decided in CustomDomainAccess but nothing asked it on the way in: /domain answered every customer on every package, so the entry package could set a domain it is not sold and cannot keep. The guard sits in mount(), not on the route. A Livewire component answers POSTs to /livewire/update by itself, so a route-only check would have left save() and the rest reachable to exactly the customers it was meant to stop. The sidebar asks the same question through a gate rather than a second copy of the rule — a customer holds no permissions at all (R21 puts all seventeen on the operator guard), so can() had nothing to filter on and the tab was shown to everyone, leading straight into the 403 the page now raises. Customer::forUser() carries the resolution the Livewire concern used to own, because the gate has to answer outside any component and a second copy of its email fallback is how the two would drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus
parent
6ef9b510ce
commit
4e3beca759
|
|
@ -15,14 +15,7 @@ trait ResolvesCustomer
|
|||
{
|
||||
protected function customer(): ?Customer
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the explicit link; fall back to email for legacy unlinked accounts.
|
||||
return Customer::query()->where('user_id', $user->id)->first()
|
||||
?? Customer::query()->where('email', $user->email)->first();
|
||||
return Customer::forUser(auth()->user());
|
||||
}
|
||||
|
||||
/** Same as customer(), but tells the user why nothing happened. */
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire;
|
|||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\Instance;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Domains\DomainVerifier;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -34,6 +35,14 @@ class CustomDomain extends Component
|
|||
|
||||
public function mount(): void
|
||||
{
|
||||
// Here rather than as route middleware: a Livewire component answers
|
||||
// POSTs to /livewire/update on its own, so a guard that only sits on
|
||||
// the page route leaves every action on this one reachable to a plan
|
||||
// that may not have a domain at all. CustomDomainAccess is the single
|
||||
// place the rule lives — plan feature or booked module, never both
|
||||
// asked separately.
|
||||
abort_unless(app(CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
|
||||
|
||||
$this->domain = (string) ($this->instance()?->custom_domain ?? '');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@ use Illuminate\Support\Str;
|
|||
|
||||
class Customer extends Model
|
||||
{
|
||||
/**
|
||||
* The customer behind a signed-in portal account.
|
||||
*
|
||||
* Here rather than in the Livewire concern that used to own it, because the
|
||||
* navigation gate has to answer the same question outside any component,
|
||||
* and a second copy of the fallback below is how the two would drift.
|
||||
*
|
||||
* Prefers the explicit link and falls back to the address: accounts created
|
||||
* before that link existed are matched by email, and any caller that
|
||||
* forgets the fallback silently reports "no customer" for them.
|
||||
*/
|
||||
public static function forUser(?object $user): ?self
|
||||
{
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::query()->where('user_id', $user->id)->first()
|
||||
?? static::query()->where('email', $user->email)->first();
|
||||
}
|
||||
|
||||
/** Comparable form: spaces and punctuation are cosmetic in a VAT number. */
|
||||
public static function normaliseVatId(?string $value): string
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ use App\Http\Middleware\RestrictConsoleNetwork;
|
|||
use App\Listeners\RecordSignInDevice;
|
||||
use App\Mail\MaintenanceCancelledMail;
|
||||
use App\Mail\Transport\MailboxTransport;
|
||||
use App\Models\Customer;
|
||||
use App\Models\MaintenanceNotification;
|
||||
use App\Provisioning\PipelineRegistry;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Dns\FileHostDnsDirectory;
|
||||
use App\Services\Dns\HetznerDnsClient;
|
||||
use App\Services\Dns\HostDnsDirectory;
|
||||
|
|
@ -34,6 +36,7 @@ use Illuminate\Mail\Events\MessageSending;
|
|||
use Illuminate\Mail\Events\MessageSent;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
|
|
@ -70,6 +73,15 @@ class AppServiceProvider extends ServiceProvider
|
|||
// so nothing here reads the database while the application boots.
|
||||
Mail::extend('mailbox', fn (array $config) => new MailboxTransport($config['purpose']));
|
||||
|
||||
// The one thing the portal navigation has to ask that is not a
|
||||
// permission: the sidebar filters on can(), and a customer holds no
|
||||
// permissions at all (R21 puts all seventeen on the operator guard).
|
||||
// The gate delegates rather than deciding — CustomDomainAccess stays
|
||||
// the single place the rule lives, so the menu entry and the page it
|
||||
// leads to can never disagree about who may be there.
|
||||
Gate::define('use-custom-domain', fn ($user) => app(CustomDomainAccess::class)
|
||||
->allowsCustomer(Customer::forUser($user)));
|
||||
|
||||
// One listener for both ways in. The console signs in through
|
||||
// App\Livewire\Auth\OperatorLogin and the portal through Fortify — two
|
||||
// paths that share no code but both raise this event, which carries the
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ final class Navigation
|
|||
['label' => null, 'items' => [
|
||||
['dashboard', 'gauge', 'overview', null],
|
||||
['cloud', 'cloud', 'cloud', null],
|
||||
['domain', 'globe', 'domain', null],
|
||||
// The only portal entry with a condition. Start carries no
|
||||
// custom domain and cannot buy one, so the tab would lead
|
||||
// straight to the 403 its page raises.
|
||||
['domain', 'globe', 'domain', 'use-custom-domain'],
|
||||
['users', 'users', 'users', null],
|
||||
['backups', 'database', 'backups', null],
|
||||
]],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Livewire\CustomDomain;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Services\Domains\DomainVerifier;
|
||||
use Livewire\Livewire;
|
||||
|
|
@ -27,18 +28,29 @@ function withResolver(array $records): void
|
|||
));
|
||||
}
|
||||
|
||||
function customerInstance(array $attributes = []): Instance
|
||||
function customerInstance(array $attributes = [], string $plan = 'business'): Instance
|
||||
{
|
||||
// customers.user_id, not users.customer_id — the link runs the other way.
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
|
||||
|
||||
return Instance::factory()->create([
|
||||
$instance = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'active',
|
||||
'subdomain' => 'berger',
|
||||
...$attributes,
|
||||
]);
|
||||
|
||||
// On business by default, because reaching this page at all now takes a
|
||||
// package that carries an own domain (CustomDomainAccess). These tests are
|
||||
// about proving ownership of a domain, not about who may have one — that
|
||||
// rule has its own suite — so they start from a customer who may.
|
||||
Subscription::factory()->plan($plan)->create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance->id,
|
||||
]);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
it('does not serve a domain nobody has proven', function () {
|
||||
|
|
@ -194,3 +206,14 @@ it('refuses something that is not a hostname', function () {
|
|||
|
||||
expect($instance->fresh()->custom_domain)->toBeNull();
|
||||
});
|
||||
|
||||
it('does not let the entry package reach the page at all', function () {
|
||||
// The guard, at the component rather than on the route: a Livewire action
|
||||
// posts to /livewire/update on its own, so a page-only check would leave
|
||||
// every action here open to a package that may not have a domain.
|
||||
$instance = customerInstance([], 'start');
|
||||
|
||||
Livewire::actingAs($instance->customer->user)
|
||||
->test(CustomDomain::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue