Extract complianceportal package from trust and probo

Split the compliance portal into an admin-facing management side and a
public-facing visitor side under pkg/complianceportal. Trust-center CRUD,
domains, custom links, frameworks, files and accesses move out of
pkg/probo, and the visitor read logic moves out of pkg/trust. IAM actions
migrate onto compliance-portal scopes.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-10 15:12:51 +02:00
parent 55a8e72c17
commit 44ad34561e
41 changed files with 2102 additions and 1544 deletions

View File

@@ -0,0 +1,432 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package management
import (
"context"
"errors"
"fmt"
"time"
"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/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/validator"
)
type (
CreateAccessRequest struct {
TrustCenterID gid.GID
IdentityID gid.GID
}
UpdateDocumentAccessRequest struct {
ID gid.GID
Status coredata.TrustCenterDocumentAccessStatus
}
UpdateAccessRequest struct {
ID gid.GID
DocumentAccesses []UpdateDocumentAccessRequest
ReportAccesses []UpdateDocumentAccessRequest
TrustCenterFileAccesses []UpdateDocumentAccessRequest
}
AccessData struct {
TrustCenterID gid.GID `json:"trust_center_id"`
Email mail.Addr `json:"email"`
}
)
func (utcar *UpdateAccessRequest) Validate() error {
v := validator.New()
v.Check(utcar.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterAccessEntityType))
for i, docAccess := range utcar.DocumentAccesses {
v.Check(docAccess.ID, fmt.Sprintf("documentAccesses[%d].ID", i), validator.Required(), validator.GID(coredata.DocumentEntityType))
}
for i, reportAccess := range utcar.ReportAccesses {
v.Check(reportAccess.ID, fmt.Sprintf("reportAccesses[%d].ID", i), validator.Required(), validator.GID(coredata.FileEntityType))
}
for i, reportAccess := range utcar.TrustCenterFileAccesses {
v.Check(reportAccess.ID, fmt.Sprintf("trustCenterFileAccesses[%d].ID", i), validator.Required(), validator.GID(coredata.TrustCenterFileEntityType))
}
return v.Error()
}
func (s *Service) ListAccesses(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.TrustCenterAccessOrderField],
) (*page.Page[*coredata.TrustCenterAccess, coredata.TrustCenterAccessOrderField], error) {
var accesses coredata.TrustCenterAccesses
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return accesses.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(accesses, cursor), nil
}
func (s *Service) ListAvailableDocumentAccesses(
ctx context.Context,
scope coredata.Scoper,
trustCenterAccessID gid.GID,
cursor *page.Cursor[coredata.TrustCenterDocumentAccessOrderField],
) (*page.Page[*coredata.TrustCenterDocumentAccess, coredata.TrustCenterDocumentAccessOrderField], error) {
var documentAccesses coredata.TrustCenterDocumentAccesses
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return documentAccesses.LoadAvailableByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(documentAccesses, cursor), nil
}
func (s *Service) GetAccess(
ctx context.Context,
scope coredata.Scoper,
accessID gid.GID,
) (*coredata.TrustCenterAccess, error) {
var access coredata.TrustCenterAccess
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return access.LoadByID(ctx, conn, scope, accessID)
},
)
if err != nil {
return nil, err
}
return &access, nil
}
func (s *Service) CountDocumentAccesses(
ctx context.Context,
scope coredata.Scoper,
trustCenterAccessID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
documentAccesses coredata.TrustCenterDocumentAccesses
err error
)
count, err = documentAccesses.CountByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID)
return err
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) CountPendingRequestDocumentAccesses(
ctx context.Context,
scope coredata.Scoper,
trustCenterAccessID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
documentAccesses coredata.TrustCenterDocumentAccesses
err error
)
count, err = documentAccesses.CountPendingRequestByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID)
return err
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) CountActiveDocumentAccesses(
ctx context.Context,
scope coredata.Scoper,
trustCenterAccessID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
documentAccesses coredata.TrustCenterDocumentAccesses
err error
)
count, err = documentAccesses.CountActiveByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID)
return err
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) UpdateAccess(
ctx context.Context,
scope coredata.Scoper,
req *UpdateAccessRequest,
) (*coredata.TrustCenterAccess, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var (
access *coredata.TrustCenterAccess
trustCenterAcessActivated bool
shouldUpdateSlackMessage bool
)
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
access = &coredata.TrustCenterAccess{}
if err := access.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
var tcdas coredata.TrustCenterDocumentAccesses
if len(req.DocumentAccesses) > 0 {
var documentData []coredata.MergeTrustCenterDocumentAccessesData
documentIDs := make([]gid.GID, 0, len(req.DocumentAccesses))
for _, d := range req.DocumentAccesses {
documentData = append(documentData, coredata.MergeTrustCenterDocumentAccessesData{
ID: d.ID,
Status: d.Status,
})
documentIDs = append(documentIDs, d.ID)
}
documents := &coredata.Documents{}
if err := documents.LoadByIDs(ctx, tx, scope, documentIDs); err != nil {
return fmt.Errorf("cannot load documents: %w", err)
}
if err := tcdas.MergeDocumentAccesses(ctx, tx, scope, access.OrganizationID, access.ID, documentData); err != nil {
return fmt.Errorf("cannot merge document accesses: %w", err)
}
}
if len(req.ReportAccesses) > 0 {
var reportData []coredata.MergeTrustCenterDocumentAccessesData
reportIDs := make([]gid.GID, 0, len(req.ReportAccesses))
for _, d := range req.ReportAccesses {
reportData = append(reportData, coredata.MergeTrustCenterDocumentAccessesData{
ID: d.ID,
Status: d.Status,
})
reportIDs = append(reportIDs, d.ID)
}
files := &coredata.Files{}
if err := files.LoadByIDs(ctx, tx, scope, reportIDs); err != nil {
return fmt.Errorf("cannot load report files: %w", err)
}
if err := tcdas.MergeReportFileAccesses(ctx, tx, scope, access.OrganizationID, access.ID, reportData); err != nil {
return fmt.Errorf("cannot merge report accesses: %w", err)
}
}
if len(req.TrustCenterFileAccesses) > 0 {
var fileData []coredata.MergeTrustCenterDocumentAccessesData
trustCenterFileIDs := make([]gid.GID, 0, len(req.TrustCenterFileAccesses))
for _, d := range req.TrustCenterFileAccesses {
fileData = append(fileData, coredata.MergeTrustCenterDocumentAccessesData{
ID: d.ID,
Status: d.Status,
})
trustCenterFileIDs = append(trustCenterFileIDs, d.ID)
}
trustCenterFiles := &coredata.TrustCenterFiles{}
if err := trustCenterFiles.LoadByIDs(ctx, tx, scope, trustCenterFileIDs); err != nil {
return fmt.Errorf("cannot load trust center files: %w", err)
}
if err := tcdas.MergeTrustCenterFileAccesses(ctx, tx, scope, access.OrganizationID, access.ID, fileData); err != nil {
return fmt.Errorf("cannot merge trust center file accesses: %w", err)
}
}
if trustCenterAcessActivated {
if err := s.sendAccessEmail(ctx, scope, tx, access); err != nil {
return fmt.Errorf("cannot send access email: %w", err)
}
}
shouldUpdateSlackMessage = trustCenterAcessActivated ||
len(req.DocumentAccesses) > 0 ||
len(req.ReportAccesses) > 0 ||
len(req.TrustCenterFileAccesses) > 0
return nil
},
)
if err != nil {
return nil, err
}
if shouldUpdateSlackMessage {
if err := s.SlackMessages.QueueSlackNotification(ctx, scope, access.IdentityID, access.TrustCenterID); err != nil {
if !errors.Is(err, slack.ErrNoSlackConnector) {
return nil, fmt.Errorf("cannot queue slack notification: %w", err)
}
}
}
return access, nil
}
func (s *Service) DeleteAccess(
ctx context.Context,
scope coredata.Scoper,
trustCenterAccessID gid.GID,
) error {
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
access := &coredata.TrustCenterAccess{}
if err := access.LoadByID(ctx, tx, scope, trustCenterAccessID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
if err := access.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete trust center access: %w", err)
}
return nil
},
)
return err
}
func (s *Service) sendAccessEmail(
ctx context.Context,
scope coredata.Scoper,
tx pg.Tx,
access *coredata.TrustCenterAccess,
) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, 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, scope); err != nil {
return fmt.Errorf("cannot update trust center access with expiration: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(
ctx,
tx,
scope,
access.IdentityID,
access.OrganizationID,
); err != nil {
return fmt.Errorf("cannot load profile: %w", err)
}
emailPresenterCfg, err := s.EmailPresenterConfig(ctx, scope, access.TrustCenterID)
if err != nil {
return fmt.Errorf("cannot get compliance page email presenter config: %w", err)
}
emailPresenter := emails.NewPresenterFromConfig(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
}

View File

@@ -0,0 +1,219 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package management
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
CreateCustomLinkRequest struct {
TrustCenterID gid.GID
Name string
URL string
}
UpdateCustomLinkRequest struct {
ID gid.GID
Name string
URL string
Rank *int
}
DeleteCustomLinkRequest struct {
ID gid.GID
}
)
func (r *CreateCustomLinkRequest) Validate() error {
v := validator.New()
v.Check(r.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(r.URL, "url", validator.Required(), validator.URL())
return v.Error()
}
func (r *UpdateCustomLinkRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceCustomLinkEntityType))
v.Check(r.URL, "url", validator.Required(), validator.URL())
v.Check(r.Rank, "rank", validator.Min(1))
return v.Error()
}
func (r *DeleteCustomLinkRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceCustomLinkEntityType))
return v.Error()
}
func (s *Service) ListCustomLinks(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.ComplianceCustomLinkOrderField],
) (*page.Page[*coredata.ComplianceCustomLink, coredata.ComplianceCustomLinkOrderField], error) {
var items coredata.ComplianceCustomLinks
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := items.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor); err != nil {
return fmt.Errorf("cannot load custom links: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(items, cursor), nil
}
func (s *Service) CreateCustomLink(
ctx context.Context,
scope coredata.Scoper,
req *CreateCustomLinkRequest,
) (*coredata.ComplianceCustomLink, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
id := gid.New(scope.GetTenantID(), coredata.ComplianceCustomLinkEntityType)
var item *coredata.ComplianceCustomLink
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
item = &coredata.ComplianceCustomLink{
ID: id,
OrganizationID: trustCenter.OrganizationID,
TrustCenterID: req.TrustCenterID,
Name: req.Name,
URL: req.URL,
CreatedAt: now,
UpdatedAt: now,
}
if err := item.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert custom link: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return item, nil
}
func (s *Service) UpdateCustomLink(
ctx context.Context,
scope coredata.Scoper,
req *UpdateCustomLinkRequest,
) (*coredata.ComplianceCustomLink, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var item *coredata.ComplianceCustomLink
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
item = &coredata.ComplianceCustomLink{}
if err := item.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load custom link: %w", err)
}
item.Name = req.Name
item.URL = req.URL
item.UpdatedAt = time.Now()
if req.Rank != nil {
item.Rank = *req.Rank
if err := item.UpdateRank(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update custom link rank: %w", err)
}
}
if err := item.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update custom link: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return item, nil
}
func (s *Service) DeleteCustomLink(
ctx context.Context,
scope coredata.Scoper,
req *DeleteCustomLinkRequest,
) error {
if err := req.Validate(); err != nil {
return err
}
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
item := &coredata.ComplianceCustomLink{}
if err := item.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load custom link: %w", err)
}
if err := item.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete custom link: %w", err)
}
return nil
},
)
}

