carlos / platform Public

settings-2 task 4: PlanAuthorize and Authorize (root key, explicit, never automatic)

Authorize returns (already bool, err error). It runs claimRawAuthorize
under one app mutation (release.Authorize), as the other signed app
writes in claim_app_writes.go do, and refuses a nil plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c41c0c4061a1ea77a32d856dedd353ed008ea96e parent fcaa86d
3 files changed, +147 −0
  • internal/release/authorize_record.go +51 −0
  • internal/release/authorize_record_test.go +83 −0
  • internal/release/claim_app_writes.go +13 −0
diff --git a/internal/release/authorize_record.go b/internal/release/authorize_record.go
new file mode 100644
index 000000000..dfbda69a2
--- /dev/null
+++ b/internal/release/authorize_record.go
@@ -0,0 +1,51 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+// ErrAuthorizeChanged is an authorize whose version was reshipped between the
+// plan and the confirmation; ErrAuthorizeRootOnly a scoped key.
+var (
+ ErrAuthorizeChanged = errors.New("release: the manifest changed since the authorize plan was made")
+ ErrAuthorizeRootOnly = errors.New("release: authorizing a build needs the root key")
+)
+
+// AuthorizePlan is what `carlos releases authorize` shows: the digest the
+// record will carry and every artifact's sha256 (spec §4.1).
+type AuthorizePlan struct {
+ Account, App, Version, ManifestDigest string
+ Artifacts map[string]Artifact
+}
+
+// PlanAuthorize reads the version's canonical manifest. It writes nothing.
+func PlanAuthorize(ctx context.Context, s Store, acct, app, version string) (*AuthorizePlan, error) {
+ m, raw, err := GetManifestRaw(ctx, s, acct, app, version)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", fmt.Sprintf(updatewords.Text("settings.authorize.not-shipped"), version), err)
+ }
+ return &AuthorizePlan{Account: acct, App: app, Version: version, ManifestDigest: ManifestDigest(raw), Artifacts: m.Artifacts}, nil
+}
+
+// claimRawAuthorize signs a record for exactly the plan's bytes: an explicit
+// act by the publisher, never automatic (spec §4.1). The manifest is read
+// again and must still hash to the plan's digest. already is true when a
+// record for these bytes exists, in which case nothing is written.
+func (sg Signer) claimRawAuthorize(ctx context.Context, s Store, plan *AuthorizePlan, actor string) (already bool, err error) {
+ if sg.scope != nil || sg.grant != nil {
+ return false, fmt.Errorf("%w: %s", ErrAuthorizeRootOnly, updatewords.Text("settings.authorize.root-only"))
+ }
+ _, raw, err := GetManifestRaw(ctx, s, plan.Account, plan.App, plan.Version)
+ if err != nil || ManifestDigest(raw) != plan.ManifestDigest {
+ return false, fmt.Errorf("%w: %s", ErrAuthorizeChanged, fmt.Sprintf(updatewords.Text("settings.authorize.changed"), plan.Version))
+ }
+ if cur, err := ReadPromotionRecord(ctx, s, sg.verifyPub(), plan.Account, plan.App, plan.Version); err == nil && cur.ManifestDigest == plan.ManifestDigest {
+ return true, nil
+ }
+ return false, sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: plan.Account, App: plan.App, Version: plan.Version,
+ ManifestDigest: plan.ManifestDigest, Authorized: true, Actor: actor})
+}
diff --git a/internal/release/authorize_record_test.go b/internal/release/authorize_record_test.go
new file mode 100644
index 000000000..795d38136
--- /dev/null
+++ b/internal/release/authorize_record_test.go
@@ -0,0 +1,83 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+// Spec §8 "Evidence": a build promoted before the feature is accepted after
+// `carlos releases authorize`.
+func TestAuthorizeSignsTheShownDigest(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ plan, err := PlanAuthorize(ctx, s, "bab", "km", "v1")
+ if err != nil || plan.ManifestDigest != manifestDigestOf(t, s, "bab", "km", "v1") || plan.Artifacts[adoptPlatform].SHA256 == "" {
+ t.Fatalf("plan = %+v, %v", plan, err)
+ }
+ if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) {
+ t.Fatal("planning wrote a record")
+ }
+ already, err := RootSigner(priv).Authorize(ctx, s, plan, "ops@example.com")
+ if err != nil || already {
+ t.Fatalf("Authorize = %v, %v", already, err)
+ }
+ rec, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1")
+ if err != nil || !rec.Authorized || rec.Channel != "" {
+ t.Fatalf("record = %+v, %v", rec, err)
+ }
+ if _, err := AuthorizedManifest(ctx, s, pub, "bab", "km", "v1"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Review Focus 3.
+func TestAuthorizeIsIdempotent(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ plan, _ := PlanAuthorize(ctx, s, "bab", "km", "v1")
+ if _, err := RootSigner(priv).Authorize(ctx, s, plan, "a"); err != nil {
+ t.Fatal(err)
+ }
+ already, err := RootSigner(priv).Authorize(ctx, s, plan, "a")
+ if err != nil || !already {
+ t.Fatalf("second authorize = %v, %v; want already", already, err)
+ }
+}
+
+func TestAuthorizeRefusesAManifestThatChanged(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ plan, _ := PlanAuthorize(ctx, s, "bab", "km", "v1")
+ if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("swapped"))
+ if _, err := RootSigner(priv).Authorize(ctx, s, plan, "a"); !errors.Is(err, ErrAuthorizeChanged) {
+ t.Fatalf("Authorize = %v; want ErrAuthorizeChanged", err)
+ }
+}
+
+func TestAuthorizeIsRootOnly(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ root, pub := testKeys(t)
+ seed, grant, _ := MintKeyGrant(root, "ci", []string{"canary"}, time.Hour)
+ 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"))
+ plan, _ := PlanAuthorize(ctx, s, "bab", "km", "v1")
+ if _, err := sg.Authorize(ctx, s, plan, "ci"); !errors.Is(err, ErrAuthorizeRootOnly) {
+ t.Fatalf("scoped Authorize = %v; want ErrAuthorizeRootOnly", err)
+ }
+}
diff --git a/internal/release/claim_app_writes.go b/internal/release/claim_app_writes.go
index 50a030f07..326a51248 100644
--- a/internal/release/claim_app_writes.go
+++ b/internal/release/claim_app_writes.go
@@ -60,3 +60,16 @@ func (sg Signer) ResignAuthorized(ctx context.Context, s Store, plan *ResignPlan
return sg.claimRawResign(ctx, s, plan, actor, authorize)
})
}
+
+// Authorize is claimRawAuthorize under one app mutation.
+func (sg Signer) Authorize(ctx context.Context, s Store, plan *AuthorizePlan, actor string) (already bool, err error) {
+ if plan == nil {
+ return false, errors.New("authorize: no plan to sign")
+ }
+ err = WithAppMutation(ctx, s, plan.Account, plan.App, "release.Authorize", func(ctx context.Context) error {
+ var err error
+ already, err = sg.claimRawAuthorize(ctx, s, plan, actor)
+ return err
+ })
+ return already, err
+}