carlos / platform Public

console: door keys, with the database as authority and a re-signed projection (#175 P7)

Task 2 of the P7 plan. door_keys and door_key_revisions tables; person-only
mint, list and revoke doors; publishDoorKeys serialises per app, rebuilds
from the database, advances only when the database is below the bucket's
verified revision, and writes by ETag compare-and-swap with at most three
attempts. A mint whose publish fails revokes the unshown key rather than
stranding it in a slot. The re-sign pass runs at startup and every 12 hours,
wired beside the console's other passes.

Built by an Opus subagent from the plan; reviewed before commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human

Prompt

Go ahead. (start P7)

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

P7 Task 2: console door-key authority, doors and re-sign pass.
claude agent, operated by paul@keymail.dev 3098e4d05df9d3a76c87e9d8a48efba5d692f84e parent 256645f
8 files changed, +1011 −4
  • cmd/console/main.go +4 −0
  • internal/console/apidoorkeys.go +340 −0
  • internal/console/apidoorkeys_test.go +427 −0
  • internal/console/authcli.go +5 −0
  • internal/console/doorkeys_store.go +215 −0
  • internal/console/server.go +6 −0
  • internal/console/store.go +5 −1
  • internal/console/svcroutes_test.go +9 −3
diff --git a/cmd/console/main.go b/cmd/console/main.go
index ae91ed32e..8b5a8e789 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -379,6 +379,10 @@ func serve() {
// SES outcomes an app asked to be told about. Same placement rule as
// every pass above; with nothing queued, a pass is two indexed queries.
go srv.StartMailEventPush(context.Background())
+ // Door keys (#175 P7): re-sign every app's record before its 72-hour
+ // expiry, once at startup and then every 12 hours. Inert without a
+ // signing key or a deployment bucket.
+ go srv.StartDoorKeyResign(context.Background())
// Once, at boot: log any route already sitting at another app's platform
// apex (internal/console/apex.go). Reports; repairs nothing.
go srv.ReportApexSquats(context.Background())
diff --git a/internal/console/apidoorkeys.go b/internal/console/apidoorkeys.go
new file mode 100644
index 000000000..919ab5366
--- /dev/null
+++ b/internal/console/apidoorkeys.go
@@ -0,0 +1,340 @@
+package console
+
+// Door keys (#175 P7): the credential an app's own processes present at a
+// box's edge-local door. The console's database is the authority
+// (doorkeys_store.go). The bucket holds a projection of it at
+// doorkey.RecordKey, signed with the deployment's root release key, carrying
+// a revision and a 72-hour expiry, and re-signed every 12 hours.
+//
+// Exactly one code path writes that projection: publishDoorKeys. It always
+// rebuilds from the database and never reads a key back out of the bucket,
+// so a record planted or copied there by a bucket writer is overwritten, not
+// extended (Astra round 1, finding 1).
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/doorkey"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ // doorKeyResignEvery is how often every record is re-signed with a fresh
+ // expiry. A record lives doorkey.Lifetime (72 h), so a console that is
+ // down for up to 60 hours past its last re-sign leaves no door refusing.
+ doorKeyResignEvery = 12 * time.Hour
+ // doorKeyPublishAttempts bounds publishDoorKeys' compare-and-swap loop.
+ doorKeyPublishAttempts = 3
+ // doorKeyPublishBudget bounds one publication started from a request or
+ // from the re-sign pass.
+ doorKeyPublishBudget = 20 * time.Second
+)
+
+// Draft copy, pending review (plan Task 8).
+const (
+ doorKeyReadOnlyMsg = "no signing key on this host. the console is running read-only"
+ doorKeyNoBucketMsg = "no deployment bucket on this host. the console is running without one"
+)
+
+// doorPublishLock is the app's publication mutex, created on first use.
+func (s *Server) doorPublishLock(sqid, app string) *sync.Mutex {
+ s.doorPublishMu.Lock()
+ defer s.doorPublishMu.Unlock()
+ if s.doorPublish == nil {
+ s.doorPublish = map[string]*sync.Mutex{}
+ }
+ k := sqid + "/" + app
+ mu := s.doorPublish[k]
+ if mu == nil {
+ mu = &sync.Mutex{}
+ s.doorPublish[k] = mu
+ }
+ return mu
+}
+
+// publishDoorKeys writes the app's signed record from the database. The
+// protocol (plan Task 2, round 2 findings 4 to 6):
+//
+// 1. Hold the app's publication mutex throughout. A renewal and a revoke
+// for the same app are serialized, and each builds its record from the
+// database inside the lock, so a renewal can never put back a key set
+// older than one already published.
+// 2. Read the bucket's object and its ETag. The bucket's revision is the one
+// a valid signature vouches for (expired or not), and zero for anything
+// absent or unverifiable: a tampered object is overwritten, never
+// trusted.
+// 3. Read the live keys and the revision in one transaction. If the
+// database is below the bucket (a restored database, or a second
+// writer), advance its counter past the bucket first and read again. An
+// equal revision is the ordinary renewal and is re-published as it is.
+// 4. Sign the record with a fresh expiry and Put it against the ETag read in
+// step 2 (create-only when there was no object).
+// 5. A lost compare-and-swap starts again at step 2, rebuilding from the
+// database, never reusing signed bytes. After doorKeyPublishAttempts
+// losses the conflict is returned.
+func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error {
+ if len(s.cfg.Key) != ed25519.PrivateKeySize {
+ return errors.New("console: door keys: no signing key")
+ }
+ if s.cfg.Releases == nil {
+ return errors.New("console: door keys: no deployment bucket")
+ }
+ pub, _ := s.cfg.Key.Public().(ed25519.PublicKey)
+ mu := s.doorPublishLock(acct.Sqid, app)
+ mu.Lock()
+ defer mu.Unlock()
+
+ key := doorkey.RecordKey(acct.Sqid, app)
+ var lost error
+ for attempt := 0; attempt < doorKeyPublishAttempts; attempt++ {
+ wire, etag, err := s.cfg.Releases.Get(ctx, key)
+ var bucketRev uint64
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ return fmt.Errorf("console: door keys %s/%s: read: %w", acct.Sqid, app, err)
+ default:
+ if rev, verr := doorkey.VerifyRevision(pub, wire); verr == nil {
+ bucketRev = rev
+ } else {
+ log.Printf("console: door keys %s/%s: overwriting an unverifiable object: %v", acct.Sqid, app, verr)
+ }
+ }
+
+ keys, rev, err := s.store.DoorKeysLive(acct.ID, app)
+ if err != nil {
+ return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err)
+ }
+ if rev < bucketRev {
+ if _, err := s.store.DoorKeyAdvanceRevision(acct.ID, app, bucketRev); err != nil {
+ return fmt.Errorf("console: door keys %s/%s: advance past %d: %w", acct.Sqid, app, bucketRev, err)
+ }
+ log.Printf("console: door keys %s/%s: database revision %d was below the bucket's %d; advanced past it",
+ acct.Sqid, app, rev, bucketRev)
+ if keys, rev, err = s.store.DoorKeysLive(acct.ID, app); err != nil {
+ return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err)
+ }
+ }
+ if rev == 0 {
+ return nil // never minted, and nothing verifiable in the bucket: nothing to publish
+ }
+
+ now := time.Now().UTC()
+ body, err := doorkey.Sign(s.cfg.Key, doorkey.Record{V: 1, Account: acct.Sqid, App: app,
+ Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now.Add(doorkey.Lifetime)})
+ if err != nil {
+ return fmt.Errorf("console: door keys %s/%s: sign: %w", acct.Sqid, app, err)
+ }
+ _, err = s.cfg.Releases.Put(ctx, key, body, etag)
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ return fmt.Errorf("console: door keys %s/%s: write: %w", acct.Sqid, app, err)
+ }
+ lost = err
+ }
+ return fmt.Errorf("console: door keys %s/%s: %d attempts: %w", acct.Sqid, app, doorKeyPublishAttempts, lost)
+}
+
+// publishDoorKeysDetached runs publishDoorKeys for a request whose database
+// write has already landed: a client that hangs up must not cancel the
+// publication of a change that is already the truth.
+func (s *Server) publishDoorKeysDetached(r *http.Request, acct *Account, app string) error {
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), doorKeyPublishBudget)
+ defer cancel()
+ return s.publishDoorKeys(ctx, acct, app)
+}
+
+// doorKeyWritable answers the 503 a console that cannot publish gives, before
+// anything is written.
+func (s *Server) doorKeyWritable(w http.ResponseWriter) bool {
+ if len(s.cfg.Key) == 0 {
+ jsonError(w, http.StatusServiceUnavailable, doorKeyReadOnlyMsg)
+ return false
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable, doorKeyNoBucketMsg)
+ return false
+ }
+ return true
+}
+
+// handleAPIDoorKeyMint mints one door key for the app. Person-only, the
+// instance-token rule: a credential may never mint a credential. The zero
+// Set at the registration refuses every service role (authcli.go), and
+// apiPerson is the inner wall.
+//
+// The key is shown exactly once. Only its sha256 is stored.
+func (s *Server) handleAPIDoorKeyMint(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again")
+ return
+ }
+ if !s.doorKeyWritable(w) {
+ return
+ }
+ var req struct {
+ Label string `json:"label"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ raw, entry, err := doorkey.NewToken(acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: door key mint %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not mint the door key. try again")
+ return
+ }
+ entry.Label = cleanLabel(req.Label)
+ if _, err := s.store.DoorKeyInsert(acct.ID, app, entry); err != nil {
+ if errors.Is(err, errDoorKeysFull) {
+ jsonError(w, http.StatusConflict, fmt.Sprintf(
+ "this app already has %d door keys. revoke one before you mint another", doorkey.MaxKeys))
+ return
+ }
+ log.Printf("console: door key mint %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not mint the door key. try again")
+ return
+ }
+ if err := s.publishDoorKeysDetached(r, acct, app); err != nil {
+ // The secret is about to be lost (it is never shown), so the row it
+ // hashes is revoked rather than left holding one of the app's slots
+ // until the next re-sign publishes a key nobody has.
+ log.Printf("console: door key mint %s/%s: publish: %v", acct.Sqid, app, err)
+ if _, rerr := s.store.DoorKeyRevoke(acct.ID, app, entry.ID); rerr != nil {
+ log.Printf("console: door key mint %s/%s: withdraw unpublished key %s: %v", acct.Sqid, app, entry.ID, rerr)
+ }
+ jsonError(w, http.StatusBadGateway, "could not publish the door key. try again")
+ return
+ }
+ s.audit(acct, app, addr, "door-key", "minted door key "+entry.ID+" for "+app+" (cli)")
+ s.bumpSerial(r.Context())
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "key": raw, "id": entry.ID, "account": acct.Sqid, "app": app,
+ })
+}
+
+// handleAPIDoorKeyList lists the app's live keys: id, label and when each
+// was minted. Never a digest.
+func (s *Server) handleAPIDoorKeyList(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ keys, _, err := s.store.DoorKeysLive(acct.ID, app)
+ if err != nil {
+ log.Printf("console: door key list %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not list the door keys. try again")
+ return
+ }
+ type listed struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ CreatedAt time.Time `json:"created_at"`
+ }
+ out := make([]listed, 0, len(keys))
+ for _, k := range keys {
+ out = append(out, listed{ID: k.ID, Label: k.Label, CreatedAt: k.CreatedAt})
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"keys": out})
+}
+
+// handleAPIDoorKeyRevoke revokes one live key and publishes the record
+// without it.
+func (s *Server) handleAPIDoorKeyRevoke(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again")
+ return
+ }
+ if !s.doorKeyWritable(w) {
+ return
+ }
+ id := r.PathValue("id")
+ if _, err := s.store.DoorKeyRevoke(acct.ID, app, id); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ jsonError(w, http.StatusNotFound, "no live door key with that id")
+ return
+ }
+ log.Printf("console: door key revoke %s/%s %s: %v", acct.Sqid, app, id, err)
+ jsonError(w, http.StatusInternalServerError, "could not revoke the door key. try again")
+ return
+ }
+ s.audit(acct, app, addr, "door-key-revoke", "revoked door key "+id+" for "+app+" (cli)")
+ if err := s.publishDoorKeysDetached(r, acct, app); err != nil {
+ // The database is the truth and the key is revoked there; the
+ // re-sign pass publishes it on its next run.
+ log.Printf("console: door key revoke %s/%s %s: publish: %v", acct.Sqid, app, id, err)
+ jsonError(w, http.StatusBadGateway,
+ "the door key is revoked but not yet published. boxes keep admitting it until the console publishes again, within 12 hours")
+ return
+ }
+ s.bumpSerial(r.Context())
+ writeJSON(w, http.StatusOK, map[string]any{"revoked": id})
+}
+
+// resignDoorKeys re-publishes every app's record with a fresh expiry. A
+// failure for one app is logged and does not stop the others.
+func (s *Server) resignDoorKeys(ctx context.Context) {
+ apps, err := s.store.DoorKeyApps()
+ if err != nil {
+ log.Printf("console: door keys re-sign: %v", err)
+ return
+ }
+ for _, a := range apps {
+ pctx, cancel := context.WithTimeout(ctx, doorKeyPublishBudget)
+ err := s.publishDoorKeys(pctx, &Account{ID: a.AccountID, Sqid: a.Sqid}, a.App)
+ cancel()
+ if err != nil {
+ log.Printf("console: door keys re-sign %s/%s: %v", a.Sqid, a.App, err)
+ }
+ }
+}
+
+// StartDoorKeyResign re-signs every door-key record now and then every 12
+// hours until ctx is done. Started from cmd/console beside the other passes,
+// and only on a console that holds the signing key: a read-only console has
+// nothing to sign with.
+func (s *Server) StartDoorKeyResign(ctx context.Context) {
+ if len(s.cfg.Key) == 0 || s.cfg.Releases == nil {
+ return
+ }
+ t := time.NewTicker(doorKeyResignEvery)
+ defer t.Stop()
+ for {
+ s.resignDoorKeys(ctx)
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+}
diff --git a/internal/console/apidoorkeys_test.go b/internal/console/apidoorkeys_test.go
new file mode 100644
index 000000000..19f96cb08
--- /dev/null
+++ b/internal/console/apidoorkeys_test.go
@@ -0,0 +1,427 @@
+package console
+
+// Door keys (#175 P7, apidoorkeys.go): the console's database is the
+// authority, and the bucket holds a signed, revisioned, expiring projection
+// of it that one serialized publisher rebuilds from the database every time.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/doorkey"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func doorKeysURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/door-keys" }
+
+func readDoorRecord(t *testing.T, fx *consoleFixture) doorkey.Record {
+ t.Helper()
+ wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, err := doorkey.Verify(fx.pub, wire, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rec
+}
+
+// mintDoorKey mints one key through the door and returns the parsed token.
+func mintDoorKey(t *testing.T, fx *consoleFixture, token string) doorkey.Token {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{"label":"router"}`)
+ if code != http.StatusCreated {
+ t.Fatalf("mint = %d %s", code, out)
+ }
+ var minted struct{ Key, ID string }
+ if err := json.Unmarshal([]byte(out), &minted); err != nil {
+ t.Fatal(err)
+ }
+ tok, err := doorkey.ParseToken(minted.Key)
+ if err != nil || tok.ID != minted.ID {
+ t.Fatalf("minted key %q: %v", minted.Key, err)
+ }
+ return tok
+}
+
+// putDoorObject overwrites whatever sits at the app's record key.
+func putDoorObject(t *testing.T, fx *consoleFixture, body []byte) {
+ t.Helper()
+ key := doorkey.RecordKey("bab", "titogo")
+ _, etag, err := fx.rel.Get(context.Background(), key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(context.Background(), key, body, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func signedDoorRecord(t *testing.T, fx *consoleFixture, rev uint64, keys []doorkey.Entry) []byte {
+ t.Helper()
+ now := time.Now().UTC()
+ wire, err := doorkey.Sign(fx.priv, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: rev,
+ Keys: keys, SignedAt: now, ExpiresAt: now.Add(time.Hour)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return wire
+}
+
+func TestDoorKeysMintListRevoke(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys")
+ code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{"label":"router"}`)
+ if code != http.StatusCreated {
+ t.Fatalf("mint = %d %s", code, out)
+ }
+ var minted struct{ Key, ID, Account, App string }
+ json.Unmarshal([]byte(out), &minted)
+ tok, err := doorkey.ParseToken(minted.Key)
+ if err != nil || tok.ID != minted.ID {
+ t.Fatalf("minted key %q: %v", minted.Key, err)
+ }
+ if minted.Account != "bab" || minted.App != "titogo" {
+ t.Fatalf("mint answered account/app %q/%q", minted.Account, minted.App)
+ }
+ rec := readDoorRecord(t, fx)
+ if !rec.Admits(tok) || rec.Revision != 1 {
+ t.Fatalf("record %+v does not admit the minted key at revision 1", rec)
+ }
+ code, _, out = rawReq(t, http.MethodGet, doorKeysURL(fx), token, "")
+ if code != http.StatusOK || !strings.Contains(out, minted.ID) || strings.Contains(out, "sha256") ||
+ !strings.Contains(out, "router") {
+ t.Fatalf("list = %d %s", code, out)
+ }
+ if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusOK {
+ t.Fatalf("revoke = %d %s", code, out)
+ }
+ if rec := readDoorRecord(t, fx); rec.Admits(tok) || rec.Revision != 2 {
+ t.Fatalf("after revoke: %+v", rec)
+ }
+ // Revoked is gone from the list, and a second revoke is an unknown id.
+ if code, _, out = rawReq(t, http.MethodGet, doorKeysURL(fx), token, ""); code != http.StatusOK || strings.Contains(out, minted.ID) {
+ t.Fatalf("list after revoke = %d %s", code, out)
+ }
+ if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusNotFound {
+ t.Fatalf("second revoke = %d %s, want 404", code, out)
+ }
+ if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/0123456789abcdef/revoke", token, ""); code != http.StatusNotFound {
+ t.Fatalf("unknown id revoke = %d %s, want 404", code, out)
+ }
+}
+
+// Finding 1: whatever sits in the bucket, the console publishes from its
+// database. A record copied in from another app's path is overwritten, not
+// extended.
+func TestDoorKeysPublishFromTheDatabaseNotTheBucket(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-copy")
+ _, planted, _ := doorkey.NewToken("bab", "titogo")
+ now := time.Now().UTC()
+ forged, _ := doorkey.Sign(fx.priv, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: 99,
+ Keys: []doorkey.Entry{planted}, SignedAt: now, ExpiresAt: now.Add(time.Hour)})
+ fx.rel.Put(context.Background(), doorkey.RecordKey("bab", "titogo"), forged, "")
+ if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{}`); code != http.StatusCreated {
+ t.Fatalf("mint = %d %s", code, out)
+ }
+ rec := readDoorRecord(t, fx)
+ for _, e := range rec.Keys {
+ if e.ID == planted.ID {
+ t.Fatal("the console carried a key it never minted into its signed record")
+ }
+ }
+ if len(rec.Keys) != 1 {
+ t.Fatalf("record carries %d keys, want the one minted", len(rec.Keys))
+ }
+}
+
+func TestDoorKeysRefuseServiceCredentialsAndReadOnlyConsoles(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-door-keys-svc")
+ admin := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin)
+ for _, c := range []struct{ method, url, body string }{
+ {http.MethodPost, doorKeysURL(fx), `{}`},
+ {http.MethodGet, doorKeysURL(fx), ""},
+ {http.MethodPost, doorKeysURL(fx) + "/0123456789abcdef/revoke", ""},
+ } {
+ if code, _, out := rawReq(t, c.method, c.url, admin, c.body); code != http.StatusForbidden {
+ t.Errorf("%s %s with an admin service credential = %d %s, want 403", c.method, c.url, code, out)
+ }
+ }
+ if _, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")); err == nil {
+ t.Fatal("a refused service credential left a door-key record")
+ }
+
+ ro := newConsoleFixtureKeyed(t, false)
+ now := time.Now()
+ if _, err := ro.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := ro.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ member := cliToken(t, ro, "alice@keymail.dev", "code-door-keys-ro")
+ if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(ro), member, `{}`); code != http.StatusServiceUnavailable {
+ t.Fatalf("mint on a read-only console = %d %s, want 503", code, out)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(ro)+"/0123456789abcdef/revoke", member, ""); code != http.StatusServiceUnavailable {
+ t.Fatalf("revoke on a read-only console = %d %s, want 503", code, out)
+ }
+ if _, _, err := ro.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")); err == nil {
+ t.Fatal("a read-only console wrote a door-key record")
+ }
+ if keys, _, err := ro.st.DoorKeysLive(OpsAccountID, "titogo"); err != nil || len(keys) != 0 {
+ t.Fatalf("a read-only console stored keys: %v %v", keys, err)
+ }
+}
+
+func TestDoorKeysRefuseANinthKey(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-full")
+ for i := 0; i < doorkey.MaxKeys; i++ {
+ mintDoorKey(t, fx, token)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{}`); code != http.StatusConflict {
+ t.Fatalf("ninth mint = %d %s, want 409", code, out)
+ }
+ rec := readDoorRecord(t, fx)
+ if len(rec.Keys) != doorkey.MaxKeys || rec.Revision != doorkey.MaxKeys {
+ t.Fatalf("record after a refused ninth: %d keys at revision %d", len(rec.Keys), rec.Revision)
+ }
+}
+
+func TestDoorKeyResignRefreshesExpiry(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-resign")
+ tok := mintDoorKey(t, fx, token)
+ before := readDoorRecord(t, fx)
+ acct, err := fx.st.AccountByID(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(2 * time.Millisecond)
+ if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil {
+ t.Fatal(err)
+ }
+ after := readDoorRecord(t, fx)
+ if after.Revision != before.Revision {
+ t.Fatalf("a renewal moved the revision %d -> %d", before.Revision, after.Revision)
+ }
+ if !after.ExpiresAt.After(before.ExpiresAt) || !after.SignedAt.After(before.SignedAt) {
+ t.Fatalf("a renewal did not refresh the expiry: %v -> %v", before.ExpiresAt, after.ExpiresAt)
+ }
+ if !after.Admits(tok) {
+ t.Fatal("a renewal dropped a live key")
+ }
+ if _, rev, _ := fx.st.DoorKeysLive(OpsAccountID, "titogo"); rev != before.Revision {
+ t.Fatalf("a renewal moved the database's revision to %d", rev)
+ }
+}
+
+// blockingStore holds the first Put to one key until release is closed,
+// signalling entered when it gets there.
+type blockingStore struct {
+ release.Store
+ key string
+ once sync.Once
+ entered chan struct{}
+ release chan struct{}
+}
+
+func (b *blockingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == b.key {
+ first := false
+ b.once.Do(func() { first = true })
+ if first {
+ close(b.entered)
+ <-b.release
+ }
+ }
+ return b.Store.Put(ctx, key, body, ifMatch)
+}
+
+// Round 2 finding 5: a renewal that read revision N must not land after a
+// revoke published N+1. The renewal is held inside its Put, under the
+// publication lock, while the revoke writes the database and queues behind
+// the lock. The bucket must end on the revoke's revision.
+func TestDoorKeyRevokeIsNotOverwrittenByARacingRenewal(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-race")
+ tok := mintDoorKey(t, fx, token)
+ acct, err := fx.st.AccountByID(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bs := &blockingStore{Store: fx.rel, key: doorkey.RecordKey("bab", "titogo"),
+ entered: make(chan struct{}), release: make(chan struct{})}
+ fx.srv.cfg.Releases = bs
+
+ var wg sync.WaitGroup
+ var renewErr error
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ renewErr = fx.srv.publishDoorKeys(context.Background(), acct, "titogo")
+ }()
+ <-bs.entered // the renewal has signed revision 1 and is holding the lock
+
+ var revokeCode int
+ var revokeOut string
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ revokeCode, _, revokeOut = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+tok.ID+"/revoke", token, "")
+ }()
+ // The revoke's database write lands before it reaches the lock.
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ if _, rev, _ := fx.st.DoorKeysLive(OpsAccountID, "titogo"); rev == 2 {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("the revoke never wrote the database")
+ }
+ time.Sleep(time.Millisecond)
+ }
+ close(bs.release)
+ wg.Wait()
+ if renewErr != nil {
+ t.Fatalf("renewal: %v", renewErr)
+ }
+ if revokeCode != http.StatusOK {
+ t.Fatalf("revoke = %d %s", revokeCode, revokeOut)
+ }
+ rec := readDoorRecord(t, fx)
+ if rec.Revision != 2 || rec.Admits(tok) {
+ t.Fatalf("bucket after the race: revision %d, admits the revoked key %v", rec.Revision, rec.Admits(tok))
+ }
+}
+
+// Round 2 finding 6: a database whose counter is behind the bucket (a
+// restore) publishes above the bucket's revision, never at or below it.
+func TestDoorKeyPublishAdvancesPastTheBucketRevision(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-advance")
+ first := mintDoorKey(t, fx, token)
+ if rec := readDoorRecord(t, fx); rec.Revision != 1 {
+ t.Fatalf("first publish at revision %d", rec.Revision)
+ }
+ putDoorObject(t, fx, signedDoorRecord(t, fx, 7, nil))
+ second := mintDoorKey(t, fx, token)
+ rec := readDoorRecord(t, fx)
+ if rec.Revision != 8 {
+ t.Fatalf("published revision %d over a bucket at 7, want 8", rec.Revision)
+ }
+ if !rec.Admits(first) || !rec.Admits(second) || len(rec.Keys) != 2 {
+ t.Fatalf("advanced record lost the database's keys: %+v", rec.Keys)
+ }
+ if _, rev, err := fx.st.DoorKeysLive(OpsAccountID, "titogo"); err != nil || rev != 8 {
+ t.Fatalf("database counter = %d %v, want 8", rev, err)
+ }
+ // An equal revision is the renewal case: republished as it is.
+ acct, _ := fx.st.AccountByID(OpsAccountID)
+ if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil {
+ t.Fatal(err)
+ }
+ if rec := readDoorRecord(t, fx); rec.Revision != 8 {
+ t.Fatalf("renewal at an equal revision published %d, want 8", rec.Revision)
+ }
+}
+
+// Round 2 finding 4: replacing an existing object works, and a tampered
+// object is overwritten rather than trusted.
+func TestDoorKeyPublishReplacesExistingAndTamperedObjects(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-replace")
+ a := mintDoorKey(t, fx, token)
+ b := mintDoorKey(t, fx, token) // the second Put replaces the first object
+ if rec := readDoorRecord(t, fx); rec.Revision != 2 || !rec.Admits(a) || !rec.Admits(b) {
+ t.Fatalf("after two mints: %+v", rec)
+ }
+ putDoorObject(t, fx, []byte("not a door-key record"))
+ if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+a.ID+"/revoke", token, ""); code != http.StatusOK {
+ t.Fatalf("revoke over a tampered object = %d %s", code, out)
+ }
+ rec := readDoorRecord(t, fx)
+ if rec.Revision != 3 || rec.Admits(a) || !rec.Admits(b) {
+ t.Fatalf("after revoking over garbage: revision %d, keys %+v", rec.Revision, rec.Keys)
+ }
+ // A validly shaped record signed by some other key is tampering too.
+ other := newConsoleFixture(t) // a different signing key
+ now := time.Now().UTC()
+ foreign, _ := doorkey.Sign(other.priv, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: 50,
+ SignedAt: now, ExpiresAt: now.Add(time.Hour)})
+ putDoorObject(t, fx, foreign)
+ acct, _ := fx.st.AccountByID(OpsAccountID)
+ if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil {
+ t.Fatal(err)
+ }
+ if rec := readDoorRecord(t, fx); rec.Revision != 3 || !rec.Admits(b) {
+ t.Fatalf("a foreign-signed revision was trusted: revision %d", rec.Revision)
+ }
+}
+
+// casConflictStore loses the first n Puts to one key.
+type casConflictStore struct {
+ release.Store
+ key string
+ mu sync.Mutex
+ lose int
+ puts int
+}
+
+func (c *casConflictStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == c.key {
+ c.mu.Lock()
+ c.puts++
+ lose := c.puts <= c.lose
+ c.mu.Unlock()
+ if lose {
+ return "", release.ErrCASConflict
+ }
+ }
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+// Step 5: a lost compare-and-swap rebuilds and retries, at most three times.
+func TestDoorKeyPublishRetriesAConflictAtMostThreeTimes(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-cas")
+ mintDoorKey(t, fx, token)
+ acct, _ := fx.st.AccountByID(OpsAccountID)
+
+ cs := &casConflictStore{Store: fx.rel, key: doorkey.RecordKey("bab", "titogo"), lose: 2}
+ fx.srv.cfg.Releases = cs
+ if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil {
+ t.Fatalf("two conflicts then a win: %v", err)
+ }
+ if cs.puts != 3 {
+ t.Fatalf("%d puts, want 3", cs.puts)
+ }
+
+ cs = &casConflictStore{Store: fx.rel, key: doorkey.RecordKey("bab", "titogo"), lose: 100}
+ fx.srv.cfg.Releases = cs
+ if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("persistent conflict = %v, want ErrCASConflict", err)
+ }
+ if cs.puts != 3 {
+ t.Fatalf("%d puts under a persistent conflict, want 3", cs.puts)
+ }
+}
+
+// The re-sign pass covers every app with a revision row.
+func TestDoorKeyResignPassCoversEveryApp(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-pass")
+ mintDoorKey(t, fx, token)
+ apps, err := fx.st.DoorKeyApps()
+ if err != nil || len(apps) != 1 || apps[0].App != "titogo" || apps[0].Sqid != "bab" || apps[0].AccountID != OpsAccountID {
+ t.Fatalf("DoorKeyApps = %+v %v", apps, err)
+ }
+ before := readDoorRecord(t, fx)
+ time.Sleep(2 * time.Millisecond)
+ fx.srv.resignDoorKeys(context.Background())
+ if after := readDoorRecord(t, fx); !after.ExpiresAt.After(before.ExpiresAt) {
+ t.Fatal("the re-sign pass did not renew the record")
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 2c77fb0c0..9fdd2e11c 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -635,6 +635,11 @@ func (s *Server) registerCLIRoutes() {
// credential gets here from 401 to 403. Pinned by
// TestNoRoleReachesAMintFleetOrPolicyDoor and TestAdminIsRefusedAtAClosedDoor.
s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken)
+ // Door keys (#175 P7): person-only, the instance-token rule. A door key
+ // reaches every instance of the app through every box's edge-local door.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke)
// The sidecar API (apisidecars.go) — the only door onto sidecars/ from
// outside a box, sharing the instance family's gate exactly
// (instanceGate) and, since the role gate landed, its Allow sets too:
diff --git a/internal/console/doorkeys_store.go b/internal/console/doorkeys_store.go
new file mode 100644
index 000000000..9b00ccee8
--- /dev/null
+++ b/internal/console/doorkeys_store.go
@@ -0,0 +1,215 @@
+package console
+
+// Door keys, storage half (#175 P7). The console's database is the
+// authority for which door keys an app has; the bucket only ever holds a
+// signed projection of these rows (apidoorkeys.go). Only a key's sha256 is
+// stored, never its secret.
+//
+// Each app has a revision counter, bumped in the same transaction as every
+// insert and revoke, so a published record's revision always names exactly
+// one key set. DoorKeyAdvanceRevision moves it forward past a revision the
+// bucket already carries, which keeps revisions monotonic across a restored
+// database.
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/doorkey"
+)
+
+// errDoorKeysFull is DoorKeyInsert's refusal when the app already has
+// doorkey.MaxKeys live keys.
+var errDoorKeysFull = errors.New("console: door keys full")
+
+// migrateDoorKeys creates the door-key tables. Additive and idempotent,
+// called from Store.migrate.
+func (s *Store) migrateDoorKeys() error {
+ for _, stmt := range []string{
+ `CREATE TABLE IF NOT EXISTS door_keys (
+ account_id INTEGER NOT NULL,
+ app TEXT NOT NULL,
+ id TEXT NOT NULL,
+ sha256 TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ revoked_at INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (account_id, app, id)
+);`,
+ `CREATE TABLE IF NOT EXISTS door_key_revisions (
+ account_id INTEGER NOT NULL,
+ app TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ PRIMARY KEY (account_id, app)
+);`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// bumpDoorKeyRevision adds one to the app's counter inside tx, creating the
+// row at 1, and returns the new revision.
+func bumpDoorKeyRevision(tx *sql.Tx, accountID int64, app string) (uint64, error) {
+ if _, err := tx.Exec(`INSERT INTO door_key_revisions (account_id, app, revision) VALUES (?, ?, 1)
+ON CONFLICT (account_id, app) DO UPDATE SET revision = revision + 1`, accountID, app); err != nil {
+ return 0, err
+ }
+ var rev uint64
+ err := tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`,
+ accountID, app).Scan(&rev)
+ return rev, err
+}
+
+// DoorKeyInsert stores a newly minted key's digest and bumps the app's
+// revision in the same transaction. It refuses with errDoorKeysFull when the
+// app already has doorkey.MaxKeys live keys.
+func (s *Store) DoorKeyInsert(accountID int64, app string, e doorkey.Entry) (uint64, error) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+ var live int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM door_keys WHERE account_id = ? AND app = ? AND revoked_at = 0`,
+ accountID, app).Scan(&live); err != nil {
+ return 0, err
+ }
+ if live >= doorkey.MaxKeys {
+ return 0, errDoorKeysFull
+ }
+ created := e.CreatedAt
+ if created.IsZero() {
+ created = time.Now()
+ }
+ if _, err := tx.Exec(`INSERT INTO door_keys (account_id, app, id, sha256, label, created_at)
+VALUES (?, ?, ?, ?, ?, ?)`, accountID, app, e.ID, e.SHA256, e.Label, created.UnixNano()); err != nil {
+ return 0, err
+ }
+ rev, err := bumpDoorKeyRevision(tx, accountID, app)
+ if err != nil {
+ return 0, err
+ }
+ return rev, tx.Commit()
+}
+
+// DoorKeyRevoke marks one live key revoked and bumps the app's revision in
+// the same transaction. An unknown or already revoked id is os.ErrNotExist.
+func (s *Store) DoorKeyRevoke(accountID int64, app, id string) (uint64, error) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+ res, err := tx.Exec(`UPDATE door_keys SET revoked_at = ?
+WHERE account_id = ? AND app = ? AND id = ? AND revoked_at = 0`, time.Now().UnixNano(), accountID, app, id)
+ if err != nil {
+ return 0, err
+ }
+ if n, err := res.RowsAffected(); err != nil {
+ return 0, err
+ } else if n == 0 {
+ return 0, os.ErrNotExist
+ }
+ rev, err := bumpDoorKeyRevision(tx, accountID, app)
+ if err != nil {
+ return 0, err
+ }
+ return rev, tx.Commit()
+}
+
+// DoorKeysLive reads the app's live keys and its revision in one
+// transaction, so the two always describe the same key set. An app with no
+// revision row answers no keys at revision 0.
+func (s *Store) DoorKeysLive(accountID int64, app string) ([]doorkey.Entry, uint64, error) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return nil, 0, err
+ }
+ defer tx.Rollback()
+ var rev uint64
+ err = tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`,
+ accountID, app).Scan(&rev)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return nil, 0, err
+ }
+ rows, err := tx.Query(`SELECT id, sha256, label, created_at FROM door_keys
+WHERE account_id = ? AND app = ? AND revoked_at = 0 ORDER BY created_at, id`, accountID, app)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+ keys := []doorkey.Entry{}
+ for rows.Next() {
+ var e doorkey.Entry
+ var created int64
+ if err := rows.Scan(&e.ID, &e.SHA256, &e.Label, &created); err != nil {
+ return nil, 0, err
+ }
+ e.CreatedAt = time.Unix(0, created).UTC()
+ keys = append(keys, e)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+ return keys, rev, tx.Commit()
+}
+
+// DoorKeyApp is one app the re-sign loop publishes.
+type DoorKeyApp struct {
+ AccountID int64
+ Sqid, App string
+}
+
+// DoorKeyApps lists every app with a revision row: every app that has ever
+// had a key minted, including one whose keys are now all revoked, because
+// its empty record still has to stay fresh.
+func (s *Store) DoorKeyApps() ([]DoorKeyApp, error) {
+ rows, err := s.db.Query(`SELECT r.account_id, a.sqid, r.app FROM door_key_revisions r
+JOIN accounts a ON a.id = r.account_id ORDER BY a.sqid, r.app`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []DoorKeyApp
+ for rows.Next() {
+ var d DoorKeyApp
+ if err := rows.Scan(&d.AccountID, &d.Sqid, &d.App); err != nil {
+ return nil, err
+ }
+ out = append(out, d)
+ }
+ return out, rows.Err()
+}
+
+// DoorKeyAdvanceRevision sets the app's revision to past+1 when it is below
+// past, and returns the resulting revision. The publisher calls it when the
+// bucket carries a verified revision above the database's (a restore, or a
+// second writer), so the next record it signs is above anything a box may
+// already have admitted.
+func (s *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error) {
+ if past >= 1<<63-1 {
+ return 0, fmt.Errorf("console: door-key revision %d out of range", past)
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`INSERT INTO door_key_revisions (account_id, app, revision) VALUES (?, ?, ?)
+ON CONFLICT (account_id, app) DO UPDATE SET revision = excluded.revision WHERE revision < ?`,
+ accountID, app, int64(past+1), int64(past)); err != nil {
+ return 0, err
+ }
+ var rev uint64
+ if err := tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`,
+ accountID, app).Scan(&rev); err != nil {
+ return 0, err
+ }
+ return rev, tx.Commit()
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 581b63162..db0eea533 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -730,6 +730,12 @@ type Server struct {
// door serialises these verbs per app" (objstore/walk.go:560) — and until
// #381 nothing did.
storeWalks *storeWalkLocks
+ // doorPublish is the door-key publication lock per app, keyed
+ // "<sqid>/<app>" (apidoorkeys.go): one publisher per app at a time, so a
+ // renewal can never land an older key set after a revoke. Built lazily
+ // under doorPublishMu, so the zero Server needs no setup.
+ doorPublishMu sync.Mutex
+ doorPublish map[string]*sync.Mutex
// sendApp is how the alert watcher (alertwatch.go) delivers keymail:
// km.SendApp in production, a capture in tests. A field rather than a
// direct call so an alertPass assertion needs no keymail server.
diff --git a/internal/console/store.go b/internal/console/store.go
index 0b4f3f3c3..964b0884c 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -412,7 +412,11 @@ func (s *Store) migrate() error {
return err
}
// Pending-change quorum's own tables (pending.go), for the same reason.
- return s.migratePendingChanges()
+ if err := s.migratePendingChanges(); err != nil {
+ return err
+ }
+ // Door keys' own tables (doorkeys_store.go), for the same reason.
+ return s.migrateDoorKeys()
}
// mailCountsBackfillVersion is the user_version this migration stamps. The
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 44765af64..ced377726 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -72,7 +72,7 @@ import (
// authority — it is the end of the boundary the roles are drawn inside. One
// substring covers all seven doors, and no role may ever reach any of them.
var (
- mintDoors = []string{"/instance-token", "/api/cli/services/", "/email/credentials"}
+ mintDoors = []string{"/instance-token", "/door-keys", "/api/cli/services/", "/email/credentials"}
fleetDoors = []string{"/api/cli/fleets/"}
policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe"}
systemDoors = []string{"/api/cli/system/"}
@@ -98,6 +98,12 @@ var closedDoors = []string{
// A credential may never mint a credential, including itself. This is
// the escalation cut of spec §3.
"POST /api/cli/apps/{account}/{app}/instance-token",
+ // Door keys (#175 P7) are credentials too: minting one is the same cut,
+ // and listing and revoking them are closed with it, as the services
+ // family's are.
+ "GET /api/cli/apps/{account}/{app}/door-keys",
+ "POST /api/cli/apps/{account}/{app}/door-keys",
+ "POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke",
// Enabling instances writes the MANIFEST — standing policy about which
// domain, pool, regions and channel the app may ever be brought up on.
// A credential the app itself holds must not rewrite the bounds it runs
@@ -794,8 +800,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 31 {
- t.Errorf("walked %d closed doors, want all %d (and 31 today: instance-token, the routes door, instances-manifest, sidecars-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)",
+ if seen != len(closedDoors) || seen != 34 {
+ t.Errorf("walked %d closed doors, want all %d (and 34 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)",
seen, len(closedDoors))
}
}