| 1 | // The `carlos features` doors (issue #347): the app-defined switches the |
| 2 | // platform stores and serves but never interprets — `sandbox=on` for |
| 3 | // titogo's router — written here, audited, and read back by the app off the |
| 4 | // deployment block the domains doors already answer with (domainCapability, |
| 5 | // domainstate.go). This is managed_domains generalized: the platform owns |
| 6 | // the write path so that enabling an app capability is never an app editing |
| 7 | // root-owned env on an edge box. |
| 8 | // |
| 9 | // Gate order is handleAPIRetentionSet's exactly — token, one byte-identical |
| 10 | // 404 for every identity failure, the shared per-principal write budget, 503 |
| 11 | // when the console has no bucket. What differs is what happens AFTER a |
| 12 | // successful write: no serial bump. Nothing on a box reads features.json; |
| 13 | // the app polls the console for it, so announcing a write would send every |
| 14 | // box looking for a change none of them can see. |
| 15 | // |
| 16 | // Roles (authcli.go): the app's own instance credential READS — it is the |
| 17 | // consumer — and never writes. A switch an app could flip on itself would |
| 18 | // be a root env flag by another name, the exact thing this door retires. |
| 19 | package console |
| 20 | |
| 21 | import ( |
| 22 | "context" |
| 23 | "log" |
| 24 | "net/http" |
| 25 | "sort" |
| 26 | "strconv" |
| 27 | "strings" |
| 28 | "time" |
| 29 | |
| 30 | "github.com/carlosframework/platform/internal/release" |
| 31 | ) |
| 32 | |
| 33 | // apiFeaturesResponse is the one wire shape both doors answer with. |
| 34 | // Features is never omitted and never null on a successful answer: an app |
| 35 | // ranges over it, and "no flags set" is spelled {}. |
| 36 | type apiFeaturesResponse struct { |
| 37 | Account string `json:"account"` |
| 38 | App string `json:"app"` |
| 39 | Features map[string]string `json:"features"` |
| 40 | SetBy string `json:"set_by,omitempty"` |
| 41 | SetAt string `json:"set_at,omitempty"` |
| 42 | // Changed is the set door's answer only: whether this call wrote |
| 43 | // anything. Absent on a read. |
| 44 | Changed *bool `json:"changed,omitempty"` |
| 45 | } |
| 46 | |
| 47 | func featuresResponse(acct, app string, rec *release.Features) apiFeaturesResponse { |
| 48 | resp := apiFeaturesResponse{Account: acct, App: app, Features: map[string]string{}} |
| 49 | if rec != nil { |
| 50 | resp.Features = rec.Features |
| 51 | resp.SetBy = rec.SetBy |
| 52 | if !rec.SetAt.IsZero() { |
| 53 | resp.SetAt = rec.SetAt.UTC().Format(time.RFC3339) |
| 54 | } |
| 55 | } |
| 56 | return resp |
| 57 | } |
| 58 | |
| 59 | // handleAPIFeaturesGet answers `carlos features list -app X`. No write |
| 60 | // budget and no audit: it is a read. An unreadable record is a 502, not an |
| 61 | // empty map — the dedicated read must never tell an operator "nothing set" |
| 62 | // about a record it could not parse. |
| 63 | func (s *Server) handleAPIFeaturesGet(w http.ResponseWriter, r *http.Request) { |
| 64 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 65 | if !ok { |
| 66 | return |
| 67 | } |
| 68 | if s.cfg.Releases == nil { |
| 69 | jsonError(w, http.StatusServiceUnavailable, |
| 70 | "no deployment bucket on this host — this console cannot read features") |
| 71 | return |
| 72 | } |
| 73 | ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| 74 | defer cancel() |
| 75 | rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app) |
| 76 | if err != nil { |
| 77 | log.Printf("console: api features get %s/%s: %v", acct.Sqid, app, err) |
| 78 | jsonError(w, http.StatusBadGateway, "could not read the app's features — try again") |
| 79 | return |
| 80 | } |
| 81 | writeJSON(w, http.StatusOK, featuresResponse(acct.Sqid, app, rec)) |
| 82 | } |
| 83 | |
| 84 | // handleAPIFeaturesSet answers `carlos features set -app X k=v [k=]`: set |
| 85 | // merges onto the current map, unset removes keys, in one CAS-guarded |
| 86 | // write. Only an actual CHANGE audits — a set to the value already held is |
| 87 | // a no-op, and a row per no-op would bury the app's log under a caller |
| 88 | // re-asserting the same switch on every deploy. |
| 89 | func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) { |
| 90 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 91 | if !ok { |
| 92 | return |
| 93 | } |
| 94 | p, _ := principalFrom(r.Context()) |
| 95 | if !s.allowWrite(p, time.Now()) { |
| 96 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 97 | return |
| 98 | } |
| 99 | if s.cfg.Releases == nil { |
| 100 | jsonError(w, http.StatusServiceUnavailable, |
| 101 | "no deployment bucket on this host — this console cannot write features") |
| 102 | return |
| 103 | } |
| 104 | var in struct { |
| 105 | Set map[string]string `json:"set"` |
| 106 | Unset []string `json:"unset"` |
| 107 | Message string `json:"message"` |
| 108 | } |
| 109 | if !readJSON(w, r, &in) { |
| 110 | return |
| 111 | } |
| 112 | message, ok := checkedActivityMessage(w, in.Message) |
| 113 | if !ok { |
| 114 | return |
| 115 | } |
| 116 | if len(in.Set) == 0 && len(in.Unset) == 0 { |
| 117 | jsonError(w, http.StatusBadRequest, "nothing to do — name at least one key to set or unset") |
| 118 | return |
| 119 | } |
| 120 | // Shape-checked HERE, before the store is touched, so a bad key is a |
| 121 | // 400 the caller can fix rather than a 502 they cannot — and so the |
| 122 | // message names the rule, not just the refusal. |
| 123 | for k, v := range in.Set { |
| 124 | if !release.ValidFeatureKey(k) { |
| 125 | jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'") |
| 126 | return |
| 127 | } |
| 128 | if !release.ValidFeatureValue(v) { |
| 129 | jsonError(w, http.StatusBadRequest, "invalid value for "+k+" — values are 1-256 printable characters (to clear a key, unset it)") |
| 130 | return |
| 131 | } |
| 132 | } |
| 133 | for _, k := range in.Unset { |
| 134 | if !release.ValidFeatureKey(k) { |
| 135 | jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'") |
| 136 | return |
| 137 | } |
| 138 | } |
| 139 | principal := auditActor(p, acct) |
| 140 | ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| 141 | defer cancel() |
| 142 | rec, changed, err := release.UpdateFeatures(ctx, s.cfg.Releases, acct.Sqid, app, in.Set, in.Unset, principal, time.Now()) |
| 143 | if err != nil { |
| 144 | if strings.Contains(err.Error(), "at most") { |
| 145 | jsonError(w, http.StatusBadRequest, "an app may hold at most "+strconv.Itoa(release.FeaturesMax)+" features") |
| 146 | return |
| 147 | } |
| 148 | log.Printf("console: api features set %s/%s: %v", acct.Sqid, app, err) |
| 149 | jsonError(w, http.StatusBadGateway, "could not write the app's features — nothing was changed; try again") |
| 150 | return |
| 151 | } |
| 152 | if changed { |
| 153 | s.auditActivity(acct, app, principal, Activity{ |
| 154 | Action: "features", TargetType: "app", Target: app, Message: message, |
| 155 | Detail: featuresAuditDetail(in.Set, in.Unset), |
| 156 | }) |
| 157 | } |
| 158 | resp := featuresResponse(acct.Sqid, app, rec) |
| 159 | resp.Changed = &changed |
| 160 | writeJSON(w, http.StatusOK, resp) |
| 161 | } |
| 162 | |
| 163 | // featuresAuditDetail spells one call out the way the operator typed it, |
| 164 | // keys sorted so two audits of the same call read the same. |
| 165 | func featuresAuditDetail(set map[string]string, unset []string) string { |
| 166 | keys := make([]string, 0, len(set)) |
| 167 | for k := range set { |
| 168 | keys = append(keys, k) |
| 169 | } |
| 170 | sort.Strings(keys) |
| 171 | parts := make([]string, 0, len(keys)+1) |
| 172 | for _, k := range keys { |
| 173 | parts = append(parts, k+"="+set[k]) |
| 174 | } |
| 175 | if len(unset) > 0 { |
| 176 | u := append([]string(nil), unset...) |
| 177 | sort.Strings(u) |
| 178 | parts = append(parts, "unset "+strings.Join(u, ", ")) |
| 179 | } |
| 180 | return "set features: " + strings.Join(parts, ", ") + " (cli)" |
| 181 | } |
| 182 | |
| 183 | // appFeatures is the deployment-block read (domainCapability): the app's |
| 184 | // map, plus when it last changed. Best-effort by design — this rides on the |
| 185 | // domains answer, and a features record the console cannot read must not |
| 186 | // take a domains listing down with it. The nil map (JSON null) is the |
| 187 | // "unknown" reading; an app tells it from {} ("none set") by that. |
| 188 | func (s *Server) appFeatures(ctx context.Context, acct, app string) (map[string]string, string) { |
| 189 | if s.cfg.Releases == nil { |
| 190 | return nil, "" |
| 191 | } |
| 192 | rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct, app) |
| 193 | if err != nil { |
| 194 | log.Printf("console: api features block %s/%s: %v", acct, app, err) |
| 195 | return nil, "" |
| 196 | } |
| 197 | if rec == nil { |
| 198 | return map[string]string{}, "" |
| 199 | } |
| 200 | setAt := "" |
| 201 | if !rec.SetAt.IsZero() { |
| 202 | setAt = rec.SetAt.UTC().Format(time.RFC3339) |
| 203 | } |
| 204 | return rec.Features, setAt |
| 205 | } |
| 206 | |