Employee document signatures recorded an acknowledgment with no cryptographic proof, unlike document approvals which already create and accept an electronic signature on every decision. Mirror the approval flow on the sign path: generate the signed document PDF, create-and-accept an esign record, and persist its id on the document_version_signatures row through a new electronic_signature_id column. Capture the signer IP and user agent in the resolver, and re-check the published/archived preconditions inside the transaction so the seal cannot race document state. Make the consent wording a single backend source of truth shared by the text that is sealed and the text shown in the UI. Define DocumentSignatureConsentText and DocumentApprovalConsentText in the probo service package and the NDA copy in the trust service, each owned by the flow that uses it, and stop esign from appending the generic clause to caller-provided consent text so approvals no longer seal a duplicated sentence. Expose the resolved consent text through GraphQL on EmployeeDocumentVersion and DocumentVersionApprovalDecision, and have the signing, approval, and NDA pages render it from the API instead of hard-coded strings, mirroring how the NDA page already worked. Align the wording with the actual interaction: the buttons read "Review and sign" and "Review and approve", the clauses reference those actions, and the inaccurate "typing my full name" phrasing is removed everywhere. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
442 lines
13 KiB
Go
442 lines
13 KiB
Go
// Copyright (c) 2025-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 trust
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"go.gearno.de/kit/log"
|
|
"go.gearno.de/kit/pg"
|
|
"go.probo.inc/probo/packages/emails"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/esign"
|
|
"go.probo.inc/probo/pkg/filemanager"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
"go.probo.inc/probo/pkg/html2pdf"
|
|
"go.probo.inc/probo/pkg/iam"
|
|
"go.probo.inc/probo/pkg/probo"
|
|
"go.probo.inc/probo/pkg/slack"
|
|
)
|
|
|
|
const NDAConsentText = "By clicking \"Review and sign\", I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature. If you have questions about the NDA, please contact security@probo.com."
|
|
|
|
type (
|
|
Service struct {
|
|
pg *pg.Client
|
|
s3 *s3.Client
|
|
bucket string
|
|
proboSvc *probo.Service
|
|
slackSigningSecret string
|
|
baseURL string
|
|
iam *iam.Service
|
|
esign *esign.Service
|
|
html2pdfConverter *html2pdf.Converter
|
|
fileManager *filemanager.Service
|
|
logger *log.Logger
|
|
slack *slack.Service
|
|
TrustCenters *TrustCenterService
|
|
Documents *DocumentService
|
|
Audits *AuditService
|
|
ThirdParties *ThirdPartyService
|
|
Frameworks *FrameworkService
|
|
ComplianceFrameworks *ComplianceFrameworkService
|
|
TrustCenterAccesses *TrustCenterAccessService
|
|
TrustCenterReferences *TrustCenterReferenceService
|
|
TrustCenterFiles *TrustCenterFileService
|
|
Reports *ReportService
|
|
Organizations *OrganizationService
|
|
ComplianceExternalURLs *ComplianceExternalURLService
|
|
}
|
|
)
|
|
|
|
func NewService(
|
|
pgClient *pg.Client,
|
|
s3Client *s3.Client,
|
|
bucket string,
|
|
baseURL string,
|
|
slackSigningSecret string,
|
|
iam *iam.Service,
|
|
esignSvc *esign.Service,
|
|
html2pdfConverter *html2pdf.Converter,
|
|
fileManagerService *filemanager.Service,
|
|
logger *log.Logger,
|
|
slack *slack.Service,
|
|
) *Service {
|
|
svc := &Service{
|
|
pg: pgClient,
|
|
s3: s3Client,
|
|
bucket: bucket,
|
|
slackSigningSecret: slackSigningSecret,
|
|
baseURL: baseURL,
|
|
iam: iam,
|
|
esign: esignSvc,
|
|
html2pdfConverter: html2pdfConverter,
|
|
fileManager: fileManagerService,
|
|
logger: logger,
|
|
slack: slack,
|
|
}
|
|
svc.TrustCenters = &TrustCenterService{svc: svc}
|
|
svc.Documents = &DocumentService{svc: svc, html2pdfConverter: html2pdfConverter}
|
|
svc.Audits = &AuditService{svc: svc}
|
|
svc.ThirdParties = &ThirdPartyService{svc: svc}
|
|
svc.Frameworks = &FrameworkService{svc: svc}
|
|
svc.ComplianceFrameworks = &ComplianceFrameworkService{svc: svc}
|
|
svc.TrustCenterAccesses = &TrustCenterAccessService{svc: svc, iamSvc: iam, logger: logger}
|
|
svc.TrustCenterReferences = &TrustCenterReferenceService{svc: svc}
|
|
svc.TrustCenterFiles = &TrustCenterFileService{svc: svc}
|
|
svc.Reports = &ReportService{svc: svc}
|
|
svc.Organizations = &OrganizationService{svc: svc}
|
|
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
|
|
|
|
return svc
|
|
}
|
|
|
|
func (s *Service) Get(
|
|
ctx context.Context,
|
|
id gid.GID,
|
|
) (*coredata.TrustCenter, error) {
|
|
trustCenter := &coredata.TrustCenter{}
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
err := trustCenter.LoadByID(ctx, conn, coredata.NewNoScope(), id)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return ErrPageNotFound
|
|
}
|
|
|
|
return fmt.Errorf("cannot load trust center: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return trustCenter, nil
|
|
}
|
|
|
|
func (s *Service) GetBySlug(
|
|
ctx context.Context,
|
|
slug string,
|
|
) (*coredata.TrustCenter, error) {
|
|
trustCenter := &coredata.TrustCenter{}
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
err := trustCenter.LoadBySlug(ctx, conn, slug)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return ErrPageNotFound
|
|
}
|
|
|
|
return fmt.Errorf("cannot load trust center: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return trustCenter, nil
|
|
}
|
|
|
|
func (s *Service) GetByDomainName(ctx context.Context, domain string) (*coredata.TrustCenter, error) {
|
|
trustCenter := &coredata.TrustCenter{}
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
var customDomain coredata.CustomDomain
|
|
if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return ErrPageNotFound
|
|
}
|
|
|
|
return fmt.Errorf("cannot load custom domain: %w", err)
|
|
}
|
|
|
|
var org coredata.Organization
|
|
if err := org.LoadByCustomDomainID(ctx, conn, coredata.NewNoScope(), customDomain.ID); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return ErrPageNotFound
|
|
}
|
|
|
|
return fmt.Errorf("cannot load organization: %w", err)
|
|
}
|
|
|
|
trustCenter = &coredata.TrustCenter{}
|
|
if err := trustCenter.LoadByOrganizationID(ctx, conn, coredata.NewNoScope(), org.ID); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return ErrPageNotFound
|
|
}
|
|
|
|
return fmt.Errorf("cannot load trust center: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return trustCenter, err
|
|
}
|
|
|
|
func (s *Service) GetCustomDomainByOrganizationID(ctx context.Context, organizationID gid.GID) (*coredata.CustomDomain, error) {
|
|
customDomain := &coredata.CustomDomain{}
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
return customDomain.LoadByOrganizationID(ctx, conn, coredata.NewNoScope(), organizationID)
|
|
},
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, ErrCustomDomainNotFound
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return customDomain, err
|
|
}
|
|
|
|
// EmailPresenterConfigByOrganizationID resolves the emails.PresenterConfig for
|
|
// the trust center that belongs to the given organization. This is used by the
|
|
// esign certificate worker which needs per-org branding at render time.
|
|
func (s *Service) EmailPresenterConfigByOrganizationID(ctx context.Context, orgID gid.GID) (emails.PresenterConfig, error) {
|
|
var trustCenter coredata.TrustCenter
|
|
|
|
scope := coredata.NewScopeFromObjectID(orgID)
|
|
|
|
err := s.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
|
return trustCenter.LoadByOrganizationID(ctx, conn, scope, orgID)
|
|
})
|
|
if err != nil {
|
|
return emails.PresenterConfig{}, fmt.Errorf("cannot load trust center for org %s: %w", orgID, err)
|
|
}
|
|
|
|
return s.TrustCenters.EmailPresenterConfig(ctx, scope, trustCenter.ID)
|
|
}
|
|
|
|
func (s *Service) GetOrganizationByTrustCenterID(
|
|
ctx context.Context,
|
|
trustCenterID gid.GID,
|
|
) (*coredata.Organization, error) {
|
|
trustCenter, err := s.Get(ctx, trustCenterID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot load trust center: %w", err)
|
|
}
|
|
|
|
org := &coredata.Organization{}
|
|
|
|
err = s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
return org.LoadByID(ctx, conn, coredata.NewNoScope(), trustCenter.OrganizationID)
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot load organization: %w", err)
|
|
}
|
|
|
|
return org, nil
|
|
}
|
|
|
|
func (s *Service) GetMembershipByCompliancePageIDAndIdentityID(ctx context.Context, compliancePageID gid.GID, identityID gid.GID) (*coredata.TrustCenterAccess, error) {
|
|
membership := &coredata.TrustCenterAccess{}
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
return membership.LoadByTrustCenterIDAndIdentityID(
|
|
ctx,
|
|
conn,
|
|
coredata.NewScopeFromObjectID(compliancePageID),
|
|
compliancePageID,
|
|
identityID,
|
|
)
|
|
},
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, ErrMembershipNotFound
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return membership, nil
|
|
}
|
|
|
|
func (s *Service) GetNDAFile(
|
|
ctx context.Context,
|
|
compliancePageID gid.GID,
|
|
) (*coredata.File, error) {
|
|
var (
|
|
file *coredata.File
|
|
scope = coredata.NewScopeFromObjectID(compliancePageID)
|
|
)
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
trustCenter := &coredata.TrustCenter{}
|
|
if err := trustCenter.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
|
|
return fmt.Errorf("cannot load trust center: %w", err)
|
|
}
|
|
|
|
if trustCenter.NonDisclosureAgreementFileID == nil {
|
|
return ErrNDAFileNotFound
|
|
}
|
|
|
|
file = &coredata.File{}
|
|
if err := file.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return ErrNDAFileNotFound
|
|
}
|
|
|
|
return fmt.Errorf("cannot load file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return file, nil
|
|
}
|
|
|
|
func (s *Service) ProvisionMember(
|
|
ctx context.Context,
|
|
compliancePageID gid.GID,
|
|
identityID gid.GID,
|
|
) (*coredata.TrustCenterAccess, error) {
|
|
var (
|
|
access *coredata.TrustCenterAccess
|
|
now = time.Now()
|
|
scope = coredata.NewScopeFromObjectID(compliancePageID)
|
|
)
|
|
|
|
err := s.pg.WithTx(
|
|
ctx,
|
|
func(ctx context.Context, tx pg.Tx) error {
|
|
compliancePage := &coredata.TrustCenter{}
|
|
if err := compliancePage.LoadByID(ctx, tx, scope, compliancePageID); err != nil {
|
|
return fmt.Errorf("cannot load trust center: %w", err)
|
|
}
|
|
|
|
identity := &coredata.Identity{}
|
|
if err := identity.LoadByID(ctx, tx, identityID); err != nil {
|
|
return fmt.Errorf("cannot load identity: %w", err)
|
|
}
|
|
|
|
access = &coredata.TrustCenterAccess{}
|
|
if err := access.LoadByTrustCenterIDAndIdentityID(ctx, tx, scope, compliancePageID, identityID); err != nil {
|
|
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return fmt.Errorf("cannot load trust center access: %w", err)
|
|
}
|
|
|
|
access = &coredata.TrustCenterAccess{
|
|
ID: gid.New(scope.GetTenantID(), coredata.TrustCenterAccessEntityType),
|
|
OrganizationID: compliancePage.OrganizationID,
|
|
TenantID: scope.GetTenantID(),
|
|
IdentityID: identityID,
|
|
TrustCenterID: compliancePageID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
var sig *coredata.ElectronicSignature
|
|
|
|
if compliancePage.NonDisclosureAgreementFileID != nil && s.esign != nil {
|
|
var err error
|
|
|
|
sig, err = s.esign.CreateSignature(
|
|
ctx,
|
|
tx,
|
|
&esign.CreateSignatureRequest{
|
|
OrganizationID: access.OrganizationID,
|
|
DocumentType: coredata.ElectronicSignatureDocumentTypeNDA,
|
|
FileID: *compliancePage.NonDisclosureAgreementFileID,
|
|
SignerEmail: identity.EmailAddress,
|
|
ConsentText: NDAConsentText,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create pending signature: %w", err)
|
|
}
|
|
}
|
|
|
|
if sig != nil {
|
|
access.ElectronicSignatureID = &sig.ID
|
|
}
|
|
|
|
if err := access.Insert(ctx, tx, scope); err != nil {
|
|
return fmt.Errorf("cannot insert trust center access: %w", err)
|
|
}
|
|
}
|
|
|
|
profile := &coredata.MembershipProfile{}
|
|
if err := profile.LoadByIdentityIDAndOrganizationID(
|
|
ctx,
|
|
tx,
|
|
coredata.NewScopeFromObjectID(access.ID),
|
|
identityID,
|
|
access.OrganizationID,
|
|
); err != nil {
|
|
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return fmt.Errorf("cannot load profile: %w", err)
|
|
}
|
|
|
|
profile = &coredata.MembershipProfile{
|
|
ID: gid.New(access.TenantID, coredata.MembershipProfileEntityType),
|
|
IdentityID: identityID,
|
|
OrganizationID: access.OrganizationID,
|
|
EmailAddress: identity.EmailAddress,
|
|
Source: coredata.ProfileSourceManual,
|
|
State: coredata.ProfileStateActive,
|
|
FullName: identity.FullName,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := profile.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert profile: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
|
|
return access, err
|
|
}
|