Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
174 lines
6.7 KiB
Go
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
|
|
}
|