diff --git a/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx b/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx index e0ac247c9..53180bc18 100644 --- a/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx +++ b/apps/console/src/pages/organizations/frameworks/FrameworkDetailPage.tsx @@ -26,10 +26,7 @@ import { Navigate, Outlet, useNavigate, useParams } from "react-router"; import { useOrganizationId } from "/hooks/useOrganizationId"; import type { FrameworkGraphNodeQuery } from "/hooks/graph/__generated__/FrameworkGraphNodeQuery.graphql"; import type { FrameworkDetailPageFragment$key } from "./__generated__/FrameworkDetailPageFragment.graphql"; -import type { - FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation, - FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation$data, -} from "./__generated__/FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation.graphql"; +import type { FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation } from "./__generated__/FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation.graphql"; import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog"; import { FrameworkControlDialog } from "./dialogs/FrameworkControlDialog"; import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; @@ -59,7 +56,7 @@ const generateFrameworkStateOfApplicabilityMutation = graphql` generateFrameworkStateOfApplicability( input: { frameworkId: $frameworkId } ) { - downloadUrl + data } } `; @@ -139,17 +136,14 @@ export default function FrameworkDetailPage(props: Props) { onClick={() => { generateFrameworkStateOfApplicability({ variables: { frameworkId: framework.id }, - onCompleted: ( - data: FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation$data - ) => { - if (data.generateFrameworkStateOfApplicability.downloadUrl) { - const link = document.createElement("a"); - link.href = - data.generateFrameworkStateOfApplicability.downloadUrl; - link.download = `${framework.name}-SOA.pdf`; // You can adjust the filename as needed - document.body.appendChild(link); + onCompleted: (data) => { + if (data.generateFrameworkStateOfApplicability?.data) { + const link = window.document.createElement("a"); + link.href = data.generateFrameworkStateOfApplicability.data; + link.download = `${framework.name}-SOA.xlsx`; + window.document.body.appendChild(link); link.click(); - document.body.removeChild(link); + window.document.body.removeChild(link); } }, }); diff --git a/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation.graphql.ts b/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation.graphql.ts index 0f39130a3..08f37ee16 100644 --- a/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation.graphql.ts +++ b/apps/console/src/pages/organizations/frameworks/__generated__/FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -14,7 +14,7 @@ export type FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation$var }; export type FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation$data = { readonly generateFrameworkStateOfApplicability: { - readonly downloadUrl: string; + readonly data: string; }; }; export type FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation = { @@ -55,7 +55,7 @@ v1 = [ "alias": null, "args": null, "kind": "ScalarField", - "name": "downloadUrl", + "name": "data", "storageKey": null } ], @@ -80,16 +80,16 @@ return { "selections": (v1/*: any*/) }, "params": { - "cacheID": "338cbffe84fb19cdf46c97b739216342", + "cacheID": "80185d474f3a9c5d20f97a387b219511", "id": null, "metadata": {}, "name": "FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation", "operationKind": "mutation", - "text": "mutation FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation(\n $frameworkId: ID!\n) {\n generateFrameworkStateOfApplicability(input: {frameworkId: $frameworkId}) {\n downloadUrl\n }\n}\n" + "text": "mutation FrameworkDetailPageGenerateFrameworkStateOfApplicabilityMutation(\n $frameworkId: ID!\n) {\n generateFrameworkStateOfApplicability(input: {frameworkId: $frameworkId}) {\n data\n }\n}\n" } }; })(); -(node as any).hash = "f5c0a7d2bebedecc3de7d55d52f06cd8"; +(node as any).hash = "eac70164d676cb8e19d7fe7f06b2b817"; export default node; diff --git a/pkg/coredata/risk.go b/pkg/coredata/risk.go index fb02ad21d..acf66cc95 100644 --- a/pkg/coredata/risk.go +++ b/pkg/coredata/risk.go @@ -418,3 +418,45 @@ DELETE FROM risks WHERE %s AND id = @id _, err := conn.Exec(ctx, q, args) return err } + +func (r *Risks) CountByDocumentID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + documentID gid.GID, + filter *RiskFilter, +) (int, error) { + q := ` +WITH rsks AS ( + SELECT + r.id, + r.tenant_id + FROM + risks r + INNER JOIN + risks_documents rd ON r.id = rd.risk_id + WHERE + rd.document_id = @document_id +) +SELECT + COUNT(id) +FROM + rsks +WHERE %s + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + + args := pgx.NamedArgs{"document_id": documentID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 86afe36e5..9452ac865 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -15,32 +15,24 @@ package probo import ( - "bytes" "context" "fmt" - "io" - "os" - "path/filepath" - "strings" "time" - "archive/tar" - "compress/gzip" - - "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/page" "github.com/getprobo/probo/pkg/slug" - "github.com/xuri/excelize/v2" + "github.com/getprobo/probo/pkg/soagen" "go.gearno.de/kit/pg" + "go.gearno.de/x/ref" ) const ( - maxControlsLimit = 10000 - maxItemsLimit = 1000 - presignExpiry = 15 * time.Minute + maxStateOfApplicabilityLimit = 10_000 + maxControlsLimit = 10000 + maxItemsLimit = 1000 + presignExpiry = 15 * time.Minute ) type ( @@ -71,18 +63,6 @@ type ( } `json:"controls"` } } - - soaRowData struct { - control *coredata.Control - applicability string - justificationExclusion string - regulatory string - contractual string - bestPractice string - riskAssessment string - securityMeasures string - isApplicable bool - } ) func (s FrameworkService) Create( @@ -292,746 +272,153 @@ func (s FrameworkService) Import( return framework, nil } -func (s FrameworkService) ExportAudit( - ctx context.Context, - frameworkID gid.GID, -) (string, error) { - var archivePath string - var objectKey string +func (s FrameworkService) StateOfApplicability(ctx context.Context, frameworkID gid.GID) ([]byte, error) { + rows := []soagen.SOARowData{} - err := s.svc.pg.WithConn(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() - exportDir := filepath.Join(os.TempDir(), "probo-export", framework.Name, now.Format("2006-01-02-15-04-05")) - - if err := os.MkdirAll(exportDir, 0755); err != nil { - return fmt.Errorf("cannot create export directory: %w", err) - } - - controls := coredata.Controls{} - controlsCursor := page.NewCursor( - 0, - nil, - page.Head, - page.OrderBy[coredata.ControlOrderField]{ - Field: coredata.ControlOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - ) - - if err := controls.LoadByFrameworkID(ctx, conn, s.svc.scope, frameworkID, controlsCursor, coredata.NewControlFilter(nil)); err != nil { - return fmt.Errorf("cannot load controls: %w", err) - } - - for _, control := range controls { - if err := s.exportControlData(ctx, conn, control, exportDir); err != nil { - return fmt.Errorf("cannot export control data: %w", err) + err := 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) } - } - archivePath = exportDir + ".tar.gz" - if err := createTarGzArchive(exportDir, archivePath); err != nil { - return fmt.Errorf("cannot create archive: %w", err) - } - defer os.Remove(archivePath) + controls := coredata.Controls{} + err := controls.LoadByFrameworkID( + ctx, + conn, + s.svc.scope, + frameworkID, + page.NewCursor( + maxStateOfApplicabilityLimit, + nil, + page.Head, + page.OrderBy[coredata.ControlOrderField]{ + Field: coredata.ControlOrderFieldSectionTitle, + Direction: page.OrderDirectionAsc, + }, + ), + coredata.NewControlFilter(nil), + ) - file, err := os.Open(archivePath) - if err != nil { - return fmt.Errorf("cannot open archive file: %w", err) - } - defer file.Close() - - objectKey = fmt.Sprintf("exports/%s/%s.tar.gz", frameworkID, now.Format("2006-01-02-15-04-05")) - _, err = s.svc.s3.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(s.svc.bucket), - Key: aws.String(objectKey), - Body: file, - }) - if err != nil { - return fmt.Errorf("cannot upload archive to S3: %w", err) - } - - return nil - }) - - if err != nil { - return "", err - } - - presignClient := s3.NewPresignClient(s.svc.s3) - - presignedReq, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(s.svc.bucket), - Key: aws.String(objectKey), - }, func(opts *s3.PresignOptions) { - opts.Expires = presignExpiry - }) - if err != nil { - return "", fmt.Errorf("cannot generate presigned URL: %w", err) - } - - return presignedReq.URL, nil -} - -func (s FrameworkService) exportControlData(ctx context.Context, conn pg.Conn, control *coredata.Control, exportDir string) error { - controlDir := filepath.Join(exportDir, filepath.Base(control.SectionTitle)) - if err := os.MkdirAll(controlDir, 0755); err != nil { - return fmt.Errorf("cannot create control directory: %w", err) - } - - measures, err := s.loadMeasuresForControl(ctx, conn, control.ID) - if err != nil { - return fmt.Errorf("cannot load measures: %w", err) - } - - documents, err := s.loadDocumentsForControl(ctx, conn, control.ID) - if err != nil { - return fmt.Errorf("cannot load documents: %w", err) - } - - if err := s.exportDocuments(ctx, conn, documents, controlDir); err != nil { - return fmt.Errorf("cannot export documents: %w", err) - } - - if err := s.exportMeasures(ctx, conn, measures, controlDir); err != nil { - return fmt.Errorf("cannot export measures: %w", err) - } - - return nil -} - -func (s FrameworkService) loadMeasuresForControl(ctx context.Context, conn pg.Conn, controlID gid.GID) (coredata.Measures, error) { - measures := coredata.Measures{} - cursor := page.NewCursor( - 0, - nil, - page.Head, - page.OrderBy[coredata.MeasureOrderField]{ - Field: coredata.MeasureOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - ) - - err := measures.LoadByControlID(ctx, conn, s.svc.scope, controlID, cursor, coredata.NewMeasureFilter(nil)) - return measures, err -} - -func (s FrameworkService) loadDocumentsForControl(ctx context.Context, conn pg.Conn, controlID gid.GID) (coredata.Documents, error) { - documents := coredata.Documents{} - cursor := page.NewCursor( - 0, - nil, - page.Head, - page.OrderBy[coredata.DocumentOrderField]{ - Field: coredata.DocumentOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - ) - - err := documents.LoadByControlID(ctx, conn, s.svc.scope, controlID, cursor, coredata.NewDocumentFilter(nil)) - return documents, err -} - -func (s FrameworkService) exportDocuments(ctx context.Context, conn pg.Conn, documents coredata.Documents, controlDir string) error { - for _, document := range documents { - documentDir := filepath.Join(controlDir, filepath.Base(document.Title)) - if err := os.MkdirAll(documentDir, 0755); err != nil { - return fmt.Errorf("cannot create document directory: %w", err) - } - - version := coredata.DocumentVersion{} - if err := version.LoadLatestVersion(ctx, conn, s.svc.scope, document.ID); err != nil { - return fmt.Errorf("cannot load document version: %w", err) - } - - documentFile := filepath.Join(documentDir, "document.md") - if err := os.WriteFile(documentFile, []byte(version.Content), 0644); err != nil { - return fmt.Errorf("cannot write document file: %w", err) - } - } - return nil -} - -func (s FrameworkService) exportMeasures(ctx context.Context, conn pg.Conn, measures coredata.Measures, controlDir string) error { - for _, measure := range measures { - measureDir := filepath.Join(controlDir, filepath.Base(measure.Name)) - if err := os.MkdirAll(measureDir, 0755); err != nil { - return fmt.Errorf("cannot create measure directory: %w", err) - } - - evidences := coredata.Evidences{} - evidenceCursor := page.NewCursor( - 0, - nil, - page.Head, - page.OrderBy[coredata.EvidenceOrderField]{ - Field: coredata.EvidenceOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - ) - - if err := evidences.LoadByMeasureID(ctx, conn, s.svc.scope, measure.ID, evidenceCursor); err != nil { - return fmt.Errorf("cannot load evidences: %w", err) - } - - for _, evidence := range evidences { - if err := s.exportEvidence(ctx, evidence, measureDir); err != nil { - return fmt.Errorf("cannot export evidence: %w", err) - } - } - } - return nil -} - -func (s FrameworkService) exportEvidence(ctx context.Context, evidence *coredata.Evidence, measureDir string) error { - if evidence.Type != coredata.EvidenceTypeFile || evidence.ObjectKey == "" { - return nil - } - - evidenceFile := filepath.Join(measureDir, filepath.Base(evidence.Filename)) - - output, 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 file: %w", err) - } - defer output.Body.Close() - - file, err := os.Create(evidenceFile) - if err != nil { - return fmt.Errorf("cannot create evidence file: %w", err) - } - defer file.Close() - - _, err = io.Copy(file, output.Body) - if err != nil { - return fmt.Errorf("cannot write evidence file: %w", err) - } - - return nil -} - -func createTarGzArchive(sourceDir, targetFile string) error { - tarFile, err := os.Create(targetFile) - if err != nil { - return fmt.Errorf("could not create archive file: %w", err) - } - defer tarFile.Close() - - gzipWriter := io.Writer(tarFile) - gzw := gzip.NewWriter(gzipWriter) - defer gzw.Close() - - tarWriter := tar.NewWriter(gzw) - defer tarWriter.Close() - - err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - header, err := tar.FileInfoHeader(info, info.Name()) - if err != nil { - return fmt.Errorf("could not create tar header: %w", err) - } - - relPath, err := filepath.Rel(sourceDir, path) - if err != nil { - return fmt.Errorf("could not get relative path: %w", err) - } - header.Name = relPath - - if err := tarWriter.WriteHeader(header); err != nil { - return fmt.Errorf("could not write tar header: %w", err) - } - - if info.Mode().IsRegular() { - file, err := os.Open(path) if err != nil { - return fmt.Errorf("could not open file %s: %w", path, err) + return fmt.Errorf("cannot load controls: %w", err) } - defer file.Close() - if _, err := io.Copy(tarWriter, file); err != nil { - return fmt.Errorf("could not copy file content: %w", err) + for _, control := range controls { + row := soagen.SOARowData{ + SectionTitle: control.SectionTitle, + ControlName: control.Name, + Applicability: soagen.NewApplicability("Yes", true), + JustificationExclusion: "", + Regulatory: ref.Ref(false), + Contractual: ref.Ref(false), + BestPractice: ref.Ref(true), + RiskAssessment: ref.Ref(false), + SecurityMeasures: []string{}, + } + + measures := coredata.Measures{} + err = measures.LoadByControlID( + ctx, + conn, + s.svc.scope, + control.ID, + page.NewCursor( + maxStateOfApplicabilityLimit, + 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 { + risks := coredata.Risks{} + risksCount, err := risks.CountByMeasureID( + ctx, + conn, + s.svc.scope, + measure.ID, + coredata.NewRiskFilter(nil), + ) + if err != nil { + return fmt.Errorf("cannot count risks: %w", err) + } + + if risksCount > 0 { + row.RiskAssessment = ref.Ref(true) + } + + row.SecurityMeasures = append(row.SecurityMeasures, measure.Name) + } + + documents := coredata.Documents{} + err = documents.LoadByControlID( + ctx, + conn, + s.svc.scope, + control.ID, + page.NewCursor( + 0, + 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 { + risks := coredata.Risks{} + risksCount, err := risks.CountByDocumentID( + ctx, + conn, + s.svc.scope, + document.ID, + coredata.NewRiskFilter(nil), + ) + if err != nil { + return fmt.Errorf("cannot count risks: %w", err) + } + + if risksCount > 0 { + row.RiskAssessment = ref.Ref(true) + } + + row.SecurityMeasures = append(row.SecurityMeasures, document.Title) + } + + rows = append(rows, row) } - } - return nil - }) - - return err -} - -func (s FrameworkService) StateOfApplicability(ctx context.Context, frameworkID gid.GID) (string, error) { - framework := &coredata.Framework{} - var presignedURL string - - err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error { - if err := framework.LoadByID(ctx, conn, s.svc.scope, frameworkID); err != nil { - return fmt.Errorf("cannot load framework: %w", err) - } - - controls, err := s.loadAllControlsForFramework(ctx, conn, frameworkID) - if err != nil { - return err - } - - soaData, err := s.buildSOAData(ctx, conn, controls) - if err != nil { - return err - } - - excelReader, err := s.createSOAExcelFile(soaData) - if err != nil { - return err - } - - // Upload to S3 and get presigned URL - presignedURL, err = s.uploadSOAToS3(ctx, framework, excelReader) - if err != nil { - return fmt.Errorf("cannot upload SOA to S3: %w", err) - } - - return nil - }) - - if err != nil { - return "", err - } - - return presignedURL, nil -} - -func (s FrameworkService) loadAllControlsForFramework(ctx context.Context, conn pg.Conn, frameworkID gid.GID) (coredata.Controls, error) { - controls := coredata.Controls{} - controlsCursor := page.NewCursor( - maxControlsLimit, - nil, - page.Head, - page.OrderBy[coredata.ControlOrderField]{ - Field: coredata.ControlOrderFieldSectionTitle, - Direction: page.OrderDirectionAsc, + return nil }, ) - if err := controls.LoadByFrameworkID(ctx, conn, s.svc.scope, frameworkID, controlsCursor, coredata.NewControlFilter(nil)); err != nil { - return nil, fmt.Errorf("cannot load controls: %w", err) - } - - return controls, nil -} - -func (s FrameworkService) buildSOAData(ctx context.Context, conn pg.Conn, controls coredata.Controls) ([]soaRowData, error) { - var soaData []soaRowData - - for _, control := range controls { - if control == nil { - continue - } - - rowData, err := s.buildSOARowData(ctx, conn, control) - if err != nil { - return nil, fmt.Errorf("cannot build SOA row data for control %s: %w", control.ID, err) - } - - soaData = append(soaData, rowData) - } - - return soaData, nil -} - -func (s FrameworkService) buildSOARowData(ctx context.Context, conn pg.Conn, control *coredata.Control) (soaRowData, error) { - measures, err := s.loadMeasuresForControl(ctx, conn, control.ID) - if err != nil { - return soaRowData{}, fmt.Errorf("cannot load measures: %w", err) - } - - policies, err := s.loadDocumentsForControl(ctx, conn, control.ID) - if err != nil { - return soaRowData{}, fmt.Errorf("cannot load policies: %w", err) - } - - hasRisks := false - for _, measure := range measures { - if measure == nil { - continue - } - - evidences := coredata.Evidences{} - evidencesCursor := page.NewCursor( - maxItemsLimit, - nil, - page.Head, - page.OrderBy[coredata.EvidenceOrderField]{ - Field: coredata.EvidenceOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - ) - - if err := evidences.LoadByMeasureID(ctx, conn, s.svc.scope, measure.ID, evidencesCursor); err != nil { - continue - } - - risks := coredata.Risks{} - risksCursor := page.NewCursor( - maxItemsLimit, - nil, - page.Head, - page.OrderBy[coredata.RiskOrderField]{ - Field: coredata.RiskOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - ) - - if err := risks.LoadByMeasureID(ctx, conn, s.svc.scope, measure.ID, risksCursor, coredata.NewRiskFilter(nil)); err != nil { - continue - } - - hasRisks = len(risks) > 0 - } - - rowData := soaRowData{ - control: control, - } - - if len(measures) > 0 || len(policies) > 0 { - rowData.applicability = "Yes" - rowData.isApplicable = true - rowData.regulatory = "YES" - rowData.bestPractice = "YES" - - if hasRisks { - rowData.riskAssessment = "YES" - } - - var measuresList []string - for _, measure := range measures { - if measure != nil { - measuresList = append(measuresList, "• "+measure.Name) - } - } - for _, policy := range policies { - if policy != nil { - measuresList = append(measuresList, "• "+policy.Title) - } - } - rowData.securityMeasures = strings.Join(measuresList, "\n") - } else { - rowData.applicability = "No" - rowData.isApplicable = false - rowData.justificationExclusion = "Not applicable to current business operations" - } - - return rowData, nil -} - -func (s FrameworkService) createSOAExcelFile(soaData []soaRowData) (io.Reader, error) { - f := excelize.NewFile() - if err := f.Close(); err != nil { - return nil, fmt.Errorf("cannot close Excel file: %w", err) - } - - sheetName := "State of Applicability" - f.SetSheetName("Sheet1", sheetName) - - styles, err := s.createExcelStyles(f) - if err != nil { - return nil, fmt.Errorf("cannot create Excel styles: %w", err) - } - - if err := s.setupExcelHeader(f, sheetName, styles); err != nil { - return nil, fmt.Errorf("cannot setup Excel header: %w", err) - } - - if err := s.populateExcelData(f, sheetName, soaData, styles); err != nil { - return nil, fmt.Errorf("cannot populate Excel data: %w", err) - } - - if err := s.applyExcelFormatting(f, sheetName, len(soaData)); err != nil { - return nil, fmt.Errorf("cannot apply Excel formatting: %w", err) - } - - // Write to buffer - buffer, err := f.WriteToBuffer() - if err != nil { - return nil, fmt.Errorf("cannot write Excel file to buffer: %w", err) - } - - // Convert to bytes.Reader for seekability (required by S3 SDK) - return bytes.NewReader(buffer.Bytes()), nil -} - -func (s FrameworkService) createExcelStyles(f *excelize.File) (map[string]int, error) { - styles := make(map[string]int) - - headerStyle, err := f.NewStyle(&excelize.Style{ - Font: &excelize.Font{Bold: true, Size: 10, Color: "#000000"}, - Fill: excelize.Fill{Type: "pattern", Color: []string{"#D9D9D9"}, Pattern: 1}, - Border: []excelize.Border{ - {Type: "left", Color: "#000000", Style: 1}, - {Type: "top", Color: "#000000", Style: 1}, - {Type: "bottom", Color: "#000000", Style: 1}, - {Type: "right", Color: "#000000", Style: 1}, - }, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, - }) if err != nil { return nil, err } - styles["header"] = headerStyle - cellStyle, err := f.NewStyle(&excelize.Style{ - Border: []excelize.Border{ - {Type: "left", Color: "#000000", Style: 1}, - {Type: "top", Color: "#000000", Style: 1}, - {Type: "bottom", Color: "#000000", Style: 1}, - {Type: "right", Color: "#000000", Style: 1}, + output, err := soagen.GenerateExcel( + soagen.SOAData{ + Rows: rows, }, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, - }) + ) if err != nil { - return nil, err + return nil, fmt.Errorf("cannot generate Excel file: %w", err) } - styles["cell"] = cellStyle - textCellStyle, err := f.NewStyle(&excelize.Style{ - Border: []excelize.Border{ - {Type: "left", Color: "#000000", Style: 1}, - {Type: "top", Color: "#000000", Style: 1}, - {Type: "bottom", Color: "#000000", Style: 1}, - {Type: "right", Color: "#000000", Style: 1}, - }, - Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true}, - }) - if err != nil { - return nil, err - } - styles["textCell"] = textCellStyle - - greenApplicabilityStyle, err := f.NewStyle(&excelize.Style{ - Border: []excelize.Border{ - {Type: "left", Color: "#000000", Style: 1}, - {Type: "top", Color: "#000000", Style: 1}, - {Type: "bottom", Color: "#000000", Style: 1}, - {Type: "right", Color: "#000000", Style: 1}, - }, - Fill: excelize.Fill{Type: "pattern", Color: []string{"#90EE90"}, Pattern: 1}, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, - }) - if err != nil { - return nil, err - } - styles["greenApplicability"] = greenApplicabilityStyle - - redApplicabilityStyle, err := f.NewStyle(&excelize.Style{ - Border: []excelize.Border{ - {Type: "left", Color: "#000000", Style: 1}, - {Type: "top", Color: "#000000", Style: 1}, - {Type: "bottom", Color: "#000000", Style: 1}, - {Type: "right", Color: "#000000", Style: 1}, - }, - Fill: excelize.Fill{Type: "pattern", Color: []string{"#FFB6C1"}, Pattern: 1}, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, - }) - if err != nil { - return nil, err - } - styles["redApplicability"] = redApplicabilityStyle - - return styles, nil -} - -func (s FrameworkService) setupExcelHeader(f *excelize.File, sheetName string, styles map[string]int) error { - // Version, Date, Comment, Author, Approver header - f.SetCellValue(sheetName, "A2", "Version") - f.SetCellValue(sheetName, "C2", "Date") - f.SetCellValue(sheetName, "D2", "Comment") - f.SetCellValue(sheetName, "E2", "Author") - f.SetCellValue(sheetName, "F2", "Approver") - - // Sample data for header - f.SetCellValue(sheetName, "A3", "1.0") - f.SetCellValue(sheetName, "C3", time.Now().Format("01/02/2006")) - f.SetCellValue(sheetName, "D3", "Initial SoA") - f.SetCellValue(sheetName, "E3", "System Admin") - f.SetCellValue(sheetName, "F3", "Security Manager") - - // Apply header styles - for _, cell := range []string{"A2", "C2", "D2", "E2", "F2"} { - f.SetCellStyle(sheetName, cell, cell, styles["header"]) - } - for _, cell := range []string{"A3", "C3", "D3", "E3", "F3"} { - f.SetCellStyle(sheetName, cell, cell, styles["cell"]) - } - - // Main table headers (row 6-7) - f.SetCellValue(sheetName, "A6", "Control") - f.SetCellValue(sheetName, "B6", "Control name") - f.SetCellValue(sheetName, "C6", "Applicability") - f.SetCellValue(sheetName, "D6", "Justification for exclusion") - f.SetCellValue(sheetName, "E6", "Justification for inclusion") - f.SetCellValue(sheetName, "I6", "List of security measure or policy") - - // Sub headers for "Justification for inclusion" - f.SetCellValue(sheetName, "E7", "Regulatory") - f.SetCellValue(sheetName, "F7", "Contractual") - f.SetCellValue(sheetName, "G7", "Best practice") - f.SetCellValue(sheetName, "H7", "Risk assessment") - - // Merge cells for main headers - f.MergeCell(sheetName, "A6", "A7") - f.MergeCell(sheetName, "B6", "B7") - f.MergeCell(sheetName, "C6", "C7") - f.MergeCell(sheetName, "D6", "D7") - f.MergeCell(sheetName, "E6", "H6") - f.MergeCell(sheetName, "I6", "I7") - - // Apply header styles - headerCells := []string{"A6", "A7", "B6", "B7", "C6", "C7", "D6", "D7", "E6", "I6", "I7", "E6", "E7", "F6", "F7", "G6", "G7", "H6", "H7"} - for _, cell := range headerCells { - f.SetCellStyle(sheetName, cell, cell, styles["header"]) - } - - return nil -} - -func (s FrameworkService) populateExcelData(f *excelize.File, sheetName string, soaData []soaRowData, styles map[string]int) error { - currentRow := 8 - - for _, rowData := range soaData { - control := rowData.control - - f.SetCellValue(sheetName, fmt.Sprintf("A%d", currentRow), control.SectionTitle) - f.SetCellValue(sheetName, fmt.Sprintf("B%d", currentRow), control.Name) - f.SetCellValue(sheetName, fmt.Sprintf("C%d", currentRow), rowData.applicability) - f.SetCellValue(sheetName, fmt.Sprintf("D%d", currentRow), rowData.justificationExclusion) - f.SetCellValue(sheetName, fmt.Sprintf("E%d", currentRow), rowData.regulatory) - f.SetCellValue(sheetName, fmt.Sprintf("F%d", currentRow), rowData.contractual) - f.SetCellValue(sheetName, fmt.Sprintf("G%d", currentRow), rowData.bestPractice) - f.SetCellValue(sheetName, fmt.Sprintf("H%d", currentRow), rowData.riskAssessment) - f.SetCellValue(sheetName, fmt.Sprintf("I%d", currentRow), rowData.securityMeasures) - - // Apply appropriate styles - for col := 'A'; col <= 'I'; col++ { - cellRef := fmt.Sprintf("%c%d", col, currentRow) - if col == 'C' { // Applicability column - if rowData.isApplicable { - f.SetCellStyle(sheetName, cellRef, cellRef, styles["greenApplicability"]) - } else { - f.SetCellStyle(sheetName, cellRef, cellRef, styles["redApplicability"]) - } - } else if col >= 'E' && col <= 'H' { // Justification columns - f.SetCellStyle(sheetName, cellRef, cellRef, styles["cell"]) - } else { // Other columns - f.SetCellStyle(sheetName, cellRef, cellRef, styles["textCell"]) - } - } - - currentRow++ - } - - return nil -} - -func (s FrameworkService) applyExcelFormatting(f *excelize.File, sheetName string, dataRowCount int) error { - if dataRowCount == 0 { - return nil - } - - lastRow := 8 + dataRowCount - 1 - - // Add data validation for Applicability column - dvRange := fmt.Sprintf("C8:C%d", lastRow) - dv := excelize.NewDataValidation(true) - dv.Sqref = dvRange - dv.SetDropList([]string{"Yes", "No"}) - dv.SetError(excelize.DataValidationErrorStyleStop, "Invalid Input", "Please select Yes or No from the dropdown list.") - if err := f.AddDataValidation(sheetName, dv); err != nil { - return fmt.Errorf("failed to add data validation: %w", err) - } - - // Add data validation for justification columns - for _, col := range []string{"E", "F", "G", "H"} { - dvRange := fmt.Sprintf("%s8:%s%d", col, col, lastRow) - dv := excelize.NewDataValidation(true) - dv.Sqref = dvRange - dv.SetDropList([]string{"YES", "NO", ""}) - dv.SetError(excelize.DataValidationErrorStyleStop, "Invalid Input", "Please select YES, NO, or leave empty.") - if err := f.AddDataValidation(sheetName, dv); err != nil { - return fmt.Errorf("failed to add data validation for column %s: %w", col, err) - } - } - - // Add auto-filter - filterRange := fmt.Sprintf("A6:I%d", lastRow) - if err := f.AutoFilter(sheetName, filterRange, []excelize.AutoFilterOptions{}); err != nil { - return fmt.Errorf("failed to add auto-filter: %w", err) - } - - // Set column widths - f.SetColWidth(sheetName, "A", "A", 12) - f.SetColWidth(sheetName, "B", "B", 35) - f.SetColWidth(sheetName, "C", "C", 12) - f.SetColWidth(sheetName, "D", "D", 25) - f.SetColWidth(sheetName, "E", "E", 12) - f.SetColWidth(sheetName, "F", "F", 12) - f.SetColWidth(sheetName, "G", "G", 12) - f.SetColWidth(sheetName, "H", "H", 12) - f.SetColWidth(sheetName, "I", "I", 40) - - // Set row heights for better visibility - f.SetRowHeight(sheetName, 6, 30) - f.SetRowHeight(sheetName, 7, 30) - - return nil -} - -func (s FrameworkService) generateSOAFileName(framework *coredata.Framework) string { - now := time.Now() - - // Sanitize framework name by replacing invalid file name characters - sanitizedName := framework.Name - invalidChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", " "} - for _, char := range invalidChars { - sanitizedName = strings.ReplaceAll(sanitizedName, char, "_") - } - - return fmt.Sprintf("SOA_%s_%s.xlsx", - sanitizedName, - now.Format("20060102_150405")) -} - -func (s FrameworkService) uploadSOAToS3(ctx context.Context, framework *coredata.Framework, excelReader io.Reader) (string, error) { - objectKey := fmt.Sprintf("soa/%s/%s", framework.ID, s.generateSOAFileName(framework)) - - // Upload file to S3 using bytes reader - _, err := s.svc.s3.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(s.svc.bucket), - Key: aws.String(objectKey), - Body: excelReader, - }) - if err != nil { - return "", fmt.Errorf("cannot upload SOA file to S3: %w", err) - } - - // Generate presigned URL - presignClient := s3.NewPresignClient(s.svc.s3) - presignedReq, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(s.svc.bucket), - Key: aws.String(objectKey), - }, func(opts *s3.PresignOptions) { - opts.Expires = presignExpiry - }) - if err != nil { - return "", fmt.Errorf("cannot generate presigned URL: %w", err) - } - - return presignedReq.URL, nil + return output, nil } diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index a8d0e3bc7..a81f4b848 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -1414,8 +1414,6 @@ type Mutation { input: CreateVendorRiskAssessmentInput! ): CreateVendorRiskAssessmentPayload! - exportAudit(input: ExportAuditInput!): ExportAuditPayload! - assessVendor(input: AssessVendorInput!): AssessVendorPayload! createAsset(input: CreateAssetInput!): CreateAssetPayload! @@ -1433,7 +1431,7 @@ input GenerateFrameworkStateOfApplicabilityInput { } type GenerateFrameworkStateOfApplicabilityPayload { - downloadUrl: String! + data: String! } input CreateOrganizationInput { @@ -2185,14 +2183,6 @@ input UploadMeasureEvidenceInput { file: Upload! } -input ExportAuditInput { - frameworkId: ID! -} - -type ExportAuditPayload { - url: String! -} - input GenerateDocumentChangelogInput { documentId: ID! } diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index c97b096a6..54e939b83 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -436,10 +436,6 @@ type ComplexityRoot struct { Node func(childComplexity int) int } - ExportAuditPayload struct { - URL func(childComplexity int) int - } - ExportDocumentVersionPDFPayload struct { Data func(childComplexity int) int } @@ -474,7 +470,7 @@ type ComplexityRoot struct { } GenerateFrameworkStateOfApplicabilityPayload struct { - DownloadURL func(childComplexity int) int + Data func(childComplexity int) int } ImportFrameworkPayload struct { @@ -554,7 +550,6 @@ type ComplexityRoot struct { DeleteTask func(childComplexity int, input types.DeleteTaskInput) int DeleteVendor func(childComplexity int, input types.DeleteVendorInput) int DeleteVendorComplianceReport func(childComplexity int, input types.DeleteVendorComplianceReportInput) int - ExportAudit func(childComplexity int, input types.ExportAuditInput) int ExportDocumentVersionPDF func(childComplexity int, input types.ExportDocumentVersionPDFInput) int FulfillEvidence func(childComplexity int, input types.FulfillEvidenceInput) int GenerateDocumentChangelog func(childComplexity int, input types.GenerateDocumentChangelogInput) int @@ -1045,7 +1040,6 @@ type MutationResolver interface { CancelSignatureRequest(ctx context.Context, input types.CancelSignatureRequestInput) (*types.CancelSignatureRequestPayload, error) ExportDocumentVersionPDF(ctx context.Context, input types.ExportDocumentVersionPDFInput) (*types.ExportDocumentVersionPDFPayload, error) CreateVendorRiskAssessment(ctx context.Context, input types.CreateVendorRiskAssessmentInput) (*types.CreateVendorRiskAssessmentPayload, error) - ExportAudit(ctx context.Context, input types.ExportAuditInput) (*types.ExportAuditPayload, error) AssessVendor(ctx context.Context, input types.AssessVendorInput) (*types.AssessVendorPayload, error) CreateAsset(ctx context.Context, input types.CreateAssetInput) (*types.CreateAssetPayload, error) UpdateAsset(ctx context.Context, input types.UpdateAssetInput) (*types.UpdateAssetPayload, error) @@ -2338,13 +2332,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.EvidenceEdge.Node(childComplexity), true - case "ExportAuditPayload.url": - if e.complexity.ExportAuditPayload.URL == nil { - break - } - - return e.complexity.ExportAuditPayload.URL(childComplexity), true - case "ExportDocumentVersionPDFPayload.data": if e.complexity.ExportDocumentVersionPDFPayload.Data == nil { break @@ -2455,12 +2442,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.GenerateDocumentChangelogPayload.Changelog(childComplexity), true - case "GenerateFrameworkStateOfApplicabilityPayload.downloadUrl": - if e.complexity.GenerateFrameworkStateOfApplicabilityPayload.DownloadURL == nil { + case "GenerateFrameworkStateOfApplicabilityPayload.data": + if e.complexity.GenerateFrameworkStateOfApplicabilityPayload.Data == nil { break } - return e.complexity.GenerateFrameworkStateOfApplicabilityPayload.DownloadURL(childComplexity), true + return e.complexity.GenerateFrameworkStateOfApplicabilityPayload.Data(childComplexity), true case "ImportFrameworkPayload.frameworkEdge": if e.complexity.ImportFrameworkPayload.FrameworkEdge == nil { @@ -3083,18 +3070,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.DeleteVendorComplianceReport(childComplexity, args["input"].(types.DeleteVendorComplianceReportInput)), true - case "Mutation.exportAudit": - if e.complexity.Mutation.ExportAudit == nil { - break - } - - args, err := ec.field_Mutation_exportAudit_args(ctx, rawArgs) - if err != nil { - return 0, false - } - - return e.complexity.Mutation.ExportAudit(childComplexity, args["input"].(types.ExportAuditInput)), true - case "Mutation.exportDocumentVersionPDF": if e.complexity.Mutation.ExportDocumentVersionPDF == nil { break @@ -4827,7 +4802,6 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputDocumentVersionOrder, ec.unmarshalInputDocumentVersionSignatureOrder, ec.unmarshalInputEvidenceOrder, - ec.unmarshalInputExportAuditInput, ec.unmarshalInputExportDocumentVersionPDFInput, ec.unmarshalInputFrameworkOrder, ec.unmarshalInputFulfillEvidenceInput, @@ -6381,8 +6355,6 @@ type Mutation { input: CreateVendorRiskAssessmentInput! ): CreateVendorRiskAssessmentPayload! - exportAudit(input: ExportAuditInput!): ExportAuditPayload! - assessVendor(input: AssessVendorInput!): AssessVendorPayload! createAsset(input: CreateAssetInput!): CreateAssetPayload! @@ -6400,7 +6372,7 @@ input GenerateFrameworkStateOfApplicabilityInput { } type GenerateFrameworkStateOfApplicabilityPayload { - downloadUrl: String! + data: String! } input CreateOrganizationInput { @@ -7152,14 +7124,6 @@ input UploadMeasureEvidenceInput { file: Upload! } -input ExportAuditInput { - frameworkId: ID! -} - -type ExportAuditPayload { - url: String! -} - input GenerateDocumentChangelogInput { documentId: ID! } @@ -9484,29 +9448,6 @@ func (ec *executionContext) field_Mutation_deleteVendor_argsInput( return zeroVal, nil } -func (ec *executionContext) field_Mutation_exportAudit_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { - var err error - args := map[string]any{} - arg0, err := ec.field_Mutation_exportAudit_argsInput(ctx, rawArgs) - if err != nil { - return nil, err - } - args["input"] = arg0 - return args, nil -} -func (ec *executionContext) field_Mutation_exportAudit_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (types.ExportAuditInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNExportAuditInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportAuditInput(ctx, tmp) - } - - var zeroVal types.ExportAuditInput - return zeroVal, nil -} - func (ec *executionContext) field_Mutation_exportDocumentVersionPDF_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -20577,50 +20518,6 @@ func (ec *executionContext) fieldContext_EvidenceEdge_node(_ context.Context, fi return fc, nil } -func (ec *executionContext) _ExportAuditPayload_url(ctx context.Context, field graphql.CollectedField, obj *types.ExportAuditPayload) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_ExportAuditPayload_url(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.URL, 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.(string) - fc.Result = res - return ec.marshalNString2string(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_ExportAuditPayload_url(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "ExportAuditPayload", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _ExportDocumentVersionPDFPayload_data(ctx context.Context, field graphql.CollectedField, obj *types.ExportDocumentVersionPDFPayload) (ret graphql.Marshaler) { fc, err := ec.fieldContext_ExportDocumentVersionPDFPayload_data(ctx, field) if err != nil { @@ -21374,8 +21271,8 @@ func (ec *executionContext) fieldContext_GenerateDocumentChangelogPayload_change return fc, nil } -func (ec *executionContext) _GenerateFrameworkStateOfApplicabilityPayload_downloadUrl(ctx context.Context, field graphql.CollectedField, obj *types.GenerateFrameworkStateOfApplicabilityPayload) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_GenerateFrameworkStateOfApplicabilityPayload_downloadUrl(ctx, field) +func (ec *executionContext) _GenerateFrameworkStateOfApplicabilityPayload_data(ctx context.Context, field graphql.CollectedField, obj *types.GenerateFrameworkStateOfApplicabilityPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_GenerateFrameworkStateOfApplicabilityPayload_data(ctx, field) if err != nil { return graphql.Null } @@ -21388,7 +21285,7 @@ func (ec *executionContext) _GenerateFrameworkStateOfApplicabilityPayload_downlo }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return obj.DownloadURL, nil + return obj.Data, nil }) if err != nil { ec.Error(ctx, err) @@ -21405,7 +21302,7 @@ func (ec *executionContext) _GenerateFrameworkStateOfApplicabilityPayload_downlo return ec.marshalNString2string(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_GenerateFrameworkStateOfApplicabilityPayload_downloadUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_GenerateFrameworkStateOfApplicabilityPayload_data(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "GenerateFrameworkStateOfApplicabilityPayload", Field: field, @@ -23306,8 +23203,8 @@ func (ec *executionContext) fieldContext_Mutation_generateFrameworkStateOfApplic IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "downloadUrl": - return ec.fieldContext_GenerateFrameworkStateOfApplicabilityPayload_downloadUrl(ctx, field) + case "data": + return ec.fieldContext_GenerateFrameworkStateOfApplicabilityPayload_data(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type GenerateFrameworkStateOfApplicabilityPayload", field.Name) }, @@ -25944,65 +25841,6 @@ func (ec *executionContext) fieldContext_Mutation_createVendorRiskAssessment(ctx return fc, nil } -func (ec *executionContext) _Mutation_exportAudit(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Mutation_exportAudit(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().ExportAudit(rctx, fc.Args["input"].(types.ExportAuditInput)) - }) - 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.ExportAuditPayload) - fc.Result = res - return ec.marshalNExportAuditPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportAuditPayload(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Mutation_exportAudit(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 "url": - return ec.fieldContext_ExportAuditPayload_url(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type ExportAuditPayload", 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_exportAudit_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { - ec.Error(ctx, err) - return fc, err - } - return fc, nil -} - func (ec *executionContext) _Mutation_assessVendor(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_assessVendor(ctx, field) if err != nil { @@ -39747,33 +39585,6 @@ func (ec *executionContext) unmarshalInputEvidenceOrder(ctx context.Context, obj return it, nil } -func (ec *executionContext) unmarshalInputExportAuditInput(ctx context.Context, obj any) (types.ExportAuditInput, error) { - var it types.ExportAuditInput - 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) unmarshalInputExportDocumentVersionPDFInput(ctx context.Context, obj any) (types.ExportDocumentVersionPDFInput, error) { var it types.ExportDocumentVersionPDFInput asMap := map[string]any{} @@ -45560,45 +45371,6 @@ func (ec *executionContext) _EvidenceEdge(ctx context.Context, sel ast.Selection return out } -var exportAuditPayloadImplementors = []string{"ExportAuditPayload"} - -func (ec *executionContext) _ExportAuditPayload(ctx context.Context, sel ast.SelectionSet, obj *types.ExportAuditPayload) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, exportAuditPayloadImplementors) - - 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("ExportAuditPayload") - case "url": - out.Values[i] = ec._ExportAuditPayload_url(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 exportDocumentVersionPDFPayloadImplementors = []string{"ExportDocumentVersionPDFPayload"} func (ec *executionContext) _ExportDocumentVersionPDFPayload(ctx context.Context, sel ast.SelectionSet, obj *types.ExportDocumentVersionPDFPayload) graphql.Marshaler { @@ -45982,8 +45754,8 @@ func (ec *executionContext) _GenerateFrameworkStateOfApplicabilityPayload(ctx co switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("GenerateFrameworkStateOfApplicabilityPayload") - case "downloadUrl": - out.Values[i] = ec._GenerateFrameworkStateOfApplicabilityPayload_downloadUrl(ctx, field, obj) + case "data": + out.Values[i] = ec._GenerateFrameworkStateOfApplicabilityPayload_data(ctx, field, obj) if out.Values[i] == graphql.Null { out.Invalids++ } @@ -46903,13 +46675,6 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } - case "exportAudit": - out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { - return ec._Mutation_exportAudit(ctx, field) - }) - if out.Values[i] == graphql.Null { - out.Invalids++ - } case "assessVendor": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_assessVendor(ctx, field) @@ -52911,25 +52676,6 @@ var ( } ) -func (ec *executionContext) unmarshalNExportAuditInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportAuditInput(ctx context.Context, v any) (types.ExportAuditInput, error) { - res, err := ec.unmarshalInputExportAuditInput(ctx, v) - return res, graphql.ErrorOnPath(ctx, err) -} - -func (ec *executionContext) marshalNExportAuditPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportAuditPayload(ctx context.Context, sel ast.SelectionSet, v types.ExportAuditPayload) graphql.Marshaler { - return ec._ExportAuditPayload(ctx, sel, &v) -} - -func (ec *executionContext) marshalNExportAuditPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportAuditPayload(ctx context.Context, sel ast.SelectionSet, v *types.ExportAuditPayload) 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._ExportAuditPayload(ctx, sel, v) -} - func (ec *executionContext) unmarshalNExportDocumentVersionPDFInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐExportDocumentVersionPDFInput(ctx context.Context, v any) (types.ExportDocumentVersionPDFInput, error) { res, err := ec.unmarshalInputExportDocumentVersionPDFInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index 95cd11f64..0754cd5d0 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -636,14 +636,6 @@ type EvidenceEdge struct { Node *Evidence `json:"node"` } -type ExportAuditInput struct { - FrameworkID gid.GID `json:"frameworkId"` -} - -type ExportAuditPayload struct { - URL string `json:"url"` -} - type ExportDocumentVersionPDFInput struct { DocumentVersionID gid.GID `json:"documentVersionId"` } @@ -694,7 +686,7 @@ type GenerateFrameworkStateOfApplicabilityInput struct { } type GenerateFrameworkStateOfApplicabilityPayload struct { - DownloadURL string `json:"downloadUrl"` + Data string `json:"data"` } type ImportFrameworkInput struct { diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index d21750d67..90ce8266b 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1189,7 +1189,10 @@ func (r *mutationResolver) GenerateFrameworkStateOfApplicability(ctx context.Con } return &types.GenerateFrameworkStateOfApplicabilityPayload{ - DownloadURL: soa, + Data: fmt.Sprintf( + "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,%s", + base64.StdEncoding.EncodeToString(soa), + ), }, nil } @@ -2051,19 +2054,6 @@ func (r *mutationResolver) CreateVendorRiskAssessment(ctx context.Context, input }, nil } -// ExportAudit is the resolver for the exportAudit field. -func (r *mutationResolver) ExportAudit(ctx context.Context, input types.ExportAuditInput) (*types.ExportAuditPayload, error) { - prb := r.ProboService(ctx, input.FrameworkID.TenantID()) - fileUrl, err := prb.Frameworks.ExportAudit(ctx, input.FrameworkID) - if err != nil { - panic(fmt.Errorf("cannot export audit: %w", err)) - } - - return &types.ExportAuditPayload{ - URL: fileUrl, - }, nil -} - // AssessVendor is the resolver for the assessVendor field. func (r *mutationResolver) AssessVendor(ctx context.Context, input types.AssessVendorInput) (*types.AssessVendorPayload, error) { prb := r.ProboService(ctx, input.ID.TenantID()) diff --git a/pkg/soagen/applicability.go b/pkg/soagen/applicability.go new file mode 100644 index 000000000..2f41b0faf --- /dev/null +++ b/pkg/soagen/applicability.go @@ -0,0 +1,56 @@ +// 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 soagen + +import "github.com/xuri/excelize/v2" + +type Applicability struct { + Value string + IsApplicable bool +} + +func NewApplicability(value string, isApplicable bool) Applicability { + return Applicability{ + Value: value, + IsApplicable: isApplicable, + } +} + +func (a Applicability) String() string { + return a.Value +} + +func (a Applicability) MarshalExcel() ExcelValue { + color := "#FFB6C1" // Light red for not applicable + if a.IsApplicable { + color = "#90EE90" // Light green for applicable + } + + return ExcelValue{ + Value: a.Value, + Style: &excelize.Style{ + Border: []excelize.Border{ + {Type: "left", Color: "#000000", Style: 1}, + {Type: "top", Color: "#000000", Style: 1}, + {Type: "bottom", Color: "#000000", Style: 1}, + {Type: "right", Color: "#000000", Style: 1}, + }, + Fill: excelize.Fill{Type: "pattern", Color: []string{color}, Pattern: 1}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + }, + Validation: []string{"Yes", "No"}, + Width: 12, + } +} diff --git a/pkg/soagen/excel_value.go b/pkg/soagen/excel_value.go new file mode 100644 index 000000000..ba476b9ea --- /dev/null +++ b/pkg/soagen/excel_value.go @@ -0,0 +1,28 @@ +// 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 soagen + +import "github.com/xuri/excelize/v2" + +type ExcelValue struct { + Value interface{} + Style *excelize.Style + Validation []string + Width float64 +} + +type ExcelMarshaler interface { + MarshalExcel() ExcelValue +} diff --git a/pkg/soagen/field_config.go b/pkg/soagen/field_config.go new file mode 100644 index 000000000..a08b9d80a --- /dev/null +++ b/pkg/soagen/field_config.go @@ -0,0 +1,101 @@ +// 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 soagen + +type FieldConfiguration struct { + Field string + Columns []string + FilterColumns []string // Specific columns that should have filters (subset of Columns) + Width []float64 // Width for each column + DefaultWidth float64 // Default width if not specified + HasFilter bool // Whether to enable auto-filter +} + +var ( + soaFieldConfigs = []FieldConfiguration{ + { + Field: "SectionTitle", + Columns: []string{"A"}, + FilterColumns: []string{}, + Width: []float64{12}, + DefaultWidth: 12, + HasFilter: false, + }, + { + Field: "ControlName", + Columns: []string{"B"}, + FilterColumns: []string{}, + Width: []float64{35}, + DefaultWidth: 35, + HasFilter: false, + }, + { + Field: "Applicability", + Columns: []string{"C"}, + FilterColumns: []string{"C"}, + Width: []float64{12}, + DefaultWidth: 12, + HasFilter: true, + }, + { + Field: "JustificationExclusion", + Columns: []string{"D"}, + FilterColumns: []string{}, + Width: []float64{25}, + DefaultWidth: 25, + HasFilter: false, + }, + { + Field: "Regulatory", + Columns: []string{"E"}, + FilterColumns: []string{}, + Width: []float64{12}, + DefaultWidth: 12, + HasFilter: false, + }, + { + Field: "Contractual", + Columns: []string{"F"}, + FilterColumns: []string{}, + Width: []float64{12}, + DefaultWidth: 12, + HasFilter: false, + }, + { + Field: "BestPractice", + Columns: []string{"G"}, + FilterColumns: []string{}, + Width: []float64{12}, + DefaultWidth: 12, + HasFilter: false, + }, + { + Field: "RiskAssessment", + Columns: []string{"H"}, + FilterColumns: []string{}, + Width: []float64{12}, + DefaultWidth: 12, + HasFilter: false, + }, + { + Field: "SecurityMeasures", + Columns: []string{"I"}, + FilterColumns: []string{}, + Width: []float64{40}, + DefaultWidth: 40, + HasFilter: false, + }, + } +) diff --git a/pkg/soagen/generator.go b/pkg/soagen/generator.go new file mode 100644 index 000000000..f01b542b6 --- /dev/null +++ b/pkg/soagen/generator.go @@ -0,0 +1,301 @@ +// 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 soagen + +import ( + "fmt" + "strings" + + "github.com/xuri/excelize/v2" +) + +func GenerateSOAExcel(data SOAData) ([]byte, error) { + f := excelize.NewFile() + defer f.Close() + + sheetName := "State of Applicability" + f.NewSheet(sheetName) + f.DeleteSheet("Sheet1") + + if err := setupSOAHeader(f, sheetName); err != nil { + return nil, fmt.Errorf("cannot setup Excel header: %w", err) + } + + if err := populateSOAData(f, sheetName, data.Rows); err != nil { + return nil, fmt.Errorf("cannot populate Excel data: %w", err) + } + + if err := applySOAFinalFormatting(f, sheetName, len(data.Rows)); err != nil { + return nil, fmt.Errorf("cannot apply final formatting: %w", err) + } + + buf, err := f.WriteToBuffer() + if err != nil { + return nil, fmt.Errorf("cannot write Excel file to buffer: %w", err) + } + + return buf.Bytes(), nil +} + +func setupSOAHeader(f *excelize.File, sheetName string) error { + headerStyle, err := f.NewStyle(getHeaderStyle()) + if err != nil { + return err + } + + cellStyle, err := f.NewStyle(getCellStyle()) + if err != nil { + return err + } + + return applyHeaderLayout(f, sheetName, getSOAHeaderLayout(), headerStyle, cellStyle) +} + +func populateSOAData(f *excelize.File, sheetName string, rows []SOARowData) error { + dataStartRow := 8 + + for i, rowData := range rows { + isFirstRow := i == 0 + filterColumns, err := writeSOARow(f, sheetName, dataStartRow+i, rowData, isFirstRow) + if err != nil { + return fmt.Errorf("cannot write row %d: %w", dataStartRow+i, err) + } + + if isFirstRow && len(filterColumns) > 0 { + if err := applySOAAutoFilter(f, sheetName, filterColumns); err != nil { + return fmt.Errorf("cannot apply auto filter: %w", err) + } + } + } + + return nil +} + +func writeSOARow(f *excelize.File, sheetName string, row int, data SOARowData, isFirstRow bool) ([]string, error) { + var allFilterColumns []string + + fields := []struct { + name string + value interface{} + }{ + {"SectionTitle", data.SectionTitle}, + {"ControlName", data.ControlName}, + {"Applicability", data.Applicability}, + {"Regulatory", data.Regulatory}, + {"Contractual", data.Contractual}, + {"BestPractice", data.BestPractice}, + {"RiskAssessment", data.RiskAssessment}, + {"JustificationExclusion", data.JustificationExclusion}, + {"SecurityMeasures", data.SecurityMeasures}, + } + + for _, field := range fields { + fieldConfig := getSOAFieldConfig(field.name) + if fieldConfig == nil { + continue + } + + filterColumns, err := processField(f, sheetName, row, field.value, *fieldConfig, isFirstRow) + if err != nil { + return nil, fmt.Errorf("cannot write field %s: %w", field.name, err) + } + + allFilterColumns = append(allFilterColumns, filterColumns...) + } + + return allFilterColumns, nil +} + +func getSOAFieldConfig(fieldName string) *FieldConfiguration { + for _, config := range soaFieldConfigs { + if config.Field == fieldName { + return &config + } + } + return nil +} + +func applySOAAutoFilter(f *excelize.File, sheetName string, filterColumns []string) error { + if len(filterColumns) == 0 { + return nil + } + + firstCol, lastCol := filterColumns[0], filterColumns[0] + for _, col := range filterColumns { + if col < firstCol { + firstCol = col + } + if col > lastCol { + lastCol = col + } + } + + filterRange := fmt.Sprintf("%s7:%s1000", firstCol, lastCol) + return f.AutoFilter(sheetName, filterRange, []excelize.AutoFilterOptions{}) +} + +func applySOAFinalFormatting(f *excelize.File, sheetName string, dataRowCount int) error { + if dataRowCount > 0 { + headerRowHeight := 30.0 + f.SetRowHeight(sheetName, 6, headerRowHeight) + f.SetRowHeight(sheetName, 7, headerRowHeight) + + return f.SetPanes(sheetName, &excelize.Panes{ + Freeze: true, + XSplit: 0, + YSplit: 7, + TopLeftCell: "A8", + }) + } + return nil +} + +func processField(f *excelize.File, sheetName string, row int, value interface{}, config FieldConfiguration, isFirstRow bool) ([]string, error) { + switch v := value.(type) { + case ExcelMarshaler: + marshaler := v + excelValue := marshaler.MarshalExcel() + + styleID, err := createCellStyle(f, excelValue.Style) + if err != nil { + return nil, err + } + + return writeSingleColumnField(f, sheetName, row, excelValue, &config, styleID, isFirstRow) + + case string: + if len(config.Columns) == 0 { + return []string{}, nil + } + + col := config.Columns[0] + cellRef := fmt.Sprintf("%s%d", col, row) + + f.SetCellValue(sheetName, cellRef, v) + + textStyleID, err := createCellStyle(f, getTextStyle()) + if err != nil { + return nil, fmt.Errorf("cannot create text style: %w", err) + } + f.SetCellStyle(sheetName, cellRef, cellRef, textStyleID) + + if isFirstRow { + width := config.DefaultWidth + if len(config.Width) > 0 { + width = config.Width[0] + } + f.SetColWidth(sheetName, col, col, width) + } + + if config.HasFilter { + if len(config.FilterColumns) > 0 { + return config.FilterColumns, nil + } + return []string{col}, nil + } + return []string{}, nil + + case []string: + var formattedLines []string + for _, item := range v { + lines := strings.Split(item, "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" { + formattedLines = append(formattedLines, "• "+strings.TrimSpace(line)) + } + } + } + joinedValue := strings.Join(formattedLines, "\n") + + if len(config.Columns) == 0 { + return []string{}, nil + } + + col := config.Columns[0] + cellRef := fmt.Sprintf("%s%d", col, row) + + f.SetCellValue(sheetName, cellRef, joinedValue) + + textStyleID, err := createCellStyle(f, getTextStyle()) + if err != nil { + return nil, fmt.Errorf("cannot create text style: %w", err) + } + f.SetCellStyle(sheetName, cellRef, cellRef, textStyleID) + + if isFirstRow { + width := config.DefaultWidth + if len(config.Width) > 0 { + width = config.Width[0] + } + f.SetColWidth(sheetName, col, col, width) + } + + if config.HasFilter { + if len(config.FilterColumns) > 0 { + return config.FilterColumns, nil + } + return []string{col}, nil + } + return []string{}, nil + + case *bool: + yesNoValue := boolToYesNo(v) + excelValue := yesNoValue.MarshalExcel() + + styleID, err := createCellStyle(f, excelValue.Style) + if err != nil { + return nil, err + } + + return writeSingleColumnField(f, sheetName, row, excelValue, &config, styleID, isFirstRow) + + default: + return nil, fmt.Errorf("no handler found for field type: %T", value) + } +} + +func GenerateExcel(data SOAData) ([]byte, error) { + return GenerateSOAExcel(data) +} + +func writeSingleColumnField(f *excelize.File, sheetName string, row int, excelValue ExcelValue, colDef *FieldConfiguration, styleID int, isFirstRow bool) ([]string, error) { + if len(colDef.Columns) == 0 { + return []string{}, nil + } + + col := colDef.Columns[0] + cellRef := fmt.Sprintf("%s%d", col, row) + + f.SetCellValue(sheetName, cellRef, excelValue.Value) + f.SetCellStyle(sheetName, cellRef, cellRef, styleID) + + if isFirstRow { + if err := applyDataValidation(f, sheetName, col, row, excelValue.Validation); err != nil { + return nil, err + } + + if err := setColumnWidth(f, sheetName, col, excelValue.Width); err != nil { + return nil, err + } + } + + if colDef.HasFilter { + if len(colDef.FilterColumns) > 0 { + return colDef.FilterColumns, nil + } + return []string{col}, nil + } + return []string{}, nil +} diff --git a/pkg/soagen/header.go b/pkg/soagen/header.go new file mode 100644 index 000000000..beb4c5f41 --- /dev/null +++ b/pkg/soagen/header.go @@ -0,0 +1,129 @@ +// 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 soagen + +import ( + "time" + + "github.com/xuri/excelize/v2" +) + +type HeaderCell struct { + Cell string + Value string + Style string // "header" or "cell" +} + +type MergedCell struct { + StartCell string + EndCell string +} + +type HeaderLayout struct { + InfoHeader []HeaderCell + InfoData []HeaderCell + MainHeader []HeaderCell + SubHeader []HeaderCell + Merges []MergedCell +} + +func getSOAHeaderLayout() HeaderLayout { + return HeaderLayout{ + InfoHeader: []HeaderCell{ + {"A2", "Version", "header"}, + {"C2", "Date", "header"}, + {"D2", "Comment", "header"}, + {"E2", "Author", "header"}, + {"F2", "Approver", "header"}, + }, + InfoData: []HeaderCell{ + {"A3", "1.0", "cell"}, + {"C3", time.Now().Format("01/02/2006"), "cell"}, + {"D3", "Initial SoA", "cell"}, + {"E3", "System Admin", "cell"}, + {"F3", "Security Manager", "cell"}, + }, + MainHeader: []HeaderCell{ + {"A6", "Control", "header"}, + {"B6", "Control name", "header"}, + {"C6", "Applicability", "header"}, + {"D6", "Justification for exclusion", "header"}, + {"E6", "Justification for inclusion", "header"}, + {"F6", "Justification for inclusion", "header"}, + {"G6", "Justification for inclusion", "header"}, + {"H6", "Justification for inclusion", "header"}, + {"I6", "List of security measure or policy", "header"}, + }, + SubHeader: []HeaderCell{ + {"A7", "Control", "header"}, + {"B7", "Control name", "header"}, + {"C7", "Applicability", "header"}, + {"D7", "Justification for exclusion", "header"}, + {"E7", "Regulatory", "header"}, + {"F7", "Contractual", "header"}, + {"G7", "Best practice", "header"}, + {"H7", "Risk assessment", "header"}, + {"I7", "List of security measure or policy", "header"}, + }, + Merges: []MergedCell{ + {"A6", "A7"}, + {"B6", "B7"}, + {"C6", "C7"}, + {"D6", "D7"}, + {"E6", "H6"}, + {"I6", "I7"}, + }, + } +} + +func applyHeaderLayout(f *excelize.File, sheetName string, layout HeaderLayout, headerStyle, cellStyle int) error { + if err := applyCells(f, sheetName, layout.InfoHeader, headerStyle, cellStyle); err != nil { + return err + } + + if err := applyCells(f, sheetName, layout.InfoData, headerStyle, cellStyle); err != nil { + return err + } + + if err := applyCells(f, sheetName, layout.MainHeader, headerStyle, cellStyle); err != nil { + return err + } + + if err := applyCells(f, sheetName, layout.SubHeader, headerStyle, cellStyle); err != nil { + return err + } + + for _, merge := range layout.Merges { + if err := f.MergeCell(sheetName, merge.StartCell, merge.EndCell); err != nil { + return err + } + } + + return nil +} + +func applyCells(f *excelize.File, sheetName string, cells []HeaderCell, headerStyle, cellStyle int) error { + for _, cell := range cells { + f.SetCellValue(sheetName, cell.Cell, cell.Value) + + style := cellStyle + if cell.Style == "header" { + style = headerStyle + } + + f.SetCellStyle(sheetName, cell.Cell, cell.Cell, style) + } + return nil +} diff --git a/pkg/soagen/soa_data.go b/pkg/soagen/soa_data.go new file mode 100644 index 000000000..68608cd91 --- /dev/null +++ b/pkg/soagen/soa_data.go @@ -0,0 +1,33 @@ +// 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 soagen + +// SOARowData represents a single row in the State of Applicability Excel +type SOARowData struct { + SectionTitle string + ControlName string + Applicability Applicability + Regulatory *bool + Contractual *bool + BestPractice *bool + RiskAssessment *bool + JustificationExclusion string + SecurityMeasures []string +} + +// SOAData contains all the data needed for State of Applicability generation +type SOAData struct { + Rows []SOARowData +} diff --git a/pkg/soagen/styles.go b/pkg/soagen/styles.go new file mode 100644 index 000000000..42cf519d4 --- /dev/null +++ b/pkg/soagen/styles.go @@ -0,0 +1,89 @@ +// 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 soagen + +import ( + "fmt" + + "github.com/xuri/excelize/v2" +) + +// getTextStyle returns the standard text style for string fields +func getTextStyle() *excelize.Style { + return &excelize.Style{ + Border: []excelize.Border{ + {Type: "left", Color: "#000000", Style: 1}, + {Type: "top", Color: "#000000", Style: 1}, + {Type: "bottom", Color: "#000000", Style: 1}, + {Type: "right", Color: "#000000", Style: 1}, + }, + Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true}, + } +} + +// getHeaderStyle returns the standard header style +func getHeaderStyle() *excelize.Style { + return &excelize.Style{ + Font: &excelize.Font{Bold: true, Size: 10, Color: "#000000"}, + Fill: excelize.Fill{Type: "pattern", Color: []string{"#D9D9D9"}, Pattern: 1}, + Border: []excelize.Border{ + {Type: "left", Color: "#000000", Style: 1}, + {Type: "top", Color: "#000000", Style: 1}, + {Type: "bottom", Color: "#000000", Style: 1}, + {Type: "right", Color: "#000000", Style: 1}, + }, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + } +} + +// getCellStyle returns the standard cell style +func getCellStyle() *excelize.Style { + return &excelize.Style{ + Border: []excelize.Border{ + {Type: "left", Color: "#000000", Style: 1}, + {Type: "top", Color: "#000000", Style: 1}, + {Type: "bottom", Color: "#000000", Style: 1}, + {Type: "right", Color: "#000000", Style: 1}, + }, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + } +} + +func createCellStyle(f *excelize.File, style *excelize.Style) (int, error) { + styleID, err := f.NewStyle(style) + if err != nil { + return 0, fmt.Errorf("cannot create style: %w", err) + } + return styleID, nil +} + +func applyDataValidation(f *excelize.File, sheetName, col string, row int, validation []string) error { + if len(validation) == 0 { + return nil + } + + dv := excelize.NewDataValidation(true) + dv.Sqref = fmt.Sprintf("%s%d:%s1000", col, row, col) // Apply to reasonable range + dv.SetDropList(validation) + dv.SetError(excelize.DataValidationErrorStyleStop, "Invalid Input", "Please select from the dropdown list.") + return f.AddDataValidation(sheetName, dv) +} + +func setColumnWidth(f *excelize.File, sheetName, col string, width float64) error { + if width > 0 { + f.SetColWidth(sheetName, col, col, width) + } + return nil +} diff --git a/pkg/soagen/yesno.go b/pkg/soagen/yesno.go new file mode 100644 index 000000000..4e4d17bb5 --- /dev/null +++ b/pkg/soagen/yesno.go @@ -0,0 +1,60 @@ +// 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 soagen + +import "github.com/xuri/excelize/v2" + +// YesNo represents a YES/NO/Empty value +type YesNo string + +const ( + Yes YesNo = "YES" + No YesNo = "NO" + Empty YesNo = "" +) + +// String returns the string representation of YesNo +func (yn YesNo) String() string { + return string(yn) +} + +// MarshalExcel implements ExcelMarshaler for YesNo +func (yn YesNo) MarshalExcel() ExcelValue { + return ExcelValue{ + Value: yn.String(), + Style: &excelize.Style{ + Border: []excelize.Border{ + {Type: "left", Color: "#000000", Style: 1}, + {Type: "top", Color: "#000000", Style: 1}, + {Type: "bottom", Color: "#000000", Style: 1}, + {Type: "right", Color: "#000000", Style: 1}, + }, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + }, + Validation: []string{string(Yes), string(No), string(Empty)}, + Width: 12, + } +} + +// boolToYesNo converts *bool to YesNo type for Excel output +func boolToYesNo(b *bool) YesNo { + if b == nil { + return Empty + } + if *b { + return Yes + } + return No +}