internal/serviceaccount: Service-Accounts als eigenstaendige Identitaetsklasse (eigene Tabelle service_accounts, getrennt von internal/user — Zitadel- Vorbild). Store.IssueToken gibt den Klartext-Token NUR einmal an den Aufrufer zurueck, gespeichert wird ausschliesslich der SHA-256-Hash (Akzeptanzkriterium 1). Scopes und optionale Ablaufzeit sind Teil des Tokens selbst (Akzeptanzkriterium 2). Store.Verify prueft Widerruf/Ablauf/Scope bei JEDEM Aufruf direkt gegen die Datenbank — kein Cache dazwischen, ein widerrufenes Token wird ab dem naechsten Request sofort abgewiesen (Akzeptanzkriterium 3). Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Token ausschliesslich gehasht gespeichert (Stichprobe in der Datenbank) — TestIssueToken_StoresOnlyHash: token_hash-Spalte direkt gelesen, enthaelt nachweislich nicht den Klartext, 32-Byte-SHA-256-Laenge bestaetigt. PASS. 2. Widerrufenes Token wird beim naechsten Request zuverlaessig abgewiesen — TestRevoke_TakesEffectImmediately: Verify vor Widerruf erfolgreich, sofort danach ErrTokenInvalid. PASS. 3. Scope-Verletzung wird korrekt abgewiesen — TestVerify_RejectsInsufficientScope: Token mit scope=read wird fuer scope=write abgewiesen (ErrScopeInsufficient), fuer scope=read akzeptiert. PASS. Zusaetzlich: TestVerify_RejectsExpiredToken belegt die optionale zeitliche Befristung aus Akzeptanzkriterium 2. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
164 lines
4.9 KiB
Go
164 lines
4.9 KiB
Go
package serviceaccount
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
|
|
t.Helper()
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS service_accounts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), service_account_id UUID NOT NULL REFERENCES service_accounts(id),
|
|
token_hash BYTEA NOT NULL UNIQUE, scopes TEXT[] NOT NULL DEFAULT '{}',
|
|
expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
cleanup := func() { pool.Close() }
|
|
return NewStore(pool), pool, cleanup
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Service-Account anlegen, eigenes gehashtes Token erhalten.
|
|
// Akzeptanzkriterium 1 + Pruefung 1: Token ausschliesslich gehasht gespeichert.
|
|
func TestIssueToken_StoresOnlyHash(t *testing.T) {
|
|
store, pool, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
sa, err := store.CreateServiceAccount(ctx, "ci-pipeline")
|
|
if err != nil {
|
|
t.Fatalf("create service account: %v", err)
|
|
}
|
|
|
|
tokenID, plaintext, err := store.IssueToken(ctx, sa.ID, []string{"read"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
if plaintext == "" {
|
|
t.Fatal("erwartet nicht-leeren klartext-token")
|
|
}
|
|
|
|
var storedHash []byte
|
|
if err := pool.QueryRow(ctx, `SELECT token_hash FROM api_tokens WHERE id = $1`, tokenID).Scan(&storedHash); err != nil {
|
|
t.Fatalf("hash lesen: %v", err)
|
|
}
|
|
if string(storedHash) == plaintext {
|
|
t.Fatal("token_hash enthaelt den klartext-token — verstoss gegen akzeptanzkriterium 1")
|
|
}
|
|
if len(storedHash) != 32 { // sha256
|
|
t.Fatalf("erwartet 32-byte sha256-hash, habe %d bytes", len(storedHash))
|
|
}
|
|
|
|
tok, err := store.Verify(ctx, plaintext, "")
|
|
if err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
if tok.ServiceAccountID != sa.ID {
|
|
t.Fatalf("service account id = %q, want %q", tok.ServiceAccountID, sa.ID)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 3: Scope-Verletzung wird abgewiesen.
|
|
func TestVerify_RejectsInsufficientScope(t *testing.T) {
|
|
store, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
sa, err := store.CreateServiceAccount(ctx, "readonly-bot")
|
|
if err != nil {
|
|
t.Fatalf("create service account: %v", err)
|
|
}
|
|
_, plaintext, err := store.IssueToken(ctx, sa.ID, []string{"read"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
|
|
if _, err := store.Verify(ctx, plaintext, "read"); err != nil {
|
|
t.Fatalf("erwartet erfolg fuer passenden scope: %v", err)
|
|
}
|
|
if _, err := store.Verify(ctx, plaintext, "write"); !errors.Is(err, ErrScopeInsufficient) {
|
|
t.Fatalf("erwartet ErrScopeInsufficient fuer fehlenden scope, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2: optionale zeitliche Befristung wird durchgesetzt.
|
|
func TestVerify_RejectsExpiredToken(t *testing.T) {
|
|
store, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
sa, err := store.CreateServiceAccount(ctx, "temp-bot")
|
|
if err != nil {
|
|
t.Fatalf("create service account: %v", err)
|
|
}
|
|
ttl := -1 * time.Second // sofort abgelaufen
|
|
_, plaintext, err := store.IssueToken(ctx, sa.ID, nil, &ttl)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
|
|
if _, err := store.Verify(ctx, plaintext, ""); !errors.Is(err, ErrTokenInvalid) {
|
|
t.Fatalf("erwartet ErrTokenInvalid bei abgelaufenem token, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 2: widerrufenes Token wird sofort abgewiesen.
|
|
func TestRevoke_TakesEffectImmediately(t *testing.T) {
|
|
store, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
sa, err := store.CreateServiceAccount(ctx, "revoke-bot")
|
|
if err != nil {
|
|
t.Fatalf("create service account: %v", err)
|
|
}
|
|
tokenID, plaintext, err := store.IssueToken(ctx, sa.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
|
|
if _, err := store.Verify(ctx, plaintext, ""); err != nil {
|
|
t.Fatalf("verify vor widerruf: %v", err)
|
|
}
|
|
|
|
if err := store.Revoke(ctx, tokenID); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
|
|
if _, err := store.Verify(ctx, plaintext, ""); !errors.Is(err, ErrTokenInvalid) {
|
|
t.Fatalf("erwartet ErrTokenInvalid sofort nach widerruf, habe %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerify_RejectsUnknownToken(t *testing.T) {
|
|
store, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
if _, err := store.Verify(ctx, "niemals-ausgestelltes-token", ""); !errors.Is(err, ErrTokenInvalid) {
|
|
t.Fatalf("erwartet ErrTokenInvalid, habe %v", err)
|
|
}
|
|
}
|