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 "errors"
6 "log"
7 "net/http"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/carlosframework/platform/internal/objstore"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// The apply door answers the OWNER, so its entitlement goes out through the
17// same tenant projection the store doors use (tenantEntitlement): an owner
18// re-applying to an already-decided account would otherwise be handed the
19// deciding operator's address straight back. The approvals and decision
20// doors are the operator's own and answer with the record itself.
21type storeEntitlementReply struct {
22 Account string `json:"account"`
23 Entitlement *storeEntitlementWire `json:"entitlement"`
24}
25
26// storeOperatorEntitlementReply is the OPERATOR's answer, and carries the
27// record itself. The projection above withholds the deciding operator's
28// address and, from a machine, the reason — neither of which makes sense to
29// withhold from the operator who just typed the reason in. `carlos store
30// decline` prints it straight back as its confirmation.
31type storeOperatorEntitlementReply struct {
32 Account string `json:"account"`
33 Entitlement *objstore.Entitlement `json:"entitlement"`
34}
35
36type storeApprovalsReply struct {
37 Applications []*objstore.Entitlement `json:"applications"`
38}
39
40func (s *Server) handleAPIStoreApply(w http.ResponseWriter, r *http.Request) {
41 p, acct, ok := s.apiOwnerAccountFor(w, r, "apply for object storage")
42 if !ok {
43 return
44 }
45 if !s.allowWrite(p, time.Now()) {
46 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
47 return
48 }
49 if s.cfg.Releases == nil {
50 storeNoBucket(w)
51 return
52 }
53 var req struct {
54 Note string `json:"note"`
55 }
56 if !readJSON(w, r, &req) {
57 return
58 }
59 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
60 defer cancel()
61 e, status, msg := s.applyStoreEntitlement(ctx, acct, p.Address, req.Note)
62 if msg != "" {
63 jsonError(w, status, msg)
64 return
65 }
66 writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: tenantEntitlement(e, ctx)})
67}
68
69// Browser and CLI mutations share the CAS, audit and notification path.
70func (s *Server) applyStoreEntitlement(ctx context.Context, acct *Account, addr, note string) (*objstore.Entitlement, int, string) {
71 e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
72 if err != nil {
73 log.Printf("console: api store apply %s: read entitlement: %v", acct.Sqid, err)
74 return nil, http.StatusBadGateway, "could not read the storage application. try again"
75 }
76 if e != nil && e.Approved() {
77 return e, http.StatusOK, ""
78 }
79 e = &objstore.Entitlement{Applied: &objstore.Applied{By: addr, At: time.Now(), Note: strings.TrimSpace(note)}}
80 if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil {
81 if errors.Is(err, release.ErrCASConflict) {
82 return nil, http.StatusConflict, "the storage application changed. try again"
83 }
84 log.Printf("console: api store apply %s: write entitlement: %v", acct.Sqid, err)
85 return nil, http.StatusBadGateway, "could not save the storage application. try again"
86 }
87 s.audit(acct, "", addr, "store-apply", e.Applied.Note)
88 go s.notifyStoreEntitlement(acct.Sqid, e)
89 return e, http.StatusOK, ""
90}
91
92func (s *Server) storeEntitlementOperator(w http.ResponseWriter, r *http.Request) (string, bool) {
93 addr, ok := s.cliUser(w, r)
94 if !ok {
95 return "", false
96 }
97 isOperator, err := s.store.IsOperator(addr)
98 if err != nil {
99 log.Printf("console: api store entitlement: is-operator: %v", err)
100 http.NotFound(w, r)
101 return "", false
102 }
103 if !isOperator || !s.storeOperator(addr) {
104 http.NotFound(w, r)
105 return "", false
106 }
107 return addr, true
108}
109
110func (s *Server) storeEntitlementTarget(w http.ResponseWriter, r *http.Request) (*Account, bool) {
111 acct, err := s.store.AccountBySqid(r.PathValue("account"))
112 if err != nil {
113 log.Printf("console: api store entitlement: account lookup: %v", err)
114 http.NotFound(w, r)
115 return nil, false
116 }
117 if acct == nil {
118 http.NotFound(w, r)
119 return nil, false
120 }
121 return acct, true
122}
123
124func (s *Server) handleAPIStoreApprove(w http.ResponseWriter, r *http.Request) {
125 s.handleAPIStoreDecision(w, r, true)
126}
127
128func (s *Server) handleAPIStoreDecline(w http.ResponseWriter, r *http.Request) {
129 s.handleAPIStoreDecision(w, r, false)
130}
131
132func (s *Server) handleAPIStoreDecision(w http.ResponseWriter, r *http.Request, approved bool) {
133 addr, ok := s.storeEntitlementOperator(w, r)
134 if !ok {
135 return
136 }
137 acct, ok := s.storeEntitlementTarget(w, r)
138 if !ok {
139 return
140 }
141 if !s.allowWrite(Principal{Address: addr}, time.Now()) {
142 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
143 return
144 }
145 if s.cfg.Releases == nil {
146 storeNoBucket(w)
147 return
148 }
149 var req struct {
150 Reason string `json:"reason"`
151 }
152 if !readJSON(w, r, &req) {
153 return
154 }
155 reason := strings.TrimSpace(req.Reason)
156 if !approved && reason == "" {
157 jsonError(w, http.StatusBadRequest, "a reason is required to decline storage")
158 return
159 }
160 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
161 defer cancel()
162 e, status, msg := s.decideStoreEntitlement(ctx, acct, addr, approved, reason)
163 if msg != "" {
164 jsonError(w, status, msg)
165 return
166 }
167 writeJSON(w, http.StatusOK, storeOperatorEntitlementReply{Account: acct.Sqid, Entitlement: e})
168}
169
170func (s *Server) decideStoreEntitlement(ctx context.Context, acct *Account, addr string, approved bool, reason string) (*objstore.Entitlement, int, string) {
171 e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
172 if err != nil {
173 log.Printf("console: api store decision %s: read entitlement: %v", acct.Sqid, err)
174 return nil, http.StatusBadGateway, "could not read the storage application. try again"
175 }
176 if e == nil || e.Applied == nil {
177 return nil, http.StatusConflict, "this account has not applied for object storage"
178 }
179 e.Decision = &objstore.Decision{Approved: approved, By: addr, At: time.Now(), Reason: reason}
180 if approved {
181 e.Decision.Reason = ""
182 }
183 if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil {
184 if errors.Is(err, release.ErrCASConflict) {
185 return nil, http.StatusConflict, "the storage application changed. try again"
186 }
187 log.Printf("console: api store decision %s: write entitlement: %v", acct.Sqid, err)
188 return nil, http.StatusBadGateway, "could not save the storage decision. try again"
189 }
190 detail := reason
191 action := "store-decline"
192 if approved {
193 detail = "approved"
194 action = "store-approve"
195 }
196 s.audit(acct, "", addr, action, detail)
197 go s.notifyStoreEntitlement(acct.Sqid, e)
198 return e, http.StatusOK, ""
199}
200
201func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) {
202 if _, ok := s.storeEntitlementOperator(w, r); !ok {
203 return
204 }
205 if s.cfg.Releases == nil {
206 storeNoBucket(w)
207 return
208 }
209 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
210 defer cancel()
211 applications, err := s.pendingStoreApplications(ctx)
212 if err != nil {
213 jsonError(w, http.StatusBadGateway, "could not list storage applications. try again")
214 return
215 }
216 writeJSON(w, http.StatusOK, storeApprovalsReply{Applications: applications})
217}
218
219func (s *Server) pendingStoreApplications(ctx context.Context) ([]*objstore.Entitlement, error) {
220 keys, err := s.cfg.Releases.List(ctx, "store/entitlements/")
221 if err != nil {
222 log.Printf("console: api store approvals: list entitlements: %v", err)
223 return nil, err
224 }
225 applications := make([]*objstore.Entitlement, 0)
226 for _, key := range keys {
227 const prefix, suffix = "store/entitlements/", ".json"
228 if !strings.HasPrefix(key, prefix) || !strings.HasSuffix(key, suffix) {
229 continue
230 }
231 acctID := strings.TrimSuffix(strings.TrimPrefix(key, prefix), suffix)
232 if !release.ValidAccount(acctID) || objstore.EntitlementKey(acctID) != key {
233 continue
234 }
235 acct, err := s.store.AccountBySqid(acctID)
236 if err != nil {
237 log.Printf("console: api store approvals: account lookup %s: %v", acctID, err)
238 return nil, err
239 }
240 if acct == nil {
241 continue
242 }
243 e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acctID)
244 if err != nil {
245 log.Printf("console: api store approvals: read %s: %v", key, err)
246 return nil, err
247 }
248 if e != nil && e.Pending() {
249 e.Account = acctID
250 applications = append(applications, e)
251 }
252 }
253 sort.Slice(applications, func(i, j int) bool {
254 if applications[i].Applied.At.Equal(applications[j].Applied.At) {
255 return applications[i].Account < applications[j].Account
256 }
257 return applications[i].Applied.At.Before(applications[j].Applied.At)
258 })
259 return applications, nil
260}
261