195 lines
6.6 KiB
Go
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
|
|
}
|