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>
152 lines
4.4 KiB
Go
152 lines
4.4 KiB
Go
// 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[:]
|
|
}
|