| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/subtle" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "log" |
| 9 | "net/http" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/objstore" |
| 15 | "github.com/carlosframework/platform/internal/usage" |
| 16 | ) |
| 17 | |
| 18 | // Only state crosses into account settings. In particular, audit detail is |
| 19 | // not safe here: apply stores the note and decline stores the reason. |
| 20 | type accountStoreView struct { |
| 21 | State string |
| 22 | CanApply bool |
| 23 | Unavailable bool |
| 24 | Events []Event |
| 25 | } |
| 26 | |
| 27 | type storeQueueView struct { |
| 28 | Applications []*objstore.Entitlement |
| 29 | Buckets int |
| 30 | SweepDate string |
| 31 | CountKnown bool |
| 32 | } |
| 33 | |
| 34 | func (s *Server) accountStorePane(ctx context.Context, acct *Account) *accountStoreView { |
| 35 | v := &accountStoreView{State: "Not applied", CanApply: true} |
| 36 | if s.cfg.Releases == nil { |
| 37 | v.Unavailable = true |
| 38 | v.CanApply = false |
| 39 | return v |
| 40 | } |
| 41 | ctx, cancel := context.WithTimeout(ctx, storeTimeout) |
| 42 | defer cancel() |
| 43 | e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| 44 | if err != nil { |
| 45 | log.Printf("console: storage settings %s: %v", acct.Sqid, err) |
| 46 | v.Unavailable = true |
| 47 | v.CanApply = false |
| 48 | } else if e != nil { |
| 49 | switch { |
| 50 | case e.Approved(): |
| 51 | v.State = "Approved" |
| 52 | v.CanApply = false |
| 53 | case e.Pending(): |
| 54 | v.State = "Pending" |
| 55 | v.CanApply = false |
| 56 | case e.Decision != nil: |
| 57 | v.State = "Declined" |
| 58 | } |
| 59 | } |
| 60 | events, err := s.store.Events(acct.ID, "", activityCap) |
| 61 | if err != nil { |
| 62 | log.Printf("console: account storage events %s: %v", acct.Sqid, err) |
| 63 | } |
| 64 | for _, e := range events { |
| 65 | switch e.Action { |
| 66 | case "store-apply", "store-approve", "store-decline": |
| 67 | // The deciding operator's identity also stays out of tenant pages. |
| 68 | detail := map[string]string{"store-apply": "Applied for object storage", "store-approve": "Object storage approved", "store-decline": "Object storage declined"}[e.Action] |
| 69 | v.Events = append(v.Events, Event{Action: e.Action, Detail: detail, CreatedAt: e.CreatedAt}) |
| 70 | } |
| 71 | } |
| 72 | return v |
| 73 | } |
| 74 | |
| 75 | func (s *Server) storeFormCSRF(w http.ResponseWriter, r *http.Request, raw string) bool { |
| 76 | r.Body = http.MaxBytesReader(w, r.Body, 1<<20) |
| 77 | if err := r.ParseForm(); err != nil { |
| 78 | s.renderMessage(w, r, http.StatusBadRequest, "Refused", "Could not read the form. Please try again.") |
| 79 | return false |
| 80 | } |
| 81 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 82 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 83 | return false |
| 84 | } |
| 85 | return true |
| 86 | } |
| 87 | |
| 88 | func (s *Server) handleStoreApplyPage(w http.ResponseWriter, r *http.Request) { |
| 89 | addr, raw, ok := s.session(r) |
| 90 | if !ok { |
| 91 | http.NotFound(w, r) |
| 92 | return |
| 93 | } |
| 94 | if !s.storeFormCSRF(w, r, raw) { |
| 95 | return |
| 96 | } |
| 97 | _, _, acct, ok := s.owner(w, r) |
| 98 | if !ok { |
| 99 | return |
| 100 | } |
| 101 | if !s.allowWrite(Principal{Address: addr}, time.Now()) { |
| 102 | s.renderMessage(w, r, http.StatusTooManyRequests, "Refused", "too many requests — wait a minute and try again") |
| 103 | return |
| 104 | } |
| 105 | if s.cfg.Releases == nil { |
| 106 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", "Object storage applications are unavailable on this deployment.") |
| 107 | return |
| 108 | } |
| 109 | ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| 110 | defer cancel() |
| 111 | _, status, msg := s.applyStoreEntitlement(ctx, acct, addr, r.FormValue("note")) |
| 112 | if msg != "" { |
| 113 | s.renderMessage(w, r, status, "Error", msg) |
| 114 | return |
| 115 | } |
| 116 | http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther) |
| 117 | } |
| 118 | |
| 119 | func (s *Server) storePageOperator(w http.ResponseWriter, r *http.Request) (string, string, bool) { |
| 120 | addr, raw, ok := s.operatorOnly(w, r) |
| 121 | if !ok { |
| 122 | return "", "", false |
| 123 | } |
| 124 | if !s.storeOperator(addr) { |
| 125 | http.NotFound(w, r) |
| 126 | return "", "", false |
| 127 | } |
| 128 | return addr, raw, true |
| 129 | } |
| 130 | |
| 131 | func (s *Server) handleStoreApprovalsPage(w http.ResponseWriter, r *http.Request) { |
| 132 | addr, raw, ok := s.storePageOperator(w, r) |
| 133 | if !ok { |
| 134 | return |
| 135 | } |
| 136 | if s.cfg.Releases == nil { |
| 137 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", "Object storage applications are unavailable on this deployment.") |
| 138 | return |
| 139 | } |
| 140 | ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| 141 | defer cancel() |
| 142 | applications, err := s.pendingStoreApplications(ctx) |
| 143 | if err != nil { |
| 144 | s.renderMessage(w, r, http.StatusBadGateway, "Error", "could not list storage applications. try again") |
| 145 | return |
| 146 | } |
| 147 | v := &storeQueueView{Applications: applications} |
| 148 | v.Buckets, v.SweepDate, v.CountKnown, err = s.storeBucketCount(ctx) |
| 149 | if err != nil { |
| 150 | log.Printf("console: storage bucket count: %v", err) |
| 151 | } |
| 152 | s.render(w, http.StatusOK, "store_approvals", page{Title: "Object storage applications", Address: addr, CSRF: raw, StoreQueue: v}) |
| 153 | } |
| 154 | |
| 155 | func (s *Server) handleStoreDecisionPage(w http.ResponseWriter, r *http.Request) { |
| 156 | addr, raw, ok := s.storePageOperator(w, r) |
| 157 | if !ok { |
| 158 | return |
| 159 | } |
| 160 | if !s.storeFormCSRF(w, r, raw) { |
| 161 | return |
| 162 | } |
| 163 | acct, ok := s.storeEntitlementTarget(w, r) |
| 164 | if !ok { |
| 165 | return |
| 166 | } |
| 167 | approved := r.PathValue("decision") == "approve" |
| 168 | if !approved && r.PathValue("decision") != "decline" { |
| 169 | http.NotFound(w, r) |
| 170 | return |
| 171 | } |
| 172 | reason := strings.TrimSpace(r.FormValue("reason")) |
| 173 | if !approved && reason == "" { |
| 174 | s.renderMessage(w, r, http.StatusBadRequest, "Refused", "a reason is required to decline storage") |
| 175 | return |
| 176 | } |
| 177 | // Approval grants future cloud spend; prove presence after every authority |
| 178 | // check. Decline only removes that authority and needs no step-up. |
| 179 | if approved && !s.requireFreshAuth(w, r, addr, raw) { |
| 180 | return |
| 181 | } |
| 182 | if !s.allowWrite(Principal{Address: addr}, time.Now()) { |
| 183 | s.renderMessage(w, r, http.StatusTooManyRequests, "Refused", "too many requests — wait a minute and try again") |
| 184 | return |
| 185 | } |
| 186 | if s.cfg.Releases == nil { |
| 187 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", "Object storage applications are unavailable on this deployment.") |
| 188 | return |
| 189 | } |
| 190 | ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| 191 | defer cancel() |
| 192 | _, status, msg := s.decideStoreEntitlement(ctx, acct, addr, approved, reason) |
| 193 | if msg != "" { |
| 194 | s.renderMessage(w, r, status, "Error", msg) |
| 195 | return |
| 196 | } |
| 197 | http.Redirect(w, r, "/fleet/store", http.StatusSeeOther) |
| 198 | } |
| 199 | |
| 200 | // A dated occupancy snapshot is not a live cloud inventory. Older snapshots |
| 201 | // lack the boolean, so they cannot establish even an empty-store count. |
| 202 | func (s *Server) storeBucketCount(ctx context.Context) (int, string, bool, error) { |
| 203 | keys, err := s.cfg.Releases.List(ctx, usage.StoragePrefix) |
| 204 | if err != nil { |
| 205 | return 0, "", false, err |
| 206 | } |
| 207 | var dates []string |
| 208 | for _, key := range keys { |
| 209 | date := strings.TrimSuffix(strings.TrimPrefix(key, usage.StoragePrefix), ".json") |
| 210 | if _, err := time.Parse("2006-01-02", date); err == nil && usage.StorageDayKey(date) == key { |
| 211 | dates = append(dates, date) |
| 212 | } |
| 213 | } |
| 214 | if len(dates) == 0 { |
| 215 | return 0, "", false, nil |
| 216 | } |
| 217 | sort.Strings(dates) |
| 218 | date := dates[len(dates)-1] |
| 219 | raw, _, err := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)) |
| 220 | if err != nil { |
| 221 | return 0, date, false, err |
| 222 | } |
| 223 | var day struct { |
| 224 | V int `json:"v"` |
| 225 | Date string `json:"date"` |
| 226 | Apps map[string]struct { |
| 227 | Provisioned *bool `json:"store_provisioned"` |
| 228 | Unswept bool `json:"store_unswept"` |
| 229 | } `json:"apps"` |
| 230 | } |
| 231 | if err := json.Unmarshal(raw, &day); err != nil { |
| 232 | return 0, date, false, err |
| 233 | } |
| 234 | if day.V != 1 || day.Date != date || day.Apps == nil { |
| 235 | return 0, date, false, fmt.Errorf("invalid storage snapshot %s", date) |
| 236 | } |
| 237 | n := 0 |
| 238 | for _, app := range day.Apps { |
| 239 | if app.Provisioned == nil || (!*app.Provisioned && app.Unswept) { |
| 240 | return 0, date, false, nil |
| 241 | } |
| 242 | if *app.Provisioned { |
| 243 | n++ |
| 244 | } |
| 245 | } |
| 246 | return n, date, true, nil |
| 247 | } |
| 248 | |