feat(admin): administrator access to a customer's Nextcloud

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 05:30:59 +02:00
parent d26200c74b
commit aae0457e19
10 changed files with 410 additions and 2 deletions

View File

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

View File

@ -0,0 +1,95 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use App\Provisioning\Jobs\IssueInstanceAdminAccess;
use App\Services\Wireguard\ConfigHandoff;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Administrator access to a customer's Nextcloud.
*
* Not impersonation: that borrows a portal session. This resets our managed
* admin account inside the customer's installation and hands the credentials
* over once the only thing stock Nextcloud offers, and honest about what it
* does rather than pretending to be a passwordless jump.
*
* The operator's own password is required, every time. Taking control of a
* customer's installation is not something an unattended browser should be able
* to do on its own.
*/
class InstanceAdminAccess extends ModalComponent
{
public string $uuid;
public string $subdomain = '';
public string $password = '';
/** Opaque handle; the credentials never enter the component snapshot. */
public ?string $token = null;
public bool $waiting = false;
public function mount(string $uuid): void
{
$this->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,
]);
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Provisioning\Jobs;
use App\Models\Instance;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Wireguard\ConfigHandoff;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Hands an operator working administrator credentials for a customer's
* Nextcloud.
*
* Nextcloud has no "log in as admin without a password", so this does the only
* honest thing stock Nextcloud allows: it rotates OUR managed admin account's
* password and hands the new one over once. The customer's own accounts are
* untouched.
*
* Runs on the provisioning queue because that worker owns the tunnel and the
* Proxmox credentials; the console has neither.
*/
class IssueInstanceAdminAccess implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
public string $instanceUuid,
/** Where to leave the credentials for the console to pick up once. */
public string $handoffToken,
public int $requestedBy,
) {
$this->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);
}
}

View File

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

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* `instances.adminlogin` obtain administrator credentials for a customer's
* Nextcloud. Stronger than impersonation, which only borrows a portal session:
* this hands over control of the customer's own installation, so Owner and
* Admin only.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->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();
}
};

19
lang/de/instances.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
'admin_title' => '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',
];

19
lang/en/instances.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
'admin_title' => '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',
];

View File

@ -36,6 +36,17 @@
{{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }}
</button>
@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'))
<button type="button" title="{{ __('instances.admin_action') }}"
x-on:click="$dispatch('openModal', { component: 'admin.instance-admin-access', arguments: { uuid: '{{ $r['instance_uuid'] }}' } })"
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="shield" class="size-3.5" />
{{ __('instances.admin_action') }}
</button>
@endif
<form method="POST" action="{{ route('admin.impersonate', $r['uuid']) }}" class="inline">
@csrf
<button type="submit"

View File

