Files
mpc/pkg/api/middleware.go
T
zeekay c3c9ca2900 remove: "api" role pass-through from requireRole — one way only
Every /v1 mutating route that previously accepted role=="api" without
permission checks now uses requireRoleOrAPIPermission — JWT human roles OR
API keys that carry the concrete permission. R2-3 regression surface:

- /v1/transactions             → mpc:transactions:create
- /v1/subscriptions/*          → mpc:subscriptions:write
- /v1/payment-requests/*       → mpc:payment-requests:write
- /v1/smart-wallets/*          → mpc:smart-wallets:write
- /v1/trade/*                  → mpc:trade:write
- /v1/intents/* (sign/co-sign) → mpc:sign (same perm as /v1/mpc/sign)
- /v1/mpc/operations/approve,reject → mpc:operations:approve

Bare role=="api" is no longer sufficient anywhere — an API key with empty
permissions hits 403. Human roles (owner/admin/signer) still pass through
on JWT auth. Also adds the canonical requireRoleOrAPIPermission helper to
middleware.go.
2026-04-18 16:46:41 -07:00

225 lines
6.1 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
type contextKey string
const (
ctxUserID contextKey = "user_id"
ctxOrgID contextKey = "org_id"
ctxRole contextKey = "role"
ctxPermissions contextKey = "permissions"
)
func getUserID(ctx context.Context) string {
v, _ := ctx.Value(ctxUserID).(string)
return v
}
func getOrgID(ctx context.Context) string {
v, _ := ctx.Value(ctxOrgID).(string)
return v
}
func getRole(ctx context.Context) string {
v, _ := ctx.Value(ctxRole).(string)
return v
}
func getPermissions(ctx context.Context) []string {
v, _ := ctx.Value(ctxPermissions).([]string)
return v
}
func hasPermission(ctx context.Context, perm string) bool {
for _, p := range getPermissions(ctx) {
if p == perm || p == "*" {
return true
}
}
return false
}
func urlParam(r *http.Request, key string) string {
return chi.URLParam(r, key)
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try JWT from Authorization header
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
token := strings.TrimPrefix(auth, "Bearer ")
claims, err := s.validateJWT(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
ctx := context.WithValue(r.Context(), ctxUserID, claims.UserID)
ctx = context.WithValue(ctx, ctxOrgID, claims.OrgID)
ctx = context.WithValue(ctx, ctxRole, claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Try API key from X-API-Key header
apiKeyHeader := r.Header.Get("X-API-Key")
if apiKeyHeader != "" {
key, err := s.validateAPIKey(r.Context(), apiKeyHeader)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
ctx := context.WithValue(r.Context(), ctxOrgID, key.OrgID)
ctx = context.WithValue(ctx, ctxRole, "api")
ctx = context.WithValue(ctx, ctxPermissions, key.Permissions)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
writeError(w, http.StatusUnauthorized, "missing authorization")
})
}
func (s *Server) auditMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
// Fire-and-forget audit log for mutating operations
if r.Method != http.MethodGet {
go s.writeAuditLog(r.Context(), r)
}
})
}
func requireRole(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := getRole(r.Context())
for _, allowed := range roles {
if role == allowed {
next.ServeHTTP(w, r)
return
}
}
writeError(w, http.StatusForbidden, "insufficient permissions")
})
}
}
// requirePermission gates API key requests by checking their permissions slice.
// JWT-authenticated users (non-"api" role) pass through — role-based checks handle them.
func requirePermission(perm string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := getRole(r.Context())
// JWT users: check role-based access, not per-permission
if role != "api" {
next.ServeHTTP(w, r)
return
}
// API key users: must have the specific permission
if !hasPermission(r.Context(), perm) {
writeError(w, http.StatusForbidden, "api key missing permission: "+perm)
return
}
next.ServeHTTP(w, r)
})
}
}
// requireRoleOrAPIPermission is the canonical signing gate. It accepts
// JWT-authenticated callers whose role is in `humanRoles`, OR API-key callers
// (role == "api") that carry the named permission. Bare role == "api" is
// rejected — R2-3 showed that /v1/mpc/sign treated "api" as an unchecked
// pass-through because `requirePermission` was never wired into the chain.
//
// Usage:
//
// r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:sign"), ...)
// .Post("/sign", s.handleMpcSignDefault)
func requireRoleOrAPIPermission(humanRoles []string, perm string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := getRole(r.Context())
for _, allowed := range humanRoles {
if role == allowed {
next.ServeHTTP(w, r)
return
}
}
if role == "api" && hasPermission(r.Context(), perm) {
next.ServeHTTP(w, r)
return
}
writeError(w, http.StatusForbidden, "insufficient permissions")
})
}
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func (s *Server) writeAuditLog(ctx context.Context, r *http.Request) {
orgID := getOrgID(ctx)
userID := getUserID(ctx)
if orgID == "" {
return
}
action := r.Method + " " + r.URL.Path
ip := r.RemoteAddr
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ip = strings.Split(xff, ",")[0]
}
entry := orm.New[db.AuditEntry](s.db.ORM)
entry.OrgID = orgID
entry.UserID = nilIfEmpty(userID)
entry.Action = action
entry.IPAddress = nilIfEmpty(ip)
entry.Create()
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
// Dedicated signing audit log — records every MPC signing operation
// with wallet ID, message hash, intent ID, and trigger source.
func (s *Server) writeSigningAuditLog(ctx context.Context, orgID, walletID, messageHash, intentID, trigger string) {
entry := orm.New[db.AuditEntry](s.db.ORM)
entry.OrgID = orgID
entry.Action = "mpc.sign"
resType := "wallet"
entry.ResourceType = &resType
entry.ResourceID = &walletID
details, _ := json.Marshal(map[string]string{
"wallet_id": walletID,
"message_hash": messageHash,
"intent_id": intentID,
"trigger": trigger,
})
entry.Details = details
entry.Create()
}