// 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[:] }