Files
archivdms/internal/storage/accounting_api_keys.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

174 lines
6.7 KiB
Go

// 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
}