diff --git a/app/Livewire/Concerns/ResolvesCustomer.php b/app/Livewire/Concerns/ResolvesCustomer.php index f41de2e..abfc8b3 100644 --- a/app/Livewire/Concerns/ResolvesCustomer.php +++ b/app/Livewire/Concerns/ResolvesCustomer.php @@ -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. */ diff --git a/app/Livewire/CustomDomain.php b/app/Livewire/CustomDomain.php index c494745..cfb4d09 100644 --- a/app/Livewire/CustomDomain.php +++ b/app/Livewire/CustomDomain.php @@ -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 ?? ''); } diff --git a/app/Models/Customer.php b/app/Models/Customer.php index bdfd646..5165bdf 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -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 { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7ad2c6b..802e947 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index bf474f4..d0139fc 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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], ]], diff --git a/tests/Feature/CustomDomainTest.php b/tests/Feature/CustomDomainTest.php index 8331c74..359984c 100644 --- a/tests/Feature/CustomDomainTest.php +++ b/tests/Feature/CustomDomainTest.php @@ -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(); +});