diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index e6b5590..1743a7f 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -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 diff --git a/tests/Feature/Admin/SecretVaultTest.php b/tests/Feature/Admin/SecretVaultTest.php index 079d010..c93ef25 100644 --- a/tests/Feature/Admin/SecretVaultTest.php +++ b/tests/Feature/Admin/SecretVaultTest.php @@ -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); }); diff --git a/tests/Feature/SecretVaultModeTest.php b/tests/Feature/SecretVaultModeTest.php new file mode 100644 index 0000000..d54a662 --- /dev/null +++ b/tests/Feature/SecretVaultModeTest.php @@ -0,0 +1,75 @@ +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'); +});