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:
1 parent
39659f0144
commit
1c2a5b71bd
6 files changed
+231
-8
No files matched your search
+17
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user