| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "net/http" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostactors" |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // Box and group roles (region HA spec 2 §7, A11-1): `carlos system box role |
| 15 | // <box> <role>` and `carlos system group role <region> <role>`. A legacy or |
| 16 | // platform box takes no new instance, relocation or move; it keeps the |
| 17 | // ones it has (H25). A group's role is every box it enrolled, now and |
| 18 | // later. The roles live in the signed roster, so the console, the only |
| 19 | // holder of the root key, is the only writer. |
| 20 | // |
| 21 | // Operator-only (apiSystemRoller, the update doors' authority), registered |
| 22 | // only with host claims on, and answering 404 unless CARLOS_REGION_HA is on |
| 23 | // too: with region HA off nothing reads a role. |
| 24 | |
| 25 | const ( |
| 26 | boxRoleRefusal = "role must be default, legacy or platform" |
| 27 | groupRoleRefusal = "a group's role must be default or legacy" |
| 28 | boxRosterUnread = "could not read the box roster. Try again." |
| 29 | boxRosterChanged = "the box roster changed while the role was being saved, and nothing was changed. Try again." |
| 30 | boxRoleWriteFailure = "could not save the role. Try again." |
| 31 | boxRosterBusy = "the box roster can't be changed right now. Try again later." |
| 32 | ) |
| 33 | |
| 34 | func noEnrolledBox(name string) string { return fmt.Sprintf("no enrolled box is named %s", name) } |
| 35 | |
| 36 | func noGroupIn(region string) string { return fmt.Sprintf("%s has no group", region) } |
| 37 | |
| 38 | // boxRoleAudit and groupRoleAudit are the detail of the ops-account audit |
| 39 | // row a role change writes, as the sibling operator doors write theirs. |
| 40 | func boxRoleAudit(box, role string) string { return fmt.Sprintf("gave %s the %s role", box, role) } |
| 41 | |
| 42 | func groupRoleAudit(region, role string) string { |
| 43 | return fmt.Sprintf("gave the group in %s and its boxes the %s role", region, role) |
| 44 | } |
| 45 | |
| 46 | func retiredBoxRole(box string) string { return fmt.Sprintf("%s is retired, so it takes no role", box) } |
| 47 | |
| 48 | // boxRoster reads the signed roster with the root signer that writes it, |
| 49 | // for the role doors alone, which have already let only an operator in |
| 50 | // (roleOperator). It is a read, of the one roster every reader reads |
| 51 | // (control/host-actors.json through hostactors.Directory). Census ruling |
| 52 | // region-ha-2a's conditions hold it to that: operator-only, read-only, the |
| 53 | // same roster, and no role it reads feeds root's claim authority (roles |
| 54 | // are read by the placement chooser alone). |
| 55 | func (s *Server) boxRoster(ctx context.Context) (hostactors.Directory, release.Signer, hostactors.Observed, error) { |
| 56 | if s.cfg.Releases == nil || s.cfg.Bucket == "" || len(s.cfg.Key) == 0 { |
| 57 | return hostactors.Directory{}, release.Signer{}, hostactors.Observed{}, errors.New("console: no deployment bucket or root key") |
| 58 | } |
| 59 | signer := release.RootSigner(s.cfg.Key) |
| 60 | dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: signer.RootPublicKey(), Bucket: s.cfg.Bucket} |
| 61 | o, err := dir.Read(ctx) |
| 62 | return dir, signer, o, err |
| 63 | } |
| 64 | |
| 65 | // rosterRef finds a box by reference, or by the one label no retired box |
| 66 | // shares (hostactors.Directory.Resolve's rule, over a roster in hand). |
| 67 | func rosterRef(r hostactors.Roster, name string) (string, bool) { |
| 68 | if _, ok := r.Boxes[name]; ok { |
| 69 | return name, true |
| 70 | } |
| 71 | found := "" |
| 72 | for ref, b := range r.Boxes { |
| 73 | if b.Label == name && b.State != "retired" { |
| 74 | if found != "" { |
| 75 | return "", false |
| 76 | } |
| 77 | found = ref |
| 78 | } |
| 79 | } |
| 80 | return found, found != "" |
| 81 | } |
| 82 | |
| 83 | // roleOperator is the role doors' gate: an operator, with region HA on. |
| 84 | func (s *Server) roleOperator(w http.ResponseWriter, r *http.Request) (string, bool) { |
| 85 | addr, _, ok := s.apiSystemRoller(w, r) |
| 86 | if !ok { |
| 87 | return "", false |
| 88 | } |
| 89 | if !s.cfg.RegionHA { |
| 90 | http.NotFound(w, r) |
| 91 | return "", false |
| 92 | } |
| 93 | return addr, true |
| 94 | } |
| 95 | |
| 96 | // roleWriteError answers a roster write that did not land. Only a lost |
| 97 | // compare-and-swap is "the roster changed while it was being saved". Any |
| 98 | // other roster refusal (a membership change in progress, a host floor |
| 99 | // mismatch, a roster that does not validate) is logged with its real cause |
| 100 | // and answered as one that cannot be changed right now. |
| 101 | func roleWriteError(w http.ResponseWriter, what string, err error) { |
| 102 | if errors.Is(err, release.ErrCASConflict) { |
| 103 | jsonError(w, http.StatusConflict, boxRosterChanged) |
| 104 | return |
| 105 | } |
| 106 | if errors.Is(err, hostactors.ErrRoster) { |
| 107 | log.Printf("console: %s: roster refused: %v", what, err) |
| 108 | jsonError(w, http.StatusConflict, boxRosterBusy) |
| 109 | return |
| 110 | } |
| 111 | log.Printf("console: %s: %v", what, err) |
| 112 | jsonError(w, http.StatusServiceUnavailable, boxRoleWriteFailure) |
| 113 | } |
| 114 | |
| 115 | func (s *Server) handleAPISystemBoxRole(w http.ResponseWriter, r *http.Request) { |
| 116 | by, ok := s.roleOperator(w, r) |
| 117 | if !ok { |
| 118 | return |
| 119 | } |
| 120 | var req struct { |
| 121 | Role string `json:"role"` |
| 122 | } |
| 123 | if !readJSON(w, r, &req) { |
| 124 | return |
| 125 | } |
| 126 | role, err := hostactors.ParseBoxRole(req.Role) |
| 127 | if err != nil { |
| 128 | jsonError(w, http.StatusBadRequest, boxRoleRefusal) |
| 129 | return |
| 130 | } |
| 131 | dir, signer, o, err := s.boxRoster(r.Context()) |
| 132 | if err != nil { |
| 133 | log.Printf("console: box role: roster: %v", err) |
| 134 | jsonError(w, http.StatusServiceUnavailable, boxRosterUnread) |
| 135 | return |
| 136 | } |
| 137 | name := r.PathValue("box") |
| 138 | ref, ok := rosterRef(o.Roster, name) |
| 139 | if !ok { |
| 140 | jsonError(w, http.StatusNotFound, noEnrolledBox(name)) |
| 141 | return |
| 142 | } |
| 143 | if o.Roster.Boxes[ref].State == "retired" { |
| 144 | jsonError(w, http.StatusConflict, retiredBoxRole(ref)) |
| 145 | return |
| 146 | } |
| 147 | next, err := dir.SetBoxRole(r.Context(), signer, o, ref, role) |
| 148 | if err != nil { |
| 149 | roleWriteError(w, "box role "+ref, err) |
| 150 | return |
| 151 | } |
| 152 | log.Printf("console: box %s has the %s role (by %s, roster revision %d)", ref, hostactors.RoleName(role), by, next.Roster.Revision) |
| 153 | if next.Roster.Revision != o.Roster.Revision { |
| 154 | s.auditSystem(by, "box-role", boxRoleAudit(ref, hostactors.RoleName(role))) |
| 155 | } |
| 156 | writeJSON(w, http.StatusOK, map[string]string{"box": ref, "role": hostactors.RoleName(role)}) |
| 157 | } |
| 158 | |
| 159 | func (s *Server) handleAPISystemGroupRole(w http.ResponseWriter, r *http.Request) { |
| 160 | by, ok := s.roleOperator(w, r) |
| 161 | if !ok { |
| 162 | return |
| 163 | } |
| 164 | var req struct { |
| 165 | Role string `json:"role"` |
| 166 | } |
| 167 | if !readJSON(w, r, &req) { |
| 168 | return |
| 169 | } |
| 170 | role, err := hostactors.ParseGroupRole(req.Role) |
| 171 | if err != nil { |
| 172 | jsonError(w, http.StatusBadRequest, groupRoleRefusal) |
| 173 | return |
| 174 | } |
| 175 | dir, signer, o, err := s.boxRoster(r.Context()) |
| 176 | if err != nil { |
| 177 | log.Printf("console: group role: roster: %v", err) |
| 178 | jsonError(w, http.StatusServiceUnavailable, boxRosterUnread) |
| 179 | return |
| 180 | } |
| 181 | region := r.PathValue("region") |
| 182 | g, ok := o.Roster.Regions[region] |
| 183 | if !ok || g.GroupName == "" { |
| 184 | jsonError(w, http.StatusNotFound, noGroupIn(region)) |
| 185 | return |
| 186 | } |
| 187 | next, boxes, err := dir.SetGroupRole(r.Context(), signer, o, region, role) |
| 188 | if err != nil { |
| 189 | roleWriteError(w, "group role "+region, err) |
| 190 | return |
| 191 | } |
| 192 | log.Printf("console: the group in %s (%s) and its boxes %v have the %s role (by %s, roster revision %d)", region, g.GroupName, boxes, hostactors.RoleName(role), by, next.Roster.Revision) |
| 193 | if next.Roster.Revision != o.Roster.Revision { |
| 194 | s.auditSystem(by, "group-role", groupRoleAudit(region, hostactors.RoleName(role))) |
| 195 | } |
| 196 | if boxes == nil { |
| 197 | boxes = []string{} |
| 198 | } |
| 199 | writeJSON(w, http.StatusOK, map[string]any{"region": region, "group": g.GroupName, "role": hostactors.RoleName(role), "boxes": boxes}) |
| 200 | } |
| 201 | |