View File

@@ -0,0 +1,327 @@
// 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 management
import (
"context"
"errors"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/complianceportal"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
)
// The compliance portal service owns the relationship between a compliance
// page (trust center) and its domains. A page has two slots stored on the
// trust center row: a default {slug}.probopage.com domain provided by Probo and
// an optional custom domain. It provisions each domain's TLS certificate
// through the generic certmanager service within the trust center's transaction
// so slot changes stay atomic with the page.
// ErrCustomDomainSlotTaken is returned when a compliance page already has a
// custom domain and another one is added.
var ErrCustomDomainSlotTaken = errors.New("compliance page already has a custom domain")
// AddCustomDomain provisions the compliance page's custom domain. It fails
// when the page already has one. The default probopage subdomain, provisioned
// at page creation, keeps serving as a fallback while the new certificate
// provisions.
func (s *Service) AddCustomDomain(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
domain string,
) (*coredata.CustomDomain, error) {
v := validator.New()
v.Check(compliancePageID, "compliance_page_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(domain, "domain", validator.Required(), validator.NotEmpty(), validator.Domain())
if err := v.Error(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var customDomain *coredata.CustomDomain
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.CustomDomainID != nil {
return ErrCustomDomainSlotTaken
}
certificate, err := s.certManager.EnsureCertificate(ctx, tx, scope, domain)
if err != nil {
return fmt.Errorf("cannot ensure certificate: %w", err)
}
customDomain = coredata.NewCustomDomain(
scope.GetTenantID(),
trustCenter.OrganizationID,
domain,
false,
)
customDomain.CertificateID = &certificate.ID
if err := customDomain.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert custom domain: %w", err)
}
trustCenter.CustomDomainID = &customDomain.ID
trustCenter.UpdatedAt = time.Now()
if err := trustCenter.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return customDomain, nil
}
// RemoveCustomDomain clears the compliance page's custom domain and deletes
// the underlying domain together with its certificate. The default domain
// cannot be removed.
func (s *Service) RemoveCustomDomain(
ctx context.Context,
scope coredata.Scoper,
customDomainID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
domain := &coredata.CustomDomain{}
if err := domain.LoadByID(ctx, tx, scope, customDomainID); err != nil {
return fmt.Errorf("cannot load custom domain: %w", err)
}
if domain.Managed {
return complianceportal.ErrCustomDomainManaged
}
trustCenter := &coredata.TrustCenter{}
err := trustCenter.LoadByDomainID(ctx, tx, customDomainID)
switch {
case err == nil:
if trustCenter.CustomDomainID != nil && *trustCenter.CustomDomainID == customDomainID {
trustCenter.CustomDomainID = nil
trustCenter.UpdatedAt = time.Now()
if err := trustCenter.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
}
case errors.Is(err, coredata.ErrResourceNotFound):
default:
return fmt.Errorf("cannot load trust center by domain id: %w", err)
}
if err := domain.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete custom domain: %w", err)
}
if domain.CertificateID != nil {
if err := s.certManager.Delete(ctx, tx, scope, *domain.CertificateID); err != nil {
return fmt.Errorf("cannot delete certificate: %w", err)
}
}
return nil
},
)
}
// GetCertificate returns the certificate backing a custom domain, or nil when
// the domain has no certificate yet.
func (s *Service) GetCertificate(
ctx context.Context,
scope coredata.Scoper,
domain *coredata.CustomDomain,
) (*coredata.Certificate, error) {
if domain == nil || domain.CertificateID == nil {
return nil, nil
}
return s.certManager.Get(ctx, scope, *domain.CertificateID)
}
// GetDefaultDomain returns the compliance page's default probopage subdomain,
// or nil when it has not been provisioned yet.
func (s *Service) GetDefaultDomain(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
) (*coredata.CustomDomain, error) {
return s.domainSlot(ctx, scope, compliancePageID, func(tc *coredata.TrustCenter) *gid.GID {
return tc.DefaultDomainID
},
)
}
// GetCustomDomain returns the compliance page's custom domain, or nil when
// none is configured.
func (s *Service) GetCustomDomain(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
) (*coredata.CustomDomain, error) {
return s.domainSlot(ctx, scope, compliancePageID, func(tc *coredata.TrustCenter) *gid.GID {
return tc.CustomDomainID
},
)
}
func (s *Service) domainSlot(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
slot func(*coredata.TrustCenter) *gid.GID,
) (*coredata.CustomDomain, error) {
var domain *coredata.CustomDomain
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)
}
domainID := slot(trustCenter)
if domainID == nil {
return nil
}
loaded := &coredata.CustomDomain{}
if err := loaded.LoadByID(ctx, conn, scope, *domainID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil
}
return fmt.Errorf("cannot load custom domain: %w", err)
}
domain = loaded
return nil
},
)
if err != nil {
return nil, err
}
return domain, nil
}
// EffectiveDomain returns the domain a compliance page is served under: the
// custom domain when it has an active certificate, otherwise the default
// subdomain when its certificate is active. It returns nil when no serving
// domain is available yet.
func (s *Service) EffectiveDomain(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
) (*coredata.CustomDomain, error) {
var effective *coredata.CustomDomain
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)
}
d, err := complianceportal.EffectiveDomainForTrustCenter(ctx, conn, scope, trustCenter)
if err != nil {
return err
}
effective = d
return nil
},
)
if err != nil {
return nil, err
}
return effective, nil
}
// EffectiveCanonicalHost returns the host a compliance page should be served
// under, or an empty string when no serving host is available yet.
func (s *Service) EffectiveCanonicalHost(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
) (string, error) {
domain, err := s.EffectiveDomain(ctx, scope, compliancePageID)
if err != nil {
return "", err
}
if domain == nil {
return "", nil
}
return domain.Domain, nil
}
// PublicURL returns the canonical public URL of a compliance page on its
// dedicated domain.
func (s *Service) PublicURL(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
) (string, error) {
var publicURL string
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)
}
url, err := complianceportal.PublicURLForTrustCenter(ctx, conn, scope, trustCenter, s.baseDomain)
if err != nil {
return err
}
publicURL = url
return nil
},
)
if err != nil {
return "", err
}
return publicURL, nil
}

