package console import ( "context" "encoding/json" "errors" "log" "net/http" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/updatewords" ) // handleAPIAuthorize is `carlos releases authorize` (settings spec ยง4.1). GET // shows the digests; POST signs a promotion record for exactly the digest the // person confirmed, if the manifest still hashes to it. The console signs // with its root key. // // Operator-only (R19). An authorize record makes a shipped build installable // on every channel, outside the pipeline's promotions, so it takes the // platform operator's authority: the same people who roll the platform, an // owner of the ops account or an address on SystemOperators. The door opens // with apiSystemRoller, whose refusal is its own bare 404, before any account // lookup, so a member who is not an operator learns nothing (R20). The // operator must then also be a member of the app's account (apiAccountApp, // whose 404 stands); an operator outside that account authorizes with the // root key against the bucket (direct-bucket `carlos releases authorize`). // // Person-only: a credential the app holds must not vouch for builds. The // POST draws on the write budget, like resign, before any store work; the GET // is a read and does not. func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) { if _, _, ok := s.apiSystemRoller(w, r); !ok { return } addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if r.Method == http.MethodPost { p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, textTooManyRequests) return } } version := r.PathValue("version") if !release.ValidVersion(version) { jsonError(w, http.StatusBadRequest, "invalid version") return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() plan, err := release.PlanAuthorize(ctx, s.cfg.Releases, acct.Sqid, app, version) if err != nil { jsonError(w, http.StatusNotFound, err.Error()) return } if r.Method == http.MethodGet { writeJSON(w, http.StatusOK, map[string]any{ "version": plan.Version, "manifest_digest": plan.ManifestDigest, "artifacts": plan.Artifacts, }) return } var req struct { ManifestDigest string `json:"manifest_digest"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil || req.ManifestDigest != plan.ManifestDigest { jsonError(w, http.StatusConflict, release.ErrAuthorizeChanged.Error()) return } if len(s.cfg.Key) == 0 { jsonError(w, http.StatusServiceUnavailable, updatewords.Text("settings.authorize.no-key")) return } already, err := release.RootSigner(s.cfg.Key).Authorize(ctx, s.cfg.Releases, plan, addr) switch { case errors.Is(err, release.ErrAuthorizeChanged): jsonError(w, http.StatusConflict, err.Error()) return case err != nil: log.Printf("console: api authorize %s/%s %s: %v", acct.Sqid, app, version, err) jsonError(w, http.StatusBadGateway, updatewords.Text("settings.authorize.write-failed")) return } result := "authorized" if already { result = "already" } else { s.auditActivity(acct, app, addr, Activity{ Action: "authorize", TargetType: "release", Target: version, Detail: "signed a promotion record for " + version + " (manifest " + plan.ManifestDigest[:12] + ") (cli)", }) } writeJSON(w, http.StatusOK, map[string]string{"result": result}) }