| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // The platform door (#209): a deployment's operator attaching a hostname in |
| 12 | // the deployment's own zones (CONSOLE_DENY_DOMAINS — carloku.com on the |
| 13 | // flagship, go.tito.io on Tito's) to an app, with members still refused. |
| 14 | |
| 15 | // platformFixture is instanceFixture on a deployment that reserves platform |
| 16 | // domains, lists carloku.test as its own, and has a tunnel host INSIDE that |
| 17 | // zone — the case where the deny list's two halves must not be confused. |
| 18 | // paul owns the ops account; alice is an ordinary member of it. |
| 19 | func platformFixture(t *testing.T, code string) (fx *consoleFixture, alice, paul string) { |
| 20 | t.Helper() |
| 21 | fx, alice = instanceFixture(t, code) |
| 22 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 23 | fx.srv.cfg.ReservePlatformDomains = true |
| 24 | fx.srv.cfg.DenyDomains = []string{"carloku.test"} |
| 25 | fx.srv.cfg.TunnelHost = "hub.carloku.test" |
| 26 | return fx, alice, cliToken(t, fx, "paul@keymail.dev", code+"-paul") |
| 27 | } |
| 28 | |
| 29 | func platformAttachBody(host, route string) string { |
| 30 | b, err := json.Marshal(map[string]any{"host": host, "route": route, "platform": true}) |
| 31 | if err != nil { |
| 32 | panic(err) |
| 33 | } |
| 34 | return string(b) |
| 35 | } |
| 36 | |
| 37 | func TestDomainHostPolicyMatrix(t *testing.T) { |
| 38 | s := &Server{cfg: Config{ |
| 39 | AppsDomain: "apps.test", |
| 40 | ReservePlatformDomains: true, |
| 41 | Origin: "https://console.carloku.test", |
| 42 | RedirectHosts: []string{"retired.carloku.test"}, |
| 43 | DenyDomains: []string{"carloku.test", "go.tito.test"}, |
| 44 | TunnelHost: "hub.carloku.test", |
| 45 | }} |
| 46 | cases := []struct { |
| 47 | host string |
| 48 | want hostPolicy |
| 49 | }{ |
| 50 | {"tickets.example.com", hostOpen}, |
| 51 | {"carloku.test", hostPlatform}, |
| 52 | {"www.carloku.test", hostPlatform}, |
| 53 | {"go.tito.test", hostPlatform}, |
| 54 | {"a.console.carloku.test", hostPlatform}, // under the console's own name |
| 55 | {"console.carloku.test", hostReserved}, // the console itself |
| 56 | {"retired.carloku.test", hostReserved}, // a retired console origin |
| 57 | {"hub.carloku.test", hostReserved}, // the tunnel host, though under a platform entry |
| 58 | {"x.hub.carloku.test", hostReserved}, |
| 59 | {"edge.apps.test", hostReserved}, // the steering host |
| 60 | {"apps.test", hostReserved}, |
| 61 | {"site.bab.apps.test", hostReserved}, // a tenant's address |
| 62 | {"web.sidecar.internal", hostReserved}, |
| 63 | } |
| 64 | for _, c := range cases { |
| 65 | if got := s.domainHostPolicy(c.host, "bab"); got != c.want { |
| 66 | t.Errorf("domainHostPolicy(%q) = %v, want %v", c.host, got, c.want) |
| 67 | } |
| 68 | if got := s.domainHostDenied(c.host, "bab"); got != (c.want != hostOpen) { |
| 69 | t.Errorf("domainHostDenied(%q) = %v, want %v", c.host, got, c.want != hostOpen) |
| 70 | } |
| 71 | } |
| 72 | // Without ReservePlatformDomains the apps domain and the console's |
| 73 | // subtree are ordinary names, as before; the operator's own list and the |
| 74 | // tunnel host are not. |
| 75 | s.cfg.ReservePlatformDomains = false |
| 76 | for host, want := range map[string]hostPolicy{ |
| 77 | "site.bab.apps.test": hostOpen, |
| 78 | "a.console.carloku.test": hostPlatform, // still under carloku.test |
| 79 | "carloku.test": hostPlatform, |
| 80 | "hub.carloku.test": hostReserved, |
| 81 | } { |
| 82 | if got := s.domainHostPolicy(host, "bab"); got != want { |
| 83 | t.Errorf("without reserve: domainHostPolicy(%q) = %v, want %v", host, got, want) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // TestPlatformAttachRefusesMembers: an ordinary member gets the old refusal |
| 89 | // without the flag and a 403 with it, and no claim is written either way. |
| 90 | func TestPlatformAttachRefusesMembers(t *testing.T) { |
| 91 | fx, alice, _ := platformFixture(t, "code-plat-member") |
| 92 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice, |
| 93 | attachBody("carloku.test", "go.tito.io")); code != http.StatusBadRequest { |
| 94 | t.Errorf("member attach without --platform = %d %s, want 400", code, out) |
| 95 | } |
| 96 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice, |
| 97 | platformAttachBody("carloku.test", "go.tito.io")) |
| 98 | if code != http.StatusForbidden { |
| 99 | t.Errorf("member attach with --platform = %d %s, want 403", code, out) |
| 100 | } |
| 101 | // The flag is refused on an ordinary hostname too: the answer depends on |
| 102 | // the caller alone, never on what the deny list holds. |
| 103 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice, |
| 104 | platformAttachBody("tickets.example.com", "go.tito.io")); code != http.StatusForbidden { |
| 105 | t.Errorf("member attach of an ordinary host with --platform = %d %s, want 403", code, out) |
| 106 | } |
| 107 | claimAbsent(t, fx, "carloku.test") |
| 108 | claimAbsent(t, fx, "tickets.example.com") |
| 109 | } |
| 110 | |
| 111 | // TestPlatformAttachRefusesServices: an app token is never an operator, flag |
| 112 | // or no flag, even one the ops owner minted (a service principal carries its |
| 113 | // minter's address). |
| 114 | func TestPlatformAttachRefusesServices(t *testing.T) { |
| 115 | fx, _, _ := platformFixture(t, "code-plat-svc") |
| 116 | tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "signup", time.Now()) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), tok, |
| 121 | platformAttachBody("carloku.test", "go.tito.io")) |
| 122 | if code != http.StatusForbidden { |
| 123 | t.Errorf("app token attach with --platform = %d %s, want 403", code, out) |
| 124 | } |
| 125 | claimAbsent(t, fx, "carloku.test") |
| 126 | } |
| 127 | |
| 128 | // TestPlatformAttachByOpsOwner: the ops owner needs the flag, and with it |
| 129 | // gets an ordinary claim naming them as its author. |
| 130 | func TestPlatformAttachByOpsOwner(t *testing.T) { |
| 131 | fx, _, paul := platformFixture(t, "code-plat-owner") |
| 132 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| 133 | attachBody("carloku.test", "go.tito.io")); code != http.StatusBadRequest { |
| 134 | t.Fatalf("owner attach WITHOUT --platform = %d %s, want 400", code, out) |
| 135 | } |
| 136 | claimAbsent(t, fx, "carloku.test") |
| 137 | for _, host := range []string{"carloku.test", "www.carloku.test"} { |
| 138 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| 139 | platformAttachBody(host, "go.tito.io")) |
| 140 | if code != http.StatusOK { |
| 141 | t.Fatalf("owner attach %s with --platform = %d %s, want 200", host, code, out) |
| 142 | } |
| 143 | c := claimOf(t, fx, host) |
| 144 | if c.Account != fx.sqid || c.App != "titogo" || c.Route != "go.tito.io" || c.CreatedBy != "paul@keymail.dev" { |
| 145 | t.Errorf("claim %s = %+v", host, c) |
| 146 | } |
| 147 | } |
| 148 | // An operator's flag on an ordinary hostname is harmless: it attaches |
| 149 | // exactly as it would without. |
| 150 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| 151 | platformAttachBody("tickets.example.com", "go.tito.io")); code != http.StatusOK { |
| 152 | t.Errorf("owner attach of an ordinary host with --platform = %d %s, want 200", code, out) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // TestPlatformAttachBySystemOperator: an address on CARLOS_SYSTEM_OPERATORS |
| 157 | // may use the door on an account it is a member of, and only there; the ops |
| 158 | // owner gets no reach into an account they are not in either. |
| 159 | func TestPlatformAttachBySystemOperator(t *testing.T) { |
| 160 | fx, _, paul := platformFixture(t, "code-plat-sysop") |
| 161 | now := time.Now() |
| 162 | if err := fx.st.CreateAccount(42, "cdef", "carloku", "bob@keymail.dev", now); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | if _, err := fx.st.CreateAppIn(42, "website", "bob@keymail.dev", now); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | if err := fx.st.AddMember(42, "bill@keymail.dev", RoleMember, now); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | bill := cliToken(t, fx, "bill@keymail.dev", "code-plat-bill") |
| 172 | |
| 173 | body := platformAttachBody("carloku.test", "website.cdef.oncarlos.test") |
| 174 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), bill, body); code != http.StatusForbidden { |
| 175 | t.Fatalf("bill before being listed = %d %s, want 403", code, out) |
| 176 | } |
| 177 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), paul, body); code != http.StatusNotFound { |
| 178 | t.Fatalf("ops owner on an account they are not in = %d %s, want 404", code, out) |
| 179 | } |
| 180 | claimAbsent(t, fx, "carloku.test") |
| 181 | |
| 182 | fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} |
| 183 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), bill, body); code != http.StatusOK { |
| 184 | t.Fatalf("listed system operator = %d %s, want 200", code, out) |
| 185 | } |
| 186 | if c := claimOf(t, fx, "carloku.test"); c.Account != "cdef" || c.App != "website" || c.CreatedBy != "bill@keymail.dev" { |
| 187 | t.Errorf("claim = %+v", c) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // TestPlatformAttachReservedStaysRefused: the door lifts the operator's own |
| 192 | // list and nothing else. The console's names, the steering host, the tunnel |
| 193 | // host and the tenant namespace stay refused to the operator too. |
| 194 | func TestPlatformAttachReservedStaysRefused(t *testing.T) { |
| 195 | fx, _, paul := platformFixture(t, "code-plat-reserved") |
| 196 | fx.srv.cfg.Origin = "https://console.carloku.test" |
| 197 | // A deny entry naming the sidecar suffix must not make it a platform |
| 198 | // name the door lifts. |
| 199 | fx.srv.cfg.DenyDomains = append(fx.srv.cfg.DenyDomains, "sidecar.internal") |
| 200 | for _, host := range []string{ |
| 201 | "console.carloku.test", |
| 202 | "hub.carloku.test", |
| 203 | "edge.oncarlos.test", |
| 204 | "oncarlos.test", |
| 205 | "titogo.bab.oncarlos.test", |
| 206 | "web.sidecar.internal", |
| 207 | } { |
| 208 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| 209 | platformAttachBody(host, "go.tito.io")) |
| 210 | if code != http.StatusBadRequest { |
| 211 | t.Errorf("operator attach of reserved %s = %d %s, want 400", host, code, out) |
| 212 | } |
| 213 | claimAbsent(t, fx, host) |
| 214 | } |
| 215 | // The shared core behind the static first-route branch must draw the |
| 216 | // same line on its own, with the door already granted. |
| 217 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 218 | if err != nil || acct == nil { |
| 219 | t.Fatalf("account: %v", err) |
| 220 | } |
| 221 | for _, host := range []string{"console.carloku.test", "hub.carloku.test", "edge.oncarlos.test", "titogo.bab.oncarlos.test"} { |
| 222 | if _, ref := fx.srv.attachDomainAs(context.Background(), acct, "titogo", host, "static", "", "paul@keymail.dev", true); ref == nil || ref.Status != http.StatusUnprocessableEntity { |
| 223 | t.Errorf("core attach of reserved %s with the door granted = %+v, want 422", host, ref) |
| 224 | } |
| 225 | claimAbsent(t, fx, host) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | // TestPlatformClaimMembersCannotChangeIt: once the operator has attached a |
| 230 | // platform hostname, the holding account's members cannot repoint it, flip |
| 231 | // its capabilities, carve up its paths or release it. Only the operator can. |
| 232 | func TestPlatformClaimMembersCannotChangeIt(t *testing.T) { |
| 233 | fx, alice, paul := platformFixture(t, "code-plat-hold") |
| 234 | const host = "carloku.test" |
| 235 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| 236 | platformAttachBody(host, "go.tito.io")); code != http.StatusOK { |
| 237 | t.Fatalf("operator attach = %d %s", code, out) |
| 238 | } |
| 239 | before := claimOf(t, fx, host) |
| 240 | |
| 241 | refusals := []struct { |
| 242 | what, method, url, body string |
| 243 | }{ |
| 244 | {"repoint", http.MethodPost, domainsURL(fx, "bab", "titogo"), attachBody(host, "two.go.tito.io")}, |
| 245 | {"repoint with --platform", http.MethodPost, domainsURL(fx, "bab", "titogo"), platformAttachBody(host, "two.go.tito.io")}, |
| 246 | {"catchall", http.MethodPost, domainsURL(fx, "bab", "titogo"), `{"host":"` + host + `","route":"go.tito.io","wildcard":true,"catchall":true}`}, |
| 247 | {"paths", http.MethodPut, pathsURL(fx, "bab", "titogo", host), `{"paths":{"admin":"go.tito.io"}}`}, |
| 248 | {"detach", http.MethodDelete, domainsURL(fx, "bab", "titogo") + "/" + host, ""}, |
| 249 | } |
| 250 | for _, r := range refusals { |
| 251 | code, _, out := rawReq(t, r.method, r.url, alice, r.body) |
| 252 | if code != http.StatusBadRequest && code != http.StatusForbidden { |
| 253 | t.Errorf("member %s = %d %s, want a refusal", r.what, code, out) |
| 254 | } |
| 255 | } |
| 256 | after := claimOf(t, fx, host) |
| 257 | if after.Route != before.Route || after.Wildcard || after.Catchall || len(after.Paths) != 0 { |
| 258 | t.Fatalf("claim changed under member writes: %+v", after) |
| 259 | } |
| 260 | |
| 261 | if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", host), paul, |
| 262 | `{"paths":{"admin":"go.tito.io"}}`); code != http.StatusOK { |
| 263 | t.Errorf("operator paths = %d %s, want 200", code, out) |
| 264 | } |
| 265 | code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/"+host, paul, "") |
| 266 | if code != http.StatusOK { |
| 267 | t.Fatalf("operator detach = %d %s, want 200", code, out) |
| 268 | } |
| 269 | var got domainDetachResp |
| 270 | if err := json.Unmarshal([]byte(out), &got); err != nil || !got.Removed { |
| 271 | t.Fatalf("operator detach body %s (err %v), want removed", out, err) |
| 272 | } |
| 273 | claimAbsent(t, fx, host) |
| 274 | } |
| 275 | |
| 276 | // TestPlatformBrowserDoors: the settings page offers no platform door. Its |
| 277 | // attach refuses a platform hostname even to the operator, and its detach |
| 278 | // lets the operator, and only the operator, release one. |
| 279 | func TestPlatformBrowserDoors(t *testing.T) { |
| 280 | fx, _, paul := platformFixture(t, "code-plat-browser") |
| 281 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 282 | if err != nil || acct == nil { |
| 283 | t.Fatalf("account: %v", err) |
| 284 | } |
| 285 | ctx := context.Background() |
| 286 | if _, ref := fx.srv.attachDomain(ctx, acct, "titogo", "carloku.test", "", "", "paul@keymail.dev"); ref == nil || ref.Status != http.StatusUnprocessableEntity { |
| 287 | t.Fatalf("browser attach by the operator = %+v, want 422", ref) |
| 288 | } |
| 289 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| 290 | platformAttachBody("carloku.test", "go.tito.io")); code != http.StatusOK { |
| 291 | t.Fatalf("operator attach = %d %s", code, out) |
| 292 | } |
| 293 | if ref := fx.srv.detachDomain(ctx, acct, "titogo", "carloku.test", "alice@keymail.dev"); ref == nil || ref.Status != http.StatusForbidden { |
| 294 | t.Fatalf("browser detach by a member = %+v, want 403", ref) |
| 295 | } |
| 296 | claimOf(t, fx, "carloku.test") |
| 297 | if ref := fx.srv.detachDomain(ctx, acct, "titogo", "carloku.test", "paul@keymail.dev"); ref != nil { |
| 298 | t.Fatalf("browser detach by the operator = %+v, want released", ref) |
| 299 | } |
| 300 | claimAbsent(t, fx, "carloku.test") |
| 301 | } |
| 302 | |
| 303 | // TestAPIAttachRefusesSidecarNames: the API door's alias branch refuses a |
| 304 | // sidecar name to a member too, with or without a deny entry covering it. |
| 305 | func TestAPIAttachRefusesSidecarNames(t *testing.T) { |
| 306 | fx, alice, _ := platformFixture(t, "code-plat-sidecar") |
| 307 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice, |
| 308 | attachBody("web.sidecar.internal", "go.tito.io")) |
| 309 | if code != http.StatusBadRequest { |
| 310 | t.Errorf("member alias attach of a sidecar name = %d %s, want 400", code, out) |
| 311 | } |
| 312 | claimAbsent(t, fx, "web.sidecar.internal") |
| 313 | } |
| 314 | |