Files
probo/pkg/probo/audit_service.go
Bryan Frimin 123e5c36b7 Add finding service layer
Introduce FindingService with create, update, delete, get, and list
operations including audit association management. Update the probo
service orchestration, actions, and policies to use findings.

Remove the old NonconformityService and ContinualImprovementService.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-03-17 15:05:31 +01:00

571 lines
13 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 probo
import (
"context"
"fmt"
"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/filevalidation"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type AuditService struct {
svc *TenantService
}
type (
CreateAuditRequest struct {
OrganizationID gid.GID
FrameworkID gid.GID
Name *string
ValidFrom *time.Time
ValidUntil *time.Time
State *coredata.AuditState
TrustCenterVisibility *coredata.TrustCenterVisibility
}
UpdateAuditRequest struct {
ID gid.GID
Name **string
ValidFrom *time.Time
ValidUntil *time.Time
State *coredata.AuditState
TrustCenterVisibility *coredata.TrustCenterVisibility
}
UploadAuditReportRequest struct {
AuditID gid.GID
File File
}
)
func (car *CreateAuditRequest) Validate() error {
v := validator.New()
v.Check(car.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(car.FrameworkID, "framework_id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
v.Check(car.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(car.ValidUntil, "valid_until", validator.After(car.ValidFrom))
v.Check(car.State, "state", validator.OneOfSlice(coredata.AuditStates()))
v.Check(car.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
return v.Error()
}
func (uar *UpdateAuditRequest) Validate() error {
v := validator.New()
v.Check(uar.ID, "id", validator.Required(), validator.GID(coredata.AuditEntityType))
v.Check(uar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(uar.ValidUntil, "valid_until", validator.After(uar.ValidFrom))
v.Check(uar.State, "state", validator.OneOfSlice(coredata.AuditStates()))
v.Check(uar.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
return v.Error()
}
func (uarr *UploadAuditReportRequest) Validate() error {
v := validator.New()
v.Check(uarr.AuditID, "audit_id", validator.Required(), validator.GID(coredata.AuditEntityType))
if err := v.Error(); err != nil {
return err
}
fv := filevalidation.NewValidator(
filevalidation.WithCategories(filevalidation.CategoryDocument),
filevalidation.WithMaxFileSize(25*1024*1024),
)
if err := fv.Validate(uarr.File.Filename, uarr.File.ContentType, uarr.File.Size); err != nil {
return fmt.Errorf("invalid audit report file: %w", err)
}
return nil
}
func (s AuditService) Get(
ctx context.Context,
auditID gid.GID,
) (*coredata.Audit, error) {
audit := &coredata.Audit{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return audit.LoadByID(ctx, conn, s.svc.scope, auditID)
},
)
if err != nil {
return nil, err
}
return audit, nil
}
func (s AuditService) GetByReportID(
ctx context.Context,
reportID gid.GID,
) (*coredata.Audit, error) {
audit := &coredata.Audit{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return audit.LoadByReportID(ctx, conn, s.svc.scope, reportID)
},
)
if err != nil {
return nil, err
}
return audit, nil
}
func (s *AuditService) Create(
ctx context.Context,
req *CreateAuditRequest,
) (*coredata.Audit, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
now := time.Now()
audit := &coredata.Audit{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.AuditEntityType),
Name: req.Name,
OrganizationID: req.OrganizationID,
FrameworkID: req.FrameworkID,
ValidFrom: req.ValidFrom,
ValidUntil: req.ValidUntil,
State: coredata.AuditStateNotStarted,
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
CreatedAt: now,
UpdatedAt: now,
}
if req.State != nil {
audit.State = *req.State
}
if req.TrustCenterVisibility != nil {
audit.TrustCenterVisibility = *req.TrustCenterVisibility
}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
framework := &coredata.Framework{}
if err := framework.LoadByID(ctx, conn, s.svc.scope, req.FrameworkID); err != nil {
return fmt.Errorf("cannot load framework: %w", err)
}
if err := audit.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert audit: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return audit, nil
}
func (s *AuditService) Update(
ctx context.Context,
req *UpdateAuditRequest,
) (*coredata.Audit, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
audit := &coredata.Audit{}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := audit.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil {
return fmt.Errorf("cannot load audit: %w", err)
}
if req.Name != nil {
audit.Name = *req.Name
}
if req.ValidFrom != nil {
audit.ValidFrom = req.ValidFrom
}
if req.ValidUntil != nil {
audit.ValidUntil = req.ValidUntil
}
if req.State != nil {
audit.State = *req.State
}
if req.TrustCenterVisibility != nil {
audit.TrustCenterVisibility = *req.TrustCenterVisibility
}
audit.UpdatedAt = time.Now()
if err := audit.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update audit: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return audit, nil
}
func (s AuditService) Delete(
ctx context.Context,
auditID gid.GID,
) error {
audit := coredata.Audit{ID: auditID}
return s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := audit.Delete(ctx, conn, s.svc.scope)
if err != nil {
return fmt.Errorf("cannot delete audit: %w", err)
}
return nil
},
)
}
func (s AuditService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.AuditOrderField],
) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) {
var audits coredata.Audits
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
filter := coredata.NewAuditFilter()
err := audits.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load audits: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(audits, cursor), nil
}
func (s AuditService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
audits := coredata.Audits{}
count, err = audits.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count audits: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s AuditService) UploadReport(
ctx context.Context,
req UploadAuditReportRequest,
) (*coredata.Audit, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
audit := &coredata.Audit{}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := audit.LoadByID(ctx, conn, s.svc.scope, req.AuditID); err != nil {
return fmt.Errorf("cannot load audit: %w", err)
}
reportID := gid.New(s.svc.scope.GetTenantID(), coredata.ReportEntityType)
now := time.Now()
objectKey, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("cannot generate object key: %w", err)
}
_, err = s.svc.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: new(s.svc.bucket),
Key: new(objectKey.String()),
Body: req.File.Content,
ContentType: new(req.File.ContentType),
CacheControl: new("private, max-age=3600"),
Metadata: map[string]string{
"type": "report",
"report-id": reportID.String(),
"organization-id": audit.OrganizationID.String(),
},
})
if err != nil {
return fmt.Errorf("cannot upload report to S3: %w", err)
}
report := &coredata.Report{
ID: reportID,
OrganizationID: audit.OrganizationID,
ObjectKey: objectKey.String(),
MimeType: req.File.ContentType,
Filename: req.File.Filename,
Size: req.File.Size,
CreatedAt: now,
UpdatedAt: now,
}
if err := report.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert report: %w", err)
}
audit.ReportID = &report.ID
audit.UpdatedAt = time.Now()
if err := audit.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update audit: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return audit, nil
}
func (s AuditService) GenerateReportURL(
ctx context.Context,
auditID gid.GID,
expiresIn time.Duration,
) (*string, error) {
audit, err := s.Get(ctx, auditID)
if err != nil {
return nil, fmt.Errorf("cannot get audit: %w", err)
}
if audit.ReportID == nil {
return nil, fmt.Errorf("audit has no report")
}
url, err := s.svc.Reports.GenerateDownloadURL(ctx, *audit.ReportID, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate report download URL: %w", err)
}
return url, nil
}
func (s AuditService) DeleteReport(
ctx context.Context,
auditID gid.GID,
) (*coredata.Audit, error) {
audit := &coredata.Audit{}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := audit.LoadByID(ctx, conn, s.svc.scope, auditID); err != nil {
return fmt.Errorf("cannot load audit: %w", err)
}
if audit.ReportID != nil {
report := &coredata.Report{ID: *audit.ReportID}
if err := report.Delete(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete report: %w", err)
}
audit.ReportID = nil
audit.UpdatedAt = time.Now()
if err := audit.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update audit: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return audit, nil
}
func (s AuditService) ListForControlID(
ctx context.Context,
controlID gid.GID,
cursor *page.Cursor[coredata.AuditOrderField],
) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) {
var audits coredata.Audits
control := &coredata.Control{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := control.LoadByID(ctx, conn, s.svc.scope, controlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
err := audits.LoadByControlID(ctx, conn, s.svc.scope, control.ID, cursor)
if err != nil {
return fmt.Errorf("cannot load audits: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(audits, cursor), nil
}
func (s AuditService) CountForControlID(
ctx context.Context,
controlID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
audits := coredata.Audits{}
count, err = audits.CountByControlID(ctx, conn, s.svc.scope, controlID)
if err != nil {
return fmt.Errorf("cannot count audits: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s AuditService) CountForFindingID(
ctx context.Context,
findingID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
audits := coredata.Audits{}
count, err = audits.CountByFindingID(ctx, conn, s.svc.scope, findingID)
if err != nil {
return fmt.Errorf("cannot count audits: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s AuditService) ListForFindingID(
ctx context.Context,
findingID gid.GID,
cursor *page.Cursor[coredata.AuditOrderField],
) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) {
var audits coredata.Audits
finding := &coredata.Finding{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := finding.LoadByID(ctx, conn, s.svc.scope, findingID); err != nil {
return fmt.Errorf("cannot load finding: %w", err)
}
err := audits.LoadByFindingID(ctx, conn, s.svc.scope, finding.ID, cursor)
if err != nil {
return fmt.Errorf("cannot load audits: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(audits, cursor), nil
}