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
parent
3cf17e5cb0
commit
bc6eee4dc3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue