package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) // ResolvedKey ist das Ergebnis einer erfolgreichen Key-Auflösung: der Schlüssel // selbst und das Projekt, zu dem er gehört. // // Beides zusammen, weil praktisch jeder Aufrufer beides braucht und ein zweiter // Datenbankzugriff pro Request unnötig wäre. type ResolvedKey struct { Key APIKey Project Project } // keyColumns steht in jeder Key-Abfrage. const keyColumns = `id, project_id, name, hash, role, created_at, last_used_at, revoked_at, expires_at` // scanKey liest eine Key-Zeile. func scanKey(scan func(...any) error) (APIKey, error) { var k APIKey var createdAt, lastUsedAt, revokedAt, expiresAt string if err := scan(&k.ID, &k.ProjectID, &k.Name, &k.Hash, &k.Role, &createdAt, &lastUsedAt, &revokedAt, &expiresAt); err != nil { return APIKey{}, err } k.CreatedAt = parseTime(createdAt) k.LastUsedAt = parseTime(lastUsedAt) k.RevokedAt = parseTime(revokedAt) k.ExpiresAt = parseTime(expiresAt) return k, nil } // CreateAPIKey legt einen Key ohne Ablaufdatum an. Übergeben wird der Hash, // nie der Klartext — den kennt nur der Aufrufer, und zwar genau einmal. func (s *Store) CreateAPIKey(ctx context.Context, projectID, name, hash, role string) (APIKey, error) { return s.CreateAPIKeyWithExpiry(ctx, projectID, name, hash, role, time.Time{}) } // CreateAPIKeyWithExpiry legt einen Key an, der ab ablauf nicht mehr gilt // (AUSBAUSTUFEN.md 4.7, Stufe 2). Der Nullzeitpunkt heißt: läuft nie ab. func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, hash, role string, ablauf time.Time) (APIKey, error) { if strings.TrimSpace(name) == "" { return APIKey{}, fmt.Errorf("der Key braucht einen Namen — daran erkennt man später, wer oder was ihn benutzt") } if !ablauf.IsZero() && !ablauf.After(time.Now()) { return APIKey{}, fmt.Errorf("das Ablaufdatum liegt in der Vergangenheit — so wäre der Key nie gültig") } k := APIKey{ ID: NewID(), ProjectID: projectID, Name: strings.TrimSpace(name), Hash: hash, Role: role, ExpiresAt: ablauf, } jetzt := NowString() ablaufText := "" if !ablauf.IsZero() { ablaufText = formatTime(ablauf) } _, err := s.schreiben.ExecContext(ctx, `INSERT INTO api_keys (id, project_id, name, hash, role, created_at, last_used_at, revoked_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, '', '', ?)`, k.ID, k.ProjectID, k.Name, k.Hash, k.Role, jetzt, ablaufText, ) if err != nil { return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err) } k.CreatedAt = parseTime(jetzt) return k, nil } // ResolveKeyHash schlägt einen Key-Hash nach und gibt Key samt Projekt zurück. // // Das ist der heißeste Pfad im ganzen Programm — er läuft bei jedem API- und // MCP-Request. Deshalb ein einziger indizierter Lookup über die UNIQUE-Spalte // "hash" statt einer Schleife über alle Keys. // // Zurückgezogene und abgelaufene Keys gelten als nicht vorhanden: der // Aufrufer erfährt nicht, ob es den Key nie gab oder ob er gesperrt wurde. func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, error) { row := s.lesen.QueryRowContext(ctx, `SELECT k.id, k.project_id, k.name, k.hash, k.role, k.created_at, k.last_used_at, k.revoked_at, k.expires_at, p.id, p.workspace_id, p.slug, p.name, p.description, p.created_at FROM api_keys k JOIN projects p ON p.id = k.project_id WHERE k.hash = ? AND k.revoked_at = '' AND (k.expires_at = '' OR k.expires_at > ?)`, hash, NowString(), ) var k APIKey var p Project var kCreated, kLastUsed, kRevoked, kExpires, pCreated string err := row.Scan( &k.ID, &k.ProjectID, &k.Name, &k.Hash, &k.Role, &kCreated, &kLastUsed, &kRevoked, &kExpires, &p.ID, &p.WorkspaceID, &p.Slug, &p.Name, &p.Description, &pCreated, ) if errors.Is(err, sql.ErrNoRows) { return ResolvedKey{}, ErrNotFound } if err != nil { return ResolvedKey{}, fmt.Errorf("Key konnte nicht aufgelöst werden: %w", err) } k.CreatedAt = parseTime(kCreated) k.LastUsedAt = parseTime(kLastUsed) k.RevokedAt = parseTime(kRevoked) k.ExpiresAt = parseTime(kExpires) p.CreatedAt = parseTime(pCreated) return ResolvedKey{Key: k, Project: p}, nil } // APIKeyByID liest einen Key anhand seiner ID — auch einen zurückgezogenen, // damit die Verwaltungsoberfläche ihn noch anzeigen kann. func (s *Store) APIKeyByID(ctx context.Context, id string) (APIKey, error) { row := s.lesen.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id) k, err := scanKey(row.Scan) if errors.Is(err, sql.ErrNoRows) { return APIKey{}, ErrNotFound } if err != nil { return APIKey{}, fmt.Errorf("Key konnte nicht gelesen werden: %w", err) } return k, nil } // ListAPIKeys gibt alle Keys eines Projekts zurück, neueste zuerst. // Zurückgezogene sind dabei — sie bleiben als Beleg im Audit-Log referenziert. func (s *Store) ListAPIKeys(ctx context.Context, projectID string) ([]APIKey, error) { rows, err := s.lesen.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? ORDER BY created_at DESC`, projectID, ) if err != nil { return nil, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err) } defer rows.Close() var keys []APIKey for rows.Next() { k, err := scanKey(rows.Scan) if err != nil { return nil, err } keys = append(keys, k) } return keys, rows.Err() } // RevokeAPIKey zieht einen Key zurück. // // Der Datensatz bleibt bestehen und wird nur markiert — sonst verlöre das // Audit-Log die Zuordnung zu allem, was dieser Key jemals geschrieben hat. func (s *Store) RevokeAPIKey(ctx context.Context, id string) error { res, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at = ''`, NowString(), id, ) if err != nil { return fmt.Errorf("Key konnte nicht zurückgezogen werden: %w", err) } return requireOneRow(res) } // TouchAPIKey vermerkt, dass ein Key gerade benutzt wurde. // // Damit sieht man in der Verwaltung, welche Keys noch aktiv sind und welche man // gefahrlos zurückziehen kann. Ein Fehler hierbei ist unerheblich — der Aufrufer // ignoriert ihn bewusst, denn der eigentliche Request soll deswegen nicht scheitern. // // Geschrieben wird höchstens einmal pro Stunde je Key: bei einem Agenten, der im // Sekundentakt anfragt, wäre ein Schreibvorgang pro Request pure Verschwendung — // und SQLite verträgt ohnehin nur einen Schreiber gleichzeitig. func (s *Store) TouchAPIKey(ctx context.Context, id string, lastUsed time.Time) error { if time.Since(lastUsed) < time.Hour { return nil } _, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, NowString(), id) return err }