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.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user