Say which slot the value in force is really coming from

Mode test, dns.token only in the :live slot, HETZNER_DNS_TOKEN also in the
.env — the state this installation is in the moment the migration runs:

  get()      -> "live-token-value"   (the stored live row is in force)
  source()   -> "environment"        -> card reads "Aus der Serverdatei"
  outline()  -> null                 -> no "Hinterlegt" block at all

So the console sent an operator to the .env to rotate a token that the
database supplies, where the edit would have had no effect. R19's dummy,
literally: a display that reads like an answer, is wrong, and stops the
next person looking.

source(), outline() and updatedAt() now ask the same one place get() does
(inForce()/fallback()), and the answer gets a THIRD value, 'stored_live'.
Not 'stored': that shows the Vergessen button, which would then have
pointed at the empty test slot. Strict entries (Stripe) still have no
fallback in either direction — pinned by its own test.

Red first (before the vault changed):

  ⨯ it names the live slot as the source when it is the one in force
    -'stored_live'
    +'environment'
  ⨯ it shows the outline and the date of the value that is actually in force

Two existing expectations moved from 'none' to 'stored_live' with the
reason written beside them: 'none' meant "nichts hinterlegt" about a
credential that was in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 17:01:33 +02:00
parent 507c976035
commit 5094f70c19
5 changed files with 133 additions and 17 deletions

View File

