Give every credential a test slot beside its live one

Each entry in the vault now stores under key:mode instead of a bare
key, and get() resolves the active mode internally — falling back
from an empty test slot to live (never the reverse) so test operation
works before anyone has filled in test credentials, but live operation
never silently reaches for a test one.

get()'s signature is unchanged on purpose: its three callers
(HttpStripeClient, StripeCheck, SshTraefikWriter) have no business
knowing which mode is active. put()/forget()/source()/outline()/
updatedAt() take an optional trailing $mode for the console, which
will use it in a later task.

SecretVaultTest's raw app_secrets queries assumed the old bare key and
needed the :live suffix plus a pinned OperatingMode — otherwise they'd
depend on whatever mode an earlier test file left in the array cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 10:53:17 +02:00
parent 3cf17e5cb0
commit bc6eee4dc3
3 changed files with 133 additions and 17 deletions

View File

@ -4,6 +4,7 @@ namespace App\Services\Secrets;
use App\Models\Operator;
use App\Services\Stripe\StripeCheck;
use App\Support\OperatingMode;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -112,6 +113,10 @@ final class SecretVault
/**
* The stored value, or the configured one when nothing is stored.
*
* Signature unchanged from before the vault grew a test/live split the
* three callers (HttpStripeClient, StripeCheck, SshTraefikWriter) have no
* business knowing which mode is active; that is resolved in here.
*
* 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.
@ -119,9 +124,24 @@ final class SecretVault
public function get(string $key): ?string
{
$this->assertKnown($key);
$row = $this->row($key);
$mode = OperatingMode::current();
$row = $this->row($key, $mode);
// Fall back to the live slot when the active mode's slot is empty.
// ONLY from test to live, never the other way: using a test credential
// in live operation because the real one is missing is the dangerous
// direction, and must stay an explicit, visible gap instead.
if ($row === null && $mode->isTest() && ! $this->isStrict($key)) {
$row = $this->row($key, OperatingMode::Live);
}
if ($row === null) {
// Strict entries never see the environment: see isStrict() below.
if ($this->isStrict($key)) {
return null;
}
$configured = config(self::REGISTRY[$key]['config']);
return $configured === null ? null : (string) $configured;
@ -137,28 +157,28 @@ final class SecretVault
}
/** Where the value in force comes from: stored, environment, or nowhere. */
public function source(string $key): string
public function source(string $key, ?OperatingMode $mode = null): string
{
$this->assertKnown($key);
return match (true) {
$this->row($key) !== null => 'stored',
$this->row($key, $mode) !== null => 'stored',
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
default => 'none',
};
}
public function outline(string $key): ?string
public function outline(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key)?->outline;
return $this->row($key, $mode)?->outline;
}
public function updatedAt(string $key): ?string
public function updatedAt(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key)?->updated_at;
return $this->row($key, $mode)?->updated_at;
}
public function put(string $key, string $value, Operator $by): void
public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void
{
$this->assertKnown($key);
@ -176,18 +196,18 @@ final class SecretVault
// 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) {
if ($this->row($key, $mode) === null) {
$attributes['created_at'] = now();
}
DB::table('app_secrets')->updateOrInsert(['key' => $key], $attributes);
DB::table('app_secrets')->updateOrInsert(['key' => $this->rowKey($key, $mode)], $attributes);
}
/** Remove the stored value, falling back to whatever the environment says. */
public function forget(string $key): void
public function forget(string $key, ?OperatingMode $mode = null): void
{
$this->assertKnown($key);
DB::table('app_secrets')->where('key', $key)->delete();
DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->delete();
}
/** Is storing credentials possible at all on this installation? */
@ -196,9 +216,21 @@ final class SecretVault
return app(SecretCipher::class)->isUsable();
}
private function row(string $key): ?object
/** The storage key for one entry: an entry has two slots, test and live. */
private function rowKey(string $key, ?OperatingMode $mode = null): string
{
return DB::table('app_secrets')->where('key', $key)->first();
return $key.':'.($mode ?? OperatingMode::current())->value;
}
private function row(string $key, ?OperatingMode $mode = null): ?object
{
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
}
/** Does this entry carry the strict trait? Populated in task 3. */
private function isStrict(string $key): bool
{
return (bool) (self::REGISTRY[$key]['strict'] ?? false);
}
private function assertKnown(string $key): void

