feat(api-keys): scopes for triage, tasks and list items (FLAW-07)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s

Keys get scopes when created (webhook:triage, webhook:task,
webhook:list; default triage, old keys become triage). The webhook
ingest learns target task (optional task list) and list_item (needs a
list the owner may write to); /inbox checks the scope of its target as
well; a missing scope answers 403. Keys can no longer open live streams,
which carried all of the owner's events.

The visible key UI lived in the account settings while EcosystemView
was never rendered; it is replaced by ApiKeysPanel in the settings with
scope chips, scope badges per key and a simulator for all three
targets. Examples send the key as X-API-Key header, not in the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:36:17 +01:00
1 parent 3900c6104e
commit 5c711c2cf7
16 files changed
+644 -518

No files matched your search

+51 -16
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"errors"
"io/fs"
@@ -382,34 +383,64 @@ func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
// authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are
// checked against the revocation list and token_version; viaAPIKey tells the caller which it was.
func (s *Server) authenticateToken(token string) (user *domain.User, viaAPIKey bool) {
// authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's
// scopes for API keys.
func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) {
token = strings.TrimSpace(token)
if token == "" {
return nil, false
return nil, nil
}
if strings.HasPrefix(token, "mindsift_sec_") {
u, err := s.store.ValidateAPIKeyAndGetUser(token)
u, scopes, err := s.store.ValidateAPIKey(token)
if err != nil || u == nil || !u.IsActive {
return nil, true
return nil, nil
}
return u, true
return u, scopes
}
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
if err != nil || claims == nil || s.store.IsTokenRevoked(token) {
return nil, false
return nil, nil
}
u, err := s.store.GetUserByID(claims.UserID)
if err != nil || u == nil || !u.IsActive {
return nil, false
return nil, nil
}
if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion {
return nil, false
return nil, nil
}
return u, false
return u, nil
}
// apiKeyAllowed: until API keys get scopes, a key has exactly one right – delivering data into MindSift
// (POST /api/v1/inbox; /api/v1/webhooks/ingest authenticates on its own). Everything else needs a login.
type apiKeyScopesKey struct{}
// requireDeliveryScope: API keys need the scope for the target they deliver to; sessions may do all.
// target: "inbox"/"triage", "task", "list_item".
func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool {
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
if !isKey {
return true
}
need := storage.ScopeTriage
switch target {
case "task":
need = storage.ScopeTask
case "list_item":
need = storage.ScopeList
case "document":
need = "" // notes cannot be created with a key
}
for _, sc := range scopes {
if need != "" && sc == need {
return true
}
}
writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion")
return false
}
// apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
// authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope).
// Everything else needs a login.
func apiKeyAllowed(r *http.Request) bool {
return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox"
}
@@ -422,16 +453,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
} else {
token = r.Header.Get("X-API-Key")
}
user, viaAPIKey := s.authenticateToken(token)
user, keyScopes := s.authenticateToken(token)
if user == nil {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
if viaAPIKey && !apiKeyAllowed(r) {
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
return
ctx := auth.WithUserID(r.Context(), user.ID)
if keyScopes != nil {
if !apiKeyAllowed(r) {
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
return
}
ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes)
}
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
next.ServeHTTP(w, r.WithContext(ctx))
})
}