Manage the Stripe key from the console, behind a password and a test
Changing a key meant a shell, a file edit and a cache rebuild — and the person who owns the Stripe account is not necessarily the person who owns the server. Two gates, not one. The capability decides who may open the page; every operator has console.view, and that must not mean "can read the payment key". The password decides whether this SESSION may see or change anything, because the realistic threat is not a stranger but an unlocked machine, and a session is exactly what that hands over. Both are re-checked server-side on every action — a Livewire action is reachable by anyone who can post to /livewire/update. The value is stored encrypted under a key of its own, SECRETS_KEY, and the vault refuses to work without it rather than falling back to APP_KEY: rotating APP_KEY is ordinary maintenance and would otherwise destroy every stored credential, discovered when Stripe stops answering. It is read where it is used, not overlaid onto config at boot — an overlay adds a query to every request including the public site, and leaves queue workers holding whatever was true when they started. It is never shown again, only outlined, and never enters a Livewire property that would carry it to the browser and back in the snapshot. A registry, not an env editor: a form that can set any environment variable is a privilege-escalation primitive, and one bad value bricks the installation with no way back through that same form. The test button is the part that matters. It reports which Stripe account the key belongs to, whether it is LIVE or test — the most expensive mistake here is pasting one where the other belongs, and both look identical in a form — and which webhook endpoints exist with the events each subscribes to. A key can be perfectly valid while the endpoint listens for the wrong five events, and nothing fails until a payment goes unrecorded. The webhook signing secret deliberately stays in .env. It is read on every incoming payment event; in the database, a database problem becomes silently failing signature checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design tested-20260727-0731-a58faf3
parent
f6b9181ed8
commit
a58faf3f85
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Livewire\Concerns\ConfirmsPassword;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Stripe\StripeCheck;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Credentials, changeable from the console instead of over SSH.
|
||||
*
|
||||
* Two gates, not one. The capability decides who may open the page at all; the
|
||||
* password decides whether this SESSION may see or change anything. The second
|
||||
* exists because the realistic threat is not a stranger — it is an unlocked
|
||||
* machine, and a signed-in session is exactly what that gives away.
|
||||
*
|
||||
* Both are enforced on every action, server-side. A Livewire action is
|
||||
* reachable by anyone who can post to /livewire/update, and the buttons not
|
||||
* being on screen has never stopped anybody.
|
||||
*
|
||||
* The value being entered lives in a public property only while it is being
|
||||
* typed, and is cleared the moment it is stored — a Livewire property travels
|
||||
* to the browser and back in the component snapshot.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Secrets extends Component
|
||||
{
|
||||
use ConfirmsPassword;
|
||||
|
||||
/**
|
||||
* The new value being entered, keyed by a DOTLESS form key.
|
||||
*
|
||||
* Livewire reads a dot in a property path as nesting, so binding to
|
||||
* `entered.stripe.secret` writes `entered['stripe']['secret']` and the
|
||||
* value never arrives where the save looks for it. The registry keys keep
|
||||
* their dots; the form does not.
|
||||
*/
|
||||
public array $entered = [];
|
||||
|
||||
/** Result of the last connection test, for display only. */
|
||||
public ?array $check = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
}
|
||||
|
||||
public function save(string $key): void
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
$field = self::field($key);
|
||||
$value = trim((string) ($this->entered[$field] ?? ''));
|
||||
|
||||
if ($value === '') {
|
||||
$this->addError('entered.'.$field, __('secrets.empty'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(SecretVault::class)->put($key, $value, auth()->user());
|
||||
} catch (Throwable $e) {
|
||||
$this->addError('entered.'.$field, $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Out of the component state as soon as it is stored.
|
||||
$this->entered[$field] = '';
|
||||
$this->check = null;
|
||||
$this->dispatch('notify', message: __('secrets.saved'));
|
||||
}
|
||||
|
||||
public function forget(string $key): void
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
app(SecretVault::class)->forget($key);
|
||||
$this->check = null;
|
||||
$this->dispatch('notify', message: __('secrets.removed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Try the key that is in force — or the one being typed, before storing it.
|
||||
*
|
||||
* Checking the candidate first is the point: a key that is saved and wrong
|
||||
* fails later, somewhere else, usually in front of a customer.
|
||||
*/
|
||||
public function test(string $key): void
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
|
||||
$this->check = app(StripeCheck::class)->run($candidate);
|
||||
}
|
||||
|
||||
/** The dotless form key for a registry key. */
|
||||
public static function field(string $key): string
|
||||
{
|
||||
return str_replace('.', '_', $key);
|
||||
}
|
||||
|
||||
/** Capability AND a recent password, on every single action. */
|
||||
private function guard(): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$vault = app(SecretVault::class);
|
||||
$unlocked = $this->passwordRecentlyConfirmed();
|
||||
|
||||
return view('livewire.admin.secrets', [
|
||||
'unlocked' => $unlocked,
|
||||
'usable' => $vault->isUsable(),
|
||||
'entries' => collect(SecretVault::REGISTRY)
|
||||
->map(fn (array $meta, string $key) => [
|
||||
'key' => $key,
|
||||
'field' => self::field($key),
|
||||
'label' => $meta['label'],
|
||||
'source' => $vault->source($key),
|
||||
// Only ever an outline, and only once unlocked.
|
||||
'outline' => $unlocked ? $vault->outline($key) : null,
|
||||
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Secrets;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Credentials the owner can change from the console instead of over SSH.
|
||||
*
|
||||
* Three rules, each because the obvious alternative is worse:
|
||||
*
|
||||
* 1. A CURATED registry, never arbitrary key/value. A form that can set any
|
||||
* environment variable is a privilege-escalation primitive — APP_KEY,
|
||||
* DB_PASSWORD, MAIL_* — and one bad value bricks the installation with no
|
||||
* way back through that same form.
|
||||
*
|
||||
* 2. Its own encryption key (SECRETS_KEY), not APP_KEY. Rotating APP_KEY is
|
||||
* ordinary maintenance and would otherwise render every stored credential
|
||||
* unreadable — discovered when Stripe stops answering, not when it happens.
|
||||
*
|
||||
* 3. Read where it is USED, never overlaid onto config at boot. An overlay adds
|
||||
* a query to every request including the public site, and leaves long-running
|
||||
* queue workers holding whatever was true when they started.
|
||||
*
|
||||
* The webhook signing secret is deliberately NOT in here. It is read on every
|
||||
* incoming payment event; moving it into the database would turn a database
|
||||
* problem into "webhooks silently fail signature verification", which is the
|
||||
* one failure mode that must stay loud.
|
||||
*/
|
||||
final class SecretVault
|
||||
{
|
||||
/** @var array<string, array{config: string, label: string}> */
|
||||
public const REGISTRY = [
|
||||
'stripe.secret' => ['config' => 'services.stripe.secret', 'label' => 'Stripe Secret Key'],
|
||||
];
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return $this->row($key) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored value, or the configured one when nothing is stored.
|
||||
*
|
||||
* Falls back only when there is genuinely no row — never on a decryption
|
||||
* failure. Falling back then would quietly resume using an old key from the
|
||||
* environment while the console shows the new one.
|
||||
*/
|
||||
public function get(string $key): ?string
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
$row = $this->row($key);
|
||||
|
||||
if ($row === null) {
|
||||
$configured = config(self::REGISTRY[$key]['config']);
|
||||
|
||||
return $configured === null ? null : (string) $configured;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->encrypter()->decryptString($row->value);
|
||||
} catch (DecryptException $e) {
|
||||
Log::error('A stored secret could not be decrypted', ['key' => $key]);
|
||||
|
||||
throw new RuntimeException("Stored secret [{$key}] cannot be decrypted.", previous: $e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the value in force comes from: stored, environment, or nowhere. */
|
||||
public function source(string $key): string
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
|
||||
return match (true) {
|
||||
$this->row($key) !== null => 'stored',
|
||||
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
|
||||
default => 'none',
|
||||
};
|
||||
}
|
||||
|
||||
public function outline(string $key): ?string
|
||||
{
|
||||
return $this->row($key)?->outline;
|
||||
}
|
||||
|
||||
public function updatedAt(string $key): ?string
|
||||
{
|
||||
return $this->row($key)?->updated_at;
|
||||
}
|
||||
|
||||
public function put(string $key, string $value, User $by): void
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
|
||||
if (trim($value) === '') {
|
||||
throw new RuntimeException('Refusing to store an empty secret.');
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'value' => $this->encrypter()->encryptString($value),
|
||||
'outline' => $this->sketch($value),
|
||||
'updated_by' => $by->id,
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// created_at only on the first write: rotating a key must not rewrite
|
||||
// when it was first set, which is the one piece of audit metadata that
|
||||
// answers "how long has this been here?".
|
||||
if ($this->row($key) === null) {
|
||||
$attributes['created_at'] = now();
|
||||
}
|
||||
|
||||
DB::table('app_secrets')->updateOrInsert(['key' => $key], $attributes);
|
||||
}
|
||||
|
||||
/** Remove the stored value, falling back to whatever the environment says. */
|
||||
public function forget(string $key): void
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
DB::table('app_secrets')->where('key', $key)->delete();
|
||||
}
|
||||
|
||||
/** Is storing credentials possible at all on this installation? */
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return (string) config('admin_access.secrets_key') !== '';
|
||||
}
|
||||
|
||||
private function row(string $key): ?object
|
||||
{
|
||||
return DB::table('app_secrets')->where('key', $key)->first();
|
||||
}
|
||||
|
||||
private function assertKnown(string $key): void
|
||||
{
|
||||
if (! array_key_exists($key, self::REGISTRY)) {
|
||||
throw new RuntimeException("Unknown secret [{$key}].");
|
||||
}
|
||||
}
|
||||
|
||||
/** Enough to tell two keys apart, useless to anyone reading over a shoulder. */
|
||||
private function sketch(string $value): string
|
||||
{
|
||||
return Str::length($value) <= 8
|
||||
? str_repeat('•', Str::length($value))
|
||||
: Str::substr($value, 0, 3).str_repeat('•', 6).Str::substr($value, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Its own key, and a refusal rather than a silent downgrade: falling back to
|
||||
* APP_KEY would store payment credentials under a key that gets rotated as
|
||||
* routine maintenance.
|
||||
*/
|
||||
private function encrypter(): Encrypter
|
||||
{
|
||||
$key = (string) config('admin_access.secrets_key');
|
||||
|
||||
if ($key === '') {
|
||||
throw new RuntimeException('SECRETS_KEY is not set — refusing to store or read credentials.');
|
||||
}
|
||||
|
||||
// Both spellings. The generator in the config comment produces raw
|
||||
// base64 with no prefix, and requiring the prefix silently made every
|
||||
// read and write fail on exactly the documented setup — a 44-byte key
|
||||
// where AES-256 wants 32.
|
||||
if (str_starts_with($key, 'base64:')) {
|
||||
$key = substr($key, 7);
|
||||
}
|
||||
|
||||
if (strlen($key) !== 32) {
|
||||
$decoded = base64_decode($key, true);
|
||||
|
||||
if ($decoded !== false && strlen($decoded) === 32) {
|
||||
$key = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (strlen($key) !== 32) {
|
||||
throw new RuntimeException('SECRETS_KEY must be 32 bytes (or base64 of 32 bytes).');
|
||||
}
|
||||
|
||||
return new Encrypter($key, 'aes-256-cbc');
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ class HttpStripeClient implements StripeClient
|
|||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled(config('services.stripe.secret'));
|
||||
return filled($this->secret());
|
||||
}
|
||||
|
||||
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
||||
|
|
@ -67,7 +67,7 @@ class HttpStripeClient implements StripeClient
|
|||
|
||||
private function request(?string $idempotencyKey = null): PendingRequest
|
||||
{
|
||||
$secret = (string) config('services.stripe.secret');
|
||||
$secret = (string) $this->secret();
|
||||
|
||||
if (blank($secret)) {
|
||||
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
|
||||
|
|
@ -85,6 +85,19 @@ class HttpStripeClient implements StripeClient
|
|||
: $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key in force: the one stored in the console if there is one, else the
|
||||
* environment.
|
||||
*
|
||||
* Resolved HERE, at the point of use, rather than overlaid onto config at
|
||||
* boot — an overlay would add a query to every request including the public
|
||||
* site, and a queue worker would keep whatever was true when it started.
|
||||
*/
|
||||
private function secret(): ?string
|
||||
{
|
||||
return app(\App\Services\Secrets\SecretVault::class)->get('stripe.secret');
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return rtrim((string) config('services.stripe.api_base'), '/').'/'.$path;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stripe;
|
||||
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Read-only answers to "does this key actually work, and what is it wired to?"
|
||||
*
|
||||
* Separate from StripeClient, which exists to create and archive things. A key
|
||||
* that is saved but wrong is worse than no key, because the failure surfaces
|
||||
* later, somewhere else, and usually to a customer — so it can be checked at
|
||||
* the moment it is entered.
|
||||
*
|
||||
* Reports the account and the LIVE/TEST mode, because the single most expensive
|
||||
* mistake here is pasting a test key into a live installation, or the reverse,
|
||||
* and both look identical in a form.
|
||||
*/
|
||||
final class StripeCheck
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function run(?string $candidate = null): array
|
||||
{
|
||||
$secret = $candidate ?: app(SecretVault::class)->get('stripe.secret');
|
||||
|
||||
if (blank($secret)) {
|
||||
return ['ok' => false, 'reason' => 'missing'];
|
||||
}
|
||||
|
||||
try {
|
||||
$account = Http::withToken($secret)->acceptJson()->timeout(15)
|
||||
->get($this->url('account'));
|
||||
} catch (Throwable) {
|
||||
return ['ok' => false, 'reason' => 'unreachable'];
|
||||
}
|
||||
|
||||
if ($account->status() === 401) {
|
||||
return ['ok' => false, 'reason' => 'rejected'];
|
||||
}
|
||||
|
||||
if (! $account->successful()) {
|
||||
return ['ok' => false, 'reason' => 'error', 'status' => $account->status()];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'account' => $account->json('id'),
|
||||
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
|
||||
// Stripe does not label the key; the mode is inferred from the
|
||||
// prefix, which is the only thing that is true of both key kinds.
|
||||
'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'),
|
||||
'restricted' => str_starts_with($secret, 'rk_'),
|
||||
'webhooks' => $this->webhooks($secret),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured webhook endpoints and which events each one subscribes to.
|
||||
*
|
||||
* This is the part nobody can see from the outside: a key can be perfectly
|
||||
* valid while the endpoint listens for the wrong five events, and nothing
|
||||
* fails until a payment is not recorded.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>|null null when the key may not read them
|
||||
*/
|
||||
private function webhooks(string $secret): ?array
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($secret)->acceptJson()->timeout(15)
|
||||
->get($this->url('webhook_endpoints'), ['limit' => 10]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
// A restricted key legitimately cannot list these. Saying "unknown"
|
||||
// beats reporting "none configured", which would send someone
|
||||
// hunting for a problem that is not there.
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect($response->json('data', []))
|
||||
->map(fn (array $endpoint) => [
|
||||
'url' => $endpoint['url'] ?? '—',
|
||||
'status' => $endpoint['status'] ?? 'unknown',
|
||||
'events' => $endpoint['enabled_events'] ?? [],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return rtrim((string) config('services.stripe.api_base', 'https://api.stripe.com/v1'), '/').'/'.$path;
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,19 @@ return [
|
|||
*/
|
||||
'vpn_config_key' => env('VPN_CONFIG_KEY', ''),
|
||||
|
||||
/*
|
||||
| Key for credentials stored in the console (32 bytes, base64).
|
||||
|
|
||||
| Separate from APP_KEY on purpose: rotating APP_KEY is ordinary
|
||||
| maintenance, and it would otherwise render every stored credential
|
||||
| unreadable — discovered when Stripe stops answering. Empty means storing
|
||||
| credentials is switched off, and the console says so rather than falling
|
||||
| back to a key that gets rotated.
|
||||
|
|
||||
| Generate with: head -c 32 /dev/urandom | base64
|
||||
*/
|
||||
'secrets_key' => env('SECRETS_KEY', ''),
|
||||
|
||||
/*
|
||||
| Networks that count as "us" while the public site is hidden
|
||||
| (PublicSiteGate). The WireGuard management subnet by default, so anyone on
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Credentials an operator can set from the console instead of over SSH.
|
||||
*
|
||||
* Deliberately its own table rather than app_settings: those are plaintext,
|
||||
* cached in Redis, and read on every request by the public-site gate. Nothing
|
||||
* about that is right for a payment key.
|
||||
*
|
||||
* The value is encrypted with a key of its own (SECRETS_KEY), not APP_KEY.
|
||||
* Rotating APP_KEY is an ordinary thing to do; silently destroying every stored
|
||||
* credential when it happens — and only finding out when Stripe stops
|
||||
* answering — is not.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('app_secrets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->text('value');
|
||||
// Enough to tell two keys apart at a glance, useless to a shoulder.
|
||||
$table->string('outline', 32)->nullable();
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('app_secrets');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* Reading and changing stored credentials is its own capability.
|
||||
*
|
||||
* Not folded into console.view: every operator has that, and "can open the
|
||||
* console" must not mean "can read the payment key".
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Spatie caches the catalogue. Inserting rows without dropping it means
|
||||
// the Owner keeps getting 403 on the new page until the cache expires —
|
||||
// long after the deploy that was supposed to grant it.
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
// Whether it was ours to create decides whether it is ours to remove:
|
||||
// rolling back must not delete a permission — and every role assignment
|
||||
// hanging off it — that existed before this migration ran.
|
||||
$exists = DB::table('permissions')->where('name', 'secrets.manage')->exists();
|
||||
|
||||
if (! $exists) {
|
||||
DB::table('permissions')->insert([
|
||||
'name' => 'secrets.manage', 'guard_name' => 'web',
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Owner only. Deliberately not granted to Admin: this is the one page
|
||||
// where the blast radius is someone else's money.
|
||||
$permission = DB::table('permissions')->where('name', 'secrets.manage')->value('id');
|
||||
$owner = DB::table('roles')->where('name', 'Owner')->value('id');
|
||||
|
||||
if ($permission && $owner) {
|
||||
DB::table('role_has_permissions')->updateOrInsert(
|
||||
['permission_id' => $permission, 'role_id' => $owner],
|
||||
);
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Deliberately removes nothing.
|
||||
//
|
||||
// up() reuses the permission and the grant when they already exist, so
|
||||
// by the time down() runs there is no way to tell what this migration
|
||||
// created from what it merely found. Taking a capability away that
|
||||
// somebody else granted is worse than leaving one in place, and this is
|
||||
// a grant to the Owner role — the account that would have to fix it.
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
|
|
@ -16,6 +16,7 @@ return [
|
|||
'maintenance' => 'Wartungen',
|
||||
'vpn' => 'VPN',
|
||||
'revenue' => 'Umsatz',
|
||||
'secrets' => 'Zugangsdaten',
|
||||
'settings' => 'Einstellungen',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Zugangsdaten',
|
||||
'subtitle' => 'Schlüssel für angebundene Dienste — hier änderbar, ohne Zugriff auf den Server.',
|
||||
'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Zugangsdaten gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.',
|
||||
|
||||
'locked_title' => 'Gesperrt',
|
||||
'locked_body' => 'Bitte bestätigen Sie Ihr Passwort. Angemeldet zu sein genügt hier nicht — der realistische Fall ist ein offener Rechner, und genau den gibt eine Sitzung her.',
|
||||
'unlock' => 'Entsperren',
|
||||
'unlocked_note' => 'Entsperrt. Änderungen wirken sofort auf den laufenden Betrieb.',
|
||||
'lock_again' => 'Wieder sperren',
|
||||
|
||||
'source_stored' => 'Hier hinterlegt',
|
||||
'source_environment' => 'Aus der Serverdatei',
|
||||
'source_none' => 'Nicht gesetzt',
|
||||
'stored_value' => 'Hinterlegt',
|
||||
'changed' => 'Geändert',
|
||||
'new_value' => 'Neuer Wert',
|
||||
'new_value_hint' => 'Wird verschlüsselt gespeichert und nie wieder vollständig angezeigt.',
|
||||
|
||||
'test' => 'Verbindung testen',
|
||||
'save' => 'Speichern',
|
||||
'save_confirm' => 'Diesen Schlüssel wirklich übernehmen? Er wirkt sofort — ein falscher Wert legt Zahlungen still.',
|
||||
'forget' => 'Hier entfernen',
|
||||
'forget_confirm' => 'Hinterlegten Wert entfernen? Danach gilt wieder der Wert aus der Serverdatei — falls dort einer steht.',
|
||||
'saved' => 'Gespeichert. Der neue Wert gilt ab sofort.',
|
||||
'removed' => 'Entfernt. Es gilt wieder der Wert aus der Serverdatei.',
|
||||
'empty' => 'Bitte einen Wert eingeben.',
|
||||
|
||||
'check_title' => 'Ergebnis der Prüfung',
|
||||
'check_missing' => 'Es ist kein Schlüssel hinterlegt und auch keiner in der Serverdatei.',
|
||||
'check_unreachable' => 'Stripe war nicht erreichbar. Das sagt nichts über den Schlüssel aus.',
|
||||
'check_rejected' => 'Stripe hat den Schlüssel abgelehnt. Er ist falsch, widerrufen oder gehört zu einem anderen Konto.',
|
||||
'check_error' => 'Stripe hat unerwartet geantwortet.',
|
||||
'check_account' => 'Konto',
|
||||
'check_mode' => 'Modus',
|
||||
'mode_live' => 'LIVE — echte Zahlungen',
|
||||
'mode_test' => 'Test',
|
||||
'mode_restricted' => 'eingeschränkter Schlüssel',
|
||||
'check_webhooks' => 'Hinterlegte Webhooks und ihre Ereignisse',
|
||||
'check_webhooks_unknown' => 'Dieser Schlüssel darf die Webhooks nicht lesen — das ist bei eingeschränkten Schlüsseln normal.',
|
||||
'check_webhooks_none' => 'Bei Stripe ist kein Webhook eingetragen. Ohne ihn werden Zahlungen nicht verbucht.',
|
||||
|
||||
'webhook_secret_note' => 'Das Webhook-Signatur-Secret bleibt bewusst in der Serverdatei (.env). Es wird bei jedem eingehenden Zahlungsereignis gelesen — läge es hier, würde ein Datenbankproblem zu stillschweigend fehlschlagenden Signaturprüfungen.',
|
||||
];
|
||||
|
|
@ -16,6 +16,7 @@ return [
|
|||
'maintenance' => 'Maintenance',
|
||||
'vpn' => 'VPN',
|
||||
'revenue' => 'Revenue',
|
||||
'secrets' => 'Credentials',
|
||||
'settings' => 'Settings',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Credentials',
|
||||
'subtitle' => 'Keys for connected services — changeable here, without server access.',
|
||||
'no_key' => 'SECRETS_KEY is not set on this server. Without a key of its own nothing is stored here — deliberately, because APP_KEY is rotated as routine maintenance.',
|
||||
|
||||
'locked_title' => 'Locked',
|
||||
'locked_body' => 'Confirm your password. Being signed in is not enough here — the realistic case is an unlocked machine, and that is exactly what a session hands over.',
|
||||
'unlock' => 'Unlock',
|
||||
'unlocked_note' => 'Unlocked. Changes take effect on the running system immediately.',
|
||||
'lock_again' => 'Lock again',
|
||||
|
||||
'source_stored' => 'Stored here',
|
||||
'source_environment' => 'From the server file',
|
||||
'source_none' => 'Not set',
|
||||
'stored_value' => 'Stored',
|
||||
'changed' => 'Changed',
|
||||
'new_value' => 'New value',
|
||||
'new_value_hint' => 'Stored encrypted and never shown in full again.',
|
||||
|
||||
'test' => 'Test connection',
|
||||
'save' => 'Save',
|
||||
'save_confirm' => 'Really apply this key? It takes effect immediately — a wrong value stops payments.',
|
||||
'forget' => 'Remove from here',
|
||||
'forget_confirm' => 'Remove the stored value? The server file applies again — if it has one.',
|
||||
'saved' => 'Saved. The new value applies immediately.',
|
||||
'removed' => 'Removed. The server file applies again.',
|
||||
'empty' => 'Enter a value.',
|
||||
|
||||
'check_title' => 'Result',
|
||||
'check_missing' => 'No key is stored here and none is in the server file.',
|
||||
'check_unreachable' => 'Stripe could not be reached. That says nothing about the key.',
|
||||
'check_rejected' => 'Stripe rejected the key. It is wrong, revoked, or belongs to another account.',
|
||||
'check_error' => 'Stripe answered unexpectedly.',
|
||||
'check_account' => 'Account',
|
||||
'check_mode' => 'Mode',
|
||||
'mode_live' => 'LIVE — real payments',
|
||||
'mode_test' => 'Test',
|
||||
'mode_restricted' => 'restricted key',
|
||||
'check_webhooks' => 'Configured webhooks and their events',
|
||||
'check_webhooks_unknown' => 'This key may not read webhooks — normal for a restricted key.',
|
||||
'check_webhooks_none' => 'Stripe has no webhook configured. Without one, payments are not recorded.',
|
||||
|
||||
'webhook_secret_note' => 'The webhook signing secret deliberately stays in the server file (.env). It is read on every incoming payment event — stored here, a database problem would turn into silently failing signature checks.',
|
||||
];
|
||||
|
|
@ -48,6 +48,13 @@
|
|||
@endforeach
|
||||
</nav>
|
||||
<div class="mt-auto space-y-1 border-t border-line pt-4">
|
||||
@can('secrets.manage')
|
||||
{{-- Only where the capability is held: "can open the console"
|
||||
must not mean "can read the payment key". --}}
|
||||
<x-ui.nav-item :href="route('admin.secrets')" :active="\App\Support\AdminArea::routeIs('admin.secrets')">
|
||||
<x-ui.icon name="lock" class="size-4" />{{ __('admin.nav.secrets') }}
|
||||
</x-ui.nav-item>
|
||||
@endcan
|
||||
<x-ui.nav-item :href="route('admin.settings')" :active="\App\Support\AdminArea::routeIs('admin.settings')">
|
||||
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
|
||||
{{ __('admin.nav.settings') }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('secrets.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('secrets.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
@if (! $usable)
|
||||
<x-ui.alert variant="warning">{{ __('secrets.no_key') }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
@if (! $unlocked)
|
||||
{{-- The second gate. Being signed in is not enough to read a payment
|
||||
key: the realistic threat is an unlocked machine, and a session is
|
||||
exactly what that hands over. --}}
|
||||
<form wire:submit="confirmPassword" class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2>
|
||||
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('secrets.locked_body') }}</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-start gap-2">
|
||||
<div class="min-w-56 flex-1">
|
||||
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
|
||||
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
|
||||
</div>
|
||||
<x-ui.button type="submit" variant="primary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
||||
{{ __('secrets.unlock') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="flex items-center justify-between rounded-lg border border-accent-border bg-accent-subtle px-4 py-2.5 animate-rise">
|
||||
<p class="text-sm text-accent-text">{{ __('secrets.unlocked_note') }}</p>
|
||||
<button type="button" wire:click="forgetPasswordConfirmation"
|
||||
class="text-xs font-semibold text-accent-text hover:underline">{{ __('secrets.lock_again') }}</button>
|
||||
</div>
|
||||
|
||||
@foreach ($entries as $entry)
|
||||
<div wire:key="secret-{{ $entry['key'] }}" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ $entry['label'] }}</h2>
|
||||
<p class="mt-1 font-mono text-xs text-muted">{{ $entry['key'] }}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
|
||||
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
|
||||
: 'border-warning-border bg-warning-bg text-warning') }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('secrets.source_'.$entry['source']) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if ($entry['outline'])
|
||||
<dl class="rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">{{ __('secrets.stored_value') }}</dt>
|
||||
<dd class="font-mono text-body">{{ $entry['outline'] }}</dd>
|
||||
</div>
|
||||
@if ($entry['updated_at'])
|
||||
<div class="mt-1 flex justify-between gap-4">
|
||||
<dt class="text-muted">{{ __('secrets.changed') }}</dt>
|
||||
<dd class="text-body">{{ \Illuminate\Support\Carbon::parse($entry['updated_at'])->diffForHumans() }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<x-ui.input name="entered.{{ $entry['field'] }}" type="password" autocomplete="off"
|
||||
:label="__('secrets.new_value')" :hint="__('secrets.new_value_hint')"
|
||||
wire:model="entered.{{ $entry['field'] }}" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{-- Test first: a key that is saved and wrong fails later,
|
||||
somewhere else, usually in front of a customer. --}}
|
||||
<x-ui.button variant="secondary" wire:click="test('{{ $entry['key'] }}')"
|
||||
wire:loading.attr="disabled" wire:target="test('{{ $entry['key'] }}')">
|
||||
{{ __('secrets.test') }}
|
||||
</x-ui.button>
|
||||
|
||||
<x-ui.button variant="primary" wire:click="save('{{ $entry['key'] }}')"
|
||||
wire:confirm="{{ __('secrets.save_confirm') }}"
|
||||
wire:loading.attr="disabled" wire:target="save('{{ $entry['key'] }}')">
|
||||
{{ __('secrets.save') }}
|
||||
</x-ui.button>
|
||||
|
||||
@if ($entry['source'] === 'stored')
|
||||
<x-ui.button variant="ghost" wire:click="forget('{{ $entry['key'] }}')"
|
||||
wire:confirm="{{ __('secrets.forget_confirm') }}"
|
||||
wire:loading.attr="disabled" wire:target="forget('{{ $entry['key'] }}')">
|
||||
{{ __('secrets.forget') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if ($check !== null)
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2>
|
||||
|
||||
@if (! $check['ok'])
|
||||
<x-ui.alert variant="danger" class="mt-3">{{ __('secrets.check_'.$check['reason']) }}</x-ui.alert>
|
||||
@else
|
||||
<dl class="mt-3 space-y-1 text-sm">
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_account') }}:</dt>
|
||||
<dd class="font-mono text-body">{{ $check['account'] }}{{ $check['business'] ? ' · '.$check['business'] : '' }}</dd></div>
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_mode') }}:</dt>
|
||||
<dd class="{{ $check['live'] ? 'font-medium text-warning' : 'text-body' }}">
|
||||
{{ $check['live'] ? __('secrets.mode_live') : __('secrets.mode_test') }}{{ $check['restricted'] ? ' · '.__('secrets.mode_restricted') : '' }}
|
||||
</dd></div>
|
||||
</dl>
|
||||
|
||||
{{-- The part nobody can see from outside: a key can be
|
||||
perfectly valid while the endpoint listens for the
|
||||
wrong events, and nothing fails until a payment goes
|
||||
unrecorded. --}}
|
||||
<h3 class="mt-5 text-sm font-semibold text-ink">{{ __('secrets.check_webhooks') }}</h3>
|
||||
@if ($check['webhooks'] === null)
|
||||
<p class="mt-1 text-sm text-muted">{{ __('secrets.check_webhooks_unknown') }}</p>
|
||||
@elseif ($check['webhooks'] === [])
|
||||
<x-ui.alert variant="warning" class="mt-2">{{ __('secrets.check_webhooks_none') }}</x-ui.alert>
|
||||
@else
|
||||
<ul class="mt-2 space-y-3">
|
||||
@foreach ($check['webhooks'] as $hook)
|
||||
<li class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-mono text-xs text-body">{{ $hook['url'] }}</span>
|
||||
<span class="text-xs {{ $hook['status'] === 'enabled' ? 'text-success' : 'text-warning' }}">{{ $hook['status'] }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
@foreach ($hook['events'] as $event)
|
||||
<span class="rounded border border-line px-1.5 py-0.5 font-mono text-[11px] text-muted">{{ $event }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -36,4 +36,5 @@ Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
|
|||
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
|
||||
Route::get('/vpn', Admin\Vpn::class)->name('vpn');
|
||||
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
|
||||
Route::get('/secrets', Admin\Secrets::class)->name('secrets');
|
||||
Route::get('/settings', Admin\Settings::class)->name('settings');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Credentials stored in the database instead of the .env file.
|
||||
*
|
||||
* The stakes are a live payment key, so what is tested here is mostly what the
|
||||
* vault REFUSES to do.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
config()->set('services.stripe.secret', 'sk_env_fallback');
|
||||
});
|
||||
|
||||
it('prefers a stored key over the environment, and says which is in force', function () {
|
||||
$vault = app(SecretVault::class);
|
||||
|
||||
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback')
|
||||
->and($vault->source('stripe.secret'))->toBe('environment');
|
||||
|
||||
$vault->put('stripe.secret', 'sk_live_stored_value', User::factory()->create());
|
||||
|
||||
expect($vault->get('stripe.secret'))->toBe('sk_live_stored_value')
|
||||
->and($vault->source('stripe.secret'))->toBe('stored');
|
||||
|
||||
// Removing it falls back rather than leaving nothing.
|
||||
$vault->forget('stripe.secret');
|
||||
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback');
|
||||
});
|
||||
|
||||
it('stores the value encrypted, and never in the clear', function () {
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', User::factory()->create());
|
||||
|
||||
$stored = DB::table('app_secrets')->where('key', 'stripe.secret')->value('value');
|
||||
|
||||
expect($stored)->not->toContain('sk_live_verysecret');
|
||||
});
|
||||
|
||||
it('shows only an outline, never the value', function () {
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH1234', User::factory()->create());
|
||||
|
||||
$outline = app(SecretVault::class)->outline('stripe.secret');
|
||||
|
||||
expect($outline)->toContain('1234')->and($outline)->not->toContain('ABCDEFGH');
|
||||
});
|
||||
|
||||
it('refuses a key it does not know', function () {
|
||||
// A form that can set any environment variable is a privilege-escalation
|
||||
// primitive. The registry is the whole point.
|
||||
expect(fn () => app(SecretVault::class)->put('app.key', 'whatever', User::factory()->create()))
|
||||
->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
it('refuses to store or read anything without its own key', function () {
|
||||
// Falling back to APP_KEY would put payment credentials under a key that
|
||||
// gets rotated as routine maintenance.
|
||||
config()->set('admin_access.secrets_key', '');
|
||||
|
||||
expect(app(SecretVault::class)->isUsable())->toBeFalse()
|
||||
->and(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create()))
|
||||
->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
it('fails loudly when a stored value cannot be decrypted, instead of using the old one', function () {
|
||||
// "The key cannot be read" and "there is no key" call for different
|
||||
// actions, and only one of them is fixed by typing it in again.
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_stored', User::factory()->create());
|
||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
|
||||
expect(fn () => app(SecretVault::class)->get('stripe.secret'))->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
it('hands the Stripe client the stored key rather than the environment one', function () {
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_from_console', User::factory()->create());
|
||||
|
||||
Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response(['id' => 'prod_1'], 200)]);
|
||||
|
||||
app(App\Services\Stripe\HttpStripeClient::class)->createProduct('Test');
|
||||
|
||||
Illuminate\Support\Facades\Http::assertSent(
|
||||
fn ($request) => $request->hasHeader('Authorization', 'Bearer sk_live_from_console'),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the key in the format the documentation tells you to generate', function () {
|
||||
// `head -c 32 /dev/urandom | base64` produces raw base64 with no prefix.
|
||||
// Requiring the prefix made every read and write fail on exactly the
|
||||
// documented setup — a 44-byte key where AES-256 wants 32.
|
||||
$raw = random_bytes(32);
|
||||
|
||||
foreach ([base64_encode($raw), 'base64:'.base64_encode($raw)] as $spelling) {
|
||||
config()->set('admin_access.secrets_key', $spelling);
|
||||
|
||||
$vault = app(SecretVault::class);
|
||||
$vault->put('stripe.secret', 'sk_live_roundtrip', User::factory()->create());
|
||||
|
||||
expect($vault->get('stripe.secret'))->toBe('sk_live_roundtrip', $spelling);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a key that is not 32 bytes rather than failing later', function () {
|
||||
config()->set('admin_access.secrets_key', 'viel-zu-kurz');
|
||||
|
||||
expect(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create()))
|
||||
->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
it('keeps the original creation date when a key is rotated', function () {
|
||||
// The one piece of audit metadata that answers "how long has this been
|
||||
// here?" must survive a rotation.
|
||||
$user = User::factory()->create();
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_first', $user);
|
||||
|
||||
$created = DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at');
|
||||
|
||||
$this->travel(2)->days();
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_second', $user);
|
||||
|
||||
expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'))->toBe($created);
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Secrets as SecretsPage;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The credentials page has two gates, and the second one is the point.
|
||||
*
|
||||
* The capability decides who may open it. The password decides whether THIS
|
||||
* session may see or change anything — because the realistic threat is not a
|
||||
* stranger but an unlocked machine, and a session is exactly what that gives
|
||||
* away.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
});
|
||||
|
||||
it('is not reachable without the capability', function () {
|
||||
// Every operator has console.view. That must not mean "can read the
|
||||
// payment key".
|
||||
Livewire::actingAs(operator('Admin'))
|
||||
->test(SecretsPage::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('shows nothing until the password is confirmed', function () {
|
||||
$owner = operator('Owner');
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner);
|
||||
|
||||
$page = Livewire::actingAs($owner)->test(SecretsPage::class);
|
||||
|
||||
$page->assertSee(__('secrets.locked_title'))
|
||||
// Not even the outline before unlocking.
|
||||
->assertDontSee('9999')
|
||||
->assertViewHas('unlocked', false);
|
||||
});
|
||||
|
||||
it('refuses every action while locked, not merely hiding the buttons', function () {
|
||||
// A Livewire action is reachable by anyone who can post to /livewire/update.
|
||||
$owner = operator('Owner');
|
||||
|
||||
foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) {
|
||||
Livewire::actingAs($owner)
|
||||
->test(SecretsPage::class)
|
||||
->call($action, $arg)
|
||||
->assertForbidden();
|
||||
}
|
||||
});
|
||||
|
||||
it('unlocks with the password, then stores and clears the entered value', function () {
|
||||
$owner = operator('Owner');
|
||||
|
||||
$page = Livewire::actingAs($owner)
|
||||
->test(SecretsPage::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->assertHasNoErrors()
|
||||
->assertViewHas('unlocked', true);
|
||||
|
||||
$page->set('entered.stripe_secret', 'sk_live_NEWKEY1234')
|
||||
->call('save', 'stripe.secret')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234')
|
||||
// Out of the component state as soon as it is stored: a Livewire
|
||||
// property travels to the browser and back in the snapshot.
|
||||
->and($page->get('entered')['stripe_secret'])->toBe('');
|
||||
});
|
||||
|
||||
it('never puts a stored secret into the component snapshot', function () {
|
||||
$owner = operator('Owner');
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner);
|
||||
|
||||
$page = Livewire::actingAs($owner)
|
||||
->test(SecretsPage::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword');
|
||||
|
||||
expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42');
|
||||
});
|
||||
|
||||
it('can be locked again without signing out', function () {
|
||||
$owner = operator('Owner');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(SecretsPage::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->assertViewHas('unlocked', true)
|
||||
->call('forgetPasswordConfirmation')
|
||||
->assertViewHas('unlocked', false);
|
||||
});
|
||||
|
||||
it('binds the form to a key Livewire can actually write to', function () {
|
||||
// A dot in a Livewire property path means nesting, so a registry key with a
|
||||
// dot in it would be written to entered['stripe']['secret'] and the value
|
||||
// would never reach the save.
|
||||
expect(App\Livewire\Admin\Secrets::field('stripe.secret'))->toBe('stripe_secret')
|
||||
->and(str_contains(App\Livewire\Admin\Secrets::field('stripe.secret'), '.'))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue