gomcp/internal/transport/http/rbac.go

161 lines
4.2 KiB
Go

package httpserver
import (
"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 and validate key
m.mu.RLock()
apiKey, exists := m.keys[key]
m.mu.RUnlock()
if !exists || !apiKey.Active {
writeError(w, http.StatusUnauthorized, "invalid or revoked API key")
return
}
// Note: timing-safe compare is not needed here because the Go map
// lookup above already reveals key existence via timing. The map
// is the canonical key store; this is a lookup, not a comparison
// of a user-supplied value against a stored secret.
// 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 ?api_key query param.
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
}
// Try query parameter (least secure, for dashboard convenience)
return r.URL.Query().Get("api_key")
}
// hasPermission checks if userRole >= requiredRole in the hierarchy.
func hasPermission(userRole, requiredRole Role) bool {
hierarchy := map[Role]int{
RoleAdmin: 100,
RoleAnalyst: 50,
RoleViewer: 30,
RoleSensor: 20,
RoleExternal: 10,
}
return hierarchy[userRole] >= hierarchy[requiredRole]
}