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
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:
1 parent
3900c6104e
commit
5c711c2cf7
16 files changed
+644
-518
No files matched your search
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// --- API KEYS & WEBHOOK INGESTION ---
|
||||
@@ -26,11 +29,12 @@ func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
key, err := s.store.CreateAPIKey(userID, req.Name)
|
||||
key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -59,11 +63,14 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
||||
user, _ := s.authenticateToken(token)
|
||||
user, keyScopes := s.authenticateToken(token)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
||||
return
|
||||
}
|
||||
if keyScopes != nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes))
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
@@ -75,6 +82,13 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
Tags []string `json:"tags"`
|
||||
DueDate string `json:"dueDate"`
|
||||
DueDate2 string `json:"due_date"`
|
||||
// Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well
|
||||
Target string `json:"target"`
|
||||
Type string `json:"type"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
ListID string `json:"listId"`
|
||||
Category string `json:"category"`
|
||||
Quantity string `json:"quantity"`
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
@@ -120,8 +134,20 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if req.Priority == "" {
|
||||
req.Priority = "high"
|
||||
target := strings.ToLower(strings.TrimSpace(req.Target))
|
||||
if target == "" {
|
||||
target = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
}
|
||||
switch target {
|
||||
case "", "triage", "inbox":
|
||||
target = "triage"
|
||||
case "task", "list_item":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item")
|
||||
return
|
||||
}
|
||||
if !requireDeliveryScope(w, r, target) {
|
||||
return
|
||||
}
|
||||
|
||||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||||
@@ -129,6 +155,18 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
source = "webhook"
|
||||
}
|
||||
|
||||
if target == "list_item" {
|
||||
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity)
|
||||
return
|
||||
}
|
||||
if target == "task" {
|
||||
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Priority == "" {
|
||||
req.Priority = "high"
|
||||
}
|
||||
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -142,3 +180,50 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
"thought": thought,
|
||||
})
|
||||
}
|
||||
|
||||
// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of
|
||||
// the key may write to.
|
||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
}
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "")
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task})
|
||||
}
|
||||
|
||||
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
|
||||
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
|
||||
if strings.TrimSpace(listID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
|
||||
status := http.StatusNotFound
|
||||
if errors.Is(err, storage.ErrListReadOnly) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item})
|
||||
}
|
||||
@@ -60,6 +60,10 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) {
|
||||
return
|
||||
}
|
||||
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
|
||||
@@ -16,21 +16,9 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
}
|
||||
}
|
||||
if token != "" {
|
||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
|
||||
return user.ID
|
||||
}
|
||||
}
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
|
||||
if !s.store.IsTokenRevoked(token) {
|
||||
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
|
||||
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
||||
return claims.UserID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Live streams carry the user's data, so API keys (which only deliver) are not accepted here
|
||||
if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil {
|
||||
return user.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+51
-16
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -796,7 +796,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
||||
|
||||
// 2. Create API key for user
|
||||
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook")
|
||||
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook", nil)
|
||||
if err != nil || apiKey == nil || apiKey.RawKey == "" {
|
||||
t.Fatalf("failed to create API key: %v", err)
|
||||
}
|
||||
@@ -1416,13 +1416,13 @@ func TestLegalTextsArePublic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// API keys have exactly one right until scopes exist: delivering data. Revoked sessions are refused
|
||||
// API keys only deliver data, and only what their scopes allow (FLAW-07). Revoked sessions are refused
|
||||
// everywhere, the webhook ingest included.
|
||||
func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant")
|
||||
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIKey: %v", err)
|
||||
}
|
||||
@@ -1441,8 +1441,36 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 {
|
||||
t.Fatalf("API key must be able to post into the inbox, got %d", code)
|
||||
}
|
||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code >= 300 {
|
||||
t.Fatalf("API key must be able to create a task via the inbox, got %d", code)
|
||||
// Default key = triage only
|
||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code != http.StatusForbidden {
|
||||
t.Fatalf("triage-only key must not create tasks, got %d", code)
|
||||
}
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Milch","target":"list_item","listId":"x"}`); code != http.StatusForbidden {
|
||||
t.Fatalf("triage-only key must not add list items, got %d", code)
|
||||
}
|
||||
full, _ := store.CreateAPIKey(claims.UserID, "Alles", []string{"webhook:task", "webhook:list"})
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"title":"Filter wechseln","target":"task","priority":"high"}`); code != http.StatusCreated {
|
||||
t.Fatalf("task scope must create a task, got %d", code)
|
||||
}
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Rauchmelder"}`); code != http.StatusForbidden {
|
||||
t.Fatalf("key without triage scope must not fill the inbox, got %d", code)
|
||||
}
|
||||
list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Milch","target":"list_item","listId":"`+list.ID+`","quantity":"2"}`); code != http.StatusCreated {
|
||||
t.Fatalf("list scope must add the item, got %d", code)
|
||||
}
|
||||
if items, _ := store.GetListItems(list.ID); len(items) != 1 || items[0].Quantity != "2" {
|
||||
t.Fatalf("list item not stored: %+v", items)
|
||||
}
|
||||
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Spam","target":"list_item","listId":"`+foreign.ID+`"}`); code != http.StatusNotFound {
|
||||
t.Fatalf("key must not write into lists its owner cannot see, got %d", code)
|
||||
}
|
||||
// Keys never open live streams (those carry the user's data)
|
||||
streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+full.RawKey, nil)
|
||||
if uid := srv.authenticateUserFromReq(streamReq); uid != "" {
|
||||
t.Fatalf("API key must not authenticate a live stream")
|
||||
}
|
||||
if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden {
|
||||
t.Fatalf("API key must not read notes, got %d", code)
|
||||
|
||||
Reference in new issue
Block a user