diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index a59cef653..267464b57 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -49,4 +49,5 @@ const ( SnapshotEntityType ContinualImprovementRegistryEntityType ProcessingActivityRegistryEntityType + FrameworkExportEntityType ) diff --git a/pkg/coredata/framework_export.go b/pkg/coredata/framework_export.go new file mode 100644 index 000000000..c09acd288 --- /dev/null +++ b/pkg/coredata/framework_export.go @@ -0,0 +1,163 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/getprobo/probo/pkg/gid" + "github.com/getprobo/probo/pkg/page" + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" +) + +type ( + FrameworkExport struct { + ID gid.GID `db:"id"` + FrameworkID gid.GID `db:"framework_id"` + Status FrameworkExportStatus `db:"status"` + FileID *gid.GID `db:"file_id"` + CreatedAt time.Time `db:"created_at"` + StartedAt *time.Time `db:"started_at"` + CompletedAt *time.Time `db:"completed_at"` + } + + FrameworkExports []*FrameworkExport +) + +var ( + ErrNoFrameworkExportAvailable = errors.New("no framework export available") +) + +func (fe FrameworkExport) CursorKey(orderBy FrameworkExportOrderField) page.CursorKey { + switch orderBy { + case FrameworkExportOrderFieldCreatedAt: + return page.NewCursorKey(fe.ID, fe.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (fe *FrameworkExport) Insert( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +INSERT INTO framework_exports ( + id, + tenant_id, + framework_id, + status, + created_at, + expires_at +) VALUES ( + @id, + @tenant_id, + @framework_id, + @status, + @created_at, + @expires_at +)` + + args := pgx.StrictNamedArgs{ + "id": fe.ID, + "tenant_id": scope.GetTenantID(), + "framework_id": fe.FrameworkID, + "status": fe.Status, + "created_at": fe.CreatedAt, + } + + _, err := conn.Exec(ctx, q, args) + return err +} + +func (fe *FrameworkExport) Update( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +UPDATE + framework_exports +SET + status = @status, + file_id = @file_id, + started_at = @started_at, + completed_at = @completed_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "status": fe.Status, + "file_id": fe.FileID, + "started_at": fe.StartedAt, + "completed_at": fe.CompletedAt, + "id": fe.ID, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + return err +} + +func (fe *FrameworkExport) LoadNextPendingForUpdateSkipLocked( + ctx context.Context, + conn pg.Conn, +) error { + q := ` +SELECT + id, + framework_id, + status, + created_at, + started_at, + completed_at +FROM + framework_exports +WHERE + status = @status +ORDER BY + created_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED +` + + args := pgx.StrictNamedArgs{"status": FrameworkExportStatusPending} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return err + } + + fe2, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[FrameworkExport]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNoFrameworkExportAvailable + } + return fmt.Errorf("cannot collect framework export: %w", err) + } + + *fe = fe2 + return nil +} diff --git a/pkg/coredata/framework_export_order_field.go b/pkg/coredata/framework_export_order_field.go new file mode 100644 index 000000000..be02d1934 --- /dev/null +++ b/pkg/coredata/framework_export_order_field.go @@ -0,0 +1,40 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +type ( + FrameworkExportOrderField string +) + +const ( + FrameworkExportOrderFieldCreatedAt FrameworkExportOrderField = "CREATED_AT" +) + +func (p FrameworkExportOrderField) Column() string { + return string(p) +} + +func (p FrameworkExportOrderField) String() string { + return string(p) +} + +func (p FrameworkExportOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *FrameworkExportOrderField) UnmarshalText(text []byte) error { + *p = FrameworkExportOrderField(text) + return nil +} diff --git a/pkg/coredata/framework_export_status.go b/pkg/coredata/framework_export_status.go new file mode 100644 index 000000000..fa717c55c --- /dev/null +++ b/pkg/coredata/framework_export_status.go @@ -0,0 +1,86 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type ( + FrameworkExportStatus string +) + +const ( + FrameworkExportStatusPending FrameworkExportStatus = "pending" + FrameworkExportStatusProcessing FrameworkExportStatus = "processing" + FrameworkExportStatusCompleted FrameworkExportStatus = "completed" + FrameworkExportStatusFailed FrameworkExportStatus = "failed" +) + +func (pvs FrameworkExportStatus) MarshalText() ([]byte, error) { + return []byte(pvs.String()), nil +} + +func (pvs *FrameworkExportStatus) UnmarshalText(data []byte) error { + val := string(data) + + switch val { + case FrameworkExportStatusPending.String(): + *pvs = FrameworkExportStatusPending + case FrameworkExportStatusProcessing.String(): + *pvs = FrameworkExportStatusProcessing + case FrameworkExportStatusCompleted.String(): + *pvs = FrameworkExportStatusCompleted + case FrameworkExportStatusFailed.String(): + *pvs = FrameworkExportStatusFailed + default: + return fmt.Errorf("invalid FrameworkExportStatus value: %q", val) + } + + return nil +} + +func (pvs FrameworkExportStatus) String() string { + var val string + + switch pvs { + case FrameworkExportStatusPending: + val = "pending" + case FrameworkExportStatusProcessing: + val = "processing" + case FrameworkExportStatusCompleted: + val = "completed" + case FrameworkExportStatusFailed: + val = "failed" + default: + panic(fmt.Errorf("invalid FrameworkExportStatus value: %q", string(pvs))) + } + + return val +} + +func (pvs *FrameworkExportStatus) Scan(value any) error { + val, ok := value.(string) + if !ok { + return fmt.Errorf("invalid scan source for FrameworkExportStatus, expected string got %T", value) + } + + return pvs.UnmarshalText([]byte(val)) +} + +func (pvs FrameworkExportStatus) Value() (driver.Value, error) { + return pvs.String(), nil +} diff --git a/pkg/coredata/measure.go b/pkg/coredata/measure.go index 25bbd3975..84e79cd69 100644 --- a/pkg/coredata/measure.go +++ b/pkg/coredata/measure.go @@ -249,6 +249,8 @@ WHERE %s ` q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + fmt.Printf("\n%s\n", q) + args := pgx.NamedArgs{"control_id": controlID} maps.Copy(args, scope.SQLArguments()) maps.Copy(args, filter.SQLArguments()) diff --git a/pkg/coredata/migrations/20250828T091522Z.sql b/pkg/coredata/migrations/20250828T091522Z.sql new file mode 100644 index 000000000..eb36773fb --- /dev/null +++ b/pkg/coredata/migrations/20250828T091522Z.sql @@ -0,0 +1,20 @@ +CREATE TYPE framework_export_status AS ENUM ( + 'pending', + 'processing', + 'completed', + 'failed' +); + +CREATE TABLE framework_exports ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + framework_id TEXT NOT NULL, + status framework_export_status NOT NULL, + file_id TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE +); + + + diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 1520dc76d..36a0239ce 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -1097,65 +1097,14 @@ func (s *DocumentService) ExportPDF( ctx context.Context, documentVersionID gid.GID, ) ([]byte, error) { - document := &coredata.Document{} - version := &coredata.DocumentVersion{} - owner := &coredata.People{} - publishedBy := &coredata.People{} - signatures := coredata.DocumentVersionSignatures{} - peopleMap := make(map[gid.GID]*coredata.People) + var data []byte - err := s.svc.pg.WithConn( + err := s.svc.pg.WithTx( ctx, - func(conn pg.Conn) error { - if err := version.LoadByID(ctx, conn, s.svc.scope, documentVersionID); err != nil { - return fmt.Errorf("cannot load document version: %w", err) - } - - if err := document.LoadByID(ctx, conn, s.svc.scope, version.DocumentID); err != nil { - return fmt.Errorf("cannot load document: %w", err) - } - - if version.PublishedBy != nil { - if err := publishedBy.LoadByID(ctx, conn, s.svc.scope, *version.PublishedBy); err != nil { - return fmt.Errorf("cannot load published by person: %w", err) - } - } - - cursor := page.NewCursor( - 100, - nil, - page.Head, - page.OrderBy[coredata.DocumentVersionSignatureOrderField]{ - Field: coredata.DocumentVersionSignatureOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - ) - - if err := signatures.LoadByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID, cursor); err != nil { - return fmt.Errorf("cannot load document version signatures: %w", err) - } - - if err := owner.LoadByID(ctx, conn, s.svc.scope, document.OwnerID); err != nil { - return fmt.Errorf("cannot load document owner: %w", err) - } - - // TODO: refactor this to use a single query - for _, sig := range signatures { - if _, ok := peopleMap[sig.SignedBy]; !ok { - people := &coredata.People{} - if err := people.LoadByID(ctx, conn, s.svc.scope, sig.SignedBy); err != nil { - return fmt.Errorf("cannot load people %q: %w", sig.SignedBy, err) - } - peopleMap[sig.SignedBy] = people - } - - if _, ok := peopleMap[sig.RequestedBy]; !ok { - people := &coredata.People{} - if err := people.LoadByID(ctx, conn, s.svc.scope, sig.RequestedBy); err != nil { - return fmt.Errorf("cannot load people %q: %w", sig.RequestedBy, err) - } - peopleMap[sig.RequestedBy] = people - } + func(conn pg.Conn) (err error) { + data, err = exportDocumentPDF(ctx, s.html2pdfConverter, conn, s.svc.scope, documentVersionID) + if err != nil { + return fmt.Errorf("cannot export document PDF: %w", err) } return nil @@ -1166,6 +1115,74 @@ func (s *DocumentService) ExportPDF( return nil, err } + return data, nil +} + +func exportDocumentPDF( + ctx context.Context, + html2pdfConverter *html2pdf.Converter, + conn pg.Conn, + scope coredata.Scoper, + documentVersionID gid.GID, +) ([]byte, error) { + document := &coredata.Document{} + version := &coredata.DocumentVersion{} + owner := &coredata.People{} + publishedBy := &coredata.People{} + signatures := coredata.DocumentVersionSignatures{} + peopleMap := make(map[gid.GID]*coredata.People) + + if err := version.LoadByID(ctx, conn, scope, documentVersionID); err != nil { + return nil, fmt.Errorf("cannot load document version: %w", err) + } + + if err := document.LoadByID(ctx, conn, scope, version.DocumentID); err != nil { + return nil, fmt.Errorf("cannot load document: %w", err) + } + + if version.PublishedBy != nil { + if err := publishedBy.LoadByID(ctx, conn, scope, *version.PublishedBy); err != nil { + return nil, fmt.Errorf("cannot load published by person: %w", err) + } + } + + cursor := page.NewCursor( + 100, + nil, + page.Head, + page.OrderBy[coredata.DocumentVersionSignatureOrderField]{ + Field: coredata.DocumentVersionSignatureOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + ) + + if err := signatures.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID, cursor); err != nil { + return nil, fmt.Errorf("cannot load document version signatures: %w", err) + } + + if err := owner.LoadByID(ctx, conn, scope, document.OwnerID); err != nil { + return nil, fmt.Errorf("cannot load document owner: %w", err) + } + + // TODO: refactor this to use a single query + for _, sig := range signatures { + if _, ok := peopleMap[sig.SignedBy]; !ok { + people := &coredata.People{} + if err := people.LoadByID(ctx, conn, scope, sig.SignedBy); err != nil { + return nil, fmt.Errorf("cannot load people %q: %w", sig.SignedBy, err) + } + peopleMap[sig.SignedBy] = people + } + + if _, ok := peopleMap[sig.RequestedBy]; !ok { + people := &coredata.People{} + if err := people.LoadByID(ctx, conn, scope, sig.RequestedBy); err != nil { + return nil, fmt.Errorf("cannot load people %q: %w", sig.RequestedBy, err) + } + peopleMap[sig.RequestedBy] = people + } + } + classification := docgen.ClassificationInternal switch document.DocumentType { case coredata.DocumentTypePolicy: @@ -1212,7 +1229,7 @@ func (s *DocumentService) ExportPDF( Scale: 1.0, } - pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg) + pdfReader, err := html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg) if err != nil { return nil, fmt.Errorf("cannot generate PDF: %w", err) } diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index b27ba92e8..59891f8e8 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -15,12 +15,17 @@ package probo import ( + "archive/zip" "context" "fmt" + "io" "time" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/gid" + "github.com/getprobo/probo/pkg/html2pdf" "github.com/getprobo/probo/pkg/page" "github.com/getprobo/probo/pkg/slug" "github.com/getprobo/probo/pkg/soagen" @@ -30,14 +35,12 @@ import ( const ( maxStateOfApplicabilityLimit = 10_000 - maxControlsLimit = 10000 - maxItemsLimit = 1000 - presignExpiry = 15 * time.Minute ) type ( FrameworkService struct { - svc *TenantService + svc *TenantService + html2pdfConverter *html2pdf.Converter } CreateFrameworkRequest struct { @@ -65,6 +68,207 @@ type ( } ) +func (s FrameworkService) RequestExport( + ctx context.Context, + frameworkID gid.GID, +) error { + return s.svc.pg.WithTx(ctx, func(conn pg.Conn) error { + framework := &coredata.Framework{} + if err := framework.LoadByID(ctx, conn, s.svc.scope, frameworkID); err != nil { + return fmt.Errorf("cannot load framework: %w", err) + } + + now := time.Now() + + frameworkExport := &coredata.FrameworkExport{ + ID: gid.New(framework.ID.TenantID(), coredata.FrameworkExportEntityType), + FrameworkID: frameworkID, + Status: coredata.FrameworkExportStatusPending, + CreatedAt: now, + } + + if err := frameworkExport.Insert(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert framework export: %w", err) + } + + return nil + }) +} + +func (s FrameworkService) Export( + ctx context.Context, + frameworkID gid.GID, + file io.Writer, +) error { + archive := zip.NewWriter(file) + defer archive.Close() + + return s.svc.pg.WithTx( + ctx, + func(conn pg.Conn) error { + framework := &coredata.Framework{} + if err := framework.LoadByID(ctx, conn, s.svc.scope, frameworkID); err != nil { + return fmt.Errorf("cannot load framework: %w", err) + } + + controls := coredata.Controls{} + err := controls.LoadByFrameworkID( + ctx, + conn, + s.svc.scope, + frameworkID, + page.NewCursor( + 10_000, + nil, + page.Head, + page.OrderBy[coredata.ControlOrderField]{ + Field: coredata.ControlOrderFieldSectionTitle, + Direction: page.OrderDirectionAsc, + }, + ), + coredata.NewControlFilter(nil), + ) + if err != nil { + return fmt.Errorf("cannot load controls: %w", err) + } + + for _, control := range controls { + _, err := archive.Create(fmt.Sprintf("%s/%s/", framework.Name, control.SectionTitle)) + if err != nil { + return fmt.Errorf("cannot create control directory in archive: %w", err) + } + + measures := coredata.Measures{} + err = measures.LoadByControlID( + ctx, + conn, + s.svc.scope, + control.ID, + page.NewCursor( + 10_000, + nil, + page.Head, + page.OrderBy[coredata.MeasureOrderField]{ + Field: coredata.MeasureOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + ), + coredata.NewMeasureFilter(nil), + ) + if err != nil { + return fmt.Errorf("cannot load measures: %w", err) + } + + for _, measure := range measures { + _, err := archive.Create(fmt.Sprintf("%s/%s/%s/", framework.Name, control.SectionTitle, measure.Name)) + if err != nil { + return fmt.Errorf("cannot create measure directory in archive: %w", err) + } + + evidences := coredata.Evidences{} + err = evidences.LoadByMeasureID( + ctx, + conn, + s.svc.scope, + measure.ID, + page.NewCursor( + 10_000, + nil, + page.Head, + page.OrderBy[coredata.EvidenceOrderField]{ + Field: coredata.EvidenceOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + ), + ) + if err != nil { + return fmt.Errorf("cannot load evidences: %w", err) + } + + for _, evidence := range evidences { + object, err := s.svc.s3.GetObject( + ctx, + &s3.GetObjectInput{ + Bucket: aws.String(s.svc.bucket), + Key: aws.String(evidence.ObjectKey), + }, + ) + if err != nil { + return fmt.Errorf("cannot download evidence: %w", err) + } + defer object.Body.Close() + + if err != nil { + return fmt.Errorf("cannot download evidence: %w", err) + } + + w, err := archive.Create(fmt.Sprintf("%s/%s/%s/%s", framework.Name, control.SectionTitle, measure.Name, evidence.Filename)) + if err != nil { + return fmt.Errorf("cannot create evidence in archive: %w", err) + } + + _, err = io.Copy(w, object.Body) + if err != nil { + return fmt.Errorf("cannot write evidence to archive: %w", err) + } + } + } + + documents := coredata.Documents{} + err = documents.LoadByControlID( + ctx, + conn, + s.svc.scope, + control.ID, + page.NewCursor( + 10_000, + nil, + page.Head, + page.OrderBy[coredata.DocumentOrderField]{ + Field: coredata.DocumentOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + ), + coredata.NewDocumentFilter(nil), + ) + if err != nil { + return fmt.Errorf("cannot load documents: %w", err) + } + + for _, document := range documents { + documentVersion := &coredata.DocumentVersion{} + if err := documentVersion.LoadLatestPublishedVersion(ctx, conn, s.svc.scope, document.ID); err != nil { + return fmt.Errorf("cannot load document version: %w", err) + } + + exportedPDF, err := exportDocumentPDF( + ctx, + s.html2pdfConverter, + conn, + s.svc.scope, + documentVersion.ID, + ) + if err != nil { + return fmt.Errorf("cannot export document PDF: %w", err) + } + + w, err := archive.Create(fmt.Sprintf("%s/%s/%s.pdf", framework.Name, control.SectionTitle, document.Title)) + if err != nil { + return fmt.Errorf("cannot create document in archive: %w", err) + } + + _, err = w.Write(exportedPDF) + if err != nil { + return fmt.Errorf("cannot write document to archive: %w", err) + } + } + } + + return nil + }, + ) +} + func (s FrameworkService) Create( ctx context.Context, req CreateFrameworkRequest, diff --git a/pkg/probo/service.go b/pkg/probo/service.go index ca35cf4c9..a8f3755c4 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -17,6 +17,7 @@ package probo import ( "context" "fmt" + "os" "time" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -27,7 +28,9 @@ import ( "github.com/getprobo/probo/pkg/gid" "github.com/getprobo/probo/pkg/html2pdf" "github.com/getprobo/probo/pkg/usrmgr" + "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" + "go.gearno.de/x/ref" ) type ( @@ -136,7 +139,10 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { agent: agents.NewAgent(nil, s.agentConfig), } - tenantService.Frameworks = &FrameworkService{svc: tenantService} + tenantService.Frameworks = &FrameworkService{ + svc: tenantService, + html2pdfConverter: s.html2pdfConverter, + } tenantService.Measures = &MeasureService{svc: tenantService} tenantService.Tasks = &TaskService{svc: tenantService} tenantService.Evidences = &EvidenceService{ @@ -184,3 +190,144 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { tenantService.ProcessingActivityRegistries = &ProcessingActivityRegistryService{svc: tenantService} return tenantService } + +func (s *Service) ExportFrameworkJob(ctx context.Context) error { + return s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + fe := &coredata.FrameworkExport{} + if err := fe.LoadNextPendingForUpdateSkipLocked(ctx, tx); err != nil { + return err + } + + scope := coredata.NewScope(fe.ID.TenantID()) + framework := &coredata.Framework{} + if err := framework.LoadByID(ctx, tx, scope, fe.FrameworkID); err != nil { + fe.Status = coredata.FrameworkExportStatusFailed + fe.CompletedAt = ref.Ref(time.Now()) + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + // s.logger.Error(ctx, "cannot load framework", "error", err) + + return nil + } + + tenantService := s.WithTenant(fe.ID.TenantID()) + + tempDir := os.TempDir() + tempFile, err := os.CreateTemp(tempDir, "probo-framework-export-*.zip") + if err != nil { + fe.Status = coredata.FrameworkExportStatusFailed + fe.CompletedAt = ref.Ref(time.Now()) + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + // s.logger.Error(ctx, "cannot create temp file", "error", err) + + return nil + } + defer tempFile.Close() + defer os.Remove(tempFile.Name()) + + err = tenantService.Frameworks.Export(ctx, fe.FrameworkID, tempFile) + if err != nil { + fe.Status = coredata.FrameworkExportStatusFailed + fe.CompletedAt = ref.Ref(time.Now()) + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + // s.logger.Error(ctx, "cannot export framework", "error", err) + return nil + } + + uuid, err := uuid.NewV4() + if err != nil { + fe.Status = coredata.FrameworkExportStatusFailed + fe.CompletedAt = ref.Ref(time.Now()) + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + // s.logger.Error(ctx, "cannot generate UUID", "error", err) + return nil + } + + if _, err := tempFile.Seek(0, 0); err != nil { + fe.Status = coredata.FrameworkExportStatusFailed + fe.CompletedAt = ref.Ref(time.Now()) + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + // s.logger.Error(ctx, "cannot seek temp file", "error", err) + return nil + } + + fileInfo, err := tempFile.Stat() + if err != nil { + fe.Status = coredata.FrameworkExportStatusFailed + fe.CompletedAt = ref.Ref(time.Now()) + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + // s.logger.Error(ctx, "cannot get temp file info", "error", err) + return nil + } + + _, err = s.s3.PutObject( + ctx, + &s3.PutObjectInput{ + Bucket: ref.Ref(s.bucket), + Key: ref.Ref(uuid.String()), + Body: tempFile, + ContentLength: ref.Ref(fileInfo.Size()), + ContentType: ref.Ref("application/zip"), + Metadata: map[string]string{ + "framework-id": framework.ID.String(), + "framework-export-id": fe.ID.String(), + }, + }, + ) + if err != nil { + fe.Status = coredata.FrameworkExportStatusFailed + fe.CompletedAt = ref.Ref(time.Now()) + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + // s.logger.Error(ctx, "cannot upload file to S3", "error", err) + return nil + } + + now := time.Now() + + file := coredata.File{ + ID: gid.New(fe.ID.TenantID(), coredata.FileEntityType), + BucketName: s.bucket, + MimeType: "application/zip", + FileName: fmt.Sprintf("%s Archive %s.zip", framework.Name, time.Now().Format("2006-01-02")), + FileKey: uuid.String(), + CreatedAt: now, + UpdatedAt: now, + } + + if err := file.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert file: %w", err) + } + + fe.FileID = &file.ID + fe.CompletedAt = &now + fe.Status = coredata.FrameworkExportStatusCompleted + if err := fe.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + return nil + }, + ) +} diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 5b835ca01..c4b136b47 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -2265,6 +2265,7 @@ type Mutation { generateFrameworkStateOfApplicability( input: GenerateFrameworkStateOfApplicabilityInput! ): GenerateFrameworkStateOfApplicabilityPayload! + exportFramework(input: ExportFrameworkInput!): ExportFrameworkPayload! # Control mutations createControl(input: CreateControlInput!): CreateControlPayload! @@ -2656,6 +2657,10 @@ input DeleteFrameworkInput { frameworkId: ID! } +input ExportFrameworkInput { + frameworkId: ID! +} + input CreateMeasureInput { organizationId: ID! name: String! @@ -3219,6 +3224,10 @@ type DeleteFrameworkPayload { deletedFrameworkId: ID! } +type ExportFrameworkPayload { + exportJobId: ID! +} + type CreateMeasurePayload { measureEdge: MeasureEdge! } diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index 8905074af..430d8f377 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -663,6 +663,10 @@ type ComplexityRoot struct { Data func(childComplexity int) int } + ExportFrameworkPayload struct { + ExportJobID func(childComplexity int) int + } + Framework struct { Controls func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ControlOrderBy, filter *types.ControlFilter) int CreatedAt func(childComplexity int) int @@ -801,6 +805,7 @@ type ComplexityRoot struct { DeleteVendorDataPrivacyAgreement func(childComplexity int, input types.DeleteVendorDataPrivacyAgreementInput) int DeleteVendorService func(childComplexity int, input types.DeleteVendorServiceInput) int ExportDocumentVersionPDF func(childComplexity int, input types.ExportDocumentVersionPDFInput) int + ExportFramework func(childComplexity int, input types.ExportFrameworkInput) int FulfillEvidence func(childComplexity int, input types.FulfillEvidenceInput) int GenerateDocumentChangelog func(childComplexity int, input types.GenerateDocumentChangelogInput) int GenerateFrameworkStateOfApplicability func(childComplexity int, input types.GenerateFrameworkStateOfApplicabilityInput) int @@ -1570,6 +1575,7 @@ type MutationResolver interface { ImportFramework(ctx context.Context, input types.ImportFrameworkInput) (*types.ImportFrameworkPayload, error) DeleteFramework(ctx context.Context, input types.DeleteFrameworkInput) (*types.DeleteFrameworkPayload, error) GenerateFrameworkStateOfApplicability(ctx context.Context, input types.GenerateFrameworkStateOfApplicabilityInput) (*types.GenerateFrameworkStateOfApplicabilityPayload, error) + ExportFramework(ctx context.Context, input types.ExportFrameworkInput) (*types.ExportFrameworkPayload, error) CreateControl(ctx context.Context, input types.CreateControlInput) (*types.CreateControlPayload, error) UpdateControl(ctx context.Context, input types.UpdateControlInput) (*types.UpdateControlPayload, error) DeleteControl(ctx context.Context, input types.DeleteControlInput) (*types.DeleteControlPayload, error) @@ -3681,6 +3687,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.ExportDocumentVersionPDFPayload.Data(childComplexity), true + case "ExportFrameworkPayload.exportJobId": + if e.complexity.ExportFrameworkPayload.ExportJobID == nil { + break + } + + return e.complexity.ExportFrameworkPayload.ExportJobID(childComplexity), true + case "Framework.controls": if e.complexity.Framework.Controls == nil { break @@ -4748,6 +4761,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.ExportDocumentVersionPDF(childComplexity, args["input"].(types.ExportDocumentVersionPDFInput)), true + case "Mutation.exportFramework": + if e.complexity.Mutation.ExportFramework == nil { + break + } + + args, err := ec.field_Mutation_exportFramework_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ExportFramework(childComplexity, args["input"].(types.ExportFrameworkInput)), true + case "Mutation.fulfillEvidence": if e.complexity.Mutation.FulfillEvidence == nil { break @@ -7806,6 +7831,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputDocumentVersionSignatureOrder, ec.unmarshalInputEvidenceOrder, ec.unmarshalInputExportDocumentVersionPDFInput, + ec.unmarshalInputExportFrameworkInput, ec.unmarshalInputFrameworkOrder, ec.unmarshalInputFulfillEvidenceInput, ec.unmarshalInputGenerateDocumentChangelogInput, @@ -10234,6 +10260,7 @@ type Mutation { generateFrameworkStateOfApplicability( input: GenerateFrameworkStateOfApplicabilityInput! ): GenerateFrameworkStateOfApplicabilityPayload! + exportFramework(input: ExportFrameworkInput!): ExportFrameworkPayload! # Control mutations createControl(input: CreateControlInput!): CreateControlPayload! @@ -10625,6 +10652,10 @@ input DeleteFrameworkInput { frameworkId: ID! } +input ExportFrameworkInput { + frameworkId: ID! +} + input CreateMeasureInput { organizationId: ID! name: String! @@ -11188,6 +11219,10 @@ type DeleteFrameworkPayload { deletedFrameworkId: ID! } +type ExportFrameworkPayload { + exportJobId: ID! +} + type CreateMeasurePayload { measureEdge: MeasureEdge! } @@ -14952,6 +14987,29 @@ func (ec *executionContext) field_Mutation_exportDocumentVersionPDF_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_exportFramework_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_exportFramework_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_exportFramework_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.ExportFrameworkInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNExportFrameworkInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportFrameworkInput(ctx, tmp) + } + + var zeroVal types.ExportFrameworkInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_fulfillEvidence_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -32208,6 +32266,50 @@ func (ec *executionContext) fieldContext_ExportDocumentVersionPDFPayload_data(_ return fc, nil } +func (ec *executionContext) _ExportFrameworkPayload_exportJobId(ctx context.Context, field graphql.CollectedField, obj *types.ExportFrameworkPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ExportFrameworkPayload_exportJobId(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ExportJobID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(gid.GID) + fc.Result = res + return ec.marshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_ExportFrameworkPayload_exportJobId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ExportFrameworkPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Framework_id(ctx context.Context, field graphql.CollectedField, obj *types.Framework) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Framework_id(ctx, field) if err != nil { @@ -35532,6 +35634,65 @@ func (ec *executionContext) fieldContext_Mutation_generateFrameworkStateOfApplic return fc, nil } +func (ec *executionContext) _Mutation_exportFramework(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_exportFramework(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ExportFramework(rctx, fc.Args["input"].(types.ExportFrameworkInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.ExportFrameworkPayload) + fc.Result = res + return ec.marshalNExportFrameworkPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportFrameworkPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_exportFramework(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "exportJobId": + return ec.fieldContext_ExportFrameworkPayload_exportJobId(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ExportFrameworkPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_exportFramework_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createControl(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_createControl(ctx, field) if err != nil { @@ -63306,6 +63467,33 @@ func (ec *executionContext) unmarshalInputExportDocumentVersionPDFInput(ctx cont return it, nil } +func (ec *executionContext) unmarshalInputExportFrameworkInput(ctx context.Context, obj any) (types.ExportFrameworkInput, error) { + var it types.ExportFrameworkInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"frameworkId"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "frameworkId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("frameworkId")) + data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.FrameworkID = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputFrameworkOrder(ctx context.Context, obj any) (types.FrameworkOrderBy, error) { var it types.FrameworkOrderBy asMap := map[string]any{} @@ -72518,6 +72706,45 @@ func (ec *executionContext) _ExportDocumentVersionPDFPayload(ctx context.Context return out } +var exportFrameworkPayloadImplementors = []string{"ExportFrameworkPayload"} + +func (ec *executionContext) _ExportFrameworkPayload(ctx context.Context, sel ast.SelectionSet, obj *types.ExportFrameworkPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, exportFrameworkPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ExportFrameworkPayload") + case "exportJobId": + out.Values[i] = ec._ExportFrameworkPayload_exportJobId(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var frameworkImplementors = []string{"Framework", "Node"} func (ec *executionContext) _Framework(ctx context.Context, sel ast.SelectionSet, obj *types.Framework) graphql.Marshaler { @@ -73552,6 +73779,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "exportFramework": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_exportFramework(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createControl": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createControl(ctx, field) @@ -83960,6 +84194,25 @@ func (ec *executionContext) marshalNExportDocumentVersionPDFPayload2ᚖgithubᚗ return ec._ExportDocumentVersionPDFPayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNExportFrameworkInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportFrameworkInput(ctx context.Context, v any) (types.ExportFrameworkInput, error) { + res, err := ec.unmarshalInputExportFrameworkInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNExportFrameworkPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportFrameworkPayload(ctx context.Context, sel ast.SelectionSet, v types.ExportFrameworkPayload) graphql.Marshaler { + return ec._ExportFrameworkPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNExportFrameworkPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportFrameworkPayload(ctx context.Context, sel ast.SelectionSet, v *types.ExportFrameworkPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ExportFrameworkPayload(ctx, sel, v) +} + func (ec *executionContext) marshalNFramework2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐFramework(ctx context.Context, sel ast.SelectionSet, v types.Framework) graphql.Marshaler { return ec._Framework(ctx, sel, &v) } diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index cfd6e5b37..02e504a7c 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -1029,6 +1029,14 @@ type ExportDocumentVersionPDFPayload struct { Data string `json:"data"` } +type ExportFrameworkInput struct { + FrameworkID gid.GID `json:"frameworkId"` +} + +type ExportFrameworkPayload struct { + ExportJobID gid.GID `json:"exportJobId"` +} + type Framework struct { ID gid.GID `json:"id"` Name string `json:"name"` diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index ad6d0aadb..790594c69 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1678,6 +1678,11 @@ func (r *mutationResolver) GenerateFrameworkStateOfApplicability(ctx context.Con }, nil } +// ExportFramework is the resolver for the exportFramework field. +func (r *mutationResolver) ExportFramework(ctx context.Context, input types.ExportFrameworkInput) (*types.ExportFrameworkPayload, error) { + panic(fmt.Errorf("not implemented: ExportFramework - exportFramework")) +} + // CreateControl is the resolver for the createControl field. func (r *mutationResolver) CreateControl(ctx context.Context, input types.CreateControlInput) (*types.CreateControlPayload, error) { prb := r.ProboService(ctx, input.FrameworkID.TenantID()) @@ -3958,6 +3963,10 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewVendor(vendor), nil case coredata.FrameworkEntityType: + if err := prb.Frameworks.Export(ctx, id, nil); err != nil { + panic(fmt.Errorf("cannot export organization frameworks: %w", err)) + } + framework, err := prb.Frameworks.Get(ctx, id) if err != nil { panic(fmt.Errorf("cannot get framework: %w", err))