@ -131,16 +131,7 @@ final class SecretVault
{
$this->assertKnown($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);
}
$row = $this->inForce($key, OperatingMode::current());
if ($row === null) {
// Strict entries never see the environment: see isStrict() below.
@ -162,13 +153,34 @@ final class SecretVault
}
}
/** Where the value in force comes from: stored, environment, or nowhere. */
/**
* Where the value in force under this mode comes from four answers now.
*
* 'stored_live' is the third, and it exists because the second-best answers
* were both lies. The measured case: mode test, dns.token only in the :live
* slot, HETZNER_DNS_TOKEN also in the .env. get() hands out the stored live
* value (the fallback it has always had), while this method reported
* 'environment' the console then read "Aus der Serverdatei" over a
* credential that is in force from the database, and an operator changing
* the .env line would have seen no effect at all. That is R19's dummy in
* miniature: a display that reads like an answer, is wrong, and stops the
* next person looking.
*
* Returning 'stored' instead would trade one lie for another: the console
* shows the "Vergessen" button only for 'stored', and it would then have
* pointed at the EMPTY test slot, which forget() would happily delete
* nothing from.
*
* The $mode argument names which mode is IN FORCE, not which row to look
* at same question get() answers, so that the two cannot disagree.
*/
public function source(string $key, ?OperatingMode $mode = null): string
{
$this->assertKnown($key);
return match (true) {
$this->row($key, $mode) !== null => 'stored',
$this->fallback($key, $mode) !== null => 'stored_live',
$this->isStrict($key) => 'none',
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
default => 'none',
@ -177,12 +189,12 @@ final class SecretVault
public function outline(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key, $mode)?->outline;
return $this->inForce($key, $mode)?->outline;
}
public function updatedAt(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key, $mode)?->updated_at;
return $this->inForce($key, $mode)?->updated_at;
}
public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void
@ -234,6 +246,39 @@ final class SecretVault
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
}
/**
* The stored row that actually applies under this mode: its own slot, or
* the live slot standing in for it.
*
* ONE place, called by get(), outline() and updatedAt() and asked by
* source() through fallback() because these four answering the same
* question separately is precisely how the console came to say "nothing
* stored" about a credential the provisioning run was using.
*/
private function inForce(string $key, ?OperatingMode $mode = null): ?object
{
return $this->row($key, $mode) ?? $this->fallback($key, $mode);
}
/**
* The live slot filling in for an empty test slot.
*
* 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. Strict entries
* (Stripe) have no fallback at all, in either direction.
*/
private function fallback(string $key, ?OperatingMode $mode = null): ?object
{
$mode ??= OperatingMode::current();
if (! $mode->isTest() || $this->isStrict($key)) {
return null;
}
return $this->row($key, OperatingMode::Live);
}
/** Does this entry carry the strict trait? Populated in task 3. */
private function isStrict(string $key): bool
{

View File

@ -34,6 +34,11 @@ return [
],
'source_stored' => 'Hier hinterlegt',
// Im Testbetrieb gilt der Live-Wert, wenn der Testplatz leer ist — genau
// das, was SecretVault::get() tut. Ohne eigene Beschriftung stand hier
// „Aus der Serverdatei" über einem Wert aus der Datenbank, und wer die
// .env daraufhin änderte, änderte nichts.
'source_stored_live' => 'Aus dem Live-Platz',
'source_environment' => 'Aus der Serverdatei',
'source_none' => 'Nicht gesetzt',
'stored_value' => 'Hinterlegt',

View File

@ -34,6 +34,10 @@ return [
],
'source_stored' => 'Stored here',
// In test mode the live value applies when the test slot is empty —
// exactly what SecretVault::get() does. Without a label of its own this
// read "From the server file" over a value coming from the database.
'source_stored_live' => 'From the live slot',
'source_environment' => 'From the server file',
'source_none' => 'Not set',
'stored_value' => 'Stored',

View File

@ -31,10 +31,15 @@
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
{{ __('secrets.slot.'.$mode->value) }}
</span>
{{-- Aufgezählt nach den zwei Enden statt nach jedem Wert einzeln:
grün heißt „liegt in DIESEM Platz", gelb heißt „nirgends", und
alles dazwischen (aus der Serverdatei, aus dem Live-Platz) ist
blau in Kraft, aber woanders her. So bekommt ein künftiger
vierter Wert nicht still die Farbe von „Nicht gesetzt". --}}
<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') }}">
: ($entry['source'] === 'none' ? 'border-warning-border bg-warning-bg text-warning'
: 'border-info-border bg-info-bg text-info') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>

View File

@ -61,7 +61,11 @@ 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');
// NICHT 'none': im Testbetrieb ist genau diese Live-Zeile in Kraft (der
// Rückfall zwei Tests weiter oben). 'none' hieße „nichts hinterlegt" über
// ein Zugangsdatum, mit dem gerade gearbeitet wird — siehe den Test
// „names the live slot as the source…" weiter unten.
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live');
});
it('forgets one slot without touching the other', function () {
@ -70,6 +74,59 @@ it('forgets one slot without touching the other', function () {
$this->vault->forget('dns.token', OperatingMode::Test);
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
// 'stored_live' statt 'stored' ist der Beweis, dass die Testzeile weg ist:
// was jetzt im Testplatz gilt, kommt aus dem Live-Platz.
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live');
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
});
/**
* Der Widerspruch, den das Schluss-Review gemessen hat und warum er zählt.
*
* Modus test, `dns.token` nur im `:live`-Platz, `HETZNER_DNS_TOKEN` in der
* `.env`: `get()` lieferte den gespeicherten Live-Wert (der Rückfall oben),
* `source()` meldete dazu „environment" und `outline()` null. Die Karte in der
* Konsole las sich damit als „Aus der Serverdatei" über ein Zugangsdatum, das
* gerade aus der Datenbank in Kraft ist ein Betreiber, der den Token wechseln
* will, wird in die `.env` geschickt, wo eine Änderung wirkungslos bleibt.
* Genau die Falle, die im Kopfkommentar von OperatingMode als Anlass dieses
* ganzen Vorhabens steht, und R19s Attrappe wörtlich: eine Anzeige, die sich
* wie eine Auskunft liest, falsch ist, und den Nächsten vom Nachsehen abhält.
*
* Ein DRITTER Rückgabewert, nicht 'stored': 'stored' blendet den
* „Vergessen"-Knopf ein, und der zeigte dann auf den leeren Testplatz.
*/
it('names the live slot as the source when it is the one in force', function () {
config()->set('provisioning.dns.token', 'from-env');
$this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('dns.token'))->toBe('live-token-value')
->and($this->vault->source('dns.token'))->toBe('stored_live');
});
it('shows the outline and the date of the value that is actually in force', function () {
// Die zweite Folge desselben Befunds: ohne Umriss fehlt der Block
// „Hinterlegt / Geändert" ganz, und die Karte behauptet durch Weglassen,
// es liege nichts vor.
$this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
$outline = $this->vault->outline('dns.token');
expect($outline)->not->toBeNull()
->and($outline)->toContain('alue')
->and($this->vault->updatedAt('dns.token'))->not->toBeNull();
});
it('keeps the strict entry out of the fallback here too', function () {
// Stripe hat keinen Rückfall, in keine Richtung — source() darf ihn
// deshalb auch nicht als 'stored_live' anzeigen, sonst widerspräche die
// Karte wieder der Kasse.
$this->vault->put('stripe.secret', 'sk_live_real', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('stripe.secret'))->toBeNull()
->and($this->vault->source('stripe.secret'))->toBe('none')
->and($this->vault->outline('stripe.secret'))->toBeNull();
});