munin/internal/store/keys.go
2026-10-19 10:13:56 +02:00

195 lines
6.6 KiB
Go

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 expiry 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, expiry 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 !expiry.IsZero() && !expiry.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: expiry,
}
now := NowString()
expiryText := ""
if !expiry.IsZero() {
expiryText = formatTime(expiry)
}
_, err := s.write.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, now, expiryText,
)
if err != nil {
return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err)
}
k.CreatedAt = parseTime(now)
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.read.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.read.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.read.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.write.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.write.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, NowString(), id)
return err
}