Files
archivdms/internal/storage/shares.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

390 lines
15 KiB
Go

// External document share-links (see migrations/009_shares.sql). A share is a
// tenant-scoped, expiring, optionally password-protected public link to a
// single document. The raw token is generated once (32 bytes crypto/rand,
// base64url) and returned to the caller exactly once at creation time; only its
// SHA-256 hash is ever persisted (token_hash). The public download endpoint
// always looks a share up by token_hash, never by id.
//
// Shares are never hard-deleted: revoking only sets revoked_at/revoked_by, and
// every access attempt (success or failure) is recorded in
// document_share_accesses for GoBD traceability. The underlying file stays in
// the WORM store and is streamed server-side; storage_path/content_hash are
// never exposed to the public client.
package storage
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const shareBcryptCost = 12
// shareDummyBcryptHash burns bcrypt time when a share has no password but a
// client nevertheless submits one, so password-protected and unprotected
// shares are not trivially distinguishable by response timing.
const shareDummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO4TW/OZ/6PdTdSU0/eV1JCJXo.0DGvTa"
// Share lifecycle / validation errors.
var (
// ErrShareNotFound is returned when a share lookup (by id+tenant or by
// token_hash) matches no row.
ErrShareNotFound = errors.New("storage: share not found")
// ErrShareRevoked is returned when a share has been revoked.
ErrShareRevoked = errors.New("storage: share revoked")
// ErrShareExpired is returned when a share is past its expires_at.
ErrShareExpired = errors.New("storage: share expired")
// ErrShareMaxReached is returned when a share hit its max_accesses cap.
ErrShareMaxReached = errors.New("storage: share max accesses reached")
// ErrShareBadPassword is returned when a required share password is
// missing or wrong.
ErrShareBadPassword = errors.New("storage: share password incorrect")
)
// Share access-result constants (mirror the CHECK constraint on
// document_share_accesses.result).
const (
ShareResultSuccess = "success"
ShareResultExpired = "expired"
ShareResultRevoked = "revoked"
ShareResultMaxReached = "max_reached"
ShareResultBadPassword = "bad_password"
ShareResultRateLimited = "rate_limited"
)
// DocumentShare is the public-safe view of a share row. token_hash and
// password_hash are deliberately NOT part of this struct so they can never be
// serialised into an API response.
type DocumentShare struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
DocumentID int64 `json:"document_id"`
CreatedBy int64 `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxAccesses *int `json:"max_accesses,omitempty"`
AccessCount int `json:"access_count"`
HasPassword bool `json:"has_password"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
RevokedBy *int64 `json:"revoked_by,omitempty"`
// DocumentTitle is joined in for the listing endpoints; empty when not
// selected.
DocumentTitle string `json:"document_title,omitempty"`
}
// CreateShareRequest holds the parameters for creating a share.
type CreateShareRequest struct {
TenantID int64
DocumentID int64
CreatedBy int64
ExpiresAt time.Time
MaxAccesses *int // nil = unlimited (until expiry)
Password string // "" = no password protection
}
// ResolvedShare carries the internal fields the public download flow needs but
// which must never reach the client: password_hash plus the document's WORM
// storage location. Its sensitive fields are unexported and reached only via
// accessor methods / the Verify* helpers, so a handler cannot accidentally
// serialise password_hash or storage_path into a response.
type ResolvedShare struct {
share DocumentShare
passwordHash string
storagePath string
contentHash string
DocumentTitle string
}
// ShareID returns the share's id.
func (rs *ResolvedShare) ShareID() int64 { return rs.share.ID }
// TenantID returns the owning tenant id.
func (rs *ResolvedShare) TenantID() int64 { return rs.share.TenantID }
// DocumentID returns the shared document's id.
func (rs *ResolvedShare) DocumentID() int64 { return rs.share.DocumentID }
// ExpiresAt returns the share's expiry.
func (rs *ResolvedShare) ExpiresAt() time.Time { return rs.share.ExpiresAt }
// HasPassword reports whether the share is password-protected.
func (rs *ResolvedShare) HasPassword() bool { return rs.passwordHash != "" }
// StoragePath returns the WORM path of the underlying file (server-side only).
func (rs *ResolvedShare) StoragePath() string { return rs.storagePath }
func (s *Store) initSharesSchema(ctx context.Context) error {
_, err := s.db.Exec(ctx, `
-- No FK on tenant_id / created_by / revoked_by: consistent with the rest
-- of the schema (plain BIGINT), because tenants/users are owned by other
-- stores that initialise after storage.New() (see cmd/archivdms/main.go).
-- document_id keeps its FK: documents is this store's own table.
CREATE TABLE IF NOT EXISTS document_shares (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
max_accesses INT,
access_count INT NOT NULL DEFAULT 0,
password_hash TEXT,
revoked_at TIMESTAMPTZ,
revoked_by BIGINT
);
CREATE INDEX IF NOT EXISTS idx_document_shares_document ON document_shares(document_id);
CREATE INDEX IF NOT EXISTS idx_document_shares_tenant ON document_shares(tenant_id);
CREATE TABLE IF NOT EXISTS document_share_accesses (
id BIGSERIAL PRIMARY KEY,
share_id BIGINT NOT NULL REFERENCES document_shares(id) ON DELETE CASCADE,
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ip_address INET,
user_agent TEXT,
result TEXT NOT NULL CHECK (result IN ('success','expired','revoked','max_reached','bad_password','rate_limited'))
);
CREATE INDEX IF NOT EXISTS idx_share_accesses_share ON document_share_accesses(share_id, accessed_at DESC);
`)
if err != nil {
return fmt.Errorf("storage: create shares tables: %w", err)
}
return nil
}
// hashShareToken returns the hex-encoded SHA-256 of a raw share token, the
// value persisted in / looked up from document_shares.token_hash.
func hashShareToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// CreateShare inserts a new share for a document and returns the stored share
// plus the raw (plaintext) token. The token is returned ONLY here and never
// again — only its SHA-256 hash is persisted. The document must belong to the
// tenant, otherwise ErrShareNotFound is returned.
func (s *Store) CreateShare(ctx context.Context, req CreateShareRequest) (*DocumentShare, string, error) {
// Ownership check: the document must belong to the tenant (IDOR guard).
var owned bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM documents WHERE id = $1 AND tenant_id = $2)
`, req.DocumentID, req.TenantID).Scan(&owned); err != nil {
return nil, "", fmt.Errorf("storage: check share document: %w", err)
}
if !owned {
return nil, "", ErrShareNotFound
}
rawBytes := make([]byte, 32)
if _, err := rand.Read(rawBytes); err != nil {
return nil, "", fmt.Errorf("storage: generate share token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(rawBytes)
tokenHash := hashShareToken(token)
var passwordHash any
if req.Password != "" {
h, err := bcrypt.GenerateFromPassword([]byte(req.Password), shareBcryptCost)
if err != nil {
return nil, "", fmt.Errorf("storage: share bcrypt: %w", err)
}
passwordHash = string(h)
}
var d DocumentShare
var pw *string
err := s.db.QueryRow(ctx, `
INSERT INTO document_shares (tenant_id, document_id, token_hash, created_by, expires_at, max_accesses, password_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, tenant_id, document_id, created_by, created_at, expires_at, max_accesses, access_count, password_hash, revoked_at, revoked_by
`, req.TenantID, req.DocumentID, tokenHash, req.CreatedBy, req.ExpiresAt, req.MaxAccesses, passwordHash,
).Scan(&d.ID, &d.TenantID, &d.DocumentID, &d.CreatedBy, &d.CreatedAt, &d.ExpiresAt, &d.MaxAccesses, &d.AccessCount, &pw, &d.RevokedAt, &d.RevokedBy)
if err != nil {
return nil, "", fmt.Errorf("storage: create share: %w", err)
}
d.HasPassword = pw != nil
return &d, token, nil
}
// ListSharesForDocument returns all shares (including revoked ones — no hard
// delete) for a document, scoped to tenant ownership, newest first.
func (s *Store) ListSharesForDocument(ctx context.Context, documentID, tenantID int64) ([]DocumentShare, error) {
rows, err := s.db.Query(ctx, `
SELECT id, tenant_id, document_id, created_by, created_at, expires_at, max_accesses, access_count,
(password_hash IS NOT NULL), revoked_at, revoked_by
FROM document_shares
WHERE document_id = $1 AND tenant_id = $2
ORDER BY created_at DESC
`, documentID, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list document shares: %w", err)
}
return scanShares(rows)
}
// ListSharesForTenant returns all shares of a tenant with the document title
// joined in, newest first (domain_admin/superadmin overview).
func (s *Store) ListSharesForTenant(ctx context.Context, tenantID int64) ([]DocumentShare, error) {
rows, err := s.db.Query(ctx, `
SELECT sh.id, sh.tenant_id, sh.document_id, sh.created_by, sh.created_at, sh.expires_at,
sh.max_accesses, sh.access_count, (sh.password_hash IS NOT NULL), sh.revoked_at, sh.revoked_by,
COALESCE(d.title, '')
FROM document_shares sh
LEFT JOIN documents d ON d.id = sh.document_id
WHERE sh.tenant_id = $1
ORDER BY sh.created_at DESC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list tenant shares: %w", err)
}
defer rows.Close()
out := make([]DocumentShare, 0)
for rows.Next() {
var d DocumentShare
if err := rows.Scan(&d.ID, &d.TenantID, &d.DocumentID, &d.CreatedBy, &d.CreatedAt, &d.ExpiresAt,
&d.MaxAccesses, &d.AccessCount, &d.HasPassword, &d.RevokedAt, &d.RevokedBy, &d.DocumentTitle); err != nil {
return nil, fmt.Errorf("storage: scan tenant share: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
func scanShares(rows pgx.Rows) ([]DocumentShare, error) {
defer rows.Close()
out := make([]DocumentShare, 0)
for rows.Next() {
var d DocumentShare
if err := rows.Scan(&d.ID, &d.TenantID, &d.DocumentID, &d.CreatedBy, &d.CreatedAt, &d.ExpiresAt,
&d.MaxAccesses, &d.AccessCount, &d.HasPassword, &d.RevokedAt, &d.RevokedBy); err != nil {
return nil, fmt.Errorf("storage: scan share: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// RevokeShare marks a share as revoked (never hard-deleted), scoped to tenant
// ownership. Idempotent-ish: revoking an already-revoked share updates
// revoked_at/revoked_by again but still succeeds. Returns ErrShareNotFound when
// no share of that id belongs to the tenant.
func (s *Store) RevokeShare(ctx context.Context, shareID, tenantID, revokedBy int64) error {
tag, err := s.db.Exec(ctx, `
UPDATE document_shares SET revoked_at = now(), revoked_by = $3
WHERE id = $1 AND tenant_id = $2
`, shareID, tenantID, revokedBy)
if err != nil {
return fmt.Errorf("storage: revoke share: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrShareNotFound
}
return nil
}
// ResolveShareByToken looks a share up by the SHA-256 hash of the raw token
// (never by id) and joins the document's storage location. Returns
// ErrShareNotFound when no share matches.
func (s *Store) ResolveShareByToken(ctx context.Context, token string) (*ResolvedShare, error) {
tokenHash := hashShareToken(token)
var rs ResolvedShare
var pw *string
err := s.db.QueryRow(ctx, `
SELECT sh.id, sh.tenant_id, sh.document_id, sh.created_by, sh.created_at, sh.expires_at,
sh.max_accesses, sh.access_count, sh.password_hash, sh.revoked_at, sh.revoked_by,
COALESCE(d.title, ''), COALESCE(d.storage_path, ''), COALESCE(d.content_hash, '')
FROM document_shares sh
JOIN documents d ON d.id = sh.document_id
WHERE sh.token_hash = $1
`, tokenHash).Scan(&rs.share.ID, &rs.share.TenantID, &rs.share.DocumentID, &rs.share.CreatedBy,
&rs.share.CreatedAt, &rs.share.ExpiresAt, &rs.share.MaxAccesses, &rs.share.AccessCount, &pw,
&rs.share.RevokedAt, &rs.share.RevokedBy, &rs.DocumentTitle, &rs.storagePath, &rs.contentHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrShareNotFound
}
return nil, fmt.Errorf("storage: resolve share by token: %w", err)
}
if pw != nil {
rs.passwordHash = *pw
rs.share.HasPassword = true
}
return &rs, nil
}
// VerifyState applies the fixed check order (revoked -> expired ->
// max_accesses) and returns the matching result string (for the access log)
// plus the sentinel error. now is passed in so callers share one timestamp.
func (rs *ResolvedShare) VerifyState(now time.Time) (string, error) {
if rs.share.RevokedAt != nil {
return ShareResultRevoked, ErrShareRevoked
}
if !now.Before(rs.share.ExpiresAt) {
return ShareResultExpired, ErrShareExpired
}
if rs.share.MaxAccesses != nil && rs.share.AccessCount >= *rs.share.MaxAccesses {
return ShareResultMaxReached, ErrShareMaxReached
}
return "", nil
}
// VerifyPassword checks a submitted password against the share's stored bcrypt
// hash. Runs a bcrypt comparison (dummy hash when the share has no password) to
// avoid leaking, via timing, whether a share is protected.
func (rs *ResolvedShare) VerifyPassword(password string) error {
if rs.passwordHash == "" {
// Burn comparable time so protected/unprotected shares look alike.
_ = bcrypt.CompareHashAndPassword([]byte(shareDummyBcryptHash), []byte(password))
return nil
}
if err := bcrypt.CompareHashAndPassword([]byte(rs.passwordHash), []byte(password)); err != nil {
return ErrShareBadPassword
}
return nil
}
// IncrementShareAccess atomically bumps access_count, but only while the share
// is still within its cap — the WHERE guard closes the race where two parallel
// downloads could both pass the in-memory max check. Returns true when the
// counter was incremented (i.e. the download may proceed).
func (s *Store) IncrementShareAccess(ctx context.Context, shareID int64) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE document_shares
SET access_count = access_count + 1
WHERE id = $1
AND revoked_at IS NULL
AND expires_at > now()
AND (max_accesses IS NULL OR access_count < max_accesses)
`, shareID)
if err != nil {
return false, fmt.Errorf("storage: increment share access: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// LogShareAccess appends an access-attempt record. ip may be empty (stored as
// NULL). Errors are returned so the caller can decide, but a logging failure
// should never block the response path — the public handler logs-and-continues.
func (s *Store) LogShareAccess(ctx context.Context, shareID int64, ip, userAgent, result string) error {
var ipArg any
if ip != "" {
ipArg = ip
}
_, err := s.db.Exec(ctx, `
INSERT INTO document_share_accesses (share_id, ip_address, user_agent, result)
VALUES ($1, $2::inet, $3, $4)
`, shareID, ipArg, userAgent, result)
if err != nil {
return fmt.Errorf("storage: log share access: %w", err)
}
return nil
}