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
parent
d26200c74b
commit
aae0457e19
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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']);
|
||||
});
|
||||
Loading…
Reference in New Issue