carlos / platform Public

release: an Authorize hook between pointer read and write (#229)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1f6c0c490f063ae7ff059e76f015785770838663 parent 248148d
3 files changed, +399 −1
  • internal/release/authorize.go +14 −0
  • internal/release/authorize_test.go +329 −0
  • internal/release/release.go +56 −1
diff --git a/internal/release/authorize.go b/internal/release/authorize.go
new file mode 100644
index 000000000..38aa8596b
--- /dev/null
+++ b/internal/release/authorize.go
@@ -0,0 +1,14 @@
+package release
+
+// AuthorizationError is a refusal from a caller's Authorize hook. The
+// primitives wrap the hook's own error in it so a caller can tell "you may
+// not" from a store or ladder failure with errors.As.
+//
+// The hook runs on a pipeline the primitive has just read (spec
+// 2026-10-05-deploy-credential-design.md §7.2): on the ladder's read, on the
+// repeat path, and between the pointer read and the conditional write. A
+// caller that passes no hook gets no extra reads.
+type AuthorizationError struct{ Err error }
+
+func (e *AuthorizationError) Error() string { return e.Err.Error() }
+func (e *AuthorizationError) Unwrap() error { return e.Err }
diff --git a/internal/release/authorize_test.go b/internal/release/authorize_test.go
new file mode 100644
index 000000000..5d5b78eba
--- /dev/null
+++ b/internal/release/authorize_test.go
@@ -0,0 +1,329 @@
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "os"
+ "testing"
+)
+
+// storeCall is one call a request made through hookStore.
+type storeCall struct {
+ seq int
+ op, key string
+ ifMatch string // put only
+}
+
+// hookStore runs before(call) ahead of every call it forwards, and after()
+// once a get returns, so a test can make another actor's writes land at an
+// exact point inside a request. The other actor writes to inner directly:
+// the same handle, which is where G2 holds for a directory store (spec
+// §7.2). seq counts only the calls made through hookStore.
+type hookStore struct {
+ inner Store
+ before func(c storeCall)
+ after func(c storeCall, body []byte, etag string, err error)
+ seq int
+}
+
+func (h *hookStore) call(op, key, ifMatch string) storeCall {
+ h.seq++
+ c := storeCall{seq: h.seq, op: op, key: key, ifMatch: ifMatch}
+ if h.before != nil {
+ h.before(c)
+ }
+ return c
+}
+
+func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c := h.call("get", key, "")
+ body, etag, err := h.inner.Get(ctx, key)
+ if h.after != nil {
+ h.after(c, body, etag, err)
+ }
+ return body, etag, err
+}
+
+func (h *hookStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ h.call("put", key, ifMatch)
+ return h.inner.Put(ctx, key, body, ifMatch)
+}
+
+func (h *hookStore) List(ctx context.Context, prefix string) ([]string, error) {
+ h.call("list", prefix, "")
+ return h.inner.List(ctx, prefix)
+}
+
+func (h *hookStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ h.call("listdirs", prefix, "")
+ return h.inner.ListDirs(ctx, prefix)
+}
+
+func (h *hookStore) Delete(ctx context.Context, key string) error {
+ h.call("delete", key, "")
+ return h.inner.Delete(ctx, key)
+}
+
+// onNth runs fn once, just before the nth (1-based) call matching op and key.
+func onNth(op, key string, n int, fn func()) func(storeCall) {
+ seen := 0
+ return func(c storeCall) {
+ if c.op == op && c.key == key {
+ seen++
+ if seen == n {
+ fn()
+ }
+ }
+ }
+}
+
+// noPipelineOnly is R2's rule for a non-canary channel: refuse if the app
+// has a pipeline.
+func noPipelineOnly(p *Pipeline) error {
+ if p != nil {
+ return errors.New("pipelined")
+ }
+ return nil
+}
+
+var edgeKey = ChannelKey("bab", "myapp", "edge")
+
+func setupEdge(t *testing.T) (context.Context, Store, ed25519.PrivateKey, ed25519.PublicKey) {
+ t.Helper()
+ ctx := context.Background()
+ inner := NewDirStore(t.TempDir())
+ priv, pub := testKeys(t)
+ for _, v := range []string{"v1", "v2", "v3", "v4"} {
+ shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v))
+ }
+ return ctx, inner, priv, pub
+}
+
+func installPipeline(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, names ...string) {
+ t.Helper()
+ var chans []PipelineChannel
+ for _, n := range names {
+ chans = append(chans, PipelineChannel{Name: n})
+ }
+ if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", chans, "owner"); err != nil {
+ t.Fatalf("SetPipeline(%v): %v", names, err)
+ }
+}
+
+func ownerPromote(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, v string) {
+ t.Helper()
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil {
+ t.Fatalf("owner promote %s: %v", v, err)
+ }
+}
+
+func edgeVersion(t *testing.T, ctx context.Context, s Store, pub ed25519.PublicKey) string {
+ t.Helper()
+ p, err := Channel(ctx, s, pub, "bab", "myapp", "edge")
+ if errors.Is(err, os.ErrNotExist) {
+ return ""
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p.Version
+}
+
+func wantAuthorizationError(t *testing.T, err error) {
+ t.Helper()
+ var ae *AuthorizationError
+ if !errors.As(err, &ae) {
+ t.Fatalf("err = %v; want *AuthorizationError", err)
+ }
+}
+
+func TestAuthorizeSeesAPipelineInstalledBeforeTheRequest(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+ if got := edgeVersion(t, ctx, inner, pub); got != "" {
+ t.Fatalf("edge = %q after a refusal", got)
+ }
+}
+
+// The refusal is the hook's, not the ladder's order error, even when the
+// pipeline would make the ladder refuse too (spec §7.2 point 1, codex R3-3).
+func TestAuthorizeRefusesBeforeTheLadderDoes(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ h := &hookStore{inner: inner}
+ h.before = onNth("get", PipelineKey("bab", "myapp"), 1, func() {
+ installPipeline(t, ctx, inner, priv, "preview")
+ })
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+}
+
+// The ladder's read saw no pipeline; one installed before the authoritative
+// read (after the pointer read) is still seen.
+func TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ h := &hookStore{inner: inner}
+ h.before = onNth("get", PipelineKey("bab", "myapp"), 2, func() {
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ })
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+ if got := edgeVersion(t, ctx, inner, pub); got != "" {
+ t.Fatalf("edge = %q after a refusal", got)
+ }
+}
+
+// Astra's round 2 counterexample: an owner promotes under a new pipeline
+// after the deploy request read the pointer. The deploy write must lose.
+func TestOwnerPromoteAfterInstallDefeatsTheDeployWrite(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() {
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ ownerPromote(t, ctx, inner, priv, "v3")
+ })
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2",
+ PromoteOptions{Authorize: noPipelineOnly})
+ if !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("err = %v; want ErrCASConflict", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
+ t.Fatalf("edge = %q; want the owner's v3", got)
+ }
+}
+
+// R5, accepted: a pipeline installed after the authorization read, and the
+// pointer written and restored to identical bytes, before the write. G2
+// holds (the bytes are what was read), so the write lands.
+func TestRestoredBytesLandAsTheAcceptedInFlightCase(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ ownerPromote(t, ctx, inner, priv, "v2")
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() {
+ orig, _, err := inner.Get(ctx, edgeKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ ownerPromote(t, ctx, inner, priv, "v4")
+ _, cur, err := inner.Get(ctx, edgeKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := inner.Put(ctx, edgeKey, orig, cur); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3",
+ PromoteOptions{Authorize: noPipelineOnly}); err != nil {
+ t.Fatalf("err = %v; R5 accepts this write (spec §16)", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
+ t.Fatalf("edge = %q; want v3", got)
+ }
+}
+
+// R5, accepted: a first promote onto an empty channel, with a pipeline
+// installed after the authorization read.
+func TestFirstPromoteOntoEmptyChannelLandsAsTheAcceptedInFlightCase(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() {
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ })
+ if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly}); err != nil {
+ t.Fatalf("err = %v; R5 accepts this write (spec §16)", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v1" {
+ t.Fatalf("edge = %q; want v1", got)
+ }
+}
+
+func TestRepeatPromoteRunsTheHook(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+ if res.NoOp {
+ t.Fatal("a refused repeat reported NoOp")
+ }
+}
+
+func TestRepeatPromoteWithNoPipelineIsANoOp(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ var seen []bool
+ hook := func(p *Pipeline) error { seen = append(seen, p != nil); return noPipelineOnly(p) }
+ res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: hook})
+ if err != nil || !res.NoOp {
+ t.Fatalf("= %+v, %v; want a no-op", res, err)
+ }
+ if len(seen) != 1 || seen[0] {
+ t.Fatalf("hook calls = %v; want one, with no pipeline", seen)
+ }
+}
+
+// A caller with no hook makes no extra pipeline reads (spec §7.2).
+func TestNoHookNoExtraReads(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ count := func(run func(Store) error) int {
+ n := 0
+ h := &hookStore{inner: inner, before: func(c storeCall) {
+ if c.op == "get" && c.key == PipelineKey("bab", "myapp") {
+ n++
+ }
+ }}
+ if err := run(h); err != nil {
+ t.Fatal(err)
+ }
+ return n
+ }
+ fresh := count(func(s Store) error {
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{})
+ return err
+ })
+ repeat := count(func(s Store) error {
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{})
+ return err
+ })
+ ownerPromote(t, ctx, inner, priv, "v2")
+ rollback := count(func(s Store) error { return RootSigner(priv).Rollback(ctx, s, "bab", "myapp", "edge") })
+ if fresh != 1 || repeat != 0 || rollback != 0 {
+ t.Fatalf("pipeline reads: fresh %d, repeat %d, rollback %d; want 1, 0, 0", fresh, repeat, rollback)
+ }
+}
+
+func TestRollbackAuthorized(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ ownerPromote(t, ctx, inner, priv, "v2")
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ err := RootSigner(priv).RollbackAuthorized(ctx, inner, "bab", "myapp", "edge", noPipelineOnly)
+ wantAuthorizationError(t, err)
+ if got := edgeVersion(t, ctx, inner, pub); got != "v2" {
+ t.Fatalf("edge = %q after a refused rollback", got)
+ }
+ if err := inner.Delete(ctx, PipelineKey("bab", "myapp")); err != nil {
+ t.Fatal(err)
+ }
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() { ownerPromote(t, ctx, inner, priv, "v3") })
+ err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", noPipelineOnly)
+ if !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("err = %v; want ErrCASConflict", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
+ t.Fatalf("edge = %q; want the owner's v3", got)
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index e7d8111cb..579f9e253 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -692,6 +692,13 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, c
// migration verb, the tests, anything moving a pointer mechanically — have no
// promotion to record; a promotion a person made does.
func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error {
+ return sg.promote(ctx, s, acct, app, channel, version, nil, rec...)
+}
+
+// promote is Promote with an optional authorize hook, called on a pipeline
+// read after the pointer read and before the conditional write. nil means no
+// hook and no extra reads.
+func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, rec ...Promotion) error {
if !ValidAccount(acct) {
return fmt.Errorf("promote: invalid account %q", acct)
}
@@ -742,6 +749,18 @@ func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, versi
default:
return err
}
+ if authorize != nil {
+ // Between the pointer read and the conditional write, so the write
+ // lands only if the pointer is still what was authorized against
+ // (spec §7.2, G1 and G2).
+ pipe, perr := GetPipeline(ctx, s, pub, acct, app)
+ if perr != nil {
+ return fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
+ }
+ if aerr := authorize(pipe); aerr != nil {
+ return &AuthorizationError{Err: aerr}
+ }
+ }
next := &ChannelPointer{Account: acct, App: app, Channel: channel, Version: version,
History: old.History, Audit: old.Audit}
if old.Version != "" && old.Version != version {
@@ -773,6 +792,12 @@ type PromoteOptions struct {
// Actor is who is promoting, recorded in the pointer's audit trail: a
// console member's address, or the operator's user@host at the CLI.
Actor string
+ // Authorize, when set, is called with the app's pipeline as read at each
+ // point a write could depend on it: the ladder's read, the repeat path,
+ // and between the pointer read and the conditional write. A non-nil
+ // error refuses the promotion as an *AuthorizationError. nil means
+ // today's behaviour exactly, with no extra reads.
+ Authorize func(p *Pipeline) error
}
// PromoteResult says what a ladder promotion actually did, so the caller can
@@ -886,6 +911,15 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
// must not fail, whichever rung the build has since climbed to.
if cur, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil {
if p, verr := verifyPointer(pub, cur, acct, app, channel); verr == nil && p.Version == version {
+ if opt.Authorize != nil {
+ pipe, perr := GetPipeline(ctx, s, pub, acct, app)
+ if perr != nil {
+ return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
+ }
+ if aerr := opt.Authorize(pipe); aerr != nil {
+ return res, &AuthorizationError{Err: aerr}
+ }
+ }
res.NoOp = true
// The same fallback res.To takes above, for the same reason: the
// build IS on this channel (that is what makes this a repeat), so
@@ -931,6 +965,11 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
if perr != nil {
return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
}
+ if opt.Authorize != nil {
+ if aerr := opt.Authorize(pipe); aerr != nil {
+ return res, &AuthorizationError{Err: aerr}
+ }
+ }
var from string
var err error
if pipe != nil {
@@ -957,7 +996,7 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
}
}
rec := Promotion{From: res.From, To: res.To, Actor: opt.Actor, Hotfix: opt.Hotfix}
- if err := sg.Promote(ctx, s, acct, app, channel, version, rec); err != nil {
+ if err := sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, rec); err != nil {
return res, err
}
stableTag(ctx, s, acct, app, version, opt.Actor, &res)
@@ -1029,6 +1068,13 @@ func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app,
// outside the scope needs the root key, which is the same answer as moving it
// forward.
func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel string) error {
+ return sg.RollbackAuthorized(ctx, s, acct, app, channel, nil)
+}
+
+// RollbackAuthorized is Rollback with an optional authorize hook, called on a
+// pipeline read after the pointer read and before the conditional write. nil
+// means no hook and no extra reads.
+func (sg Signer) RollbackAuthorized(ctx context.Context, s Store, acct, app, channel string, authorize func(*Pipeline) error) error {
if !ValidAccount(acct) {
return fmt.Errorf("rollback: invalid account %q", acct)
}
@@ -1051,6 +1097,15 @@ func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel strin
if len(old.History) == 0 {
return fmt.Errorf("rollback: %s/%s has no previous version recorded", app, channel)
}
+ if authorize != nil {
+ pipe, perr := GetPipeline(ctx, s, pub, acct, app)
+ if perr != nil {
+ return fmt.Errorf("rollback: cannot read the pipeline for %s/%s: %w", acct, app, perr)
+ }
+ if aerr := authorize(pipe); aerr != nil {
+ return &AuthorizationError{Err: aerr}
+ }
+ }
next := &ChannelPointer{
Account: acct,
App: app,