Refactor SOA generation

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-07-07 15:44:22 +02:00
parent ba71f0d725
commit afa5a822aa
16 changed files with 1008 additions and 1070 deletions

View File

@@ -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);
}
},
});

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<bcdaed0c84805f387ee7d1de8ece0068>>
* @generated SignedSource<<b7c54e98eb3ba12bf4619530b45fdbab>>
* @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;

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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!
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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())

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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,
}
}

28
pkg/soagen/excel_value.go Normal file
View File

@@ -0,0 +1,28 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package soagen
import "github.com/xuri/excelize/v2"
type ExcelValue struct {
Value interface{}
Style *excelize.Style
Validation []string
Width float64
}
type ExcelMarshaler interface {
MarshalExcel() ExcelValue
}

101
pkg/soagen/field_config.go Normal file
View File

@@ -0,0 +1,101 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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,
},
}
)

301
pkg/soagen/generator.go Normal file
View File

@@ -0,0 +1,301 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

129
pkg/soagen/header.go Normal file
View File

@@ -0,0 +1,129 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

33
pkg/soagen/soa_data.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

89
pkg/soagen/styles.go Normal file
View File

@@ -0,0 +1,89 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

60
pkg/soagen/yesno.go Normal file
View File

@@ -0,0 +1,60 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}