From ec30be7246d77f82102322849ee321e6d6a20974 Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Mon, 9 Mar 2026 23:52:26 +0100 Subject: [PATCH] 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 --- backend/api/apikeys.go | 18 ++++- backend/api/middleware.go | 101 +++++++++++++++++++++++---- backend/db/postgres.go | 67 +++++++++++++++--- frontend/src/api/client.js | 4 +- frontend/src/pages/SettingsPage.jsx | 103 +++++++++++++++++++++------- 5 files changed, 238 insertions(+), 55 deletions(-) diff --git a/backend/api/apikeys.go b/backend/api/apikeys.go index 5928f01..a5bf688 100644 --- a/backend/api/apikeys.go +++ b/backend/api/apikeys.go @@ -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) } diff --git a/backend/api/middleware.go b/backend/api/middleware.go index 2121994..0571368 100644 --- a/backend/api/middleware.go +++ b/backend/api/middleware.go @@ -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)) diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 1a3ff65..7fdebed 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -201,6 +201,11 @@ func (d *Database) migrate() error { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used TIMESTAMPTZ )`) + // Migration: customer_id + permissions Spalten hinzufuegen falls nicht vorhanden + d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL`) + d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS permissions TEXT NOT NULL DEFAULT 'admin'`) + // Bestehende Keys auf 'admin' setzen (Abwaertskompatibilitaet) + d.db.Exec(`UPDATE api_keys SET permissions = 'admin' WHERE permissions = ''`) // Installer-Pakete (install.sh + Plugin als ZIP pro Plattform) d.db.Exec(`CREATE TABLE IF NOT EXISTS installers ( @@ -1135,16 +1140,23 @@ func (d *Database) GetAgentStatus(agentID string) string { // ==================== API-Keys ==================== +// Permissions: "agent" | "read" | "write" | "admin" +// - agent: nur /api/v1/agent/ws + /api/v1/agent/heartbeat (fuer Agents auf Kundenservern) +// - read: GET-Endpoints, optional auf customer_id beschraenkt +// - write: alles ausser Terminal (fuer externe Integrationen) +// - admin: voll (kein Terminal — das ist JWT-only) type APIKey struct { - ID int `json:"id"` - Name string `json:"name"` - Key string `json:"key"` - CreatedAt time.Time `json:"created_at"` - LastUsed *time.Time `json:"last_used,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + Permissions string `json:"permissions"` + CustomerID *int `json:"customer_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastUsed *time.Time `json:"last_used,omitempty"` } func (d *Database) ListAPIKeys() ([]APIKey, error) { - rows, err := d.db.Query("SELECT id, name, key, created_at, last_used FROM api_keys ORDER BY created_at") + rows, err := d.db.Query("SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys ORDER BY created_at") if err != nil { return nil, err } @@ -1154,12 +1166,17 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) { for rows.Next() { var k APIKey var lastUsed sql.NullTime - if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt, &lastUsed); err != nil { + var custID sql.NullInt64 + if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed); err != nil { return nil, err } if lastUsed.Valid { k.LastUsed = &lastUsed.Time } + if custID.Valid { + id := int(custID.Int64) + k.CustomerID = &id + } keys = append(keys, k) } if keys == nil { @@ -1168,15 +1185,24 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) { return keys, rows.Err() } -func (d *Database) CreateAPIKey(name, key string) (*APIKey, error) { +func (d *Database) CreateAPIKey(name, key, permissions string, customerID *int) (*APIKey, error) { var k APIKey + var custID sql.NullInt64 + if customerID != nil { + custID = sql.NullInt64{Int64: int64(*customerID), Valid: true} + } + var scannedCustID sql.NullInt64 err := d.db.QueryRow( - "INSERT INTO api_keys (name, key) VALUES ($1, $2) RETURNING id, name, key, created_at", - name, key, - ).Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt) + "INSERT INTO api_keys (name, key, permissions, customer_id) VALUES ($1, $2, $3, $4) RETURNING id, name, key, permissions, customer_id, created_at", + name, key, permissions, custID, + ).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &scannedCustID, &k.CreatedAt) if err != nil { return nil, err } + if scannedCustID.Valid { + id := int(scannedCustID.Int64) + k.CustomerID = &id + } return &k, nil } @@ -1192,6 +1218,25 @@ func (d *Database) DeleteAPIKey(id int) error { return nil } +// GetAPIKeyInfo gibt Key + Permissions + CustomerID zurueck (fuer Middleware) +func (d *Database) GetAPIKeyInfo(key string) (*APIKey, error) { + var k APIKey + var custID sql.NullInt64 + var lastUsed sql.NullTime + err := d.db.QueryRow( + "SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys WHERE key = $1", + key, + ).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed) + if err != nil { + return nil, err + } + if custID.Valid { + id := int(custID.Int64) + k.CustomerID = &id + } + return &k, nil +} + func (d *Database) GetAllAPIKeys() ([]string, error) { rows, err := d.db.Query("SELECT key FROM api_keys") if err != nil { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 9eb94c6..a3f969f 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -250,8 +250,8 @@ class ApiClient { return this.get('/api/v1/apikeys') } - createAPIKey(name) { - return this.post('/api/v1/apikeys', { name }) + createAPIKey(name, permissions = 'read', customerId = null) { + return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId }) } deleteAPIKey(id) { diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 1b3bbf3..f9c8fd3 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -199,18 +199,28 @@ export default function SettingsPage() { ) } +const PERM_LABELS = { + agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50', desc: 'Nur WS-Connect + Heartbeat' }, + read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50', desc: 'GET-Endpoints, kein Schreiben' }, + write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50', desc: 'Lesen + Schreiben, kein Terminal' }, + admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50', desc: 'Voll (kein Terminal)' }, +} + function APIKeysSection() { const [keys, setKeys] = useState([]) + const [customers, setCustomers] = useState([]) const [showAdd, setShowAdd] = useState(false) const [newName, setNewName] = useState('') + const [newPerms, setNewPerms] = useState('read') + const [newCustomer, setNewCustomer] = useState('') const [newKey, setNewKey] = useState(null) const [copied, setCopied] = useState(null) const [loading, setLoading] = useState(true) const reload = () => { setLoading(true) - api.getAPIKeys() - .then(k => setKeys(k || [])) + Promise.all([api.getAPIKeys(), api.getCustomers()]) + .then(([k, c]) => { setKeys(k || []); setCustomers(c || []) }) .finally(() => setLoading(false)) } useEffect(() => { reload() }, []) @@ -218,9 +228,11 @@ function APIKeysSection() { const handleCreate = async () => { if (!newName.trim()) return try { - const result = await api.createAPIKey(newName.trim()) + const result = await api.createAPIKey(newName.trim(), newPerms, newCustomer ? parseInt(newCustomer) : null) setNewKey(result) setNewName('') + setNewPerms('read') + setNewCustomer('') reload() } catch (e) { alert('Fehler: ' + e.message) @@ -258,25 +270,56 @@ function APIKeysSection() { {/* Neuer Key Dialog */} {showAdd && !newKey && ( -
-
- - setNewName(e.target.value)} - placeholder="z.B. Agent-Key Kunde XY" - className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500" - autoFocus - onKeyDown={(e) => e.key === 'Enter' && handleCreate()} - /> +
+
+
+ + setNewName(e.target.value)} + placeholder="z.B. Agent-Key Kunde XY" + className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500" + autoFocus + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+
+ + +
+
+ + +
+ +
- - + {newPerms && PERM_LABELS[newPerms] && ( +
{PERM_LABELS[newPerms].desc}
+ )}
)} @@ -311,11 +354,22 @@ function APIKeysSection() {
Keine API-Keys vorhanden
) : (
- {keys.map((k) => ( + {keys.map((k) => { + const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read + const customer = k.customer_id ? customers.find(c => c.id === k.customer_id) : null + return (
{k.name} + + {perm.label} + + {customer && ( + + {customer.number} + + )}
{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)} @@ -331,7 +385,7 @@ function APIKeysSection() { {k.last_used && ( - Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')} + Zuletzt: {new Date(k.last_used).toLocaleString('de-DE')} )}
@@ -344,7 +398,8 @@ function APIKeysSection() {
- ))} + ) + })}
)}