Files
mpc/pkg/api/middleware_test.go
zeekay 21de07f2e1 R3-2: remove * wildcard bypass in API-key hasPermission
A misissued API key with permissions=["*"] was acting as a god-key,
passing every named permission gate including mpc:settlement:sign,
mpc:operations:approve, mpc:wallet:sweep, and mpc:policy:write.

Every API key must now enumerate its explicit permissions. This is the
one-way-to-express-authority rule: the list is the source of truth —
no wildcards, no implicit grants.

Tests:
- TestRequireRoleOrAPIPermission updated: `*` is rejected on every check
- New TestRequireRoleOrAPIPermission_WildcardRejectedOnSensitive: table
  over every sensitive permission, asserts wildcard is forbidden
- New TestRequirePermission_WildcardRejected: same rule on the lower
  gate used by trade approval

Breaking change: any operator who issued keys with permissions=["*"]
must reissue with explicit permissions. v1.7.0.
2025-12-28 10:56:11 -08:00

393 lines
11 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthMiddleware_ValidToken(t *testing.T) {
s := newTestServer("middleware-secret")
token, err := s.generateJWT("user-mid", "org-mid", "admin")
if err != nil {
t.Fatalf("generateJWT: %v", err)
}
// Handler that checks context values were set
var gotUserID, gotOrgID, gotRole string
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID = getUserID(r.Context())
gotOrgID = getOrgID(r.Context())
gotRole = getRole(r.Context())
w.WriteHeader(http.StatusOK)
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if gotUserID != "user-mid" {
t.Errorf("UserID = %q, want %q", gotUserID, "user-mid")
}
if gotOrgID != "org-mid" {
t.Errorf("OrgID = %q, want %q", gotOrgID, "org-mid")
}
if gotRole != "admin" {
t.Errorf("Role = %q, want %q", gotRole, "admin")
}
}
func TestAuthMiddleware_NoToken(t *testing.T) {
s := newTestServer("middleware-secret")
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// No Authorization header, no X-API-Key header
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if called {
t.Error("inner handler should not have been called")
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["error"] != "missing authorization" {
t.Errorf("error = %q, want %q", body["error"], "missing authorization")
}
}
func TestAuthMiddleware_InvalidToken(t *testing.T) {
s := newTestServer("middleware-secret")
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token-data")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if called {
t.Error("inner handler should not have been called")
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["error"] != "invalid token" {
t.Errorf("error = %q, want %q", body["error"], "invalid token")
}
}
func TestRequireRole_Allowed(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := requireRole("admin", "operator")(inner)
// Set role=admin in context
req := httptest.NewRequest(http.MethodGet, "/test", nil)
ctx := context.WithValue(req.Context(), ctxRole, "admin")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !called {
t.Error("inner handler should have been called")
}
}
func TestRequireRole_Denied(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
handler := requireRole("admin")(inner)
// Set role=viewer in context (not in allowed list)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
ctx := context.WithValue(req.Context(), ctxRole, "viewer")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
if called {
t.Error("inner handler should not have been called")
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["error"] != "insufficient permissions" {
t.Errorf("error = %q, want %q", body["error"], "insufficient permissions")
}
}
func TestRequireRole_NoRoleInContext(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
handler := requireRole("admin")(inner)
// No role in context at all
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
if called {
t.Error("inner handler should not have been called")
}
}
// R2-3: requireRoleOrAPIPermission must admit human roles on their role
// alone, but require API-key callers to carry the explicit permission. A
// bare "api" role with permissions=[] must be rejected.
func TestRequireRoleOrAPIPermission(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
gate := requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:sign")
run := func(name, role string, perms []string, wantCode int) {
t.Helper()
called = false
req := httptest.NewRequest(http.MethodPost, "/sign", nil)
ctx := context.WithValue(req.Context(), ctxRole, role)
if perms != nil {
ctx = context.WithValue(ctx, ctxPermissions, perms)
}
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
gate(inner).ServeHTTP(rec, req)
if rec.Code != wantCode {
t.Errorf("%s: status=%d want=%d body=%s", name, rec.Code, wantCode, rec.Body.String())
}
}
// Human roles pass without any permission check.
run("owner passes", "owner", nil, http.StatusOK)
run("admin passes", "admin", nil, http.StatusOK)
run("signer passes", "signer", nil, http.StatusOK)
// API key with the right permission passes.
run("api+mpc:sign passes", "api", []string{"mpc:sign"}, http.StatusOK)
// R3-2: `*` wildcard is NOT honored. A misissued key with perms=["*"]
// must be rejected on every named permission check — no god-key.
run("api+* rejected (no wildcard)", "api", []string{"*"}, http.StatusForbidden)
run("api+*-plus-unrelated rejected", "api", []string{"*", "unrelated:scope"}, http.StatusForbidden)
// Bare API key without the permission — R2-3 regression.
run("api+no-perms rejected", "api", []string{}, http.StatusForbidden)
run("api+wrong-perm rejected", "api", []string{"sign"}, http.StatusForbidden)
// Unknown role rejected.
run("guest rejected", "guest", nil, http.StatusForbidden)
run("empty rejected", "", nil, http.StatusForbidden)
_ = called
}
// R3-2: assert wildcard does not bypass sensitive gates. Every one of
// these permission names must reject a `*`-only key. Done explicitly so
// a regression on any one of them fails the table row by name.
func TestRequireRoleOrAPIPermission_WildcardRejectedOnSensitive(t *testing.T) {
sensitivePerms := []string{
"mpc:sign",
"mpc:settlement:sign",
"mpc:operations:approve",
"mpc:wallet:sweep",
"mpc:policy:write",
}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
for _, perm := range sensitivePerms {
perm := perm
t.Run("wildcard_rejects_"+perm, func(t *testing.T) {
gate := requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, perm)
req := httptest.NewRequest(http.MethodPost, "/sensitive", nil)
ctx := context.WithValue(req.Context(), ctxRole, "api")
ctx = context.WithValue(ctx, ctxPermissions, []string{"*"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
gate(inner).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("perm=%q with permissions=[*] must be rejected; got %d body=%s",
perm, rec.Code, rec.Body.String())
}
})
}
}
// R3-2: assert requirePermission (the lower-level gate used by trade
// approval etc.) also no longer accepts `*` for a named permission.
func TestRequirePermission_WildcardRejected(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
gate := requirePermission("trade:submit")
req := httptest.NewRequest(http.MethodPost, "/trade", nil)
ctx := context.WithValue(req.Context(), ctxRole, "api")
ctx = context.WithValue(ctx, ctxPermissions, []string{"*"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
gate(inner).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("requirePermission must reject `*`; got %d body=%s",
rec.Code, rec.Body.String())
}
}
func TestRequireRole_MultipleAllowed(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := requireRole("admin", "operator", "api")(inner)
// role=operator is in the allowed list
req := httptest.NewRequest(http.MethodGet, "/test", nil)
ctx := context.WithValue(req.Context(), ctxRole, "operator")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !called {
t.Error("inner handler should have been called")
}
}
func TestContextHelpers(t *testing.T) {
// Empty context returns zero values
ctx := context.Background()
if v := getUserID(ctx); v != "" {
t.Errorf("getUserID(empty) = %q, want empty", v)
}
if v := getOrgID(ctx); v != "" {
t.Errorf("getOrgID(empty) = %q, want empty", v)
}
if v := getRole(ctx); v != "" {
t.Errorf("getRole(empty) = %q, want empty", v)
}
// With values
ctx = context.WithValue(ctx, ctxUserID, "u1")
ctx = context.WithValue(ctx, ctxOrgID, "o1")
ctx = context.WithValue(ctx, ctxRole, "admin")
if v := getUserID(ctx); v != "u1" {
t.Errorf("getUserID = %q, want %q", v, "u1")
}
if v := getOrgID(ctx); v != "o1" {
t.Errorf("getOrgID = %q, want %q", v, "o1")
}
if v := getRole(ctx); v != "admin" {
t.Errorf("getRole = %q, want %q", v, "admin")
}
}
func TestWriteJSON(t *testing.T) {
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusCreated, map[string]string{"msg": "ok"})
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["msg"] != "ok" {
t.Errorf("body[msg] = %q, want %q", body["msg"], "ok")
}
}
func TestWriteError(t *testing.T) {
rec := httptest.NewRecorder()
writeError(rec, http.StatusBadRequest, "bad input")
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["error"] != "bad input" {
t.Errorf("body[error] = %q, want %q", body["error"], "bad input")
}
}
func TestNilIfEmpty(t *testing.T) {
if v := nilIfEmpty(""); v != nil {
t.Errorf("nilIfEmpty(\"\") = %v, want nil", v)
}
if v := nilIfEmpty("hello"); v == nil || *v != "hello" {
t.Errorf("nilIfEmpty(\"hello\") = %v, want *\"hello\"", v)
}
}