carlos / platform Public

provision: Record.Gate, validated as a bcrypt hash and converged into routes.gate

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e8646523b1b649b2e6fc464ce5db09bb18d57323 parent a1d097b
4 files changed, +92 −0
  • internal/provision/provision.go +18 −0
  • internal/provision/provision_test.go +11 −0
  • internal/provision/reconcile.go +19 −0
  • internal/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