Stop mailing the initial admin password, hold it in the panel until noted

The password no longer travels through third-party mail servers or sits
forever in an inbox: it moves onto the instance (encrypted, same as
nc_admin_ref) and shows on the dashboard until the customer clicks
"Notiert", which deletes it for good.
feat/granted-plans
nexxo 2026-07-28 23:19:20 +02:00
parent 00b9107ee3
commit 75b4a47768
14 changed files with 324 additions and 45 deletions

View File

@ -42,12 +42,13 @@ class Dashboard extends Component
$contract = $instance?->subscription;
$maintenance = MaintenanceWindow::forInstance($instance)->first();
$domain = $this->domain($instance);
return view('livewire.dashboard', [
'customer' => $customer,
'instance' => $instance,
'contract' => $contract,
'domain' => $this->domain($instance),
'domain' => $domain,
'location' => $this->location($instance),
'seats' => $this->seats($customer, $contract),
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
@ -73,10 +74,62 @@ class Dashboard extends Component
'term' => (string) $contract->term,
],
'nextInvoice' => $this->nextInvoice($contract, $instance),
// The initial admin password, if it is still waiting to be
// acknowledged. Read straight off $instance (already scoped to
// THIS customer above) rather than a fresh unscoped lookup, and
// never stored as a public property — see acknowledgeCredentials().
'credentials' => $this->credentials($instance, $domain),
'asOf' => Carbon::now(),
]);
}
/**
* Deletes the instance's stored admin password and stamps the
* acknowledgement the customer has confirmed they noted it down.
*
* Re-resolves both the customer AND the instance from the authenticated
* session rather than trusting $uuid alone: this method is reachable by
* anyone who can post to /livewire/update, not only through this card's
* button, so ownership is checked here again rather than relying on the
* card simply not being shown to anyone else.
*/
public function acknowledgeCredentials(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->where('uuid', $uuid)->whereNotNull('admin_password')->first();
if ($instance === null) {
return;
}
$instance->update([
'admin_password' => null,
'credentials_acknowledged_at' => now(),
]);
$this->dispatch('notify', message: __('dashboard.credentials.acknowledged'));
}
/**
* @return array{uuid: string, url: string, user: string, password: string}|null
*/
private function credentials(?Instance $instance, ?string $domain): ?array
{
if ($instance === null || $instance->admin_password === null) {
return null;
}
return [
'uuid' => $instance->uuid,
'url' => 'https://'.($domain ?: $instance->subdomain),
'user' => (string) $instance->nc_admin_ref,
'password' => $instance->admin_password,
];
}
/**
* Who holds the seats, so the card can say something true underneath the
* figure rather than repeating it.

View File

@ -16,16 +16,22 @@ class Instance extends Model
protected $fillable = [
'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb',
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref',
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at',
'route_written', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at',
];
protected $hidden = ['nc_admin_ref'];
protected $hidden = ['nc_admin_ref', 'admin_password'];
protected function casts(): array
{
return [
'nc_admin_ref' => 'encrypted',
// The initial Nextcloud admin password, held only until the
// customer acknowledges it on the dashboard (App\Livewire\
// Dashboard::acknowledgeCredentials). Same 'encrypted' cast as
// nc_admin_ref and Host::api_token_ref, for the same reason.
'admin_password' => 'encrypted',
'credentials_acknowledged_at' => 'datetime',
'route_written' => 'boolean',
'cert_ok' => 'boolean',
'vmid' => 'integer',

View File

@ -8,17 +8,23 @@ use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Crypt;
/**
* Delivers the initial admin credentials once provisioning completes.
* Announces that provisioning has finished. Carries NO credential.
*
* The initial admin password used to travel here in clear text crossing
* third-party mail servers and then sitting in an inbox forever, the weakest
* link in the chain. It now lives on the instance instead
* (Instance::$admin_password, encrypted see CompleteProvisioning) until the
* customer acknowledges it on their dashboard, at which point it is deleted
* for good. This mail only says the cloud is ready and where to find them.
*
* Sent SYNCHRONOUSLY from CompleteProvisioning (not queued): the step only
* records the `credentials_sent` breadcrumb and scrubs the password AFTER the
* send returns, so a mail failure re-runs the step (password preserved) instead
* of losing the credential in a failed queue job. Delivery is at-least-once a
* crash in the tiny window after send but before the breadcrumb can re-send a
* duplicate welcome email, which is preferable to a lost credential.
* records the `credentials_sent` breadcrumb AFTER the send returns, so a mail
* failure re-runs the step instead of losing the breadcrumb in a failed queue
* job. Delivery is at-least-once a crash in the tiny window after send but
* before the breadcrumb can re-send a duplicate welcome email, which is
* preferable to the customer never being told their cloud is ready.
*/
class CloudReady extends Notification
{
@ -26,8 +32,6 @@ class CloudReady extends Notification
public function __construct(
public Instance $instance,
public string $adminUser,
public string $adminPasswordEncrypted, // ciphertext — safe to serialize into the queue
) {}
/** @return array<int, string> */
@ -56,8 +60,8 @@ class CloudReady extends Notification
->subject(__('provisioning.mail.ready_subject'))
->greeting(__('provisioning.mail.ready_greeting'))
->line(__('provisioning.mail.ready_line'))
->line(__('provisioning.mail.ready_user', ['user' => $this->adminUser]))
->line(__('provisioning.mail.ready_password', ['password' => Crypt::decryptString($this->adminPasswordEncrypted)]))
->action(__('provisioning.mail.ready_action'), $url);
->line(__('provisioning.mail.ready_address', ['url' => $url]))
->line(__('provisioning.mail.ready_credentials'))
->action(__('provisioning.mail.ready_action'), route('dashboard'));
}
}

