provision: Record.Gate, validated as a bcrypt hash and converged into routes.gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+92
−0
internal/provision/provision.go+18 −0internal/provision/provision_test.go+11 −0internal/provision/reconcile.go+19 −0internal/provision/reconcile_test.go+44 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index cd5f59187..029876490 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -20,6 +20,8 @@ import ( |
| "strings" |
| "time" |
| + "golang.org/x/crypto/bcrypt" |
| + |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -121,6 +123,17 @@ type Record struct { |
| // converger (Tasks 4-6) are what give a non-empty value any effect, and |
| // even then only once a deployment arms CARLOS_STEERING_ZONE_ID. |
| Steering string `json:"steering,omitempty"` |
| + |
| + // Gate is the bcrypt hash of the host's shared password ("" = open, |
| + // the value every record written before this field existed decodes |
| + // to). The ONLY place the secret material lives in the bucket; the |
| + // plaintext crosses the console once, at the door that hashes it |
| + // (internal/gate.Hash), and is never stored, logged or echoed — the |
| + // console's record views shadow this field (apiinstances.go). A |
| + // string, not a pointer: absent and "" are the same fact, an open |
| + // host. Converged into routes.gate by convergeGate and read by the |
| + // edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| + Gate string `json:"gate,omitempty"` |
| } |
| // Health is the per-route probe config (HA spec Part II). Zero value = |
| @@ -384,6 +397,11 @@ func ValidateShape(rec Record) error { |
| if !ValidSteering(rec.Steering) { |
| return fmt.Errorf("steering %q: want \"\" or \"latency\"", rec.Steering) |
| } |
| + if rec.Gate != "" { |
| + if _, err := bcrypt.Cost([]byte(rec.Gate)); err != nil { |
| + return errors.New("gate: want a bcrypt hash — the console hashes the password; a record never carries it in clear") |
| + } |
| + } |
| if rec.Addr != "" { |
| if err := validAddr(rec.Addr); err != nil { |
| return err |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 37bf85390..407dbb631 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -995,3 +995,14 @@ func TestMoveFieldAbsentOnPreMoveRecords(t *testing.T) { |
| t.Fatalf("re-marshal grew a move key: %s", out) |
| } |
| } |
| + |
| +func TestValidateShapeGate(t *testing.T) { |
| + ok := Record{Kind: "static", Gate: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"} |
| + if err := ValidateShape(ok); err != nil { |
| + t.Fatalf("bcrypt gate refused: %v", err) |
| + } |
| + bad := Record{Kind: "static", Gate: "hunter2"} |
| + if err := ValidateShape(bad); err == nil || !strings.Contains(err.Error(), "gate") { |
| + t.Fatalf("plaintext gate accepted: %v", err) |
| + } |
| +} |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 7f620aeae..610088ef8 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -293,6 +293,7 @@ func routeFor(rec Record, dataDir, seed string) registry.Route { |
| Channel: rec.Channel, Environment: rec.Environment, |
| Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(), |
| Steering: rec.Steering, |
| + Gate: rec.Gate, |
| Provisioned: true, Seed: seed, Status: "ready", |
| } |
| if rec.DerivesPaths() { |
| @@ -837,6 +838,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("provision: converged %s hibernate %v -> %v%s", rec.Host, existing.Hibernate, *rec.Hibernate, dropped) |
| } |
| } |
| + if existing.Gate != rec.Gate { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate)) |
| + } else if err := reg.SetGate(rec.Host, rec.Gate); err != nil { |
| + log.Printf("provision: converge gate on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate)) |
| + } |
| + } |
| // seedIsKnown guards against stamping a transient manifest-read |
| // failure's "" fallback over a working seed — see seedFor. |
| if seedIsKnown && existing.Seed != seed { |
| @@ -1002,6 +1012,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| return nil |
| } |
| +// gateWord renders a gate column for a log line: "open" or "set", never the |
| +// hash — it has no business in a journal. |
| +func gateWord(hash string) string { |
| + if hash == "" { |
| + return "open" |
| + } |
| + return "set" |
| +} |
| + |
| // knownAccounts lists accounts/ and materializes the sqid set this pass |
| // trusts for ParseRecordKey's "known account" guard — the same three-way |
| // sqid agreement domains.Reconcile enforces (reconcile.go:106): the key |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index d71e9b35d..ed0a87d9a 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -566,6 +566,50 @@ func TestReconcileConvergesSteering(t *testing.T) { |
| } |
| } |
| +func TestReconcileConvergesGate(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + if before.Gate != "" { |
| + t.Fatalf("minted Gate = %q, want open", before.Gate) |
| + } |
| + |
| + const hash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" |
| + rec.Gate = hash |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.Gate != hash { |
| + t.Errorf("gate = %q, want the hash", after.Gate) |
| + } |
| + after.Gate = before.Gate |
| + if after != before { |
| + t.Errorf("converging gate touched another column:\n before %+v\n after %+v", before, after) |
| + } |
| + |
| + // A record with no gate field is an OPEN host — it is a string, not a |
| + // pointer: absent and "" are the same fact, and clearing must reach the |
| + // row through SetGate. |
| + rec.Gate = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Gate; got != "" { |
| + t.Errorf("gate = %q, want it cleared", got) |
| + } |
| +} |
| + |
| // An explicit Hibernate converges; an absent one never does. Absent means |
| // "the record does not speak to this", and a pass that stamped its |
| // derivation over an operator's deliberate SetHibernate would be the silent |