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