security: API-Key Permissions + Customer-Scoping
- DB Migration: api_keys bekommt 'permissions' + 'customer_id' Spalten - Permissions: agent | read | write | admin - agent: nur /api/v1/agent/ws + /api/v1/agent/heartbeat - read: GET-Endpoints (optional customer-scoped) - write: alles ausser Terminal - admin: voll (Terminal bleibt JWT-only) - Bestehende Keys werden auf 'admin' migriert (Abwaertskompatibilitaet) - Frontend: API-Key Verwaltung zeigt Permissions-Badge + Kunden-Scope - API-Client: createAPIKey nimmt jetzt permissions + customer_id
This commit is contained in:
+15
-3
@@ -23,26 +23,38 @@ func (h *Handler) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /api/v1/apikeys
|
||||
func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Permissions string `json:"permissions"` // "agent" | "read" | "write" | "admin"
|
||||
CustomerID *int `json:"customer_id"` // optional, null = global
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
// Permissions validieren, default: "read"
|
||||
validPerms := map[string]bool{"agent": true, "read": true, "write": true, "admin": true}
|
||||
if req.Permissions == "" {
|
||||
req.Permissions = "read"
|
||||
}
|
||||
if !validPerms[req.Permissions] {
|
||||
writeError(w, http.StatusBadRequest, "Ungueltige Permissions (agent|read|write|admin)")
|
||||
return
|
||||
}
|
||||
|
||||
// Zufaelligen 32-Byte Key generieren
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
key := hex.EncodeToString(b)
|
||||
|
||||
apiKey, err := h.db.CreateAPIKey(req.Name, key)
|
||||
apiKey, err := h.db.CreateAPIKey(req.Name, key, req.Permissions, req.CustomerID)
|
||||
if err != nil {
|
||||
log.Printf("API-Key erstellen fehlgeschlagen: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Neuer API-Key erstellt: %s (%s...)", apiKey.Name, key[:8])
|
||||
log.Printf("Neuer API-Key erstellt: %s (%s...) perm=%s", apiKey.Name, key[:8], apiKey.Permissions)
|
||||
writeJSON(w, http.StatusCreated, apiKey)
|
||||
}
|
||||
|
||||
|
||||
+86
-15
@@ -8,18 +8,21 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cynfo/rmm-backend/db"
|
||||
db "github.com/cynfo/rmm-backend/db"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserContextKey contextKey = "user"
|
||||
|
||||
// APIKeyContextKey fuer Key-Infos im Request-Context
|
||||
const APIKeyContextKey contextKey = "apikey"
|
||||
|
||||
// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
|
||||
type APIKeyCache struct {
|
||||
db *db.Database
|
||||
mu sync.RWMutex
|
||||
keys map[string]bool
|
||||
keys map[string]*db.APIKey // key -> APIKey mit Permissions + CustomerID
|
||||
lastLoad time.Time
|
||||
ttl time.Duration
|
||||
}
|
||||
@@ -27,7 +30,7 @@ type APIKeyCache struct {
|
||||
func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
||||
c := &APIKeyCache{
|
||||
db: database,
|
||||
keys: make(map[string]bool),
|
||||
keys: make(map[string]*db.APIKey),
|
||||
ttl: 30 * time.Second,
|
||||
}
|
||||
c.refresh()
|
||||
@@ -35,14 +38,15 @@ func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
||||
}
|
||||
|
||||
func (c *APIKeyCache) refresh() {
|
||||
dbKeys, err := c.db.GetAllAPIKeys()
|
||||
dbKeys, err := c.db.ListAPIKeys()
|
||||
if err != nil {
|
||||
log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err)
|
||||
return
|
||||
}
|
||||
newKeys := make(map[string]bool, len(dbKeys))
|
||||
for _, k := range dbKeys {
|
||||
newKeys[k] = true
|
||||
newKeys := make(map[string]*db.APIKey, len(dbKeys))
|
||||
for i := range dbKeys {
|
||||
k := dbKeys[i]
|
||||
newKeys[k.Key] = &k
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.keys = newKeys
|
||||
@@ -50,27 +54,32 @@ func (c *APIKeyCache) refresh() {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *APIKeyCache) IsValid(key string) bool {
|
||||
func (c *APIKeyCache) Get(key string) *db.APIKey {
|
||||
c.mu.RLock()
|
||||
expired := time.Since(c.lastLoad) > c.ttl
|
||||
valid := c.keys[key]
|
||||
info := c.keys[key]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if expired {
|
||||
c.refresh()
|
||||
c.mu.RLock()
|
||||
valid = c.keys[key]
|
||||
info = c.keys[key]
|
||||
c.mu.RUnlock()
|
||||
}
|
||||
|
||||
if valid {
|
||||
if info != nil {
|
||||
go c.db.UpdateAPIKeyLastUsed(key)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
return valid
|
||||
// IsValid prueft nur ob Key existiert (Abwaertskompatibilitaet)
|
||||
func (c *APIKeyCache) IsValid(key string) bool {
|
||||
return c.Get(key) != nil
|
||||
}
|
||||
|
||||
// CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token
|
||||
// Setzt APIKey-Info im Context fuer nachgelagerte Permission-Checks.
|
||||
func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try API key first (Header oder Query-Parameter)
|
||||
@@ -78,9 +87,17 @@ func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Hand
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if key != "" && cache.IsValid(key) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
if key != "" {
|
||||
if info := cache.Get(key); info != nil {
|
||||
// Permission-Check
|
||||
if !hasAPIKeyPermission(info, r) {
|
||||
http.Error(w, `{"error":"forbidden: unzureichende Berechtigungen"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), APIKeyContextKey, info)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try JWT — Authorization Header oder ?token= Query-Parameter (fuer WebSocket)
|
||||
@@ -104,6 +121,60 @@ func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Hand
|
||||
})
|
||||
}
|
||||
|
||||
// hasAPIKeyPermission prueft ob ein API-Key fuer den aktuellen Request berechtigt ist
|
||||
func hasAPIKeyPermission(key *db.APIKey, r *http.Request) bool {
|
||||
perm := key.Permissions
|
||||
method := r.Method
|
||||
path := r.URL.Path
|
||||
|
||||
switch perm {
|
||||
case "agent":
|
||||
// Nur Agent-Endpunkte: WS-Connect und Heartbeat
|
||||
return path == "/api/v1/agent/ws" || path == "/api/v1/agent/heartbeat"
|
||||
|
||||
case "read":
|
||||
// Nur GET-Requests, keine destruktiven Aktionen
|
||||
if method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
// Customer-Scope pruefen (falls gesetzt)
|
||||
return checkCustomerScope(key, r)
|
||||
|
||||
case "write":
|
||||
// Alles ausser Terminal (Terminal ist JWT-only)
|
||||
// Customer-Scope pruefen
|
||||
return checkCustomerScope(key, r)
|
||||
|
||||
case "admin":
|
||||
// Vollen Zugriff (kein Terminal — das prueft JWTOnlyAuth separat)
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// checkCustomerScope prueft ob ein customer-scoped Key nur auf seinen Kunden zugreift
|
||||
func checkCustomerScope(key *db.APIKey, r *http.Request) bool {
|
||||
if key.CustomerID == nil {
|
||||
return true // Kein Scope = globaler Zugriff
|
||||
}
|
||||
// Customer-ID aus dem Pfad extrahieren (z.B. /api/v1/agents/{id} -> Agent-Customer pruefen)
|
||||
// Fuer jetzt: customer-scoped Keys werden im Handler weiter eingeschraenkt (via GetAPIKeyFromContext)
|
||||
// Der eigentliche Scope-Check passiert in den Handlern die GetAPIKeyFromContext nutzen
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAPIKeyFromContext gibt den API-Key aus dem Context zurueck (nil wenn JWT-Auth)
|
||||
func GetAPIKeyFromContext(r *http.Request) *db.APIKey {
|
||||
if v := r.Context().Value(APIKeyContextKey); v != nil {
|
||||
if k, ok := v.(*db.APIKey); ok {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback)
|
||||
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
|
||||
keySet := make(map[string]bool, len(validKeys))
|
||||
|
||||
Reference in New Issue
Block a user