View File

@@ -0,0 +1,454 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package management
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
CreateFileRequest struct {
OrganizationID gid.GID
Name string
Category string
File File
TrustCenterVisibility coredata.TrustCenterVisibility
}
UpdateFileRequest struct {
ID gid.GID
Name *string
Category *string
TrustCenterVisibility *coredata.TrustCenterVisibility
}
)
func (ctcfr *CreateFileRequest) Validate() error {
v := validator.New()
v.Check(ctcfr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(ctcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctcfr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ctcfr.File, "file", validator.Required())
v.Check(ctcfr.TrustCenterVisibility, "trust_center_visibility", validator.Required(), validator.OneOfSlice(coredata.TrustCenterVisibilities()))
return v.Error()
}
func (utcfr *UpdateFileRequest) Validate() error {
v := validator.New()
v.Check(utcfr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterFileEntityType))
v.Check(utcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utcfr.Category, "category", validator.SafeText(TitleMaxLength))
v.Check(utcfr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
return v.Error()
}
func (s *Service) ListFilesForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
cursor *page.Cursor[coredata.TrustCenterFileOrderField],
filter *coredata.TrustCenterFileFilter,
) (*page.Page[*coredata.TrustCenterFile, coredata.TrustCenterFileOrderField], error) {
var files coredata.TrustCenterFiles
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := files.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil {
return fmt.Errorf("cannot load trust center files: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return page.NewPage(files, cursor), nil
}
func (s *Service) CountFilesForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
count, err = (&coredata.TrustCenterFiles{}).CountByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count trust center files: %w", err)
}
return nil
})
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) GetFile(
ctx context.Context,
scope coredata.Scoper,
id gid.GID,
) (*coredata.TrustCenterFile, error) {
var file *coredata.TrustCenterFile
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
file = &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, conn, scope, id); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return file, nil
}
func (s *Service) CreateFile(
ctx context.Context,
scope coredata.Scoper,
req *CreateFileRequest,
) (*coredata.TrustCenterFile, error) {
if err := req.Validate(); err != nil {
return nil, err
}
// Validate file
filename := req.File.Filename
contentType := req.File.ContentType
fileSize, err := filemanager.GetFileSize(req.File.Content)
if err != nil {
return nil, fmt.Errorf("cannot get file size: %w", err)
}
if err := s.fileValidator.Validate(filename, contentType, fileSize); err != nil {
return nil, err
}
now := time.Now()
trustCenterFileID := gid.New(scope.GetTenantID(), coredata.TrustCenterFileEntityType)
var (
file *coredata.TrustCenterFile
s3Key string
)
err = s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
fileID, objectKey, err := s.uploadFile(ctx, scope, tx, req.File, trustCenterFileID, req.OrganizationID, now)
if err != nil {
return fmt.Errorf("cannot upload file: %w", err)
}
s3Key = objectKey
file = &coredata.TrustCenterFile{
ID: trustCenterFileID,
OrganizationID: req.OrganizationID,
Name: req.Name,
Category: req.Category,
FileID: fileID,
TrustCenterVisibility: req.TrustCenterVisibility,
CreatedAt: now,
UpdatedAt: now,
}
if err := file.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert trust center file: %w", err)
}
return nil
},
)
if err != nil {
s.cleanupFileS3Object(ctx, scope, s3Key)
return nil, err
}
return file, nil
}
func (s *Service) UpdateFile(
ctx context.Context,
scope coredata.Scoper,
req *UpdateFileRequest,
) (*coredata.TrustCenterFile, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
var file *coredata.TrustCenterFile
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
file = &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
if req.Name != nil {
file.Name = *req.Name
}
if req.Category != nil {
file.Category = *req.Category
}
if req.TrustCenterVisibility != nil {
file.TrustCenterVisibility = *req.TrustCenterVisibility
}
file.UpdatedAt = now
if err := file.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update trust center file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return file, nil
}
func (s *Service) DeleteFile(
ctx context.Context,
scope coredata.Scoper,
trustCenterFileID gid.GID,
) error {
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
file := &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, tx, scope, trustCenterFileID); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
if err := file.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete trust center file: %w", err)
}
return nil
})
return err
}
func (s *Service) GenerateFileURL(
ctx context.Context,
scope coredata.Scoper,
trustCenterFileID gid.GID,
duration time.Duration,
) (string, error) {
var storedFile *coredata.File
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
file := &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, conn, scope, trustCenterFileID); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
storedFile = &coredata.File{}
if err := storedFile.LoadByID(ctx, conn, scope, file.FileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
fileURL, err := s.fileManager.GeneratePresignedURL(ctx, storedFile, duration)
if err != nil {
return "", fmt.Errorf("cannot generate file URL: %w", err)
}
return fileURL, nil
}
func (s *Service) uploadFile(
ctx context.Context,
scope coredata.Scoper,
tx pg.Tx,
file File,
trustCenterFileID gid.GID,
organizationID gid.GID,
now time.Time,
) (gid.GID, string, error) {
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
objectKey, err := uuid.NewV7()
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot generate object key: %w", err)
}
var (
fileSize int64
fileContent io.ReadSeeker
)
filename := file.Filename
contentType := file.ContentType
if readSeeker, ok := file.Content.(io.ReadSeeker); ok {
if file.Size <= 0 {
size, err := readSeeker.Seek(0, io.SeekEnd)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot determine file size: %w", err)
}
fileSize = size
_, err = readSeeker.Seek(0, io.SeekStart)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot reset file position: %w", err)
}
} else {
fileSize = file.Size
}
fileContent = readSeeker
} else {
buf, err := io.ReadAll(file.Content)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot read file: %w", err)
}
fileSize = int64(len(buf))
fileContent = bytes.NewReader(buf)
}
if contentType == "" {
contentType = "application/octet-stream"
if filename != "" {
if detectedType := mime.TypeByExtension(filepath.Ext(filename)); detectedType != "" {
contentType = detectedType
}
}
}
_, err = s.s3.PutObject(
ctx,
&s3.PutObjectInput{
Bucket: new(s.bucket),
Key: new(objectKey.String()),
Body: fileContent,
ContentType: new(contentType),
CacheControl: new("private, max-age=3600"),
Metadata: map[string]string{
"type": "trust-center-file",
"trust-center-file-id": trustCenterFileID.String(),
"organization-id": organizationID.String(),
},
},
)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot upload file to S3: %w", err)
}
fileRecord := &coredata.File{
ID: fileID,
OrganizationID: organizationID,
BucketName: s.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: fileSize,
Visibility: coredata.FileVisibilityPrivate,
CreatedAt: now,
UpdatedAt: now,
}
if err := fileRecord.Insert(ctx, tx, scope); err != nil {
return gid.GID{}, "", fmt.Errorf("cannot insert file: %w", err)
}
return fileID, objectKey.String(), nil
}
func (s *Service) cleanupFileS3Object(
ctx context.Context,
scope coredata.Scoper,
s3Key string,
) {
if s3Key == "" {
return
}
_, _ = s.s3.DeleteObject(
ctx,
&s3.DeleteObjectInput{
Bucket: new(s.bucket),
Key: new(s3Key),
},
)
}

