API keys for server scope: edges, server alerts and orgs can be scripted

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 16:54:14 -06:00
1 parent 39659f0144
commit 1c2a5b71bd
6 files changed
+232 -9

No files matched your search

+17 -2
View File
@@ -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
+83
View File
@@ -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)
}
}
+36
View File
@@ -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_<id>_<secret>`.
## 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).
+12 -1
View File
@@ -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
+79 -5
View File
@@ -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) {
+5 -1
View File
@@ -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))