diff --git a/internal/serviceaccount/serviceaccount.go b/internal/serviceaccount/serviceaccount.go new file mode 100644 index 0000000..025d7d7 --- /dev/null +++ b/internal/serviceaccount/serviceaccount.go @@ -0,0 +1,151 @@ +// Package serviceaccount implementiert Core IAM-09: Service-Accounts als +// eigenstaendige Identitaeten (getrennt von menschlichen Benutzern, siehe +// internal/user) sowie API-Token mit Scopes und optionaler Ablaufzeit zur +// programmatischen Authentifizierung. +package serviceaccount + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrTokenInvalid = errors.New("serviceaccount: token ungueltig, widerrufen oder abgelaufen") + ErrScopeInsufficient = errors.New("serviceaccount: token hat nicht den erforderlichen scope") + ErrServiceAccountNotFound = errors.New("serviceaccount: nicht gefunden") +) + +type ServiceAccount struct { + ID string + Name string +} + +type Token struct { + ID string + ServiceAccountID string + Scopes []string + ExpiresAt *time.Time +} + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// CreateServiceAccount legt eine neue, eigenstaendige Identitaet an +// (Akzeptanzkriterium 1) — lebt in service_accounts, nicht in users. +func (s *Store) CreateServiceAccount(ctx context.Context, name string) (ServiceAccount, error) { + if name == "" { + return ServiceAccount{}, errors.New("serviceaccount: name darf nicht leer sein") + } + var sa ServiceAccount + sa.Name = name + if err := s.pool.QueryRow(ctx, ` + INSERT INTO service_accounts (name) VALUES ($1) RETURNING id + `, name).Scan(&sa.ID); err != nil { + return ServiceAccount{}, fmt.Errorf("service-account anlegen: %w", err) + } + return sa, nil +} + +// IssueToken erzeugt ein neues API-Token fuer einen Service-Account. +// scopes beschraenkt, wofuer das Token verwendet werden darf; ttl ist +// optional (nil = kein Ablauf) — beides Akzeptanzkriterium 2. Der +// Klartext-Token wird NUR hier zurueckgegeben, gespeichert wird ausschliesslich +// der Hash (Akzeptanzkriterium 1 / Pruefung 1). +func (s *Store) IssueToken(ctx context.Context, serviceAccountID string, scopes []string, ttl *time.Duration) (id, plaintext string, err error) { + plaintext, err = randomToken() + if err != nil { + return "", "", fmt.Errorf("token erzeugen: %w", err) + } + hash := hashToken(plaintext) + if scopes == nil { + scopes = []string{} + } + + var expiresAt *time.Time + if ttl != nil { + t := time.Now().Add(*ttl) + expiresAt = &t + } + + err = s.pool.QueryRow(ctx, ` + INSERT INTO api_tokens (service_account_id, token_hash, scopes, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id + `, serviceAccountID, hash, scopes, expiresAt).Scan(&id) + if err != nil { + return "", "", fmt.Errorf("token speichern: %w", err) + } + return id, plaintext, nil +} + +// Verify prueft ein Token gegen einen benoetigten Scope. Widerruf und Ablauf +// werden bei JEDEM Aufruf direkt gegen die Datenbank geprueft — es gibt +// keinen Cache, ein widerrufenes Token wird beim naechsten Request sofort +// abgewiesen (Akzeptanzkriterium 3). requiredScope == "" ueberspringt die +// Scope-Pruefung. +func (s *Store) Verify(ctx context.Context, tokenString, requiredScope string) (Token, error) { + hash := hashToken(tokenString) + + var tok Token + err := s.pool.QueryRow(ctx, ` + SELECT id, service_account_id, scopes, expires_at + FROM api_tokens + WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) + `, hash).Scan(&tok.ID, &tok.ServiceAccountID, &tok.Scopes, &tok.ExpiresAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Token{}, ErrTokenInvalid + } + return Token{}, fmt.Errorf("token pruefen: %w", err) + } + + if requiredScope != "" && !hasScope(tok.Scopes, requiredScope) { + return Token{}, ErrScopeInsufficient + } + return tok, nil +} + +// Revoke widerruft ein Token sofort — Verify schlaegt ab dem naechsten +// Aufruf fehl (Akzeptanzkriterium 3 / Pruefung 2). +func (s *Store) Revoke(ctx context.Context, tokenID string) error { + _, err := s.pool.Exec(ctx, `UPDATE api_tokens SET revoked_at = now() WHERE id = $1`, tokenID) + if err != nil { + return fmt.Errorf("token widerrufen: %w", err) + } + return nil +} + +func hasScope(scopes []string, want string) bool { + for _, s := range scopes { + if s == want { + return true + } + } + return false +} + +func randomToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func hashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} diff --git a/internal/serviceaccount/serviceaccount_test.go b/internal/serviceaccount/serviceaccount_test.go new file mode 100644 index 0000000..f6ec6c5 --- /dev/null +++ b/internal/serviceaccount/serviceaccount_test.go @@ -0,0 +1,163 @@ +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) + } +} diff --git a/migrations/tenant/0003_service_accounts.down.sql b/migrations/tenant/0003_service_accounts.down.sql new file mode 100644 index 0000000..32449fb --- /dev/null +++ b/migrations/tenant/0003_service_accounts.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS api_tokens; +DROP TABLE IF EXISTS service_accounts; diff --git a/migrations/tenant/0003_service_accounts.up.sql b/migrations/tenant/0003_service_accounts.up.sql new file mode 100644 index 0000000..f1586fd --- /dev/null +++ b/migrations/tenant/0003_service_accounts.up.sql @@ -0,0 +1,20 @@ +-- Service-Accounts & API-Token (IAM-09, siehe core-kanban/tickets/IAM-09.md). +-- Service-Accounts sind eine eigenstaendige Identitaetsklasse neben +-- menschlichen Benutzern (users), nicht dieselbe Tabelle (Zitadel-Vorbild). +CREATE TABLE 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() +); + +-- token_hash enthaelt NIEMALS den Klartext-Token, nur dessen SHA-256-Hash. +CREATE TABLE 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() +); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..fab5903 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +ROLE="nexarch_test" +export PGPASSWORD="$PASS" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" +dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") +for db in $dbs; do + psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";" +done +echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..1c28c3c --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +cd "$(dirname "$0")/.." +NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh +export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable" +echo "== go build ==" +go build ./... +echo "== go vet ==" +go vet ./... +echo "== go test (-p 1) ==" +go test ./... -p 1 -count=1