View File

@@ -0,0 +1,214 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package management
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
CreateFrameworkRequest struct {
TrustCenterID gid.GID
FrameworkID gid.GID
}
UpdateFrameworkRequest struct {
ID gid.GID
Rank int
}
DeleteFrameworkRequest struct {
ID gid.GID
}
)
func (r *CreateFrameworkRequest) Validate() error {
v := validator.New()
v.Check(r.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(r.FrameworkID, "framework_id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
return v.Error()
}
func (r *UpdateFrameworkRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceFrameworkEntityType))
return v.Error()
}
func (r *DeleteFrameworkRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceFrameworkEntityType))
return v.Error()
}
func (s *Service) ListFrameworksWithHidden(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.ComplianceFrameworkOrderField],
) (*page.Page[*coredata.ComplianceFramework, coredata.ComplianceFrameworkOrderField], error) {
var cfs coredata.ComplianceFrameworks
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := cfs.LoadWithHiddenByTrustCenterID(ctx, conn, scope, trustCenterID, cursor); err != nil {
return fmt.Errorf("cannot load frameworks with hidden: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(cfs, cursor), nil
}
func (s *Service) CreateFramework(
ctx context.Context,
scope coredata.Scoper,
req *CreateFrameworkRequest,
) (*coredata.ComplianceFramework, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
cfID := gid.New(scope.GetTenantID(), coredata.ComplianceFrameworkEntityType)
var cf *coredata.ComplianceFramework
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
framework := &coredata.Framework{}
if err := framework.LoadByID(ctx, tx, scope, req.FrameworkID); err != nil {
return fmt.Errorf("cannot load framework: %w", err)
}
cf = &coredata.ComplianceFramework{
ID: cfID,
OrganizationID: trustCenter.OrganizationID,
TrustCenterID: req.TrustCenterID,
FrameworkID: req.FrameworkID,
CreatedAt: now,
UpdatedAt: now,
}
if err := cf.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert framework: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return cf, nil
}
func (s *Service) UpdateFramework(
ctx context.Context,
scope coredata.Scoper,
req *UpdateFrameworkRequest,
) (*coredata.ComplianceFramework, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var cf *coredata.ComplianceFramework
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
cf = &coredata.ComplianceFramework{}
if err := cf.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load framework: %w", err)
}
cf.Rank = req.Rank
cf.UpdatedAt = time.Now()
if err := cf.UpdateRank(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update framework rank: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return cf, nil
}
func (s *Service) DeleteFramework(
ctx context.Context,
scope coredata.Scoper,
req *DeleteFrameworkRequest,
) error {
if err := req.Validate(); err != nil {
return err
}
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
cf := &coredata.ComplianceFramework{}
if err := cf.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load framework: %w", err)
}
if err := cf.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete framework: %w", err)
}
return nil
},
)
}

