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>
3 files changed,
+147
−0
internal/release/authorize_record.go+51 −0internal/release/authorize_record_test.go+83 −0internal/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 |
| +} |