@ -0,0 +1,54 @@
<div class="p-6" @if ($waiting && ! $credentials && ! $failed) wire:poll.2s @endif>
<h3 class="text-base font-semibold text-ink">{{ __('instances.admin_title', ['name' => $subdomain]) }}</h3>
@if ($credentials)
<p class="mt-1 text-sm text-muted">{{ __('instances.admin_ready') }}</p>
<dl class="mt-4 space-y-2 rounded-lg border border-line bg-surface-2 p-4 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-muted">{{ __('instances.admin_url') }}</dt>
<dd><a href="{{ $credentials['url'] }}" target="_blank" rel="noopener"
class="font-mono text-accent-text hover:underline">{{ $credentials['url'] }}</a></dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-muted">{{ __('instances.admin_user') }}</dt>
<dd class="font-mono text-body">{{ $credentials['username'] }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-muted">{{ __('instances.admin_password') }}</dt>
<dd class="font-mono text-body">{{ $credentials['password'] }}</dd>
</div>
</dl>
<p class="mt-3 text-xs text-faint">{{ __('instances.admin_once') }}</p>
<div class="mt-5 flex justify-end">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>
</div>
@elseif ($failed)
<p class="mt-2 rounded-lg border border-danger-border bg-danger-bg p-3 text-sm text-body">
{{ __('instances.admin_failed') }}
</p>
<div class="mt-5 flex justify-end">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>
</div>
@elseif ($waiting)
<div class="mt-6 flex items-center gap-3 text-sm text-muted">
<span class="size-2 animate-pulse rounded-pill bg-accent" aria-hidden="true"></span>
{{ __('instances.admin_working') }}
</div>
@else
<p class="mt-1 text-sm text-muted">{{ __('instances.admin_body') }}</p>
<form wire:submit="request" class="mt-4 space-y-3">
<x-ui.input name="password" type="password" wire:model="password"
:label="__('instances.your_password')" autocomplete="current-password" />
<div class="flex justify-end gap-2">
<x-ui.button variant="secondary" type="button" x-on:click="Livewire.dispatch('closeModal')">{{ __('instances.cancel') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
<x-ui.icon name="unlock" class="size-4" />{{ __('instances.admin_confirm') }}
</x-ui.button>
</div>
</form>
@endif
</div>

View File

@ -0,0 +1,87 @@
<?php
use App\Livewire\Admin\InstanceAdminAccess;
use App\Models\Instance;
use App\Provisioning\Jobs\IssueInstanceAdminAccess;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Wireguard\ConfigHandoff;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Queue;
function adminAccessInstance(): Instance
{
return Instance::factory()->create([
'host_id' => App\Models\Host::factory()->active()->create()->id,
'vmid' => 1042,
'nc_admin_ref' => 'admin',
'subdomain' => 'kanzlei-berger',
'status' => 'active',
]);
}
it('is offered only to operators who may take over an installation', function () {
$instance = adminAccessInstance();
foreach (['Support', 'Billing', 'Read-only', 'Developer'] as $role) {
Livewire\Livewire::actingAs(operator($role))
->test(InstanceAdminAccess::class, ['uuid' => $instance->uuid])
->assertForbidden();
}
Livewire\Livewire::actingAs(operator('Owner'))
->test(InstanceAdminAccess::class, ['uuid' => $instance->uuid])
->assertOk();
});
it('asks for the operator password before touching the instance', function () {
Queue::fake();
$instance = adminAccessInstance();
$owner = operator('Owner');
$owner->forceFill(['password' => Hash::make('geheim-1234')])->save();
$modal = Livewire\Livewire::actingAs($owner)
->test(InstanceAdminAccess::class, ['uuid' => $instance->uuid])
->set('password', 'falsch')
->call('request')
->assertHasErrors('password');
Queue::assertNothingPushed();
$modal->set('password', 'geheim-1234')->call('request')->assertHasNoErrors();
Queue::assertPushed(IssueInstanceAdminAccess::class);
});
it('resets our managed admin account and hands the credentials over once', function () {
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = adminAccessInstance();
$token = 'handoff-token-for-the-test';
(new IssueInstanceAdminAccess($instance->uuid, $token, 1))->handle($pve);
$payload = json_decode(ConfigHandoff::get($token), true);
expect($payload['username'])->toBe('admin')
->and($payload['password'])->toHaveLength(24)
->and($payload['url'])->toContain('kanzlei-berger');
// The password is handed over, never written to the instance record.
expect($instance->fresh()->getAttributes())->not->toContain($payload['password']);
});
it('reports a silent instance instead of pretending it worked', function () {
$pve = new class extends FakeProxmoxClient
{
public function guestExec(string $node, int $vmid, string $command): array
{
return ['exitcode' => 1, 'out-data' => 'connection refused'];
}
};
app()->instance(ProxmoxClient::class, $pve);
$instance = adminAccessInstance();
$token = 'handoff-failure';
(new IssueInstanceAdminAccess($instance->uuid, $token, 1))->handle($pve);
expect(json_decode(ConfigHandoff::get($token), true))->toBe(['error' => 'failed']);
});