From 1c2a5b71bd6c3a4a11c34fe8fd1a243843c265eb Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:54:14 -0600 Subject: [PATCH] API keys for server scope: edges, server alerts and orgs can be scripted Co-Authored-By: Claude Sonnet 5.5 --- accounts.go | 19 +++++++++- apikeys_test.go | 83 +++++++++++++++++++++++++++++++++++++++++ docs/api-automation.md | 36 ++++++++++++++++++ infra.go | 13 ++++++- orgs.go | 84 +++++++++++++++++++++++++++++++++++++++--- server.go | 6 ++- 6 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 apikeys_test.go create mode 100644 docs/api-automation.md diff --git a/accounts.go b/accounts.go index 45742ef..7a8c2ae 100644 --- a/accounts.go +++ b/accounts.go @@ -54,6 +54,9 @@ func (s *Server) principalFrom(r *http.Request) *principal { if k.ID == parts[1] && hmac.Equal([]byte(k.Hash), []byte(sha(parts[2]))) { k.LastUsed = time.Now() kc := *k + if k.OrgID == "" { // a server key acts as a server administrator + return &principal{Key: &kc, User: User{Email: "apikey:" + k.Name, ServerAdmin: true}} + } return &principal{Key: &kc} } } @@ -73,14 +76,23 @@ func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal } } +// serverKey reports whether the caller authenticated with a server-level API key. +func (p *principal) serverKey() bool { return p.Key != nil && p.Key.OrgID == "" } + +// withServerAdmin lets signed-in server administrators and server API keys through. func (s *Server) withServerAdmin(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc { - return s.withUser(func(w http.ResponseWriter, r *http.Request, p *principal) { + return func(w http.ResponseWriter, r *http.Request) { + p := s.principalFrom(r) + if p == nil || (p.User.ID == "" && !p.serverKey()) { + fail(w, 401, errors.New("Sign in to continue.")) + return + } if !p.User.ServerAdmin { fail(w, 403, errors.New("server administrators only")) return } fn(w, r, p) - }) + } } // orgCtx is the caller's standing inside one org. @@ -107,6 +119,9 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ if org != nil { c.Org = *org switch { + case p.serverKey(): + allowed, c.Admin = true, true + c.Actor = "apikey:" + p.Key.Name case p.Key != nil: allowed = p.Key.OrgID == org.ID c.Admin = allowed diff --git a/apikeys_test.go b/apikeys_test.go new file mode 100644 index 0000000..b0b23bd --- /dev/null +++ b/apikeys_test.go @@ -0,0 +1,83 @@ +package hammerhead + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func keyCall(t *testing.T, s *Server, key, method, path, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + var out map[string]any + json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out +} + +func mintKey(t *testing.T, s *Server, org string) string { + t.Helper() + k := "hhk_" + randHex(4) + "_" + randHex(24) + parts := strings.SplitN(k, "_", 3) + s.mu.Lock() + s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: parts[1], OrgID: org, Name: "t", Hash: sha(parts[2])}) + s.mu.Unlock() + return k +} + +func TestServerKeysManageEdgesAndOrgs(t *testing.T) { + f := newFx(t) + server := mintKey(t, f.s, "") + orgKey := mintKey(t, f.s, f.org) + + if c, _ := keyCall(t, f.s, server, "POST", "/api/edges", `{"name":"e2","tunnelAddr":"1.2.3.4:51820"}`); c != 200 { + t.Fatalf("server key should create an edge, got %d", c) + } + if c, _ := keyCall(t, f.s, orgKey, "POST", "/api/edges", `{"name":"e3","tunnelAddr":"1.2.3.4:51820"}`); c != 401 { + t.Fatalf("org key must not create edges, got %d", c) + } + if c, _ := keyCall(t, f.s, server, "POST", "/api/orgs", `{"name":"Second"}`); c != 200 { + t.Fatalf("server key should create an org, got %d", c) + } + // server key reaches any org's endpoints; an org key only its own + if c, _ := keyCall(t, f.s, server, "POST", "/api/orgs/"+f.org+"/sites", `{"name":"x"}`); c != 200 { + t.Fatalf("server key on org: %d", c) + } + if c, _ := keyCall(t, f.s, orgKey, "POST", "/api/orgs/nope/sites", `{"name":"x"}`); c != 404 { + t.Fatalf("org key on other org: %d", c) + } + // server alert channels + if c, _ := keyCall(t, f.s, server, "GET", "/api/admin/alerts", ""); c != 200 { + t.Fatalf("server key alerts: %d", c) + } + // keys can't mint keys at server level + if c, _ := keyCall(t, f.s, server, "POST", "/api/admin/apikeys", `{"name":"k"}`); c != 401 { + t.Fatalf("server key must not mint server keys, got %d", c) + } + if c, _ := keyCall(t, f.s, "", "GET", "/api/orgs", ""); c != 401 { + t.Fatalf("anonymous list orgs: %d", c) + } + req := httptest.NewRequest("GET", "/api/orgs", nil) + req.Header.Set("Authorization", "Bearer "+orgKey) + rec := httptest.NewRecorder() + f.s.Handler().ServeHTTP(rec, req) + var orgs []Org + json.Unmarshal(rec.Body.Bytes(), &orgs) + if rec.Code != http.StatusOK || len(orgs) != 1 || orgs[0].ID != f.org { + t.Fatalf("org key should list only its org: %d %s", rec.Code, rec.Body) + } + // org key lists edges its org may use + req = httptest.NewRequest("GET", "/api/edges", nil) + req.Header.Set("Authorization", "Bearer "+orgKey) + rec = httptest.NewRecorder() + f.s.Handler().ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "edge-1") { + t.Fatalf("org key edges: %d %s", rec.Code, rec.Body) + } +} diff --git a/docs/api-automation.md b/docs/api-automation.md new file mode 100644 index 0000000..6114836 --- /dev/null +++ b/docs/api-automation.md @@ -0,0 +1,36 @@ +# Scripting Hammerhead: API audit + +Everything the dashboard does goes through the JSON API (`server.go` lists the routes). Callers +authenticate with the `hh_session` cookie or `Authorization: Bearer hhk__`. + +## Credentials + +| Credential | Reaches | Made at | +|---|---|---| +| Organization key | that organization's `/api/orgs/{org}/...` routes (admin rights), `GET /api/orgs`, `GET /api/edges` (edges the org may use) | `POST /api/orgs/{org}/apikeys` | +| Server key | every organization, plus the server-admin routes: edges, `/api/admin/alerts`, users, settings, `POST /api/orgs` | `POST /api/admin/apikeys` (signed-in server admin only) | + +The secret is shown once, in the `key` field of the create response. A key cannot mint keys at server +level, and a server key has no user, so `/api/me` and other per-user routes refuse it. + +## Gaps found, and what was done + +| Gap | Resolution | +|---|---| +| Edges, server alerts, `POST /api/orgs` required a browser session (`withServerAdmin` refused keys) | server keys, accepted by `withServerAdmin` | +| No way to mint an unattended server credential | `GET/POST/DELETE /api/admin/apikeys` | +| `GET /api/edges` was session-only | accepts keys; an org key sees the edges its org may use | +| No way to list orgs with a key | `GET /api/orgs` | +| Key creation did not return the key's ID | create response now has `id` as well as `key` | +| `createOrg` added the caller as a member | skipped for server keys (they have no user) | + +## Known remaining gaps (not blocking scripting) + +- No per-resource `GET`: sites, domains, ports and alert channels are read from `GET /api/orgs/{org}/state` and + `GET /api/orgs/{org}/alerts`. Clients filter by ID. +- Alert channel secrets (webhook URL, Sandbar key) are never returned, so a client can't detect drift in them; a + blank value on `PUT` keeps the stored one. +- Keys are all-powerful within their scope (no read-only or per-resource scopes), and have no expiry. +- `PUT` on ports replaces the whole object; omitted fields reset. +- Site tokens are returned on create and, for admins, in `state`. +- Deleting an edge/site does not check for ports that use it (the server cleans up or refuses; clients should order deletes). diff --git a/infra.go b/infra.go index 0c6efe3..41819cf 100644 --- a/infra.go +++ b/infra.go @@ -64,11 +64,22 @@ func validAddr(a string) bool { return n > 0 } -func (s *Server) listEdges(w http.ResponseWriter, r *http.Request, p *principal) { +func (s *Server) listEdges(w http.ResponseWriter, r *http.Request) { + p := s.principalFrom(r) + if p == nil { + fail(w, 401, errors.New("Sign in to continue.")) + return + } s.mu.Lock() defer s.mu.Unlock() out := []edgeView{} for _, e := range s.st.Edges { + if p.Key != nil && !p.serverKey() { // an org key sees the edges its organization may use + if s.edgeAllowedFor(e, p.Key.OrgID) { + out = append(out, s.viewEdge(e, false)) + } + continue + } if p.User.ServerAdmin { out = append(out, s.viewEdge(e, true)) continue diff --git a/orgs.go b/orgs.go index 24ce6b0..61879ac 100644 --- a/orgs.go +++ b/orgs.go @@ -29,7 +29,9 @@ func (s *Server) createOrg(w http.ResponseWriter, r *http.Request, p *principal) } s.mu.Lock() org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal) - s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID}) + if p.User.ID != "" { // a server key belongs to no user + s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID}) + } s.save() o := *org s.mu.Unlock() @@ -423,7 +425,8 @@ func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, c *orgCtx) { writeJSON(w, 200, out) } -func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) { +// issueKey stores a new key for the org ("" = a server key) and returns its ID and full token. +func (s *Server) issueKey(w http.ResponseWriter, r *http.Request, org, actor string) { var in struct{ Name string } if !decode(w, r, &in) { return @@ -435,11 +438,82 @@ func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) { } id, secret := randHex(4), randHex(24) s.mu.Lock() - s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: c.Org.ID, Name: in.Name, Hash: sha(secret), Created: time.Now()}) + s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: org, Name: in.Name, Hash: sha(secret), Created: time.Now()}) s.save() s.mu.Unlock() - s.audit(c.Org.ID, c.Actor, "apikey.create", in.Name) - writeJSON(w, 200, map[string]string{"key": "hhk_" + id + "_" + secret}) + s.audit(org, actor, "apikey.create", in.Name) + writeJSON(w, 200, map[string]string{"id": id, "key": "hhk_" + id + "_" + secret}) +} + +func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) { + s.issueKey(w, r, c.Org.ID, c.Actor) +} + +// Server keys manage edges, server-level alerts and organizations, so only a signed-in +// server administrator may mint, list or revoke them (a key can't make more keys). + +func (s *Server) withServerAdminUser(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc { + return s.withUser(func(w http.ResponseWriter, r *http.Request, p *principal) { + if !p.User.ServerAdmin { + fail(w, 403, errors.New("server administrators only")) + return + } + fn(w, r, p) + }) +} + +func (s *Server) listServerKeys(w http.ResponseWriter, r *http.Request, p *principal) { + s.mu.Lock() + defer s.mu.Unlock() + out := []APIKey{} + for _, k := range s.st.APIKeys { + if k.OrgID == "" { + cp := *k + cp.Hash = "" + out = append(out, cp) + } + } + writeJSON(w, 200, out) +} + +func (s *Server) createServerKey(w http.ResponseWriter, r *http.Request, p *principal) { + s.issueKey(w, r, "", p.User.Email) +} + +func (s *Server) deleteServerKey(w http.ResponseWriter, r *http.Request, p *principal) { + id := r.PathValue("id") + s.mu.Lock() + defer s.mu.Unlock() + s.st.APIKeys = filter(s.st.APIKeys, func(k *APIKey) bool { return !(k.ID == id && k.OrgID == "") }) + s.save() + s.audit("", p.User.Email, "apikey.delete", id) + ok(w) +} + +// listOrgs shows the organizations the caller can reach: a server key sees all of them, +// an org key its own. +func (s *Server) listOrgs(w http.ResponseWriter, r *http.Request) { + p := s.principalFrom(r) + if p == nil { + fail(w, 401, errors.New("Sign in to continue.")) + return + } + s.mu.Lock() + defer s.mu.Unlock() + out := []Org{} + for _, o := range s.st.Orgs { + switch { + case p.serverKey() || p.User.ServerAdmin: + out = append(out, *o) + case p.Key != nil: + if p.Key.OrgID == o.ID { + out = append(out, *o) + } + case s.member(p.User.ID, o.ID) != nil: + out = append(out, *o) + } + } + writeJSON(w, 200, out) } func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) { diff --git a/server.go b/server.go index 2b60aae..eb0c8c5 100644 --- a/server.go +++ b/server.go @@ -79,13 +79,17 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/me/sessions", s.withUser(s.mySessions)) mux.HandleFunc("DELETE /api/me/sessions/{id}", s.withUser(s.endSession)) mux.HandleFunc("POST /api/me/sessions/revoke-others", s.withUser(s.endOtherSessions)) - mux.HandleFunc("GET /api/edges", s.withUser(s.listEdges)) + mux.HandleFunc("GET /api/edges", s.listEdges) // server administrators mux.HandleFunc("POST /api/edges", s.withServerAdmin(s.saveEdge)) mux.HandleFunc("PUT /api/edges/{id}", s.withServerAdmin(s.saveEdge)) mux.HandleFunc("DELETE /api/edges/{id}", s.withServerAdmin(s.deleteEdge)) + mux.HandleFunc("GET /api/orgs", s.listOrgs) mux.HandleFunc("POST /api/orgs", s.withServerAdmin(s.createOrg)) + mux.HandleFunc("GET /api/admin/apikeys", s.withServerAdminUser(s.listServerKeys)) + mux.HandleFunc("POST /api/admin/apikeys", s.withServerAdminUser(s.createServerKey)) + mux.HandleFunc("DELETE /api/admin/apikeys/{id}", s.withServerAdminUser(s.deleteServerKey)) mux.HandleFunc("GET /api/admin/users", s.withServerAdmin(s.adminUsers)) mux.HandleFunc("POST /api/admin/users", s.withServerAdmin(s.adminCreateUser)) mux.HandleFunc("PUT /api/admin/users/{id}", s.withServerAdmin(s.adminUpdateUser))