Files
probo/pkg/trust/trust_center_access_service.go
Bryan Frimin 2f8edfb6be Add per-email sender name for compliance page emails
When an email has a sender name set (the organization name), the
mailer composes the From header as "OrgName via Probo" instead of
the default global sender name. This gives compliance page
recipients clearer context about which organization is contacting
them.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-03-22 10:56:51 +01:00

636 lines
19 KiB
Go

// Copyright (c) 2025 Probo Inc <hello@getprobo.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"
"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/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mail"
)
type (
TrustCenterAccessService struct {
svc *TenantService
iamSvc *iam.Service
logger *log.Logger
}
TrustCenterAccessRequest struct {
TrustCenterID gid.GID
IdentityID gid.GID
DocumentIDs []gid.GID
ReportIDs []gid.GID
TrustCenterFileIDs []gid.GID
}
)
const (
TrustCenterAccessURLFormat = "https://%s/organizations/%s/trust-center/access"
)
func (s TrustCenterAccessService) Request(
ctx context.Context,
req *TrustCenterAccessRequest,
) (*coredata.TrustCenterAccess, error) {
var (
now = time.Now()
access *coredata.TrustCenterAccess
)
err := s.svc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, s.svc.scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
access = &coredata.TrustCenterAccess{}
if err := access.LoadByTrustCenterIDAndIdentityID(ctx, tx, s.svc.scope, req.TrustCenterID, req.IdentityID); err != nil {
return fmt.Errorf("cannot load compliance page membership: %w", err)
}
organizationID := trustCenter.OrganizationID
documentIDs := req.DocumentIDs
if req.DocumentIDs == nil {
var allDocuments coredata.Documents
filter := coredata.NewDocumentTrustCenterFilter()
if err := allDocuments.LoadAllByOrganizationID(ctx, tx, s.svc.scope, organizationID, filter); err != nil {
return fmt.Errorf("cannot list documents: %w", err)
}
for _, doc := range allDocuments {
documentIDs = append(documentIDs, doc.ID)
}
}
reportIDs := req.ReportIDs
if req.ReportIDs == nil {
var allAudits coredata.Audits
auditFilter := coredata.NewAuditTrustCenterFilter()
if err := allAudits.LoadAllByOrganizationID(ctx, tx, s.svc.scope, organizationID, auditFilter); err != nil {
return fmt.Errorf("cannot list audits: %w", err)
}
for _, audit := range allAudits {
if audit.ReportID != nil {
reportIDs = append(reportIDs, *audit.ReportID)
}
}
}
trustCenterFileIDs := req.TrustCenterFileIDs
if req.TrustCenterFileIDs == nil {
var allTrustCenterFiles coredata.TrustCenterFiles
filter := coredata.NewTrustCenterFileFilter(
coredata.WithTrustCenterFileVisibilities(coredata.TrustCenterVisibilityPrivate, coredata.TrustCenterVisibilityNone),
)
if err := allTrustCenterFiles.LoadAllByOrganizationID(ctx, tx, s.svc.scope, organizationID, filter); err != nil {
return fmt.Errorf("cannot list trust center files: %w", err)
}
for _, file := range allTrustCenterFiles {
trustCenterFileIDs = append(trustCenterFileIDs, file.ID)
}
}
var existingAccesses coredata.TrustCenterDocumentAccesses
if err := existingAccesses.LoadAllByTrustCenterAccessID(ctx, tx, s.svc.scope, access.ID); err != nil {
return fmt.Errorf("cannot load existing access records: %w", err)
}
existingDocumentIDs, existingReportIDs, existingTrustCenterFileIDs := extractExistingIDs(existingAccesses)
newDocumentIDs := filterExistingIDs(documentIDs, existingDocumentIDs)
newReportIDs := filterExistingIDs(reportIDs, existingReportIDs)
newTrustCenterFileIDs := filterExistingIDs(trustCenterFileIDs, existingTrustCenterFileIDs)
var accesses coredata.TrustCenterDocumentAccesses
if err := accesses.BulkInsertDocumentAccesses(
ctx,
tx,
s.svc.scope,
access.ID,
access.OrganizationID,
newDocumentIDs,
coredata.TrustCenterDocumentAccessStatusRequested,
now,
); err != nil {
return fmt.Errorf("cannot bulk insert trust center document accesses: %w", err)
}
if err := accesses.BulkInsertReportAccesses(
ctx,
tx,
s.svc.scope,
access.ID,
access.OrganizationID,
newReportIDs,
coredata.TrustCenterDocumentAccessStatusRequested,
now,
); err != nil {
return fmt.Errorf("cannot bulk insert trust center report accesses: %w", err)
}
if err := accesses.BulkInsertTrustCenterFileAccesses(
ctx,
tx,
s.svc.scope,
access.ID,
access.OrganizationID,
newTrustCenterFileIDs,
coredata.TrustCenterDocumentAccessStatusRequested,
now,
); err != nil {
return fmt.Errorf("cannot bulk insert trust center file accesses: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if err := s.svc.SlackMessages.QueueSlackNotification(ctx, req.IdentityID, req.TrustCenterID); err != nil {
s.logger.ErrorCtx(ctx, "cannot queue slack notification", log.Error(err))
}
return access, nil
}
func (s TrustCenterAccessService) GetAccess(
ctx context.Context,
trustCenterID gid.GID,
identityID gid.GID,
) (coredata.TrustCenterAccess, error) {
var access coredata.TrustCenterAccess
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return access.LoadByTrustCenterIDAndIdentityID(ctx, conn, s.svc.scope, trustCenterID, identityID)
})
return access, err
}
func (s TrustCenterAccessService) GetDocumentAccess(
ctx context.Context,
trustCenterID gid.GID,
identityID gid.GID,
documentID gid.GID,
) (*coredata.TrustCenterDocumentAccess, error) {
var documentAccess *coredata.TrustCenterDocumentAccess
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
access := &coredata.TrustCenterAccess{}
err := access.LoadByTrustCenterIDAndIdentityID(ctx, conn, s.svc.scope, trustCenterID, identityID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMembershipNotFound
}
return fmt.Errorf("cannot load trust center access: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, s.svc.scope, identityID, access.OrganizationID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrUserNotFound
}
}
if profile.State != coredata.ProfileStateActive {
return ErrUserInactive
}
documentAccess = &coredata.TrustCenterDocumentAccess{}
err = documentAccess.LoadByTrustCenterAccessIDAndDocumentID(ctx, conn, s.svc.scope, access.ID, documentID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrDocumentAccessNotFound
}
return fmt.Errorf("cannot load document access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return documentAccess, nil
}
func (s TrustCenterAccessService) GetReportAccess(
ctx context.Context,
trustCenterID gid.GID,
identityID gid.GID,
reportID gid.GID,
) (*coredata.TrustCenterDocumentAccess, error) {
var reportAccess *coredata.TrustCenterDocumentAccess
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
access := &coredata.TrustCenterAccess{}
err := access.LoadByTrustCenterIDAndIdentityID(ctx, conn, s.svc.scope, trustCenterID, identityID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMembershipNotFound
}
return fmt.Errorf("cannot load trust center access: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, s.svc.scope, identityID, access.OrganizationID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrUserNotFound
}
}
if profile.State != coredata.ProfileStateActive {
return ErrUserInactive
}
reportAccess = &coredata.TrustCenterDocumentAccess{}
err = reportAccess.LoadByTrustCenterAccessIDAndReportID(ctx, conn, s.svc.scope, access.ID, reportID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrDocumentAccessNotFound
}
return fmt.Errorf("cannot load report access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return reportAccess, nil
}
func (s TrustCenterAccessService) GetTrustCenterFileAccess(
ctx context.Context,
trustCenterID gid.GID,
identityID gid.GID,
trustCenterFileID gid.GID,
) (*coredata.TrustCenterDocumentAccess, error) {
var fileAccess *coredata.TrustCenterDocumentAccess
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
access := &coredata.TrustCenterAccess{}
err := access.LoadByTrustCenterIDAndIdentityID(ctx, conn, s.svc.scope, trustCenterID, identityID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMembershipNotFound
}
return fmt.Errorf("cannot load trust center access: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, s.svc.scope, identityID, access.OrganizationID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrUserNotFound
}
}
if profile.State != coredata.ProfileStateActive {
return ErrUserInactive
}
fileAccess = &coredata.TrustCenterDocumentAccess{}
err = fileAccess.LoadByTrustCenterAccessIDAndTrustCenterFileID(ctx, conn, s.svc.scope, access.ID, trustCenterFileID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrDocumentAccessNotFound
}
return fmt.Errorf("cannot load trust center file access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return fileAccess, nil
}
func (s *TrustCenterAccessService) GrantByIDs(
ctx context.Context,
organizationID gid.GID,
email mail.Addr,
documentIDs []gid.GID,
reportIDs []gid.GID,
fileIDs []gid.GID,
) error {
return s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByOrganizationID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
identity := &coredata.Identity{}
if err := identity.LoadByEmail(ctx, tx, email); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
access := &coredata.TrustCenterAccess{}
if err := access.LoadByTrustCenterIDAndIdentityID(ctx, tx, s.svc.scope, trustCenter.ID, identity.ID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, s.svc.scope, identity.ID, access.OrganizationID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrUserNotFound
}
}
if profile.State != coredata.ProfileStateActive {
return ErrUserInactive
}
shouldSendEmail := profile.State != coredata.ProfileStateActive
now := time.Now()
if len(documentIDs) > 0 {
if err := coredata.GrantByDocumentIDs(ctx, tx, s.svc.scope, access.ID, documentIDs, now); err != nil {
return fmt.Errorf("cannot grant document accesses: %w", err)
}
}
if len(reportIDs) > 0 {
if err := coredata.GrantByReportIDs(ctx, tx, s.svc.scope, access.ID, reportIDs, now); err != nil {
return fmt.Errorf("cannot grant report accesses: %w", err)
}
}
if len(fileIDs) > 0 {
if err := coredata.GrantByTrustCenterFileIDs(ctx, tx, s.svc.scope, access.ID, fileIDs, now); err != nil {
return fmt.Errorf("cannot grant trust center file accesses: %w", err)
}
}
if shouldSendEmail {
profile.State = coredata.ProfileStateActive
profile.UpdatedAt = now
if err := profile.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update profile: %w", err)
}
if err := s.sendAccessEmail(ctx, tx, access, profile); err != nil {
return fmt.Errorf("cannot send access email: %w", err)
}
}
return nil
})
}
func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Conn, access *coredata.TrustCenterAccess, profile *coredata.MembershipProfile) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, access.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
now := time.Now()
access.UpdatedAt = now
if err := access.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update trust center access with expiration: %w", err)
}
emailPresenterCfg, err := s.svc.TrustCenters.EmailPresenterConfig(ctx, access.TrustCenterID)
if err != nil {
return fmt.Errorf("cannot get compliance page email presenter config: %w", err)
}
emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, profile.FullName)
subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(ctx, organization.Name)
if err != nil {
return fmt.Errorf("cannot render trust center access email: %w", err)
}
accessEmail := coredata.NewEmail(
profile.FullName,
profile.EmailAddress,
subject,
textBody,
htmlBody,
&coredata.EmailOptions{
SenderName: new(organization.Name),
},
)
if err := accessEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert access email: %w", err)
}
return nil
}
func (s *TrustCenterAccessService) RejectOrRevokeByIDs(
ctx context.Context,
organizationID gid.GID,
email mail.Addr,
documentIDs []gid.GID,
reportIDs []gid.GID,
fileIDs []gid.GID,
) error {
return s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByOrganizationID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
identity := &coredata.Identity{}
if err := identity.LoadByEmail(ctx, tx, email); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
access := &coredata.TrustCenterAccess{}
if err := access.LoadByTrustCenterIDAndIdentityID(ctx, tx, s.svc.scope, trustCenter.ID, identity.ID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, s.svc.scope, identity.ID, access.OrganizationID); err != nil {
return fmt.Errorf("cannot load profile: %w", err)
}
shouldSendEmail := false
now := time.Now()
if len(documentIDs) > 0 {
shouldSendEmail = true
if err := coredata.RejectOrRevokeByDocumentIDs(ctx, tx, s.svc.scope, access.ID, documentIDs, now); err != nil {
return fmt.Errorf("cannot reject/revoke document accesses: %w", err)
}
}
if len(reportIDs) > 0 {
shouldSendEmail = true
if err := coredata.RejectOrRevokeByReportIDs(ctx, tx, s.svc.scope, access.ID, reportIDs, now); err != nil {
return fmt.Errorf("cannot reject/revoke report accesses: %w", err)
}
}
if len(fileIDs) > 0 {
shouldSendEmail = true
if err := coredata.RejectOrRevokeByTrustCenterFileIDs(ctx, tx, s.svc.scope, access.ID, fileIDs, now); err != nil {
return fmt.Errorf("cannot reject/revoke trust center file accesses: %w", err)
}
}
if shouldSendEmail {
if err := s.sendDocumentAccessRejectedEmail(ctx, tx, access, profile, documentIDs, reportIDs, fileIDs); err != nil {
return fmt.Errorf("cannot send access email: %w", err)
}
}
return nil
})
}
func (s *TrustCenterAccessService) sendDocumentAccessRejectedEmail(
ctx context.Context,
tx pg.Conn,
access *coredata.TrustCenterAccess,
profile *coredata.MembershipProfile,
documentIDs []gid.GID,
reportIDs []gid.GID,
fileIDs []gid.GID,
) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, access.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
var fileNames []string
var documents coredata.Documents
if len(documentIDs) > 0 {
if err := documents.LoadByIDs(ctx, tx, s.svc.scope, documentIDs); err != nil {
return fmt.Errorf("cannot load documents by IDs: %w", err)
}
for _, d := range documents {
fileNames = append(fileNames, d.Title)
}
}
var reports coredata.Reports
if len(reportIDs) > 0 {
if err := reports.LoadByIDs(ctx, tx, s.svc.scope, reportIDs); err != nil {
return fmt.Errorf("cannot load reports by IDs: %w", err)
}
for _, r := range reports {
fileNames = append(fileNames, r.Filename)
}
}
var files coredata.TrustCenterFiles
if len(fileIDs) > 0 {
if err := files.LoadByIDs(ctx, tx, s.svc.scope, fileIDs); err != nil {
return fmt.Errorf("cannot load files by IDs: %w", err)
}
for _, f := range files {
fileNames = append(fileNames, f.Name)
}
}
emailPresenterCfg, err := s.svc.TrustCenters.EmailPresenterConfig(ctx, access.TrustCenterID)
if err != nil {
return fmt.Errorf("cannot get compliance page email presenter config: %w", err)
}
emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, profile.FullName)
subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterDocumentAccessRejected(
ctx,
fileNames,
organization.Name,
)
if err != nil {
return fmt.Errorf("cannot render trust center documents access rejected email: %w", err)
}
accessEmail := coredata.NewEmail(
profile.FullName,
profile.EmailAddress,
subject,
textBody,
htmlBody,
&coredata.EmailOptions{
SenderName: new(organization.Name),
},
)
if err := accessEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert access email: %w", err)
}
return nil
}
func extractExistingIDs(accesses coredata.TrustCenterDocumentAccesses) ([]gid.GID, []gid.GID, []gid.GID) {
var documentIDs []gid.GID
var reportIDs []gid.GID
var trustCenterFileIDs []gid.GID
for _, access := range accesses {
if access.DocumentID != nil {
documentIDs = append(documentIDs, *access.DocumentID)
}
if access.ReportID != nil {
reportIDs = append(reportIDs, *access.ReportID)
}
if access.TrustCenterFileID != nil {
trustCenterFileIDs = append(trustCenterFileIDs, *access.TrustCenterFileID)
}
}
return documentIDs, reportIDs, trustCenterFileIDs
}
func filterExistingIDs(allIDs []gid.GID, existingIDs []gid.GID) []gid.GID {
existingMap := make(map[gid.GID]bool)
for _, id := range existingIDs {
existingMap[id] = true
}
var newIDs []gid.GID
for _, id := range allIDs {
if !existingMap[id] {
newIDs = append(newIDs, id)
}
}
return newIDs
}