mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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.
393 lines
11 KiB
Go
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)
|
|
}
|
|
}
|