carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "log"
8 "net/http"
9 "time"
10
11 "github.com/carlosframework/platform/internal/release"
12 "github.com/carlosframework/platform/internal/updatewords"
13)
14
15// handleAPIAuthorize is `carlos releases authorize` (settings spec §4.1). GET
16// shows the digests; POST signs a promotion record for exactly the digest the
17// person confirmed, if the manifest still hashes to it. The console signs
18// with its root key.
19//
20// Operator-only (R19). An authorize record makes a shipped build installable
21// on every channel, outside the pipeline's promotions, so it takes the
22// platform operator's authority: the same people who roll the platform, an
23// owner of the ops account or an address on SystemOperators. The door opens
24// with apiSystemRoller, whose refusal is its own bare 404, before any account
25// lookup, so a member who is not an operator learns nothing (R20). The
26// operator must then also be a member of the app's account (apiAccountApp,
27// whose 404 stands); an operator outside that account authorizes with the
28// root key against the bucket (direct-bucket `carlos releases authorize`).
29//
30// Person-only: a credential the app holds must not vouch for builds. The
31// POST draws on the write budget, like resign, before any store work; the GET
32// is a read and does not.
33func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) {
34 if _, _, ok := s.apiSystemRoller(w, r); !ok {
35 return
36 }
37 addr, acct, app, ok := s.apiAccountApp(w, r)
38 if !ok {
39 return
40 }
41 if r.Method == http.MethodPost {
42 p, _ := principalFrom(r.Context())
43 if !s.allowWrite(p, time.Now()) {
44 jsonError(w, http.StatusTooManyRequests, textTooManyRequests)
45 return
46 }
47 }
48 version := r.PathValue("version")
49 if !release.ValidVersion(version) {
50 jsonError(w, http.StatusBadRequest, "invalid version")
51 return
52 }
53 ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
54 defer cancel()
55 plan, err := release.PlanAuthorize(ctx, s.cfg.Releases, acct.Sqid, app, version)
56 if err != nil {
57 jsonError(w, http.StatusNotFound, err.Error())
58 return
59 }
60 if r.Method == http.MethodGet {
61 writeJSON(w, http.StatusOK, map[string]any{
62 "version": plan.Version, "manifest_digest": plan.ManifestDigest, "artifacts": plan.Artifacts,
63 })
64 return
65 }
66 var req struct {
67 ManifestDigest string `json:"manifest_digest"`
68 }
69 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil || req.ManifestDigest != plan.ManifestDigest {
70 jsonError(w, http.StatusConflict, release.ErrAuthorizeChanged.Error())
71 return
72 }
73 if len(s.cfg.Key) == 0 {
74 jsonError(w, http.StatusServiceUnavailable, updatewords.Text("settings.authorize.no-key"))
75 return
76 }
77 already, err := release.RootSigner(s.cfg.Key).Authorize(ctx, s.cfg.Releases, plan, addr)
78 switch {
79 case errors.Is(err, release.ErrAuthorizeChanged):
80 jsonError(w, http.StatusConflict, err.Error())
81 return
82 case err != nil:
83 log.Printf("console: api authorize %s/%s %s: %v", acct.Sqid, app, version, err)
84 jsonError(w, http.StatusBadGateway, updatewords.Text("settings.authorize.write-failed"))
85 return
86 }
87 result := "authorized"
88 if already {
89 result = "already"
90 } else {
91 s.auditActivity(acct, app, addr, Activity{
92 Action: "authorize", TargetType: "release", Target: version,
93 Detail: "signed a promotion record for " + version + " (manifest " + plan.ManifestDigest[:12] + ") (cli)",
94 })
95 }
96 writeJSON(w, http.StatusOK, map[string]string{"result": result})
97}
98