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.
231 lines
6.5 KiB
Go
231 lines
6.5 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
|
|
}
|
|
|
|
// hasPermission reports whether the API key in ctx carries the named
|
|
// permission. Wildcards are NOT honored — R3-2: a misissued key with
|
|
// permissions=["*"] would otherwise act as a god-key, bypassing every
|
|
// named gate including mpc:settlement:sign / mpc:operations:approve /
|
|
// mpc:wallet:sweep / mpc:policy:write. Every API key must list its
|
|
// explicit permissions. One way to express authority — the list.
|
|
func hasPermission(ctx context.Context, perm string) bool {
|
|
for _, p := range getPermissions(ctx) {
|
|
if p == perm {
|
|
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()
|
|
}
|