View File

@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Notifications\CloudReady;
use App\Provisioning\StepResult;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Notification;
class CompleteProvisioning extends CustomerStep
@ -32,9 +33,14 @@ class CompleteProvisioning extends CustomerStep
$encrypted = $run->context('admin_password');
if ($encrypted !== null && ! $this->hasResource($run, 'credentials_sent')) {
// Pass the ENCRYPTED password (decrypted only when building the mail).
// Move the password onto the instance (encrypted cast — see
// Instance::casts()) so it survives until the customer
// acknowledges it on the dashboard. Decrypted here only to
// re-encrypt it under that cast; it never reaches the mail.
$instance->update(['admin_password' => Crypt::decryptString($encrypted)]);
Notification::route('mail', $order->customer->email)->notify(
new CloudReady($instance, (string) $instance->nc_admin_ref, $encrypted),
new CloudReady($instance),
);
// Record delivery so a retry after a crash doesn't re-send.
$this->recordResource($run, $instance->host, 'credentials_sent', (string) $instance->nc_admin_ref);

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The initial Nextcloud admin password no longer travels to the customer by
* mail. It moves here encrypted, same as nc_admin_ref and stays only
* until the customer confirms they have noted it, at which point it is
* deleted and credentials_acknowledged_at is stamped.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->text('admin_password')->nullable()->after('nc_admin_ref');
$table->timestamp('credentials_acknowledged_at')->nullable()->after('admin_password');
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn(['admin_password', 'credentials_acknowledged_at']);
});
}
};

View File

