From aae0457e19c5ab65c5006efbf44ba119d3e04384 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 05:30:59 +0200 Subject: [PATCH] feat(admin): administrator access to a customer's Nextcloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Impersonation borrows the customer's portal session; this is access to their installation, which is a different thing and now a different button. Nextcloud has no passwordless admin jump, so this does the only honest thing it offers: it resets OUR managed admin account inside that installation and hands the credentials over once. The customer's own accounts are untouched, and the next request sets a new password again. - New capability instances.adminlogin, Owner and Admin only — stronger than impersonation, because it hands over control rather than a session. - The operator's own password is required every time, rate-limited: taking over a customer's installation is not something an unattended browser should manage on its own. - The reset runs on the provisioning worker (it owns the tunnel and the Proxmox credentials); the console polls a handoff token, so the credentials never enter a Livewire snapshot. - A silent instance is reported as such instead of appearing to succeed. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Admin/Customers.php | 2 + app/Livewire/Admin/InstanceAdminAccess.php | 95 +++++++++++++++++++ .../Jobs/IssueInstanceAdminAccess.php | 86 +++++++++++++++++ app/Services/Wireguard/ConfigHandoff.php | 5 +- ...6_000000_add_instance_admin_capability.php | 34 +++++++ lang/de/instances.php | 19 ++++ lang/en/instances.php | 19 ++++ .../views/livewire/admin/customers.blade.php | 11 +++ .../admin/instance-admin-access.blade.php | 54 +++++++++++ .../Feature/Admin/InstanceAdminAccessTest.php | 87 +++++++++++++++++ 10 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Admin/InstanceAdminAccess.php create mode 100644 app/Provisioning/Jobs/IssueInstanceAdminAccess.php create mode 100644 database/migrations/2026_07_26_000000_add_instance_admin_capability.php create mode 100644 lang/de/instances.php create mode 100644 lang/en/instances.php create mode 100644 resources/views/livewire/admin/instance-admin-access.blade.php create mode 100644 tests/Feature/Admin/InstanceAdminAccessTest.php diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php index 17fd0e4..fff5c14 100644 --- a/app/Livewire/Admin/Customers.php +++ b/app/Livewire/Admin/Customers.php @@ -53,6 +53,8 @@ class Customers extends Component 'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—', 'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale), 'instance' => $instance->subdomain ?? '—', + // Only an instance that exists can hand out an admin login. + 'instance_uuid' => $instance->uuid ?? null, 'closed' => $c->closed_at !== null || $c->status === 'closed', 'suspended' => $c->status === 'suspended', 'status' => match (true) { diff --git a/app/Livewire/Admin/InstanceAdminAccess.php b/app/Livewire/Admin/InstanceAdminAccess.php new file mode 100644 index 0000000..97053c1 --- /dev/null +++ b/app/Livewire/Admin/InstanceAdminAccess.php @@ -0,0 +1,95 @@ +authorize('instances.adminlogin'); + + $instance = Instance::query()->where('uuid', $uuid)->firstOrFail(); + $this->uuid = $uuid; + $this->subdomain = $instance->subdomain; + } + + public function hydrate(): void + { + $this->authorize('instances.adminlogin'); + } + + public function request(): void + { + $this->authorize('instances.adminlogin'); + $this->resetErrorBag('password'); + + $key = 'instance-admin:'.auth()->id(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])); + + return; + } + + if (! Hash::check($this->password, auth()->user()->password)) { + RateLimiter::hit($key, 300); + $this->addError('password', __('instances.wrong_password')); + + return; + } + + RateLimiter::clear($key); + $this->reset('password'); + + // The reset runs on the provisioning worker — it owns the tunnel. The + // token is where it will leave the result. + $this->token = Str::random(40); + $this->waiting = true; + + IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, (int) auth()->id()); + } + + public function render() + { + $payload = null; + if ($this->token !== null) { + $raw = ConfigHandoff::get($this->token); + $payload = $raw === null ? null : json_decode($raw, true); + } + + return view('livewire.admin.instance-admin-access', [ + 'credentials' => isset($payload['password']) ? $payload : null, + 'failed' => isset($payload['error']) ? $payload['error'] : null, + ]); + } +} diff --git a/app/Provisioning/Jobs/IssueInstanceAdminAccess.php b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php new file mode 100644 index 0000000..efc0c10 --- /dev/null +++ b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php @@ -0,0 +1,86 @@ +onConnection('provisioning'); + $this->onQueue('provisioning'); + } + + public function handle(ProxmoxClient $proxmox): void + { + $instance = Instance::query()->with('host')->where('uuid', $this->instanceUuid)->first(); + + if ($instance === null || $instance->host === null || blank($instance->nc_admin_ref)) { + ConfigHandoff::put(json_encode(['error' => 'unavailable']), $this->handoffToken); + + return; + } + + $username = $instance->nc_admin_ref; + $password = Str::password(24, symbols: false); + + $result = $proxmox->forHost($instance->host)->guestExec( + $instance->host->node ?? 'pve', + (int) $instance->vmid, + 'cd /opt/nextcloud && OC_PASS='.escapeshellarg($password). + ' docker compose exec -T -e OC_PASS app php occ user:resetpassword --password-from-env '. + escapeshellarg($username), + ); + + if ((int) ($result['exitcode'] ?? 1) !== 0) { + Log::warning('instance admin access failed', [ + 'instance' => $instance->uuid, 'user' => $this->requestedBy, + ]); + ConfigHandoff::put(json_encode(['error' => 'failed']), $this->handoffToken); + + return; + } + + // Auditable on its own: who asked, for which instance, when. The + // credentials themselves are never logged. + Log::info('instance admin access issued', [ + 'instance' => $instance->uuid, 'user' => $this->requestedBy, + ]); + + ConfigHandoff::put(json_encode([ + 'url' => 'https://'.($instance->custom_domain ?: $instance->subdomain.'.'.config('provisioning.dns.zone')), + 'username' => $username, + 'password' => $password, + ]), $this->handoffToken); + } +} diff --git a/app/Services/Wireguard/ConfigHandoff.php b/app/Services/Wireguard/ConfigHandoff.php index 6129d81..e630d97 100644 --- a/app/Services/Wireguard/ConfigHandoff.php +++ b/app/Services/Wireguard/ConfigHandoff.php @@ -23,9 +23,10 @@ final class ConfigHandoff private const TTL_SECONDS = 600; - public static function put(#[SensitiveParameter] string $plaintext): string + /** @param string|null $token reuse a handle the caller already handed out */ + public static function put(#[SensitiveParameter] string $plaintext, ?string $token = null): string { - $token = Str::random(40); + $token ??= Str::random(40); Cache::put(self::PREFIX.$token, $plaintext, self::TTL_SECONDS); return $token; diff --git a/database/migrations/2026_07_26_000000_add_instance_admin_capability.php b/database/migrations/2026_07_26_000000_add_instance_admin_capability.php new file mode 100644 index 0000000..e746a62 --- /dev/null +++ b/database/migrations/2026_07_26_000000_add_instance_admin_capability.php @@ -0,0 +1,34 @@ +forgetCachedPermissions(); + + Permission::findOrCreate('instances.adminlogin', 'web'); + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'web')->givePermissionTo('instances.adminlogin'); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::query()->where('name', 'instances.adminlogin')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/instances.php b/lang/de/instances.php new file mode 100644 index 0000000..723c6d0 --- /dev/null +++ b/lang/de/instances.php @@ -0,0 +1,19 @@ + 'Administrator-Zugang zu :name', + 'admin_body' => 'Setzt das Passwort unseres verwalteten Administrator-Kontos in dieser Nextcloud neu und zeigt es einmalig an. Konten des Kunden bleiben unberührt. Zur Bestätigung Ihr eigenes Passwort.', + 'admin_confirm' => 'Zugang erzeugen', + 'admin_working' => 'Passwort wird in der Instanz gesetzt …', + 'admin_ready' => 'Zugangsdaten erzeugt.', + 'admin_url' => 'Adresse', + 'admin_user' => 'Benutzer', + 'admin_password' => 'Passwort', + 'admin_once' => 'Nur jetzt sichtbar. Beim nächsten Mal wird wieder ein neues Passwort gesetzt.', + 'admin_failed' => 'Die Instanz hat nicht geantwortet. Läuft die VM und ist der Tunnel aktiv?', + 'your_password' => 'Ihr Passwort', + 'wrong_password' => 'Passwort stimmt nicht.', + 'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.', + 'cancel' => 'Abbrechen', + 'admin_action' => 'Als Administrator anmelden', +]; diff --git a/lang/en/instances.php b/lang/en/instances.php new file mode 100644 index 0000000..98610cd --- /dev/null +++ b/lang/en/instances.php @@ -0,0 +1,19 @@ + 'Administrator access to :name', + 'admin_body' => 'Resets the password of our managed administrator account inside this Nextcloud and shows it once. The customer\'s own accounts are untouched. Confirm with your own password.', + 'admin_confirm' => 'Issue access', + 'admin_working' => 'Setting the password on the instance …', + 'admin_ready' => 'Credentials issued.', + 'admin_url' => 'Address', + 'admin_user' => 'User', + 'admin_password' => 'Password', + 'admin_once' => 'Visible only now. Next time a new password is set again.', + 'admin_failed' => 'The instance did not answer. Is the VM running and the tunnel up?', + 'your_password' => 'Your password', + 'wrong_password' => 'That password is not correct.', + 'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.', + 'cancel' => 'Cancel', + 'admin_action' => 'Sign in as administrator', +]; diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index 56c1c98..91e4551 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -36,6 +36,17 @@ {{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }} @endunless + {{-- Impersonation borrows the customer's PORTAL session; this + is administrator access to their Nextcloud itself. Two + different things, so two buttons. --}} + @if ($r['instance_uuid'] && auth()->user()?->can('instances.adminlogin')) + + @endif
@csrf