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:
cynfo3000
2026-03-09 23:52:26 +01:00
parent 91385b42d1
commit ec30be7246
5 changed files with 238 additions and 55 deletions
+15 -3
View File
@@ -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
View File
@@ -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))
+56 -11
View File
@@ -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 {