settings-2 task 2: the signed promotion record and AuthorizedManifest
The record lives at releases/<acct>/<app>/promoted/<version>. The hostops mutation classifier gains a reviewed, app-scoped promotion-record family for exactly that key, so a claim-enabled console can write it (ruling R1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+395
−0
internal/hostops/mutation_classifier.go+16 −0internal/hostops/promotionrecord_classifier_test.go+54 −0internal/release/promotion.go+181 −0internal/release/promotion_test.go+144 −0
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go| index e9694df65..69ae1ce2a 100644 |
| --- a/internal/hostops/mutation_classifier.go |
| +++ b/internal/hostops/mutation_classifier.go |
| @@ -148,6 +148,8 @@ func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter, |
| return classifyGeoip(t) |
| case strings.HasPrefix(t.Key, "store/entitlements/") && len(p) == 3: |
| return Classification{Family: "store-entitlement", NonApp: "operator-set account entitlement; no app state"}, nil |
| + case p[0] == "releases" && len(p) == 5 && p[3] == "promoted": |
| + return classifyPromotionRecord(t, p) |
| case p[0] == "releases": |
| return classifyPointerDigestMarker(t, p) |
| } |
| @@ -179,6 +181,20 @@ func classifyPointerDigestMarker(t MutationTarget, p []string) (Classification, |
| NonApp: "root-signed account switch for pointer digest emission; no app state"}, nil |
| } |
| +// classifyPromotionRecord admits a build's signed promotion record, |
| +// releases/<acct>/<app>/promoted/<version> (settings plan 2, spec §4.1), |
| +// written when a version is promoted or authorized and deleted when the app |
| +// is purged. It is app state, scoped to exactly that app. Anything else |
| +// under releases/<acct>/<app>/ stays unreviewed. |
| +func classifyPromotionRecord(t MutationTarget, p []string) (Classification, error) { |
| + if len(p) != 5 || p[3] != "promoted" || !controlname.ValidAccount(p[1]) || !controlname.ValidApp(p[2]) || |
| + !release.ValidVersion(p[4]) || p[4] == "dev" || t.Key != release.PromotionRecordKey(p[1], p[2], p[4]) || |
| + (t.Method != "Put" && t.Method != "Delete") { |
| + return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key) |
| + } |
| + return appScoped("promotion-record", p[1], p[2]) |
| +} |
| + |
| func appScoped(family, account, app string) (Classification, error) { |
| if !controlname.ValidAccount(account) || !controlname.ValidApp(app) { |
| return Classification{}, ErrIdentity |
diff --git a/internal/hostops/promotionrecord_classifier_test.go b/internal/hostops/promotionrecord_classifier_test.go| new file mode 100644 |
| index 000000000..adda9c3dc |
| --- /dev/null |
| +++ b/internal/hostops/promotionrecord_classifier_test.go |
| @@ -0,0 +1,54 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestClassifyPromotionRecordIsExact: a build's signed promotion record, |
| +// releases/<acct>/<app>/promoted/<version> (settings plan 2, spec §4.1), is |
| +// a reviewed family scoped to exactly its app, put and deleted; nothing |
| +// else under releases/<acct>/<app>/ is. |
| +func TestClassifyPromotionRecordIsExact(t *testing.T) { |
| + ctx := context.Background() |
| + key := release.PromotionRecordKey("bab", "km", "v1") |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"record put", MutationTarget{Surface: "bucket", Method: "Put", Key: key, After: []byte(`{}`)}}, |
| + {"record put over an existing one", MutationTarget{Surface: "bucket", Method: "Put", Key: key, ETag: "x", Before: []byte(`{}`), After: []byte(`{}`)}}, |
| + {"record delete", MutationTarget{Surface: "bucket", Method: "Delete", Key: key}}, |
| + } { |
| + got, err := ClassifyMutation(ctx, nil, nil, c.target) |
| + if err != nil { |
| + t.Errorf("%s: %v", c.name, err) |
| + continue |
| + } |
| + if got.Family != "promotion-record" || got.NonApp != "" || got.MailGate || |
| + len(got.Scopes) != 1 || got.Scopes[0] != (AppScope{"bab", "km"}) { |
| + t.Errorf("%s: classified %+v, want family promotion-record scoped to exactly {bab km}", c.name, got) |
| + } |
| + } |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"an invalid account", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/BAB!/km/promoted/v1", After: []byte(`{}`)}}, |
| + {"an invalid app", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/KM!/promoted/v1", After: []byte(`{}`)}}, |
| + {"an empty version", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/promoted/", After: []byte(`{}`)}}, |
| + {"a sixth segment", MutationTarget{Surface: "bucket", Method: "Put", Key: key + "/x", After: []byte(`{}`)}}, |
| + {"no version", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/promoted", After: []byte(`{}`)}}, |
| + {"another name under the app", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/other/v1", After: []byte(`{}`)}}, |
| + {"the dev version", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/km/promoted/dev", After: []byte(`{}`)}}, |
| + {"a delete-if-match", MutationTarget{Surface: "bucket", Method: "DeleteIfMatch", Key: key, ETag: "x"}}, |
| + } { |
| + got, err := ClassifyMutation(ctx, nil, nil, c.target) |
| + if !errors.Is(err, ErrUnclassified) && !errors.Is(err, ErrIdentity) { |
| + t.Errorf("%s: classified %+v, err %v; want ErrUnclassified or ErrIdentity", c.name, got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/release/promotion.go b/internal/release/promotion.go| new file mode 100644 |
| index 000000000..ed73eef25 |
| --- /dev/null |
| +++ b/internal/release/promotion.go |
| @@ -0,0 +1,181 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| +) |
| + |
| +// PromotionRecordKey is the durable record of one promoted build (spec §4.1). |
| +// It sits outside AppPrefix, beside the account's pointer digest marker, so |
| +// PurgeApp sweeps it explicitly. |
| +func PromotionRecordKey(acct, app, version string) string { |
| + return "releases/" + acct + "/" + app + "/promoted/" + version |
| +} |
| + |
| +// PromotionRecord binds a version name to the exact manifest that was |
| +// promoted (or explicitly authorized). It keeps a build authorized after it |
| +// falls out of the pointer's history, and a reused name cannot inherit it: |
| +// the new name's manifest hashes to another digest. |
| +type PromotionRecord struct { |
| + Schema int `json:"schema"` // 1 |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Version string `json:"version"` |
| + ManifestDigest string `json:"manifest_digest"` |
| + Channel string `json:"channel,omitempty"` |
| + Authorized bool `json:"authorized,omitempty"` |
| + Actor string `json:"actor,omitempty"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +// ErrNoPromotionRecord is a build with no record: promoted before records |
| +// existed, or never promoted. It wraps os.ErrNotExist. |
| +var ErrNoPromotionRecord = fmt.Errorf("release: no promotion record: %w", os.ErrNotExist) |
| + |
| +func validateRecord(r *PromotionRecord, acct, app, version string) error { |
| + if r.Schema != 1 { |
| + return fmt.Errorf("unsupported promotion record schema %d", r.Schema) |
| + } |
| + if r.Account != acct || r.App != app || r.Version != version { |
| + return fmt.Errorf("promotion record names %s/%s/%s, expected %s/%s/%s", r.Account, r.App, r.Version, acct, app, version) |
| + } |
| + if !ValidAccount(acct) || !ValidApp(app) || !ValidVersion(version) || version == "dev" { |
| + return fmt.Errorf("promotion record for invalid %s/%s/%s", acct, app, version) |
| + } |
| + if !validDigest(r.ManifestDigest) { |
| + return fmt.Errorf("promotion record has an invalid manifest digest") |
| + } |
| + if r.Channel != "" && !ValidChannel(r.Channel) { |
| + return fmt.Errorf("promotion record has an invalid channel %q", r.Channel) |
| + } |
| + if r.Channel == "" && !r.Authorized { |
| + return fmt.Errorf("promotion record names no channel and is not an authorization") |
| + } |
| + if r.At.IsZero() { |
| + return fmt.Errorf("promotion record has no time") |
| + } |
| + return nil |
| +} |
| + |
| +// WritePromotionRecord signs rec, stamped now, and writes it unless the key |
| +// already holds a verified record for the same digest: one record per |
| +// version, kept from its first promotion. A record for other bytes (a name |
| +// pruned and reshipped) is replaced. A scoped signer may write only a record |
| +// whose channel its grant covers, and never an authorization. |
| +func (sg Signer) WritePromotionRecord(ctx context.Context, s Store, rec PromotionRecord) error { |
| + if err := sg.usable(); err != nil { |
| + return err |
| + } |
| + rec.Schema = 1 |
| + rec.At = time.Now().UTC().Truncate(time.Second) |
| + if err := validateRecord(&rec, rec.Account, rec.App, rec.Version); err != nil { |
| + return err |
| + } |
| + if rec.Channel == "" || rec.Authorized { |
| + if sg.scope != nil { |
| + return fmt.Errorf("promotion record: an authorization needs the deployment's root key") |
| + } |
| + } else if err := sg.allows(rec.Channel, rec.At); err != nil { |
| + return fmt.Errorf("promotion record: %w", err) |
| + } |
| + key := PromotionRecordKey(rec.Account, rec.App, rec.Version) |
| + _, etag, err := s.Get(ctx, key) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + return err |
| + default: |
| + if cur, rerr := ReadPromotionRecord(ctx, s, sg.verifyPub(), rec.Account, rec.App, rec.Version); rerr == nil && cur.ManifestDigest == rec.ManifestDigest { |
| + return nil |
| + } |
| + } |
| + payload, err := json.MarshalIndent(rec, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + wire, err := json.MarshalIndent(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(sg.key, []byte(b64))), |
| + Grant: sg.grant, |
| + }, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, wire, etag) |
| + return err |
| +} |
| + |
| +// ReadPromotionRecord verifies the record at version's key: root -> grant -> |
| +// record, the chain verifyPointer checks, and the signed payload must name |
| +// this account, app and version. (nil, ErrNoPromotionRecord) when absent; any |
| +// other failure is an error that is never read as "absent". |
| +func ReadPromotionRecord(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*PromotionRecord, error) { |
| + raw, _, err := s.Get(ctx, PromotionRecordKey(acct, app, version)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, fmt.Errorf("%s/%s: %w", app, version, ErrNoPromotionRecord) |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var sw signedWrapper |
| + if err := json.Unmarshal(raw, &sw); err != nil { |
| + return nil, fmt.Errorf("promotion record wrapper: %v", err) |
| + } |
| + signer, scope := pub, (*Grant)(nil) |
| + if sw.Grant != nil { |
| + g, err := verifyGrant(pub, sw.Grant) |
| + if err != nil { |
| + return nil, err |
| + } |
| + gpub, err := ParsePublicKey(g.Pub, "key grant pub") |
| + if err != nil { |
| + return nil, err |
| + } |
| + signer, scope = gpub, g |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| + if err != nil || len(signer) != ed25519.PublicKeySize || !ed25519.Verify(signer, []byte(sw.Payload), sig) { |
| + return nil, fmt.Errorf("promotion record %s/%s: signature verification failed", app, version) |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| + if err != nil { |
| + return nil, fmt.Errorf("promotion record payload: %v", err) |
| + } |
| + var rec PromotionRecord |
| + if err := json.Unmarshal(payload, &rec); err != nil { |
| + return nil, fmt.Errorf("promotion record payload: %v", err) |
| + } |
| + if err := validateRecord(&rec, acct, app, version); err != nil { |
| + return nil, err |
| + } |
| + if scope != nil { |
| + if rec.Channel == "" || rec.Authorized { |
| + return nil, fmt.Errorf("promotion record %s/%s: an authorization signed by a scoped key", app, version) |
| + } |
| + if err := scope.allows(rec.Channel, rec.At); err != nil { |
| + return nil, fmt.Errorf("promotion record %s/%s: %w", app, version, err) |
| + } |
| + } |
| + return &rec, nil |
| +} |
| + |
| +// AuthorizedManifest is §4.1's evidence for every install the settings add: |
| +// the build's signed record, and its canonical manifest hashing to the |
| +// record's digest. The manifest it returns is the one it checked; the caller |
| +// stages from it and never re-reads it. |
| +func AuthorizedManifest(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*Manifest, error) { |
| + rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, version) |
| + if err != nil { |
| + return nil, err |
| + } |
| + m, _, err := CheckManifestDigest(ctx, s, acct, app, version, rec.ManifestDigest) |
| + return m, err |
| +} |
diff --git a/internal/release/promotion_test.go b/internal/release/promotion_test.go| new file mode 100644 |
| index 000000000..99444b8de |
| --- /dev/null |
| +++ b/internal/release/promotion_test.go |
| @@ -0,0 +1,144 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func recordFor(t *testing.T, s Store, sg Signer, acct, app, ver, channel string) PromotionRecord { |
| + t.Helper() |
| + raw, _, err := s.Get(context.Background(), ManifestKey(acct, app, ver)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := PromotionRecord{Account: acct, App: app, Version: ver, ManifestDigest: ManifestDigest(raw), Channel: channel, Actor: "test"} |
| + if err := sg.WritePromotionRecord(context.Background(), s, rec); err != nil { |
| + t.Fatalf("WritePromotionRecord: %v", err) |
| + } |
| + return rec |
| +} |
| + |
| +func TestPromotionRecordRoundTrip(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + want := recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + got, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + if err != nil || got.ManifestDigest != want.ManifestDigest || got.Channel != "stable" || got.At.IsZero() { |
| + t.Fatalf("ReadPromotionRecord = %+v, %v", got, err) |
| + } |
| + if m, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); err != nil || m.Version != "v1" { |
| + t.Fatalf("AuthorizedManifest = %+v, %v", m, err) |
| + } |
| +} |
| + |
| +// Spec §8: a fresh, never-promoted version with a matching manifest is |
| +// refused for a pin. |
| +func TestANeverPromotedVersionHasNoRecord(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + _, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1") |
| + if !errors.Is(err, ErrNoPromotionRecord) || !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("AuthorizedManifest = %v; want ErrNoPromotionRecord wrapping os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +// Spec §8 "Promotion binding": a reused version name with a replayed record |
| +// and a different manifest is refused. |
| +func TestAReplayedRecordDoesNotAuthorizeAReusedName(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("promoted bytes")) |
| + recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + saved, _, _ := s.Get(ctx, PromotionRecordKey("bab", "km", "v1")) |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("nobody promoted these")) |
| + _, etag, gerr := s.Get(ctx, PromotionRecordKey("bab", "km", "v1")) |
| + if errors.Is(gerr, os.ErrNotExist) { |
| + etag = "" |
| + } |
| + if _, err := s.Put(ctx, PromotionRecordKey("bab", "km", "v1"), saved, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrManifestDigest) { |
| + t.Fatalf("AuthorizedManifest = %v; want ErrManifestDigest", err) |
| + } |
| +} |
| + |
| +// Review Focus 2. |
| +func TestARecordCopiedToAnotherKeyIsRefused(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + raw, _, _ := s.Get(ctx, PromotionRecordKey("bab", "km", "v1")) |
| + if _, err := s.Put(ctx, PromotionRecordKey("bab", "blog", "v1"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "blog", "v1"); err == nil || errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a copied record = %v; want a refusal that is not 'absent'", err) |
| + } |
| +} |
| + |
| +func TestScopedRecordsFollowTheGrant(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + root, pub := testKeys(t) |
| + seed, grant, err := MintKeyGrant(root, "ci", []string{"canary"}, time.Hour) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, _ := ParseSigningKey(seed, "test") |
| + sg, err := ScopedSigner(key, grant, pub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + raw, _, _ := s.Get(ctx, ManifestKey("bab", "km", "v1")) |
| + d := ManifestDigest(raw) |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "km", Version: "v1", ManifestDigest: d, Channel: "canary/x"}); err != nil { |
| + t.Fatalf("in scope: %v", err) |
| + } |
| + if rec, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); err != nil || rec.Channel != "canary/x" { |
| + t.Fatalf("scoped record = %+v, %v", rec, err) |
| + } |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "km", Version: "v1", ManifestDigest: d, Channel: "stable"}); err == nil { |
| + t.Fatal("a canary-scoped key signed a stable record") |
| + } |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "km", Version: "v1", ManifestDigest: d, Authorized: true}); err == nil { |
| + t.Fatal("a scoped key signed an authorize record") |
| + } |
| +} |
| + |
| +func TestWriteKeepsAnIdenticalRecordAndReplacesAnother(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + first := recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "canary/x") |
| + got1, _ := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + time.Sleep(1100 * time.Millisecond) |
| + recordFor(t, s, RootSigner(priv), "bab", "km", "v1", "stable") |
| + got2, _ := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1") |
| + if !got2.At.Equal(got1.At) || got2.Channel != "canary/x" { |
| + t.Fatalf("an identical digest replaced the record: %+v -> %+v", got1, got2) |
| + } |
| + other := first |
| + other.ManifestDigest = ManifestDigest([]byte("another manifest")) |
| + if err := RootSigner(priv).WritePromotionRecord(ctx, s, other); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got3, _ := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); got3.ManifestDigest != other.ManifestDigest { |
| + t.Fatal("a record for different bytes did not replace the old one") |
| + } |
| +} |