package console import ( "context" "errors" "log" "net/http" "sort" "strings" "time" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // The apply door answers the OWNER, so its entitlement goes out through the // same tenant projection the store doors use (tenantEntitlement): an owner // re-applying to an already-decided account would otherwise be handed the // deciding operator's address straight back. The approvals and decision // doors are the operator's own and answer with the record itself. type storeEntitlementReply struct { Account string `json:"account"` Entitlement *storeEntitlementWire `json:"entitlement"` } // storeOperatorEntitlementReply is the OPERATOR's answer, and carries the // record itself. The projection above withholds the deciding operator's // address and, from a machine, the reason — neither of which makes sense to // withhold from the operator who just typed the reason in. `carlos store // decline` prints it straight back as its confirmation. type storeOperatorEntitlementReply struct { Account string `json:"account"` Entitlement *objstore.Entitlement `json:"entitlement"` } type storeApprovalsReply struct { Applications []*objstore.Entitlement `json:"applications"` } func (s *Server) handleAPIStoreApply(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.apiOwnerAccountFor(w, r, "apply for object storage") if !ok { return } if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { storeNoBucket(w) return } var req struct { Note string `json:"note"` } if !readJSON(w, r, &req) { return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() e, status, msg := s.applyStoreEntitlement(ctx, acct, p.Address, req.Note) if msg != "" { jsonError(w, status, msg) return } writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: tenantEntitlement(e, ctx)}) } // Browser and CLI mutations share the CAS, audit and notification path. func (s *Server) applyStoreEntitlement(ctx context.Context, acct *Account, addr, note string) (*objstore.Entitlement, int, string) { e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) if err != nil { log.Printf("console: api store apply %s: read entitlement: %v", acct.Sqid, err) return nil, http.StatusBadGateway, "could not read the storage application. try again" } if e != nil && e.Approved() { return e, http.StatusOK, "" } e = &objstore.Entitlement{Applied: &objstore.Applied{By: addr, At: time.Now(), Note: strings.TrimSpace(note)}} if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { return nil, http.StatusConflict, "the storage application changed. try again" } log.Printf("console: api store apply %s: write entitlement: %v", acct.Sqid, err) return nil, http.StatusBadGateway, "could not save the storage application. try again" } s.audit(acct, "", addr, "store-apply", e.Applied.Note) go s.notifyStoreEntitlement(acct.Sqid, e) return e, http.StatusOK, "" } func (s *Server) storeEntitlementOperator(w http.ResponseWriter, r *http.Request) (string, bool) { addr, ok := s.cliUser(w, r) if !ok { return "", false } isOperator, err := s.store.IsOperator(addr) if err != nil { log.Printf("console: api store entitlement: is-operator: %v", err) http.NotFound(w, r) return "", false } if !isOperator || !s.storeOperator(addr) { http.NotFound(w, r) return "", false } return addr, true } func (s *Server) storeEntitlementTarget(w http.ResponseWriter, r *http.Request) (*Account, bool) { acct, err := s.store.AccountBySqid(r.PathValue("account")) if err != nil { log.Printf("console: api store entitlement: account lookup: %v", err) http.NotFound(w, r) return nil, false } if acct == nil { http.NotFound(w, r) return nil, false } return acct, true } func (s *Server) handleAPIStoreApprove(w http.ResponseWriter, r *http.Request) { s.handleAPIStoreDecision(w, r, true) } func (s *Server) handleAPIStoreDecline(w http.ResponseWriter, r *http.Request) { s.handleAPIStoreDecision(w, r, false) } func (s *Server) handleAPIStoreDecision(w http.ResponseWriter, r *http.Request, approved bool) { addr, ok := s.storeEntitlementOperator(w, r) if !ok { return } acct, ok := s.storeEntitlementTarget(w, r) if !ok { return } if !s.allowWrite(Principal{Address: addr}, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { storeNoBucket(w) return } var req struct { Reason string `json:"reason"` } if !readJSON(w, r, &req) { return } reason := strings.TrimSpace(req.Reason) if !approved && reason == "" { jsonError(w, http.StatusBadRequest, "a reason is required to decline storage") return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() e, status, msg := s.decideStoreEntitlement(ctx, acct, addr, approved, reason) if msg != "" { jsonError(w, status, msg) return } writeJSON(w, http.StatusOK, storeOperatorEntitlementReply{Account: acct.Sqid, Entitlement: e}) } func (s *Server) decideStoreEntitlement(ctx context.Context, acct *Account, addr string, approved bool, reason string) (*objstore.Entitlement, int, string) { e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) if err != nil { log.Printf("console: api store decision %s: read entitlement: %v", acct.Sqid, err) return nil, http.StatusBadGateway, "could not read the storage application. try again" } if e == nil || e.Applied == nil { return nil, http.StatusConflict, "this account has not applied for object storage" } e.Decision = &objstore.Decision{Approved: approved, By: addr, At: time.Now(), Reason: reason} if approved { e.Decision.Reason = "" } if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { return nil, http.StatusConflict, "the storage application changed. try again" } log.Printf("console: api store decision %s: write entitlement: %v", acct.Sqid, err) return nil, http.StatusBadGateway, "could not save the storage decision. try again" } detail := reason action := "store-decline" if approved { detail = "approved" action = "store-approve" } s.audit(acct, "", addr, action, detail) go s.notifyStoreEntitlement(acct.Sqid, e) return e, http.StatusOK, "" } func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) { if _, ok := s.storeEntitlementOperator(w, r); !ok { return } if s.cfg.Releases == nil { storeNoBucket(w) return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() applications, err := s.pendingStoreApplications(ctx) if err != nil { jsonError(w, http.StatusBadGateway, "could not list storage applications. try again") return } writeJSON(w, http.StatusOK, storeApprovalsReply{Applications: applications}) } func (s *Server) pendingStoreApplications(ctx context.Context) ([]*objstore.Entitlement, error) { keys, err := s.cfg.Releases.List(ctx, "store/entitlements/") if err != nil { log.Printf("console: api store approvals: list entitlements: %v", err) return nil, err } applications := make([]*objstore.Entitlement, 0) for _, key := range keys { const prefix, suffix = "store/entitlements/", ".json" if !strings.HasPrefix(key, prefix) || !strings.HasSuffix(key, suffix) { continue } acctID := strings.TrimSuffix(strings.TrimPrefix(key, prefix), suffix) if !release.ValidAccount(acctID) || objstore.EntitlementKey(acctID) != key { continue } acct, err := s.store.AccountBySqid(acctID) if err != nil { log.Printf("console: api store approvals: account lookup %s: %v", acctID, err) return nil, err } if acct == nil { continue } e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acctID) if err != nil { log.Printf("console: api store approvals: read %s: %v", key, err) return nil, err } if e != nil && e.Pending() { e.Account = acctID applications = append(applications, e) } } sort.Slice(applications, func(i, j int) bool { if applications[i].Applied.At.Equal(applications[j].Applied.At) { return applications[i].Account < applications[j].Account } return applications[i].Applied.At.Before(applications[j].Applied.At) }) return applications, nil }