// Per-tenant API keys for the read-only Buchhaltungs-Pull-API (see // migrations/026_accounting_api_keys.sql and // internal/api/accounting_handlers.go). // // Token handling follows exactly the share-link pattern in shares.go: the raw // key is generated once (32 bytes crypto/rand, base64url, with a fixed // "adms_" prefix so it is recognisable in logs/config files), returned to the // caller EXACTLY once at creation time, and only its hex SHA-256 hash is ever // persisted. Lookup for authentication is always by key_hash, never by id. // // Keys are never hard-deleted: revoking only sets revoked_at, so the audit // trail of which key pulled which documents stays resolvable (GoBD // Nachvollziehbarkeit). package storage import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // accountingKeyPrefix marks a raw accounting API key as such. It is part of // the hashed value (the whole string is hashed), it is NOT a separate column. const accountingKeyPrefix = "adms_" // ErrAccountingKeyNotFound is returned when a key lookup (by id+tenant or by // key_hash) matches no usable row — unknown key, wrong tenant, or revoked. var ErrAccountingKeyNotFound = errors.New("storage: accounting api key not found") // AccountingAPIKey is the safe view of an accounting_api_keys row. The hash is // deliberately NOT part of this struct so it can never be serialised into an // API response, and the plaintext key exists only as the second return value // of CreateAccountingAPIKey. type AccountingAPIKey struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` Label string `json:"label"` CreatedBy *int64 `json:"created_by,omitempty"` CreatedAt time.Time `json:"created_at"` RevokedAt *time.Time `json:"revoked_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` } func (s *Store) initAccountingAPIKeysSchema(ctx context.Context) error { _, err := s.db.Exec(ctx, ` -- No FK on tenant_id / created_by: consistent with the rest of the -- schema (plain BIGINT), because tenants/users are owned by other -- stores that initialise after storage.New(). CREATE TABLE IF NOT EXISTS accounting_api_keys ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, key_hash TEXT NOT NULL UNIQUE, label TEXT NOT NULL DEFAULT '', created_by BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_accounting_api_keys_tenant ON accounting_api_keys(tenant_id); `) if err != nil { return fmt.Errorf("storage: create accounting_api_keys table: %w", err) } return nil } // hashAccountingKey returns the hex-encoded SHA-256 of a raw accounting API // key, the value persisted in / looked up from accounting_api_keys.key_hash. func hashAccountingKey(key string) string { sum := sha256.Sum256([]byte(key)) return hex.EncodeToString(sum[:]) } // CreateAccountingAPIKey inserts a new key for a tenant and returns the stored // row plus the raw (plaintext) key. The plaintext is returned ONLY here and // never again — only its SHA-256 hash is persisted. func (s *Store) CreateAccountingAPIKey(ctx context.Context, tenantID int64, label string, createdBy *int64) (*AccountingAPIKey, string, error) { rawBytes := make([]byte, 32) if _, err := rand.Read(rawBytes); err != nil { return nil, "", fmt.Errorf("storage: generate accounting api key: %w", err) } key := accountingKeyPrefix + base64.RawURLEncoding.EncodeToString(rawBytes) var k AccountingAPIKey err := s.db.QueryRow(ctx, ` INSERT INTO accounting_api_keys (tenant_id, key_hash, label, created_by) VALUES ($1, $2, $3, $4) RETURNING id, tenant_id, label, created_by, created_at, revoked_at, last_used_at `, tenantID, hashAccountingKey(key), label, createdBy, ).Scan(&k.ID, &k.TenantID, &k.Label, &k.CreatedBy, &k.CreatedAt, &k.RevokedAt, &k.LastUsedAt) if err != nil { return nil, "", fmt.Errorf("storage: create accounting api key: %w", err) } return &k, key, nil } // ResolveAccountingAPIKey authenticates a raw key: it hashes the key, looks the // row up by key_hash, rejects revoked keys, refreshes last_used_at and returns // the owning tenant id plus the key id. // // The returned tenantID is THE ONLY trusted tenant source for the pull // endpoints — no caller may take a tenant_id from the request itself. // Unknown and revoked keys both yield ErrAccountingKeyNotFound so the caller // cannot distinguish them. func (s *Store) ResolveAccountingAPIKey(ctx context.Context, rawKey string) (tenantID int64, keyID int64, err error) { if rawKey == "" { return 0, 0, ErrAccountingKeyNotFound } // Single statement: authenticate + touch last_used_at atomically. The // revoked_at IS NULL guard lives in the WHERE clause, so a revoked key can // never return a tenant id. err = s.db.QueryRow(ctx, ` UPDATE accounting_api_keys SET last_used_at = now() WHERE key_hash = $1 AND revoked_at IS NULL RETURNING tenant_id, id `, hashAccountingKey(rawKey)).Scan(&tenantID, &keyID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, 0, ErrAccountingKeyNotFound } return 0, 0, fmt.Errorf("storage: resolve accounting api key: %w", err) } return tenantID, keyID, nil } // ListAccountingAPIKeys returns all keys of a tenant (including revoked ones — // no hard delete), newest first. Never returns the hash or the plaintext. func (s *Store) ListAccountingAPIKeys(ctx context.Context, tenantID int64) ([]AccountingAPIKey, error) { rows, err := s.db.Query(ctx, ` SELECT id, tenant_id, label, created_by, created_at, revoked_at, last_used_at FROM accounting_api_keys WHERE tenant_id = $1 ORDER BY created_at DESC `, tenantID) if err != nil { return nil, fmt.Errorf("storage: list accounting api keys: %w", err) } defer rows.Close() out := make([]AccountingAPIKey, 0) for rows.Next() { var k AccountingAPIKey if err := rows.Scan(&k.ID, &k.TenantID, &k.Label, &k.CreatedBy, &k.CreatedAt, &k.RevokedAt, &k.LastUsedAt); err != nil { return nil, fmt.Errorf("storage: scan accounting api key: %w", err) } out = append(out, k) } return out, rows.Err() } // RevokeAccountingAPIKey marks a key as revoked (never hard-deleted), scoped to // tenant ownership (IDOR guard: id AND tenant_id). Returns // ErrAccountingKeyNotFound when no key of that id belongs to the tenant. func (s *Store) RevokeAccountingAPIKey(ctx context.Context, id, tenantID int64) error { tag, err := s.db.Exec(ctx, ` UPDATE accounting_api_keys SET revoked_at = now() WHERE id = $1 AND tenant_id = $2 AND revoked_at IS NULL `, id, tenantID) if err != nil { return fmt.Errorf("storage: revoke accounting api key: %w", err) } if tag.RowsAffected() == 0 { return ErrAccountingKeyNotFound } return nil }