rotate: LIVE-REAL proof level + execute-time proof gate (M2)
Add ProofLiveReal (highest level, no driver yet) and enforce it at execute time: rotate --execute only runs drivers with proof >= LIVE-VM (MinExecuteProof). MOCK-ONLY/UNPROVEN drivers are skipped before any provider call unless --allow-mock-proven is passed. ExecuteResult gains Skipped/SkipReason; the CLI reports and audit-logs skips separately from failures. Closes the last structural gap where an emulator-only driver could touch a real credential unattended. Covered by execute_gate_test.go. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,9 +48,12 @@ credentials that legitimately require a human. See `docs/ROTATION.md` Part II.
|
|||||||
`rotate --execute` runs behind `INCREDIGO_ALLOW_EXECUTE=1` **and** the mandatory
|
`rotate --execute` runs behind `INCREDIGO_ALLOW_EXECUTE=1` **and** the mandatory
|
||||||
verified-backup gate (`Snapshot`). Proof status is DATA (`internal/rotate/proofs.go`
|
verified-backup gate (`Snapshot`). Proof status is DATA (`internal/rotate/proofs.go`
|
||||||
mirrored in `docs/ROTATION-PROOFS.md`): **8 LIVE-VM** (postgres, mysql, redis,
|
mirrored in `docs/ROTATION-PROOFS.md`): **8 LIVE-VM** (postgres, mysql, redis,
|
||||||
mongo, wireguard, gitea, appsecret, k8s), **14 MOCK-ONLY**. **No real (host)
|
mongo, wireguard, gitea, appsecret, k8s), **14 MOCK-ONLY**, plus a `LIVE-REAL`
|
||||||
credential has been rotated yet** — every real rotation still requires explicit
|
level no driver carries yet. `--execute` enforces a proof gate: only drivers
|
||||||
per-credential authorization (safe-candidate ladder, ROADMAP M2).
|
with proof ≥ LIVE-VM (`MinExecuteProof`) run; MOCK-ONLY/UNPROVEN are skipped
|
||||||
|
unless `--allow-mock-proven` is passed. **No real (host) credential has been
|
||||||
|
rotated yet** — every real rotation still requires explicit per-credential
|
||||||
|
authorization (safe-candidate ladder, ROADMAP M2).
|
||||||
- **Guided manual layer done:** `internal/links` + `internal/worklist` +
|
- **Guided manual layer done:** `internal/links` + `internal/worklist` +
|
||||||
`internal/tui` → `incredigo worklist` / `incredigo guide` (read-only).
|
`internal/tui` → `incredigo worklist` / `incredigo guide` (read-only).
|
||||||
- **Phase B scaffolded, not wired:** 12 pwstore adapters, pwgen, staging;
|
- **Phase B scaffolded, not wired:** 12 pwstore adapters, pwgen, staging;
|
||||||
|
|||||||
+22
-12
@@ -378,7 +378,7 @@ func importCmd() *cobra.Command {
|
|||||||
// are zero registered drivers, and --execute is refused.
|
// are zero registered drivers, and --execute is refused.
|
||||||
func rotateCmd() *cobra.Command {
|
func rotateCmd() *cobra.Command {
|
||||||
var prefix, backupOut string
|
var prefix, backupOut string
|
||||||
var execute, dryRun, blastScan bool
|
var execute, dryRun, blastScan, allowMock bool
|
||||||
var blastRoots []string
|
var blastRoots []string
|
||||||
c := &cobra.Command{
|
c := &cobra.Command{
|
||||||
Use: "rotate",
|
Use: "rotate",
|
||||||
@@ -389,7 +389,9 @@ func rotateCmd() *cobra.Command {
|
|||||||
"full spine (rotate→verify→revoke) using the noop driver, touching no service.\n\n" +
|
"full spine (rotate→verify→revoke) using the noop driver, touching no service.\n\n" +
|
||||||
"--execute performs REAL, destructive rotation of the gopass entries under --prefix\n" +
|
"--execute performs REAL, destructive rotation of the gopass entries under --prefix\n" +
|
||||||
"(rotate→verify→store→re-read→revoke). It is refused unless INCREDIGO_ALLOW_EXECUTE=1\n" +
|
"(rotate→verify→store→re-read→revoke). It is refused unless INCREDIGO_ALLOW_EXECUTE=1\n" +
|
||||||
"is set and at least one driver is registered. See docs/ROTATION.md §16.",
|
"is set and at least one driver is registered. Only drivers proven against real\n" +
|
||||||
|
"target software (proof ≥ LIVE-VM) run; MOCK-ONLY drivers are skipped unless\n" +
|
||||||
|
"--allow-mock-proven is passed. See docs/ROTATION.md §16.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if execute {
|
if execute {
|
||||||
// Real, destructive rotation. Gated by an explicit env authorization
|
// Real, destructive rotation. Gated by an explicit env authorization
|
||||||
@@ -448,29 +450,36 @@ func rotateCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
results := rotate.Execute(ctx, gp, ecreds, v)
|
results := rotate.Execute(ctx, gp, ecreds, v, allowMock)
|
||||||
ew := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
ew := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
fmt.Fprintln(ew, "DRIVER\tPROOF\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR")
|
fmt.Fprintln(ew, "DRIVER\tPROOF\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR")
|
||||||
var rotated, failed int
|
var rotated, failed, skipped int
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
errStr := ""
|
errStr := ""
|
||||||
if r.Err != nil {
|
outcome := "ok"
|
||||||
|
switch {
|
||||||
|
case r.Skipped:
|
||||||
|
errStr = "SKIPPED: " + r.SkipReason
|
||||||
|
outcome = errStr
|
||||||
|
skipped++
|
||||||
|
case r.Err != nil:
|
||||||
errStr = r.Err.Error()
|
errStr = r.Err.Error()
|
||||||
|
outcome = errStr
|
||||||
failed++
|
failed++
|
||||||
} else {
|
default:
|
||||||
rotated++
|
rotated++
|
||||||
}
|
}
|
||||||
fmt.Fprintf(ew, "%s\t%s\t%s\t%t\t%t\t%t\t%t\t%s\n",
|
fmt.Fprintf(ew, "%s\t%s\t%s\t%t\t%t\t%t\t%t\t%s\n",
|
||||||
r.Driver, rotate.ProofLevelOf(r.Driver).String(), r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr)
|
r.Driver, rotate.ProofLevelOf(r.Driver).String(), r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr)
|
||||||
out := "ok"
|
|
||||||
if r.Err != nil {
|
|
||||||
out = r.Err.Error()
|
|
||||||
}
|
|
||||||
log.Write(audit.Entry{Action: "rotate-execute", Source: r.Credential.Source,
|
log.Write(audit.Entry{Action: "rotate-execute", Source: r.Credential.Source,
|
||||||
Identity: r.StorePath, Location: backupOut, Outcome: out})
|
Identity: r.StorePath, Location: backupOut, Outcome: outcome})
|
||||||
}
|
}
|
||||||
ew.Flush()
|
ew.Flush()
|
||||||
fmt.Fprintf(os.Stderr, "\nROTATED %d credential(s), %d failed. Backup: %s\n", rotated, failed, backupOut)
|
fmt.Fprintf(os.Stderr, "\nROTATED %d credential(s), %d failed, %d skipped (proof gate). Backup: %s\n", rotated, failed, skipped, backupOut)
|
||||||
|
if skipped > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "%d credential(s) skipped: driver proof below %s. Re-run with --allow-mock-proven to rotate them anyway.\n",
|
||||||
|
skipped, rotate.MinExecuteProof)
|
||||||
|
}
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
return fmt.Errorf("rotation completed with %d failure(s) — old credential(s) left intact; restore from backup if needed", failed)
|
return fmt.Errorf("rotation completed with %d failure(s) — old credential(s) left intact; restore from backup if needed", failed)
|
||||||
}
|
}
|
||||||
@@ -563,6 +572,7 @@ func rotateCmd() *cobra.Command {
|
|||||||
c.Flags().StringVar(&backupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/rotation-backups/<ts>.age)")
|
c.Flags().StringVar(&backupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/rotation-backups/<ts>.age)")
|
||||||
c.Flags().BoolVar(&execute, "execute", false, "(reserved) perform rotation — refused in design phase")
|
c.Flags().BoolVar(&execute, "execute", false, "(reserved) perform rotation — refused in design phase")
|
||||||
c.Flags().BoolVar(&dryRun, "dry-run", false, "walk the full rotation spine with the noop driver (touches no service, persists nothing)")
|
c.Flags().BoolVar(&dryRun, "dry-run", false, "walk the full rotation spine with the noop driver (touches no service, persists nothing)")
|
||||||
|
c.Flags().BoolVar(&allowMock, "allow-mock-proven", false, "also execute drivers proven only against a mock/emulator (MOCK-ONLY); default skips them")
|
||||||
c.Flags().BoolVar(&blastScan, "blast", false, "map each credential's blast radius — which files consume it (read-only, non-secret markers)")
|
c.Flags().BoolVar(&blastScan, "blast", false, "map each credential's blast radius — which files consume it (read-only, non-secret markers)")
|
||||||
c.Flags().StringArrayVar(&blastRoots, "blast-root", nil, "directories to search for consumers (default: current directory)")
|
c.Flags().StringArrayVar(&blastRoots, "blast-root", nil, "directories to search for consumers (default: current directory)")
|
||||||
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include in the plan")
|
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include in the plan")
|
||||||
|
|||||||
+7
-6
@@ -66,12 +66,13 @@ paths proven at the CLI level.
|
|||||||
Rule (unchanged): **one credential at a time, explicit authorization per
|
Rule (unchanged): **one credential at a time, explicit authorization per
|
||||||
credential, verified backup + restore-drill first, verify-new-before-revoke-old.**
|
credential, verified backup + restore-drill first, verify-new-before-revoke-old.**
|
||||||
|
|
||||||
- [ ] Add `ProofLiveReal` to `proofs.go` (+ docs mirror + PROOF column) so
|
- [x] Add `ProofLiveReal` to `proofs.go` (+ docs mirror + PROOF column) so
|
||||||
promotions are recorded as data.
|
promotions are recorded as data. (Level added, ordered highest; no driver
|
||||||
- [ ] Policy gate in `execute.go`: `--execute` only runs drivers with proof ≥
|
carries it yet — filled by the ladder below.)
|
||||||
LIVE-VM; MOCK-ONLY requires an explicit `--allow-mock-proven` opt-in flag.
|
- [x] Policy gate in `execute.go`: `--execute` only runs drivers with proof ≥
|
||||||
Today nothing distinguishes them at execute time — closing this is the last
|
LIVE-VM (`MinExecuteProof`); MOCK-ONLY/UNPROVEN are SKIPPED (Rotate never
|
||||||
structural safety gap.
|
called) unless `--allow-mock-proven` is passed. Result rows carry
|
||||||
|
`Skipped`/`SkipReason`; covered by `execute_gate_test.go`.
|
||||||
- [ ] Climb the ladder, promoting proofs as you go (lowest blast radius, fastest
|
- [ ] Climb the ladder, promoting proofs as you go (lowest blast radius, fastest
|
||||||
recovery first):
|
recovery first):
|
||||||
1. **appsecret** — local file, trivially reversible.
|
1. **appsecret** — local file, trivially reversible.
|
||||||
|
|||||||
+14
-2
@@ -19,16 +19,28 @@ stops a mock-only driver from ever being mistaken for a live one.
|
|||||||
|
|
||||||
## Levels
|
## Levels
|
||||||
|
|
||||||
|
Ordered lowest → highest (a higher level is a strictly stronger cutover proof):
|
||||||
|
|
||||||
| Level | Meaning |
|
| Level | Meaning |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| **LIVE-VM** | Cutover proven against the **real target software** running in the sandbox VM (real PostgreSQL / MariaDB / Redis / `wg` / Gitea / local files). |
|
|
||||||
| **MOCK-ONLY** | Cutover proven only against an **emulator** — our own `httptest` / in-process SSH server, **or a third-party mock** (e.g. `moto` for AWS). **NOT** proven against the real target service. |
|
|
||||||
| **UNPROVEN** | No cutover proof recorded (e.g. the dry-run noop helper). |
|
| **UNPROVEN** | No cutover proof recorded (e.g. the dry-run noop helper). |
|
||||||
|
| **MOCK-ONLY** | Cutover proven only against an **emulator** — our own `httptest` / in-process SSH server, **or a third-party mock** (e.g. `moto` for AWS). **NOT** proven against the real target service. |
|
||||||
|
| **LIVE-VM** | Cutover proven against the **real target software** running in the sandbox VM (real PostgreSQL / MariaDB / Redis / `wg` / Gitea / local files). |
|
||||||
|
| **LIVE-REAL** | Cutover proven against a **real, host-owned credential at the real provider** (not the VM, not an emulator) — recorded only after an authorized per-credential rotation on the safe-candidate ladder (ROADMAP M2). **No driver carries this yet.** |
|
||||||
|
|
||||||
> **Honesty note:** running in the VM does **not** automatically make a driver LIVE-VM.
|
> **Honesty note:** running in the VM does **not** automatically make a driver LIVE-VM.
|
||||||
> AWS ran in the VM but only against **moto** (a mock AWS), so it is **MOCK-ONLY**. LIVE-VM
|
> AWS ran in the VM but only against **moto** (a mock AWS), so it is **MOCK-ONLY**. LIVE-VM
|
||||||
> means the *real* target software validated the cutover.
|
> means the *real* target software validated the cutover.
|
||||||
|
|
||||||
|
### Execute-time proof gate
|
||||||
|
|
||||||
|
`rotate --execute` runs a driver **only if its proof is ≥ LIVE-VM** (`MinExecuteProof`
|
||||||
|
in `proofs.go`). MOCK-ONLY and UNPROVEN drivers are **skipped** — their
|
||||||
|
`Rotate`/`Verify`/`RevokeOld` are never called, so the old secret is left fully intact —
|
||||||
|
unless the operator passes the explicit **`--allow-mock-proven`** opt-in. This closes the
|
||||||
|
last structural gap where a driver proven only against an emulator could touch a real
|
||||||
|
credential unattended.
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
### LIVE-VM — proven against real target software in the sandbox VM
|
### LIVE-VM — proven against real target software in the sandbox VM
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type ExecuteResult struct {
|
|||||||
Verified bool // the new secret authenticated
|
Verified bool // the new secret authenticated
|
||||||
Stored bool // the new secret was written to gopass and re-read OK
|
Stored bool // the new secret was written to gopass and re-read OK
|
||||||
Revoked bool // RevokeOld was invoked
|
Revoked bool // RevokeOld was invoked
|
||||||
|
Skipped bool // gated off by the proof policy — the driver was NOT run
|
||||||
|
SkipReason string // why it was gated (only when Skipped)
|
||||||
Err error // first error for this credential, if any
|
Err error // first error for this credential, if any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,14 @@ type ExecuteResult struct {
|
|||||||
// The MANDATORY backup gate (Snapshot) MUST already have succeeded — Execute does
|
// The MANDATORY backup gate (Snapshot) MUST already have succeeded — Execute does
|
||||||
// not perform it. Each credential's StorePath is taken from c.Location (the exact
|
// not perform it. Each credential's StorePath is taken from c.Location (the exact
|
||||||
// gopass path it was sourced from), so the new secret overwrites the same entry.
|
// gopass path it was sourced from), so the new secret overwrites the same entry.
|
||||||
func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential, v *vault.Vault) []ExecuteResult {
|
//
|
||||||
|
// Proof gate (Hard Rule spirit — no unattended real cutover through an unvalidated
|
||||||
|
// path): a driver runs only if its recorded proof level is ≥ MinExecuteProof
|
||||||
|
// (LIVE-VM). Drivers proven only against an emulator/mock (MOCK-ONLY) or with no
|
||||||
|
// proof at all (UNPROVEN) are SKIPPED — their Rotate/Verify/RevokeOld are never
|
||||||
|
// called — unless the caller passes allowMock, the explicit --allow-mock-proven
|
||||||
|
// opt-in. Skipped credentials leave the old secret fully intact.
|
||||||
|
func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential, v *vault.Vault, allowMock bool) []ExecuteResult {
|
||||||
drivers := Drivers()
|
drivers := Drivers()
|
||||||
var out []ExecuteResult
|
var out []ExecuteResult
|
||||||
for _, c := range creds {
|
for _, c := range creds {
|
||||||
@@ -51,6 +60,16 @@ func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential,
|
|||||||
}
|
}
|
||||||
res := ExecuteResult{Credential: c, Driver: d.Name(), StorePath: c.Location}
|
res := ExecuteResult{Credential: c, Driver: d.Name(), StorePath: c.Location}
|
||||||
|
|
||||||
|
// Proof gate: refuse to run a driver that has not been proven against real
|
||||||
|
// target software, unless the operator explicitly opted in. This is checked
|
||||||
|
// BEFORE any provider call, so a gated driver touches nothing.
|
||||||
|
if lvl := ProofLevelOf(d.Name()); lvl < MinExecuteProof && !allowMock {
|
||||||
|
res.Skipped = true
|
||||||
|
res.SkipReason = fmt.Sprintf("proof %s < %s; pass --allow-mock-proven to override", lvl, MinExecuteProof)
|
||||||
|
out = append(out, res)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
newH, err := d.Rotate(ctx, c, v)
|
newH, err := d.Rotate(ctx, c, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Err = fmt.Errorf("rotate: %w", err)
|
res.Err = fmt.Errorf("rotate: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package rotate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"incredigo/internal/discover"
|
||||||
|
"incredigo/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// spyGateDriver is a minimal registered driver used to observe whether Execute's
|
||||||
|
// proof gate actually reached (or skipped) a driver's Rotate. It contacts no
|
||||||
|
// service: Rotate records that it ran and returns rotateErr, short-circuiting the
|
||||||
|
// spine before any gopass write, so allowMock=true can be proven without a store.
|
||||||
|
type spyGateDriver struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
rotateErr error
|
||||||
|
rotated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyGateDriver) Name() string { return s.name }
|
||||||
|
func (s *spyGateDriver) Detect(c discover.Credential) bool { return c.Source == s.source }
|
||||||
|
func (s *spyGateDriver) Verify(context.Context, *vault.Handle, *vault.Vault) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *spyGateDriver) RevokeOld(context.Context, discover.Credential, *vault.Vault) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *spyGateDriver) Rotate(context.Context, discover.Credential, *vault.Vault) (*vault.Handle, error) {
|
||||||
|
s.rotated = true
|
||||||
|
return nil, s.rotateErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecuteProofGateSkipsMockOnly proves that by default (allowMock=false) a
|
||||||
|
// driver whose proof is below MinExecuteProof is SKIPPED: its Rotate is never
|
||||||
|
// called and the credential is reported as skipped, not rotated.
|
||||||
|
func TestExecuteProofGateSkipsMockOnly(t *testing.T) {
|
||||||
|
v := vault.New()
|
||||||
|
defer v.Purge()
|
||||||
|
|
||||||
|
// Unknown name => ProofUnproven (< MinExecuteProof); unique source so no real
|
||||||
|
// driver claims it.
|
||||||
|
spy := &spyGateDriver{name: "spy-unproven", source: "faketest-gate-skip"}
|
||||||
|
Register(spy)
|
||||||
|
|
||||||
|
creds := []discover.Credential{{Source: "faketest-gate-skip", Identity: "x", Location: "imported/x"}}
|
||||||
|
res := Execute(context.Background(), nil, creds, v, false)
|
||||||
|
|
||||||
|
if len(res) != 1 {
|
||||||
|
t.Fatalf("got %d results, want 1", len(res))
|
||||||
|
}
|
||||||
|
r := res[0]
|
||||||
|
if !r.Skipped {
|
||||||
|
t.Errorf("mock-only driver was not skipped: %+v", r)
|
||||||
|
}
|
||||||
|
if r.SkipReason == "" {
|
||||||
|
t.Error("skipped result has no SkipReason")
|
||||||
|
}
|
||||||
|
if r.Rotated || r.Err != nil {
|
||||||
|
t.Errorf("skipped credential should not be rotated or errored: %+v", r)
|
||||||
|
}
|
||||||
|
if spy.rotated {
|
||||||
|
t.Error("gate leaked: Rotate was called on a gated driver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecuteProofGateAllowMockOverride proves --allow-mock-proven (allowMock=true)
|
||||||
|
// lets a below-threshold driver through: Rotate IS called (we make it fail fast so
|
||||||
|
// no gopass write is needed) and the result is not marked Skipped.
|
||||||
|
func TestExecuteProofGateAllowMockOverride(t *testing.T) {
|
||||||
|
v := vault.New()
|
||||||
|
defer v.Purge()
|
||||||
|
|
||||||
|
spy := &spyGateDriver{name: "spy-unproven-override", source: "faketest-gate-allow", rotateErr: errors.New("boom")}
|
||||||
|
Register(spy)
|
||||||
|
|
||||||
|
creds := []discover.Credential{{Source: "faketest-gate-allow", Identity: "x", Location: "imported/x"}}
|
||||||
|
res := Execute(context.Background(), nil, creds, v, true)
|
||||||
|
|
||||||
|
if len(res) != 1 {
|
||||||
|
t.Fatalf("got %d results, want 1", len(res))
|
||||||
|
}
|
||||||
|
r := res[0]
|
||||||
|
if r.Skipped {
|
||||||
|
t.Errorf("allowMock should not skip the driver: %+v", r)
|
||||||
|
}
|
||||||
|
if !spy.rotated {
|
||||||
|
t.Error("allowMock did not let the driver run: Rotate never called")
|
||||||
|
}
|
||||||
|
if r.Err == nil {
|
||||||
|
t.Error("expected the driver's Rotate error to surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecuteProofGateLiveVMRuns proves a driver at or above MinExecuteProof runs
|
||||||
|
// WITHOUT the override. The spy takes the name "postgres" (LIVE-VM in proofs.go)
|
||||||
|
// but a unique source so only it claims the credential; Rotate fails fast to avoid
|
||||||
|
// any real service call.
|
||||||
|
func TestExecuteProofGateLiveVMRuns(t *testing.T) {
|
||||||
|
if ProofLevelOf("postgres") < MinExecuteProof {
|
||||||
|
t.Skip("postgres proof level changed; test assumption invalid")
|
||||||
|
}
|
||||||
|
v := vault.New()
|
||||||
|
defer v.Purge()
|
||||||
|
|
||||||
|
spy := &spyGateDriver{name: "postgres", source: "faketest-gate-livevm", rotateErr: errors.New("boom")}
|
||||||
|
Register(spy)
|
||||||
|
|
||||||
|
creds := []discover.Credential{{Source: "faketest-gate-livevm", Identity: "x", Location: "imported/x"}}
|
||||||
|
res := Execute(context.Background(), nil, creds, v, false)
|
||||||
|
|
||||||
|
if len(res) != 1 {
|
||||||
|
t.Fatalf("got %d results, want 1", len(res))
|
||||||
|
}
|
||||||
|
if res[0].Skipped {
|
||||||
|
t.Errorf("LIVE-VM driver was gated by default: %+v", res[0])
|
||||||
|
}
|
||||||
|
if !spy.rotated {
|
||||||
|
t.Error("LIVE-VM driver did not run under the default gate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProofLevelOrderAndString locks the level ordering (Unproven < MockOnly <
|
||||||
|
// LiveVM < LiveReal) and the LIVE-REAL token the CLI/manifest render.
|
||||||
|
func TestProofLevelOrderAndString(t *testing.T) {
|
||||||
|
if !(ProofUnproven < ProofMockOnly && ProofMockOnly < ProofLiveVM && ProofLiveVM < ProofLiveReal) {
|
||||||
|
t.Fatal("proof levels are not ordered lowest→highest")
|
||||||
|
}
|
||||||
|
if MinExecuteProof != ProofLiveVM {
|
||||||
|
t.Errorf("MinExecuteProof = %v, want ProofLiveVM", MinExecuteProof)
|
||||||
|
}
|
||||||
|
want := map[ProofLevel]string{
|
||||||
|
ProofUnproven: "UNPROVEN",
|
||||||
|
ProofMockOnly: "MOCK-ONLY",
|
||||||
|
ProofLiveVM: "LIVE-VM",
|
||||||
|
ProofLiveReal: "LIVE-REAL",
|
||||||
|
}
|
||||||
|
for lvl, s := range want {
|
||||||
|
if got := lvl.String(); got != s {
|
||||||
|
t.Errorf("%d.String() = %q, want %q", lvl, got, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ package rotate
|
|||||||
// ProofLevel records how a driver's real-cutover behaviour has been validated.
|
// ProofLevel records how a driver's real-cutover behaviour has been validated.
|
||||||
type ProofLevel int
|
type ProofLevel int
|
||||||
|
|
||||||
|
// Ordered lowest→highest: a higher level is a strictly stronger cutover proof.
|
||||||
|
// The execute-time gate (see execute.go) compares against ProofLiveVM.
|
||||||
const (
|
const (
|
||||||
// ProofUnproven: no cutover proof recorded (e.g. the dry-run noop helper).
|
// ProofUnproven: no cutover proof recorded (e.g. the dry-run noop helper).
|
||||||
ProofUnproven ProofLevel = iota
|
ProofUnproven ProofLevel = iota
|
||||||
@@ -26,11 +28,24 @@ const (
|
|||||||
// ProofLiveVM: cutover proven against the REAL target software running in the
|
// ProofLiveVM: cutover proven against the REAL target software running in the
|
||||||
// sandbox VM (real PostgreSQL/MariaDB/Redis/wireguard-tools/Gitea/local files).
|
// sandbox VM (real PostgreSQL/MariaDB/Redis/wireguard-tools/Gitea/local files).
|
||||||
ProofLiveVM
|
ProofLiveVM
|
||||||
|
// ProofLiveReal: cutover proven against a REAL, host-owned credential at the
|
||||||
|
// real provider (not the VM, not an emulator) — the highest level, recorded
|
||||||
|
// only after an authorized per-credential rotation on the safe-candidate ladder
|
||||||
|
// (ROADMAP M2). No driver carries this yet; promotions land here as data.
|
||||||
|
ProofLiveReal
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MinExecuteProof is the minimum proof level a driver must carry to run under
|
||||||
|
// `rotate --execute` WITHOUT the explicit --allow-mock-proven opt-in. Anything
|
||||||
|
// below this (MOCK-ONLY, UNPROVEN) is gated off by default so a driver never
|
||||||
|
// proven against real target software cannot touch a real credential unattended.
|
||||||
|
const MinExecuteProof = ProofLiveVM
|
||||||
|
|
||||||
// String renders the level as the token shown in the CLI and the manifest.
|
// String renders the level as the token shown in the CLI and the manifest.
|
||||||
func (p ProofLevel) String() string {
|
func (p ProofLevel) String() string {
|
||||||
switch p {
|
switch p {
|
||||||
|
case ProofLiveReal:
|
||||||
|
return "LIVE-REAL"
|
||||||
case ProofLiveVM:
|
case ProofLiveVM:
|
||||||
return "LIVE-VM"
|
return "LIVE-VM"
|
||||||
case ProofMockOnly:
|
case ProofMockOnly:
|
||||||
|
|||||||
Reference in New Issue
Block a user