@ -28,6 +28,20 @@ return [
'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.',
'no_instance_cta' => 'Paket wählen',
// Das Erst-Passwort steht hier — statt in der Mail — bis der Kunde es
// bestätigt. Nach der Bestätigung ist es unwiderruflich gelöscht.
'credentials' => [
'title' => 'Ihre Zugangsdaten',
'body' => 'Notieren Sie sich Benutzername und Passwort an einem sicheren Ort. Nach der Bestätigung werden sie hier endgültig gelöscht.',
'address' => 'Adresse',
'user' => 'Benutzername',
'password' => 'Passwort',
'copy' => 'Kopieren',
'copied' => 'Kopiert',
'acknowledge' => 'Notiert',
'acknowledged' => 'Zugangsdaten bestätigt — das Passwort ist jetzt gelöscht.',
],
'master' => [
'label' => 'Stammblatt',
'package' => 'Paket',

View File

@ -22,9 +22,9 @@ return [
'mail' => [
'ready_subject' => 'Ihre CluPilot Cloud ist bereit',
'ready_greeting' => 'Willkommen bei CluPilot!',
'ready_line' => 'Ihre Cloud ist eingerichtet und einsatzbereit. Hier sind Ihre Zugangsdaten:',
'ready_user' => 'Benutzername: :user',
'ready_password' => 'Initial-Passwort: :password (bitte nach dem ersten Login ändern)',
'ready_action' => 'Zur Cloud',
'ready_line' => 'Ihre Cloud ist eingerichtet und einsatzbereit.',
'ready_address' => 'Sie erreichen sie unter :url.',
'ready_credentials' => 'Ihre Zugangsdaten liegen in Ihrem Kundenportal für Sie bereit.',
'ready_action' => 'Zum Kundenportal',
],
];

View File

@ -28,6 +28,20 @@ return [
'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.',
'no_instance_cta' => 'Choose a package',
// The initial password lives here — not in the mail — until the customer
// confirms it. Once confirmed it is deleted for good.
'credentials' => [
'title' => 'Your credentials',
'body' => 'Note down the username and password somewhere safe. Once you confirm, they are deleted here for good.',
'address' => 'Address',
'user' => 'Username',
'password' => 'Password',
'copy' => 'Copy',
'copied' => 'Copied',
'acknowledge' => 'Noted',
'acknowledged' => 'Credentials confirmed — the password has now been deleted.',
],
'master' => [
'label' => 'Master record',
'package' => 'Package',

View File

@ -22,9 +22,9 @@ return [
'mail' => [
'ready_subject' => 'Your CluPilot cloud is ready',
'ready_greeting' => 'Welcome to CluPilot!',
'ready_line' => 'Your cloud is set up and ready to use. Here are your credentials:',
'ready_user' => 'Username: :user',
'ready_password' => 'Initial password: :password (please change it after your first login)',
'ready_action' => 'Open your cloud',
'ready_line' => 'Your cloud is set up and ready to use.',
'ready_address' => 'You can reach it at :url.',
'ready_credentials' => 'Your credentials are waiting for you in your customer portal.',
'ready_action' => 'Open customer portal',
],
];

View File

@ -39,6 +39,69 @@
{{-- Live provisioning progress (only while a run is in flight) --}}
<livewire:customer-provisioning />
@if ($credentials)
{{-- The initial admin password, shown here instead of mailed. Gone the
moment the customer confirms they noted it acknowledgeCredentials()
deletes it outright, not just this card. --}}
<section
x-data="{
copiedField: null,
copy(field, text) {
navigator.clipboard.writeText(text).then(() => {
this.copiedField = field;
setTimeout(() => { if (this.copiedField === field) this.copiedField = null; }, 2000);
});
},
}"
class="rounded-lg border border-warning-border bg-warning-bg p-6 shadow-xs animate-rise"
>
<div class="flex gap-4">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning" aria-hidden="true">
<x-ui.icon name="lock" class="size-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-warning">{{ __('dashboard.credentials.title') }}</h2>
<p class="mt-1 max-w-lg text-sm text-warning">{{ __('dashboard.credentials.body') }}</p>
<dl class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="min-w-0">
<dt class="text-xs font-medium text-warning">{{ __('dashboard.credentials.address') }}</dt>
<dd class="mt-1 break-all rounded-lg border border-warning-border bg-surface px-3 py-2 font-mono text-xs text-ink">
{{ $credentials['url'] }}
</dd>
</div>
<div class="min-w-0">
<dt class="text-xs font-medium text-warning">{{ __('dashboard.credentials.user') }}</dt>
<dd class="mt-1 flex items-center gap-1.5">
<span class="select-all break-all rounded-lg border border-warning-border bg-surface px-3 py-2 font-mono text-sm text-ink">{{ $credentials['user'] }}</span>
<button type="button" class="shrink-0 text-warning hover:text-ink" x-on:click="copy('user', @js($credentials['user']))" aria-label="{{ __('dashboard.credentials.copy') }}">
<x-ui.icon name="copy" class="size-4" />
</button>
</dd>
</div>
<div class="min-w-0">
<dt class="text-xs font-medium text-warning">{{ __('dashboard.credentials.password') }}</dt>
<dd class="mt-1 flex items-center gap-1.5">
<span class="select-all break-all rounded-lg border border-warning-border bg-surface px-3 py-2 font-mono text-sm text-ink">{{ $credentials['password'] }}</span>
<button type="button" class="shrink-0 text-warning hover:text-ink" x-on:click="copy('password', @js($credentials['password']))" aria-label="{{ __('dashboard.credentials.copy') }}">
<x-ui.icon name="copy" class="size-4" />
</button>
</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-center gap-3">
<x-ui.button variant="primary" wire:click="acknowledgeCredentials('{{ $credentials['uuid'] }}')"
wire:loading.attr="disabled" wire:target="acknowledgeCredentials">
<x-ui.icon name="check" class="size-4" />{{ __('dashboard.credentials.acknowledge') }}
</x-ui.button>
<p x-show="copiedField" x-cloak class="text-xs text-warning">{{ __('dashboard.credentials.copied') }}</p>
</div>
</div>
</div>
</section>
@endif
@if ($instance === null)
{{-- Said plainly rather than papered over with an empty record: a page
of dashes reads as broken, and "we are still setting it up" and

View File

@ -1,6 +1,11 @@
<?php
use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
it('redirects guests to the login page', function () {
$this->get('/dashboard')->assertRedirect('/login');
@ -48,3 +53,69 @@ it('shows no next invoice once the customer has given notice', function () {
->assertViewHas('planPrice', fn (?array $p) => $p !== null && $p['cents'] === 17900)
->assertDontSee(__('dashboard.invoice.label'));
});
it('shows the credentials card, and lets acknowledging delete the password rather than just hide it', function () {
$user = User::factory()->create(['email' => 'ack@cred.test']);
$customer = Customer::factory()->create(['email' => 'ack@cred.test', 'user_id' => $user->id]);
$order = Order::factory()->create(['customer_id' => $customer->id]);
$instance = Instance::factory()->create([
'customer_id' => $customer->id,
'order_id' => $order->id,
'status' => 'active',
'nc_admin_ref' => 'admin',
'admin_password' => 'my-secret-pw',
]);
Livewire::actingAs($user)
->test(Dashboard::class)
->assertSee(__('dashboard.credentials.title'))
->assertSee('my-secret-pw')
->call('acknowledgeCredentials', $instance->uuid)
->assertDontSee('my-secret-pw')
->assertDontSee(__('dashboard.credentials.title'));
// Not merely hidden from the card — actually gone, at the raw stored
// column too, so a mutant that only toggled a "hide the card" flag
// without clearing admin_password is caught here.
expect($instance->fresh()->admin_password)->toBeNull()
->and($instance->fresh()->credentials_acknowledged_at)->not->toBeNull();
$raw = DB::table('instances')->where('id', $instance->id)->value('admin_password');
expect($raw)->toBeNull();
});
it("refuses to acknowledge — or leak — another customer's credentials", function () {
$userA = User::factory()->create(['email' => 'a@cred.test']);
$customerA = Customer::factory()->create(['email' => 'a@cred.test', 'user_id' => $userA->id]);
$orderA = Order::factory()->create(['customer_id' => $customerA->id]);
$instanceA = Instance::factory()->create([
'customer_id' => $customerA->id,
'order_id' => $orderA->id,
'status' => 'active',
'nc_admin_ref' => 'admin',
'admin_password' => 'a-secret-pw',
]);
$userB = User::factory()->create(['email' => 'b@cred.test']);
$customerB = Customer::factory()->create(['email' => 'b@cred.test', 'user_id' => $userB->id]);
$orderB = Order::factory()->create(['customer_id' => $customerB->id]);
Instance::factory()->create([
'customer_id' => $customerB->id,
'order_id' => $orderB->id,
'status' => 'active',
]);
// B's own dashboard render must not show A's password — render() scopes
// $instance to $customer's own instances, so this should hold already.
Livewire::actingAs($userB)
->test(Dashboard::class)
->assertDontSee('a-secret-pw')
// B posting A's instance uuid directly — exactly what an unchecked
// Livewire action allows, since the card that would normally supply
// this uuid is never rendered for B. acknowledgeCredentials() must
// re-resolve the instance from B's own customer, not trust the uuid.
->call('acknowledgeCredentials', $instanceA->uuid);
expect($instanceA->fresh()->admin_password)->not->toBeNull()
->and($instanceA->fresh()->credentials_acknowledged_at)->toBeNull();
});

View File

@ -11,7 +11,6 @@ use App\Services\Mail\MailPurpose;
use App\Services\Maintenance\MaintenanceNotifier;
use App\Support\Settings;
use Illuminate\Mail\SendQueuedMailable;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
@ -129,11 +128,7 @@ it('sends the provisioning notification from the provisioning mailbox', function
]);
Settings::set(MailPurpose::settingKey(MailPurpose::PROVISIONING), 'no-reply');
$notification = new CloudReady(
Instance::factory()->create(),
'admin',
Crypt::encryptString('geheim'),
);
$notification = new CloudReady(Instance::factory()->create());
$message = $notification->toMail(new stdClass);
@ -152,11 +147,7 @@ it('sets Reply-To on the provisioning notification when its mailbox allows repli
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com', 'no_reply' => false]);
Settings::set(MailPurpose::settingKey(MailPurpose::PROVISIONING), 'support');
$notification = new CloudReady(
Instance::factory()->create(),
'admin',
Crypt::encryptString('geheim'),
);
$notification = new CloudReady(Instance::factory()->create());
$message = $notification->toMail(new stdClass);
@ -169,11 +160,7 @@ it("leaves the provisioning notification's sender fields at MailMessage's defaul
// Settings mapping exists, so MailboxResolver::for() returns null. Must
// not throw dereferencing a null mailbox, and must leave from/replyTo
// exactly as `new MailMessage` starts them — [] and [].
$notification = new CloudReady(
Instance::factory()->create(),
'admin',
Crypt::encryptString('geheim'),
);
$notification = new CloudReady(Instance::factory()->create());
$message = $notification->toMail(new stdClass);

View File

@ -53,9 +53,13 @@ it('provisions a paid order all the way to active (mocked)', function () {
expect(RunResource::where('run_id', $run->id)->where('kind', $kind)->count())->toBe(1);
}
// Secrets scrubbed from state; credentials delivered.
// Secrets scrubbed from run state; the admin password moved onto the
// instance instead — held there, not mailed, until the customer
// acknowledges it on the dashboard.
expect($run->context('admin_password'))->toBeNull()
->and($run->context('db_password'))->toBeNull();
->and($run->context('db_password'))->toBeNull()
->and($instance->fresh()->admin_password)->not->toBeNull()
->and($instance->fresh()->credentials_acknowledged_at)->toBeNull();
Notification::assertSentOnDemand(CloudReady::class);
});

View File

@ -358,11 +358,39 @@ it('completes: activates instance+order, seeds onboarding, delivers credentials'
expect($instance->fresh()->status)->toBe('active')
->and($order->fresh()->status)->toBe('active')
->and($instance->fresh()->onboardingTasks()->count())->toBeGreaterThan(0)
->and($run->fresh()->context('admin_password'))->toBeNull();
->and($run->fresh()->context('admin_password'))->toBeNull()
// The password moved onto the instance rather than the mail — held
// until the customer acknowledges it on the dashboard.
->and($instance->fresh()->admin_password)->toBe('s3cret-pw')
->and($instance->fresh()->credentials_acknowledged_at)->toBeNull();
// Re-running must NOT deliver the credentials a second time.
// Re-running must NOT deliver the credentials a second time, and must not
// disturb the password already stored on the instance.
app(CompleteProvisioning::class)->execute($run->fresh());
Notification::assertSentOnDemandTimes(CloudReady::class, 1);
expect($instance->fresh()->admin_password)->toBe('s3cret-pw');
});
it('never puts the admin password in the CloudReady notification it sends', function () {
Notification::fake();
fakeServices();
['run' => $run] = reservedRun([
'admin_password' => Crypt::encryptString('s3cret-pw'),
]);
app(CompleteProvisioning::class)->execute($run);
Notification::assertSentOnDemand(CloudReady::class, function (CloudReady $notification) {
// Nothing about the password is even given to the notification — the
// constructor only takes the instance now — so there is nothing to
// render into the mail body or serialize into a queued job payload.
$mail = $notification->toMail(new stdClass);
$rendered = collect($mail->introLines)->implode(' ');
expect($rendered)->not->toContain('s3cret-pw');
return true;
});
});
// 13b. Monitoring resilience — observability must not block delivery.