Files
mpc/pkg/api/policy_engine.go
zeekay be54888298 feat: WebAuthn biometric signing, enforce approver roles in policies
- Add WebAuthn/FIDO2 registration and verification endpoints
  POST /webauthn/register/begin, /register/complete, /verify
- Biometric approval: Face ID/Touch ID/YubiKey approve transactions
- Challenge bound to transaction ID (SHA256) preventing replay
- Enforce ApproverRoles from policy engine during transaction approval
- Add WebAuthnCredential DB model (pending_registration, active, revoked)
- Policy decisions now carry ApproverRoles for role-gated approvals
2025-12-27 11:52:10 -08:00

141 lines
3.2 KiB
Go

package api
import (
"context"
"encoding/json"
"math/big"
"sort"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
type PolicyDecision struct {
Action string `json:"action"`
Reason string `json:"reason"`
RequiredApprovers int `json:"required_approvers"`
ApproverRoles []string `json:"approver_roles,omitempty"`
}
type PolicyConditions struct {
MaxAmount string `json:"max_amount,omitempty"`
AllowedChains []string `json:"allowed_chains,omitempty"`
AllowedAddresses []string `json:"allowed_addresses,omitempty"`
}
func evaluateTransaction(amount, chain, toAddress string, policies []*db.Policy) PolicyDecision {
// Sort by priority descending
sort.Slice(policies, func(i, j int) bool {
return policies[i].Priority > policies[j].Priority
})
for _, policy := range policies {
if !policy.Enabled {
continue
}
var conditions PolicyConditions
if err := json.Unmarshal(policy.Conditions, &conditions); err != nil {
continue
}
if !matchesConditions(amount, chain, toAddress, conditions) {
continue
}
switch policy.Action {
case "deny":
return PolicyDecision{Action: "deny", Reason: policy.Name}
case "approve":
return PolicyDecision{Action: "approve", Reason: policy.Name}
case "require_approval":
return PolicyDecision{
Action: "require_approval",
Reason: policy.Name,
RequiredApprovers: policy.RequiredApprovers,
ApproverRoles: policy.ApproverRoles,
}
}
}
// Default: require approval with 1 admin
return PolicyDecision{
Action: "require_approval",
Reason: "default policy",
RequiredApprovers: 1,
}
}
func matchesConditions(amount, chain, toAddress string, cond PolicyConditions) bool {
if len(cond.AllowedChains) > 0 {
found := false
for _, c := range cond.AllowedChains {
if c == chain {
found = true
break
}
}
if !found {
return false
}
}
if len(cond.AllowedAddresses) > 0 {
found := false
for _, a := range cond.AllowedAddresses {
if a == toAddress {
found = true
break
}
}
if !found {
return false
}
}
if cond.MaxAmount != "" && amount != "" {
max, ok1 := new(big.Float).SetString(cond.MaxAmount)
val, ok2 := new(big.Float).SetString(amount)
if ok1 && ok2 && val.Cmp(max) > 0 {
return true
}
}
return true
}
func (s *Server) loadPolicies(ctx context.Context, orgID string, vaultID *string) ([]*db.Policy, error) {
q := orm.TypedQuery[db.Policy](s.db.ORM).
Filter("orgId=", orgID).
Order("-priority")
policies, err := q.GetAll(ctx)
if err != nil {
// No policies defined yet — return empty list, not error
return []*db.Policy{}, nil
}
// Filter enabled policies in-memory (JSONB boolean filters unreliable)
var enabled []*db.Policy
for _, p := range policies {
if p.Enabled {
enabled = append(enabled, p)
}
}
policies = enabled
// Filter by vaultID if provided (include policies with no vaultID or matching vaultID)
if vaultID != nil {
var filtered []*db.Policy
for _, p := range policies {
if p.VaultID == nil || *p.VaultID == *vaultID {
filtered = append(filtered, p)
}
}
return filtered, nil
}
return policies, nil
}