Files
probo/pkg/coredata/electronic_signature.go
Sacha Al Himdani 9ac71f948f Update contact email to hello@probo.com
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
2026-06-09 16:45:23 +02:00

427 lines
13 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/crypto/hash"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/policy"
)
type ElectronicSignature struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
OrganizationID gid.GID `db:"organization_id"`
Status ElectronicSignatureStatus `db:"status"`
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
DocumentName *string `db:"document_name"`
FileID gid.GID `db:"file_id"`
SignerEmail string `db:"signer_email"`
ConsentText string `db:"consent_text"`
EmailSubject string `db:"email_subject"`
SignerFullName *string `db:"signer_full_name"`
SignerIPAddress *string `db:"signer_ip_address"`
SignerUserAgent *string `db:"signer_user_agent"`
FileHash *string `db:"file_hash"`
Seal *string `db:"seal"`
SealVersion int `db:"seal_version"`
TSAToken []byte `db:"tsa_token"`
SignedAt *time.Time `db:"signed_at"`
CertificateFileID *gid.GID `db:"certificate_file_id"`
CertificateProcessingStartedAt *time.Time `db:"certificate_processing_started_at"`
AttemptCount int `db:"attempt_count"`
MaxAttempts int `db:"max_attempts"`
LastAttemptedAt *time.Time `db:"last_attempted_at"`
LastError *string `db:"last_error"`
ProcessingStartedAt *time.Time `db:"processing_started_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (es *ElectronicSignature) AuthorizationAttributes(
ctx context.Context,
conn pg.Querier,
resourceIDs []gid.GID,
) (policy.AttributesByID, error) {
q := `SELECT id, organization_id FROM electronic_signatures WHERE id = ANY(@resource_ids::text[])`
args := pgx.StrictNamedArgs{
"resource_ids": resourceIDs,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query electronic signature authorization attributes: %w", err)
}
defer rows.Close()
attrsByID := make(policy.AttributesByID)
for rows.Next() {
var id, organizationID gid.GID
if err := rows.Scan(&id, &organizationID); err != nil {
return nil, fmt.Errorf("cannot scan electronic signature authorization attributes: %w", err)
}
attrsByID[id] = policy.Attributes{
"organization_id": organizationID.String(),
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("cannot iterate electronic signature authorization attributes: %w", err)
}
return attrsByID, nil
}
func (es *ElectronicSignature) NewEvent(
eventType ElectronicSignatureEventType,
eventSource ElectronicSignatureEventSource,
) ElectronicSignatureEvent {
now := time.Now()
return ElectronicSignatureEvent{
ID: gid.New(es.ID.TenantID(), ElectronicSignatureEventEntityType),
ElectronicSignatureID: es.ID,
EventType: eventType,
EventSource: eventSource,
ActorEmail: es.SignerEmail,
ActorIPAddress: ref.UnrefOrZero(es.SignerIPAddress),
ActorUserAgent: ref.UnrefOrZero(es.SignerUserAgent),
OccurredAt: now,
CreatedAt: now,
}
}
func (es *ElectronicSignature) Insert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO electronic_signatures (
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, email_subject, seal_version, attempt_count, max_attempts,
created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @status, @document_type, @document_name, @file_id,
@signer_email, @consent_text, @email_subject, @seal_version, @attempt_count, @max_attempts,
@created_at, @updated_at
)
`
args := pgx.StrictNamedArgs{
"id": es.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": es.OrganizationID,
"status": es.Status,
"document_type": es.DocumentType,
"document_name": es.DocumentName,
"file_id": es.FileID,
"signer_email": es.SignerEmail,
"consent_text": es.ConsentText,
"email_subject": es.EmailSubject,
"seal_version": es.SealVersion,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"created_at": es.CreatedAt,
"updated_at": es.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) Update(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
UPDATE electronic_signatures SET
status = @status,
signer_full_name = @signer_full_name,
signer_ip_address = @signer_ip_address,
signer_user_agent = @signer_user_agent,
file_hash = @file_hash,
seal = @seal,
seal_version = @seal_version,
tsa_token = @tsa_token,
signed_at = @signed_at,
certificate_file_id = @certificate_file_id,
certificate_processing_started_at = @certificate_processing_started_at,
attempt_count = @attempt_count,
max_attempts = @max_attempts,
last_attempted_at = @last_attempted_at,
last_error = @last_error,
processing_started_at = @processing_started_at,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": es.ID,
"status": es.Status,
"signer_full_name": es.SignerFullName,
"signer_ip_address": es.SignerIPAddress,
"signer_user_agent": es.SignerUserAgent,
"file_hash": es.FileHash,
"seal": es.Seal,
"seal_version": es.SealVersion,
"tsa_token": es.TSAToken,
"signed_at": es.SignedAt,
"certificate_file_id": es.CertificateFileID,
"certificate_processing_started_at": es.CertificateProcessingStartedAt,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"last_attempted_at": es.LastAttemptedAt,
"last_error": es.LastError,
"processing_started_at": es.ProcessingStartedAt,
"updated_at": es.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
id gid.GID,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, email_subject, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE %s AND id = @id
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": id}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
ctx context.Context,
conn pg.Tx,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, email_subject, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'ACCEPTED' AND attempt_count < max_attempts
ORDER BY updated_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query accepted signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
ctx context.Context,
conn pg.Tx,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, email_subject, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NULL
AND attempt_count < max_attempts
ORDER BY signed_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query completed signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func ResetStaleProcessingSignatures(
ctx context.Context,
conn pg.Querier,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET status = 'ACCEPTED', processing_started_at = NULL, updated_at = NOW()
WHERE status = 'PROCESSING'
AND processing_started_at < NOW() - $1::interval
`
_, err := conn.Exec(ctx, q, staleAfter)
if err != nil {
return fmt.Errorf("cannot reset stale processing signatures: %w", err)
}
return nil
}
func (es *ElectronicSignature) ComputeSeal(version int) (string, error) {
switch version {
case 1:
return es.computeSealV1()
default:
return "", fmt.Errorf("unsupported seal version %d", version)
}
}
func (es *ElectronicSignature) computeSealV1() (string, error) {
if es.SignedAt == nil {
return "", fmt.Errorf("signed_at must not be nil")
}
fields := []string{
es.ID.String(),
es.OrganizationID.String(),
es.DocumentType.String(),
es.FileID.String(),
ref.UnrefOrZero(es.FileHash),
ref.UnrefOrZero(es.SignerFullName),
strings.ToLower(es.SignerEmail),
ref.UnrefOrZero(es.SignerIPAddress),
ref.UnrefOrZero(es.SignerUserAgent),
es.ConsentText,
es.SignedAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano),
}
for i, f := range fields {
if f == "" {
return "", fmt.Errorf("seal field %d must not be empty", i)
}
if strings.Contains(f, "\n") {
return "", fmt.Errorf("seal field %d must not contain newline", i)
}
}
input := strings.Join(fields, "\n")
return hash.SHA256HexString(input), nil
}
func ResetStaleCertificateProcessing(
ctx context.Context,
conn pg.Querier,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET certificate_processing_started_at = NULL, updated_at = NOW()
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NOT NULL
AND certificate_processing_started_at < NOW() - $1::interval
AND attempt_count < max_attempts
`
_, err := conn.Exec(ctx, q, staleAfter)
if err != nil {
return fmt.Errorf("cannot reset stale certificate processing: %w", err)
}
return nil
}