mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-26 21:06:21 +02:00
184 lines
4.9 KiB
Go
184 lines
4.9 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Role defines access level for RBAC.
|
|
type Role string
|
|
|
|
const (
|
|
RoleAdmin Role = "admin" // Full access: read + write + config
|
|
RoleAnalyst Role = "analyst" // Read + write (ingest, verdict)
|
|
RoleViewer Role = "viewer" // Read-only
|
|
RoleSensor Role = "sensor" // Ingest only (POST events + heartbeat)
|
|
RoleExternal Role = "external" // Kill Chain + dashboard only
|
|
)
|
|
|
|
// APIKey represents a registered API key with role.
|
|
type APIKey struct {
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Role Role `json:"role"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastUsed time.Time `json:"last_used,omitempty"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
// RBACConfig holds authentication configuration.
|
|
type RBACConfig struct {
|
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
|
Keys map[string]APIKey // key hash → APIKey
|
|
}
|
|
|
|
// RBACMiddleware provides role-based access control for HTTP endpoints (§17).
|
|
type RBACMiddleware struct {
|
|
mu sync.RWMutex
|
|
config RBACConfig
|
|
keys map[string]*APIKey // raw key → APIKey
|
|
}
|
|
|
|
// NewRBACMiddleware creates RBAC middleware. If not enabled, all requests pass through.
|
|
func NewRBACMiddleware(config RBACConfig) *RBACMiddleware {
|
|
m := &RBACMiddleware{
|
|
config: config,
|
|
keys: make(map[string]*APIKey),
|
|
}
|
|
return m
|
|
}
|
|
|
|
// RegisterKey adds an API key with a role.
|
|
func (m *RBACMiddleware) RegisterKey(name, key string, role Role) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.keys[key] = &APIKey{
|
|
Key: key,
|
|
Name: name,
|
|
Role: role,
|
|
CreatedAt: time.Now(),
|
|
Active: true,
|
|
}
|
|
}
|
|
|
|
// RevokeKey deactivates an API key.
|
|
func (m *RBACMiddleware) RevokeKey(key string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if k, ok := m.keys[key]; ok {
|
|
k.Active = false
|
|
}
|
|
}
|
|
|
|
// ListKeys returns all registered keys (with keys masked).
|
|
func (m *RBACMiddleware) ListKeys() []APIKey {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
result := make([]APIKey, 0, len(m.keys))
|
|
for _, k := range m.keys {
|
|
masked := *k
|
|
if len(masked.Key) > 8 {
|
|
masked.Key = masked.Key[:4] + "..." + masked.Key[len(masked.Key)-4:]
|
|
}
|
|
result = append(result, masked)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Require returns middleware that enforces minimum role for the endpoint.
|
|
func (m *RBACMiddleware) Require(minRole Role, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !m.config.Enabled {
|
|
next(w, r)
|
|
return
|
|
}
|
|
|
|
// Extract API key from Authorization header or query param
|
|
key := extractAPIKey(r)
|
|
if key == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing API key: use Authorization: Bearer <key>")
|
|
return
|
|
}
|
|
|
|
// Lookup key using constant-time comparison to prevent timing oracle.
|
|
// A plain map lookup reveals key existence via variable-time hash probing.
|
|
m.mu.RLock()
|
|
var apiKey *APIKey
|
|
keyBytes := []byte(key)
|
|
for storedKey, candidate := range m.keys {
|
|
// HMAC comparison: constant-time regardless of match position
|
|
if hmac.Equal(keyBytes, []byte(storedKey)) {
|
|
apiKey = candidate
|
|
break
|
|
}
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if apiKey == nil || !apiKey.Active {
|
|
writeError(w, http.StatusUnauthorized, "invalid or revoked API key")
|
|
return
|
|
}
|
|
|
|
|
|
// Check role hierarchy
|
|
if !hasPermission(apiKey.Role, minRole) {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions: requires "+string(minRole))
|
|
return
|
|
}
|
|
|
|
// Update last used
|
|
m.mu.Lock()
|
|
apiKey.LastUsed = time.Now()
|
|
m.mu.Unlock()
|
|
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// extractAPIKey gets the API key from Authorization header or X-API-Key header.
|
|
// Query parameter auth is intentionally NOT supported (credential leak vector).
|
|
func extractAPIKey(r *http.Request) string {
|
|
// Try Authorization: Bearer <key>
|
|
auth := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
return strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
// Try X-API-Key header
|
|
if key := r.Header.Get("X-API-Key"); key != "" {
|
|
return key
|
|
}
|
|
// SECURITY: Query parameter auth removed — keys in URLs leak via
|
|
// server logs, Referer headers, browser history, and CDN logs.
|
|
return ""
|
|
}
|
|
|
|
// hasPermission checks if userRole >= requiredRole in the hierarchy.
|
|
// Default-deny: undefined roles map to 0 and are rejected.
|
|
func hasPermission(userRole, requiredRole Role) bool {
|
|
hierarchy := map[Role]int{
|
|
RoleAdmin: 100,
|
|
RoleAnalyst: 50,
|
|
RoleViewer: 30,
|
|
RoleSensor: 20,
|
|
RoleExternal: 10,
|
|
}
|
|
userLevel, userOK := hierarchy[userRole]
|
|
reqLevel, reqOK := hierarchy[requiredRole]
|
|
// Reject if either role is undefined (defense against typos / injection)
|
|
if !userOK || !reqOK {
|
|
return false
|
|
}
|
|
return userLevel >= reqLevel
|
|
}
|
|
|
|
// hmacKeyHash returns the SHA-256 HMAC of a key for secure comparison.
|
|
// Unused directly but documents the design intent for future key hashing.
|
|
func hmacKeyHash(key []byte) []byte {
|
|
h := hmac.New(sha256.New, []byte("syntrex-rbac-v1"))
|
|
h.Write(key)
|
|
return h.Sum(nil)
|
|
}
|