View File

@@ -0,0 +1,759 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package management
import (
"context"
"fmt"
"io"
"mime"
"net/mail"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/complianceportal"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filevalidation"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
)
type (
UpdateRequest struct {
ID gid.GID
Active *bool
Slug *string
SearchEngineIndexing *coredata.SearchEngineIndexing
NonDisclosureAgreementFileID *gid.GID
Description **string
WebsiteURL **string
Email **string
HeadquarterAddress **string
}
UploadNDARequest struct {
TrustCenterID gid.GID
File io.Reader
FileName string
}
UpdateBrandRequest struct {
TrustCenterID gid.GID
LogoFile **FileUpload
DarkLogoFile **FileUpload
}
)
const maxBrandFileSize = 5 * 1024 * 1024 // 5MB
func (utcr *UpdateRequest) Validate() error {
v := validator.New()
v.Check(utcr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(utcr.Slug, "slug", validator.SafeText(NameMaxLength))
v.Check(utcr.NonDisclosureAgreementFileID, "non_disclosure_agreement_file_id", validator.GID(coredata.FileEntityType))
if utcr.Description != nil {
v.Check(*utcr.Description, "description", validator.SafeText(ContentMaxLength))
}
if utcr.WebsiteURL != nil {
v.Check(*utcr.WebsiteURL, "website_url", validator.SafeText(2048))
}
if utcr.Email != nil {
v.Check(*utcr.Email, "email", validator.SafeText(255))
}
if utcr.HeadquarterAddress != nil {
v.Check(*utcr.HeadquarterAddress, "headquarter_address", validator.SafeText(2048))
}
return v.Error()
}
func (utcndar *UploadNDARequest) Validate() error {
v := validator.New()
v.Check(utcndar.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(utcndar.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
return v.Error()
}
func (req *UpdateBrandRequest) Validate() error {
fv := filevalidation.NewValidator(
filevalidation.WithCategories(filevalidation.CategoryImage),
filevalidation.WithMaxFileSize(maxBrandFileSize),
)
if req.LogoFile != nil && *req.LogoFile != nil {
logoFile := *req.LogoFile
if err := fv.Validate(logoFile.Filename, logoFile.ContentType, logoFile.Size); err != nil {
return fmt.Errorf("invalid logo file: %w", err)
}
}
if req.DarkLogoFile != nil && *req.DarkLogoFile != nil {
darkLogoFile := *req.DarkLogoFile
if err := fv.Validate(darkLogoFile.Filename, darkLogoFile.ContentType, darkLogoFile.Size); err != nil {
return fmt.Errorf("invalid dark logo file: %w", err)
}
}
return nil
}
func (s *Service) Get(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
) (*coredata.TrustCenter, error) {
var trustCenter *coredata.TrustCenter
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot load trust center: %w", err)
}
return trustCenter, nil
}
func (s *Service) GetByOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
) (*coredata.TrustCenter, error) {
var trustCenter *coredata.TrustCenter
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByOrganizationID(ctx, conn, scope, organizationID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return trustCenter, nil
}
func (s *Service) Update(
ctx context.Context,
scope coredata.Scoper,
req *UpdateRequest,
) (*coredata.TrustCenter, *coredata.File, error) {
if err := req.Validate(); err != nil {
return nil, nil, err
}
var (
trustCenter *coredata.TrustCenter
file *coredata.File
)
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if req.Active != nil {
trustCenter.Active = *req.Active
}
if req.Slug != nil {
trustCenter.Slug = *req.Slug
}
if req.SearchEngineIndexing != nil {
trustCenter.SearchEngineIndexing = *req.SearchEngineIndexing
}
if req.Description != nil {
trustCenter.Description = *req.Description
}
if req.WebsiteURL != nil {
trustCenter.WebsiteURL = *req.WebsiteURL
}
if req.Email != nil {
if *req.Email != nil {
if _, err := mail.ParseAddress(**req.Email); err != nil {
return fmt.Errorf("invalid email address: %w", err)
}
}
trustCenter.Email = *req.Email
}
if req.HeadquarterAddress != nil {
trustCenter.HeadquarterAddress = *req.HeadquarterAddress
}
trustCenter.UpdatedAt = time.Now()
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID != nil {
file = &coredata.File{}
if err := file.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, file, nil
}
func (s *Service) UploadNDA(
ctx context.Context,
scope coredata.Scoper,
req *UploadNDARequest,
) (*coredata.TrustCenter, *coredata.File, error) {
if err := req.Validate(); err != nil {
return nil, nil, err
}
var (
trustCenter *coredata.TrustCenter
file *coredata.File
)
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.OrganizationID == gid.Nil {
return fmt.Errorf("trust center %s has no organization", req.TrustCenterID)
}
objectKey, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("cannot generate object key: %w", err)
}
mimeType := mime.TypeByExtension(filepath.Ext(req.FileName))
if mimeType == "" {
mimeType = "application/octet-stream"
}
now := time.Now()
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
file = &coredata.File{
ID: fileID,
OrganizationID: trustCenter.OrganizationID,
BucketName: s.bucket,
MimeType: mimeType,
FileName: req.FileName,
FileKey: objectKey.String(),
Visibility: coredata.FileVisibilityPrivate,
CreatedAt: now,
UpdatedAt: now,
}
fileSize, err := s.fileManager.PutFile(
ctx,
file,
req.File,
map[string]string{
"type": "trust-center-nda",
"trust-center-id": req.TrustCenterID.String(),
"organization-id": trustCenter.OrganizationID.String(),
},
)
if err != nil {
return fmt.Errorf("cannot upload file to S3: %w", err)
}
file.FileSize = fileSize
if err := file.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert file: %w", err)
}
trustCenter.NonDisclosureAgreementFileID = &fileID
trustCenter.UpdatedAt = now
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, file, nil
}
func (s *Service) DeleteNDA(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
) (*coredata.TrustCenter, *coredata.File, error) {
var trustCenter *coredata.TrustCenter
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
trustCenter.NonDisclosureAgreementFileID = nil
trustCenter.UpdatedAt = time.Now()
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, nil, nil
}
func (s *Service) UpdateBrand(
ctx context.Context,
scope coredata.Scoper,
req *UpdateBrandRequest,
) (*coredata.TrustCenter, *coredata.File, error) {
if err := req.Validate(); err != nil {
return nil, nil, err
}
var (
trustCenter *coredata.TrustCenter
ndaFile *coredata.File
)
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
now := time.Now()
if req.LogoFile != nil {
if *req.LogoFile == nil {
trustCenter.LogoFileID = nil
} else {
file, err := s.uploadBrandFile(ctx, scope, conn, *req.LogoFile, "trust-center-logo", trustCenter)
if err != nil {
return fmt.Errorf("cannot upload logo file: %w", err)
}
trustCenter.LogoFileID = &file.ID
}
}
if req.DarkLogoFile != nil {
if *req.DarkLogoFile == nil {
trustCenter.DarkLogoFileID = nil
} else {
file, err := s.uploadBrandFile(ctx, scope, conn, *req.DarkLogoFile, "trust-center-dark-logo", trustCenter)
if err != nil {
return fmt.Errorf("cannot upload dark logo file: %w", err)
}
trustCenter.DarkLogoFileID = &file.ID
}
}
trustCenter.UpdatedAt = now
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID != nil {
ndaFile = &coredata.File{}
if err := ndaFile.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
return fmt.Errorf("cannot load nda file: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, ndaFile, nil
}
func (s *Service) uploadBrandFile(
ctx context.Context,
scope coredata.Scoper,
conn pg.Tx,
fileUpload *FileUpload,
fileType string,
trustCenter *coredata.TrustCenter,
) (*coredata.File, error) {
objectKey, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("cannot generate object key: %w", err)
}
mimeType := fileUpload.ContentType
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(fileUpload.Filename))
}
_, err = s.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: new(objectKey.String()),
Body: fileUpload.Content,
ContentType: &mimeType,
CacheControl: new("max-age=3600, public"),
Metadata: map[string]string{
"type": fileType,
"trust-center-id": trustCenter.ID.String(),
"organization-id": trustCenter.OrganizationID.String(),
},
})
if err != nil {
return nil, fmt.Errorf("cannot upload file to S3: %w", err)
}
headOutput, err := s.s3.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: new(s.bucket),
Key: new(objectKey.String()),
})
if err != nil {
return nil, fmt.Errorf("cannot get object metadata: %w", err)
}
now := time.Now()
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
file := &coredata.File{
ID: fileID,
OrganizationID: trustCenter.OrganizationID,
BucketName: s.bucket,
MimeType: mimeType,
FileName: fileUpload.Filename,
FileKey: objectKey.String(),
FileSize: *headOutput.ContentLength,
Visibility: coredata.FileVisibilityPublic,
CreatedAt: now,
UpdatedAt: now,
}
if err := file.Insert(ctx, conn, scope); err != nil {
return nil, fmt.Errorf("cannot insert file: %w", err)
}
return file, nil
}
func (s *Service) GenerateNDAFileURL(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
expiresIn time.Duration,
) (*string, error) {
var file *coredata.File
trustCenter := &coredata.TrustCenter{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID == nil {
return nil
}
file = &coredata.File{}
if err := file.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if trustCenter.NonDisclosureAgreementFileID == nil {
return nil, nil
}
presignedURL, err := s.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, nil
}
func (s *Service) GenerateLogoURL(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
expiresIn time.Duration,
) (*string, error) {
file := &coredata.File{}
compliancePage := &coredata.TrustCenter{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.LogoFileID == nil {
return nil
}
if err := file.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if compliancePage.LogoFileID == nil {
return nil, nil
}
if file.FileKey == "" {
return nil, nil
}
presignedURL, err := s.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, nil
}
func (s *Service) GenerateDarkLogoURL(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
expiresIn time.Duration,
) (*string, error) {
file := &coredata.File{}
compliancePage := &coredata.TrustCenter{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.DarkLogoFileID == nil {
return nil
}
if err := file.LoadByID(ctx, conn, scope, *compliancePage.DarkLogoFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if compliancePage.DarkLogoFileID == nil {
return nil, nil
}
if file.FileKey == "" {
return nil, nil
}
presignedURL, err := s.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, nil
}
func (s *Service) EmailPresenterConfig(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
) (emails.PresenterConfig, error) {
var (
compliancePage = &coredata.TrustCenter{}
organization = &coredata.Organization{}
logoFile = &coredata.File{}
compliancePageURL string
emailPresenterCfg = emails.DefaultPresenterConfig(s.baseURL)
)
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.LogoFileID != nil {
if err := logoFile.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil {
return fmt.Errorf("cannot load logoFile: %w", err)
}
}
if err := organization.LoadByID(ctx, conn, scope, compliancePage.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
publicURL, err := complianceportal.PublicURLForTrustCenter(
ctx,
conn,
scope,
compliancePage,
s.baseDomain,
)
if err != nil {
return fmt.Errorf("cannot resolve compliance page URL: %w", err)
}
compliancePageURL = publicURL
return nil
},
)
if err != nil {
return emailPresenterCfg, err
}
emailPresenterCfg.BaseURL = compliancePageURL
if compliancePage.LogoFileID != nil {
if logoFile.FileKey == "" {
return emailPresenterCfg, nil
}
emailPresenterCfg.SenderCompanyLogoPath = filepath.Join("/api/files/v1/public/", logoFile.ID.String())
emailPresenterCfg.SenderCompanyName = organization.Name
if compliancePage.WebsiteURL != nil {
emailPresenterCfg.SenderCompanyWebsiteURL = *compliancePage.WebsiteURL
}
if compliancePage.HeadquarterAddress != nil {
emailPresenterCfg.SenderCompanyHeadquarterAddress = *compliancePage.HeadquarterAddress
}
}
return emailPresenterCfg, nil
}
func (s *Service) GetMailingList(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
) (*coredata.MailingList, error) {
var mailingList *coredata.MailingList
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.MailingListID == nil {
return nil
}
mailingList = &coredata.MailingList{}
if err := mailingList.LoadByID(ctx, conn, scope, *trustCenter.MailingListID); err != nil {
return fmt.Errorf("cannot load mailing list: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return mailingList, nil
}

View File

@@ -0,0 +1,467 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package management
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
CreateReferenceRequest struct {
TrustCenterID gid.GID
Name string
Description *string
WebsiteURL string
LogoFile File
}
UpdateReferenceRequest struct {
ID gid.GID
Name *string
Description **string
WebsiteURL *string
LogoFile *File
Rank *int
}
)
func (ctcrr *CreateReferenceRequest) Validate() error {
v := validator.New()
v.Check(ctcrr.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(ctcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctcrr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(ctcrr.WebsiteURL, "website_url", validator.Required(), validator.SafeText(2048))
return v.Error()
}
func (utcrr *UpdateReferenceRequest) Validate() error {
v := validator.New()
v.Check(utcrr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterReferenceEntityType))
v.Check(utcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utcrr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(utcrr.WebsiteURL, "website_url", validator.SafeText(2048))
return v.Error()
}
func (s *Service) ListReferences(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.TrustCenterReferenceOrderField],
) (*page.Page[*coredata.TrustCenterReference, coredata.TrustCenterReferenceOrderField], error) {
var references coredata.TrustCenterReferences
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
err := references.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor)
if err != nil {
return fmt.Errorf("cannot load trust center references: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(references, cursor), nil
}
func (s *Service) CountReferences(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
references := coredata.TrustCenterReferences{}
count, err = references.CountByTrustCenterID(ctx, conn, scope, trustCenterID)
if err != nil {
return fmt.Errorf("cannot count trust center references: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) GetReference(
ctx context.Context,
scope coredata.Scoper,
referenceID gid.GID,
) (*coredata.TrustCenterReference, error) {
var reference coredata.TrustCenterReference
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
err := reference.LoadByID(ctx, conn, scope, referenceID)
if err != nil {
return fmt.Errorf("cannot load trust center reference: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &reference, nil
}
func (s *Service) CreateReference(
ctx context.Context,
scope coredata.Scoper,
req *CreateReferenceRequest,
) (*coredata.TrustCenterReference, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
referenceID := gid.New(scope.GetTenantID(), coredata.TrustCenterReferenceEntityType)
var reference *coredata.TrustCenterReference
var logoKey string
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
fileID, s3Key, err := s.uploadReferenceLogoFile(ctx, scope, tx, req.LogoFile, referenceID, req.TrustCenterID, now)
if err != nil {
return fmt.Errorf("cannot upload logo file: %w", err)
}
logoKey = s3Key
reference = &coredata.TrustCenterReference{
ID: referenceID,
OrganizationID: trustCenter.OrganizationID,
TrustCenterID: req.TrustCenterID,
Name: req.Name,
Description: req.Description,
WebsiteURL: req.WebsiteURL,
LogoFileID: fileID,
CreatedAt: now,
UpdatedAt: now,
}
if err := reference.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert trust center reference: %w", err)
}
return nil
},
)
if err != nil {
s.cleanupReferenceS3Object(ctx, scope, logoKey)
return nil, err
}
return reference, nil
}
func (s *Service) UpdateReference(
ctx context.Context,
scope coredata.Scoper,
req *UpdateReferenceRequest,
) (*coredata.TrustCenterReference, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
var (
reference *coredata.TrustCenterReference
newFileID *gid.GID
logoKey string
)
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
reference = &coredata.TrustCenterReference{}
if err := reference.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center reference: %w", err)
}
if req.LogoFile != nil {
fileID, s3Key, err := s.uploadReferenceLogoFile(ctx, scope, tx, *req.LogoFile, req.ID, reference.TrustCenterID, now)
if err != nil {
return fmt.Errorf("cannot upload logo file: %w", err)
}
newFileID = &fileID
logoKey = s3Key
}
if req.Name != nil {
reference.Name = *req.Name
}
if req.Description != nil {
reference.Description = *req.Description
}
if req.WebsiteURL != nil {
reference.WebsiteURL = *req.WebsiteURL
}
if newFileID != nil {
reference.LogoFileID = *newFileID
}
reference.UpdatedAt = now
if req.Rank != nil {
reference.Rank = *req.Rank
if err := reference.UpdateRank(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update rank: %w", err)
}
}
if err := reference.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update trust center reference: %w", err)
}
return nil
},
)
if err != nil {
s.cleanupReferenceS3Object(ctx, scope, logoKey)
return nil, err
}
return reference, nil
}
func (s *Service) DeleteReference(
ctx context.Context,
scope coredata.Scoper,
trustCenterReferenceID gid.GID,
) error {
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
reference := &coredata.TrustCenterReference{}
if err := reference.LoadByID(ctx, tx, scope, trustCenterReferenceID); err != nil {
return fmt.Errorf("cannot load trust center reference: %w", err)
}
if err := reference.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete trust center reference: %w", err)
}
return nil
},
)
return err
}
func (s *Service) GenerateReferenceLogoURL(
ctx context.Context,
scope coredata.Scoper,
referenceID gid.GID,
) (string, error) {
reference := &coredata.TrustCenterReference{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
return reference.LoadByID(ctx, tx, scope, referenceID)
},
)
if err != nil {
return "", fmt.Errorf("cannot load trust center reference: %w", err)
}
file, err := s.fileManager.GetPublicFile(ctx, reference.LogoFileID)
if err != nil {
return "", err
}
return s.fileManager.GenerateFileURL(file), nil
}
func (s *Service) uploadReferenceLogoFile(
ctx context.Context,
scope coredata.Scoper,
tx pg.Tx,
file File,
referenceID gid.GID,
trustCenterID gid.GID,
now time.Time,
) (gid.GID, string, error) {
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
objectKey, err := uuid.NewV7()
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot generate object key: %w", err)
}
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, trustCenterID); err != nil {
return gid.GID{}, "", fmt.Errorf("cannot load trust center: %w", err)
}
var (
fileSize int64
fileContent io.ReadSeeker
)
filename := file.Filename
contentType := file.ContentType
if readSeeker, ok := file.Content.(io.ReadSeeker); ok {
if file.Size <= 0 {
size, err := readSeeker.Seek(0, io.SeekEnd)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot determine file size: %w", err)
}
fileSize = size
_, err = readSeeker.Seek(0, io.SeekStart)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot reset file position: %w", err)
}
} else {
fileSize = file.Size
}
fileContent = readSeeker
} else {
buf, err := io.ReadAll(file.Content)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot read file: %w", err)
}
fileSize = int64(len(buf))
fileContent = bytes.NewReader(buf)
}
if contentType == "" {
contentType = "application/octet-stream"
if filename != "" {
if detectedType := mime.TypeByExtension(filepath.Ext(filename)); detectedType != "" {
contentType = detectedType
}
}
}
_, err = s.s3.PutObject(
ctx,
&s3.PutObjectInput{
Bucket: new(s.bucket),
Key: new(objectKey.String()),
Body: fileContent,
ContentType: new(contentType),
CacheControl: new("max-age=3600, public"),
Metadata: map[string]string{
"type": "trust-center-reference-logo",
"trust-center-reference-id": referenceID.String(),
"organization-id": trustCenter.OrganizationID.String(),
},
},
)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot upload logo file to S3: %w", err)
}
fileRecord := &coredata.File{
ID: fileID,
OrganizationID: trustCenter.OrganizationID,
BucketName: s.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: fileSize,
Visibility: coredata.FileVisibilityPublic,
CreatedAt: now,
UpdatedAt: now,
}
if err := fileRecord.Insert(ctx, tx, scope); err != nil {
return gid.GID{}, "", fmt.Errorf("cannot insert file: %w", err)
}
return fileID, objectKey.String(), nil
}
func (s *Service) cleanupReferenceS3Object(
ctx context.Context,
scope coredata.Scoper,
s3Key string,
) {
if s3Key == "" {
return
}
_, _ = s.s3.DeleteObject(
ctx,
&s3.DeleteObjectInput{
Bucket: new(s.bucket),
Key: new(s3Key),
},
)
}