View File

@ -5,6 +5,7 @@ use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
@ -17,6 +18,12 @@ use Illuminate\Support\Facades\Http;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
config()->set('services.stripe.secret', 'sk_env_fallback');
// None of these tests are about the test/live split (that is
// SecretVaultModeTest); they call put()/get() without a $mode and must
// therefore land on a known slot regardless of what an earlier test file
// left in the array cache that Settings and OperatingMode share.
OperatingMode::set(OperatingMode::Live);
});
it('prefers a stored key over the environment, and says which is in force', function () {
@ -38,7 +45,9 @@ it('prefers a stored key over the environment, and says which is in force', func
it('stores the value encrypted, and never in the clear', function () {
app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', Operator::factory()->create());
$stored = DB::table('app_secrets')->where('key', 'stripe.secret')->value('value');
// The row key carries a mode suffix (SecretVaultModeTest) — the active
// mode here is pinned to live above.
$stored = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('value');
expect($stored)->not->toContain('sk_live_verysecret');
});
@ -161,10 +170,10 @@ it('keeps the original creation date when a key is rotated', function () {
$operator = Operator::factory()->create();
app(SecretVault::class)->put('stripe.secret', 'sk_first', $operator);
$created = DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at');
$created = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at');
$this->travel(2)->days();
app(SecretVault::class)->put('stripe.secret', 'sk_second', $operator);
expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'))->toBe($created);
expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at'))->toBe($created);
});

View File

@ -0,0 +1,75 @@
<?php
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
/**
* Zwei Plätze je Zugangsdatum, und eine Rückfallregel, die für vier von fünf
* Einträgen richtig ist: es gibt bei Hetzner, Uptime Kuma und SSH nur ein
* Konto, und zwei Felder mit zwingend gleichem Inhalt wären eine Attrappe.
*/
beforeEach(function () {
$this->by = Operator::factory()->create();
$this->vault = app(SecretVault::class);
// HETZNER_DNS_TOKEN is real in this operator's .env and, unlike
// STRIPE_WEBHOOK_SECRET, not blanked in phpunit.xml — left alone, the
// fallback-to-environment tests below would silently pass or fail
// depending on whose machine runs them instead of on the code.
config()->set('provisioning.dns.token', null);
});
it('reads the slot of the active mode', function () {
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
$this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('dns.token'))->toBe('test-token');
OperatingMode::set(OperatingMode::Live);
expect($this->vault->get('dns.token'))->toBe('live-token');
});
it('falls back to the live slot when the test slot is empty', function () {
// Die Regel des Inhabers: wo es keinen Testzugang gibt, wird der echte
// benutzt, damit der Testbetrieb überhaupt arbeiten kann.
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('dns.token'))->toBe('live-token');
});
it('does not fall back from live to test', function () {
// Die andere Richtung wäre absurd: im Livebetrieb einen Testzugang zu
// benutzen, weil der echte fehlt.
$this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);
OperatingMode::set(OperatingMode::Live);
expect($this->vault->get('dns.token'))->toBeNull();
});
it('still falls back to the environment when neither slot is filled', function () {
// Der Weg für eine Installation, in der noch gar nichts gespeichert ist.
// Einwertig und modusfrei — den Anfangszustand gibt es nur einmal.
config()->set('provisioning.dns.token', 'from-env');
expect($this->vault->get('dns.token'))->toBe('from-env');
});
it('reports the source per slot', function () {
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
});
it('forgets one slot without touching the other', function () {
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
$this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);
$this->vault->forget('dns.token', OperatingMode::Test);
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
});