View File

@@ -0,0 +1,105 @@
// 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 management holds the scoped, admin-facing compliance portal services
// (trust center CRUD, domains, frameworks, external URLs, references, files and
// accesses). It is the write side of the compliance portal feature.
package management
import (
"io"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/certmanager"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/filevalidation"
"go.probo.inc/probo/pkg/slack"
)
const (
NameMaxLength = 100
TitleMaxLength = 1000
ContentMaxLength = 5000
)
type (
// Service is the admin-facing compliance portal service. It exposes the
// scoped CRUD operations for the trust center and its related resources as
// methods on a single type.
Service struct {
pg *pg.Client
s3 *s3.Client
bucket string
baseURL string
baseDomain string
fileManager *filemanager.Service
certManager *certmanager.Service
logger *log.Logger
SlackMessages *slack.Service
fileValidator *filevalidation.FileValidator
}
// FileUpload is an in-memory file supplied by a caller for upload.
FileUpload struct {
Content io.Reader
Filename string
Size int64
ContentType string
}
// File is an in-memory file supplied by a caller for upload.
File struct {
Content io.Reader
Filename string
Size int64
ContentType string
}
)
func NewService(
pgClient *pg.Client,
s3Client *s3.Client,
bucket string,
baseURL string,
baseDomain string,
fileManagerService *filemanager.Service,
certManagerService *certmanager.Service,
slackService *slack.Service,
logger *log.Logger,
) *Service {
return &Service{
pg: pgClient,
s3: s3Client,
bucket: bucket,
baseURL: baseURL,
baseDomain: baseDomain,
fileManager: fileManagerService,
certManager: certManagerService,
logger: logger,
SlackMessages: slackService,
fileValidator: filevalidation.NewValidator(
filevalidation.WithCategories(
filevalidation.CategoryData,
filevalidation.CategoryDocument,
filevalidation.CategoryImage,
filevalidation.CategoryPresentation,
filevalidation.CategorySpreadsheet,
filevalidation.CategoryText,
),
filevalidation.WithMaxFileSize(10*1024*1024), // 10MB
),
}
}