SOA as document: replace export with publish workflow

Statements of Applicability are no longer exported as one-off PDFs.
Instead, each SOA owns a persistent document that accumulates versions
over time, following the same publish/approve lifecycle as authored
documents.

Publishing without approvers publishes immediately; publishing with
approvers creates a draft pending approval via the existing quorum
system. SOAs can also store default approvers that are pre-populated in
the publish dialog.

The SOA is removed from the snapshot system — applicability statements
are now queried directly (snapshot_id IS NULL) rather than through
snapshot copies.

A standalone migration script (cmd/migrate-soa-snapshots-to-documents)
converts existing SOA snapshots into documents with proper ProseMirror
content, preserving version history and approval decisions.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-10 13:38:42 +02:00
parent 53edc5ba26
commit c635492f75
74 changed files with 3509 additions and 1595 deletions

View File

@@ -78,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
flagOrderBy string
flagOrderDir string
flagQuery string
flagWriteMode string
flagDocumentType string
flagClassification string
flagStatus string
@@ -148,11 +149,21 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
if flagQuery != "" {
filter["query"] = flagQuery
}
if flagWriteMode != "" {
if err := cmdutil.ValidateEnum(
"write-mode",
flagWriteMode,
[]string{"AUTHORED", "GENERATED"},
); err != nil {
return err
}
filter["writeModes"] = []string{flagWriteMode}
}
if flagDocumentType != "" {
if err := cmdutil.ValidateEnum(
"document-type",
flagDocumentType,
[]string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"},
[]string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE", "STATEMENT_OF_APPLICABILITY"},
); err != nil {
return err
}
@@ -261,7 +272,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (TITLE, CREATED_AT, UPDATED_AT, DOCUMENT_TYPE)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
cmd.Flags().StringVarP(&flagQuery, "query", "q", "", "Search query")
cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Filter by document type (OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE)")
cmd.Flags().StringVar(&flagWriteMode, "write-mode", "", "Filter by write mode (AUTHORED, GENERATED)")
cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Filter by document type (OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE, STATEMENT_OF_APPLICABILITY)")
cmd.Flags().StringVar(&flagClassification, "classification", "", "Filter by classification (PUBLIC, INTERNAL, CONFIDENTIAL, SECRET)")
cmd.Flags().StringVar(&flagStatus, "status", "", "Filter by status (ACTIVE, ARCHIVED)")
flagOutput = cmdutil.AddOutputFlag(cmd)

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2026 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 publish
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const publishMutation = `
mutation($input: PublishStatementOfApplicabilityInput!) {
publishStatementOfApplicability(input: $input) {
documentEdge {
node {
id
status
createdAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
}
}
}
}
`
type publishResponse struct {
PublishStatementOfApplicability struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Title string `json:"title"`
Major int `json:"major"`
Minor int `json:"minor"`
Status string `json:"status"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishStatementOfApplicability"`
}
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
var flagApprover []string
cmd := &cobra.Command{
Use: "publish <soa-id>",
Short: "Publish a statement of applicability as a document version",
Example: ` # Publish an SOA
prb soa publish SOA_ID
# Publish with approvers
prb soa publish SOA_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
)
input := map[string]any{
"statementOfApplicabilityId": args[0],
}
if len(flagApprover) > 0 {
input["approverIds"] = flagApprover
}
data, err := client.Do(
publishMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp publishResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
v := resp.PublishStatementOfApplicability.DocumentVersionEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Published statement of applicability %s (v%d.%d)\n",
v.Title,
v.Major,
v.Minor,
)
return nil
},
}
cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
return cmd
}

View File

@@ -20,6 +20,7 @@ import (
"go.probo.inc/probo/pkg/cmd/soa/create"
"go.probo.inc/probo/pkg/cmd/soa/delete"
"go.probo.inc/probo/pkg/cmd/soa/list"
"go.probo.inc/probo/pkg/cmd/soa/publish"
"go.probo.inc/probo/pkg/cmd/soa/statement"
"go.probo.inc/probo/pkg/cmd/soa/update"
"go.probo.inc/probo/pkg/cmd/soa/view"
@@ -36,6 +37,7 @@ func NewCmdSoa(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(publish.NewCmdPublish(f))
cmd.AddCommand(statement.NewCmdStatement(f))
return cmd

View File

@@ -156,7 +156,6 @@ WITH current_soa AS (
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
)
SELECT
soac.id,
@@ -351,7 +350,6 @@ WITH current_soa AS (
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
)
DELETE FROM applicability_statements
WHERE statement_of_applicability_id IN (SELECT id FROM current_soa)
@@ -467,6 +465,57 @@ WHERE
return nil
}
func (sacs *ApplicabilityStatements) LoadAllByStatementOfApplicabilityID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
statementOfApplicabilityID gid.GID,
) error {
q := `
SELECT
a.id,
a.statement_of_applicability_id,
a.control_id,
a.organization_id,
a.snapshot_id,
a.applicability,
a.justification,
a.created_at,
a.updated_at,
f.name || ' - ' || c.section_title AS section_title
FROM
applicability_statements a
INNER JOIN
controls c ON c.id = a.control_id
INNER JOIN
frameworks f ON f.id = c.framework_id
WHERE
a.%s
AND a.statement_of_applicability_id = @statement_of_applicability_id
ORDER BY
section_title ASC;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"statement_of_applicability_id": statementOfApplicabilityID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query applicability_statements: %w", err)
}
controls, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ApplicabilityStatement])
if err != nil {
return fmt.Errorf("cannot collect applicability_statements: %w", err)
}
*sacs = controls
return nil
}
func (sacs *ApplicabilityStatements) CountByStatementOfApplicabilityID(
ctx context.Context,
conn pg.Querier,
@@ -484,7 +533,7 @@ WHERE
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"statement_of_applicability_id": statementOfApplicabilityID}
args := pgx.StrictNamedArgs{"statement_of_applicability_id": statementOfApplicabilityID}
maps.Copy(args, scope.SQLArguments())
var count int

View File

@@ -33,6 +33,11 @@ type (
}
ControlObligations []*ControlObligation
ControlObligationType struct {
ControlID gid.GID `db:"control_id"`
ObligationType ObligationType `db:"obligation_type"`
}
)
func (co ControlObligation) Upsert(
@@ -137,3 +142,48 @@ WHERE %s
return count, nil
}
func LoadObligationTypesByControlIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
controlIDs []gid.GID,
) ([]ControlObligationType, error) {
q := `
WITH control_obls AS (
SELECT DISTINCT
co.control_id,
o.type AS obligation_type,
o.tenant_id
FROM
controls_obligations co
INNER JOIN
obligations o ON co.obligation_id = o.id
WHERE
co.control_id = ANY(@control_ids)
)
SELECT
control_id,
obligation_type
FROM
control_obls
WHERE
%s;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"control_ids": controlIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot load obligation types by control IDs: %w", err)
}
result, err := pgx.CollectRows(rows, pgx.RowToStructByName[ControlObligationType])
if err != nil {
return nil, fmt.Errorf("cannot collect control obligation types: %w", err)
}
return result, nil
}

View File

@@ -35,6 +35,7 @@ type (
CurrentPublishedMajor *int `db:"current_published_major"`
CurrentPublishedMinor *int `db:"current_published_minor"`
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
WriteMode DocumentWriteMode `db:"write_mode"`
Status DocumentStatus `db:"status"`
ArchivedAt *time.Time `db:"archived_at"`
CreatedAt time.Time `db:"created_at"`
@@ -102,6 +103,7 @@ SELECT
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.write_mode,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
@@ -161,6 +163,7 @@ SELECT
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.write_mode,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
@@ -221,6 +224,7 @@ SELECT
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.write_mode,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
@@ -311,6 +315,7 @@ base AS (
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.write_mode,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
@@ -370,6 +375,7 @@ SELECT
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.write_mode,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
@@ -443,6 +449,7 @@ base AS (
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.write_mode,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
@@ -497,6 +504,7 @@ INSERT INTO
organization_id,
current_published_major,
current_published_minor,
write_mode,
trust_center_visibility,
status,
archived_at,
@@ -509,6 +517,7 @@ VALUES (
@organization_id,
@current_published_major,
@current_published_minor,
@write_mode,
@trust_center_visibility,
@status,
@archived_at,
@@ -523,6 +532,7 @@ VALUES (
"organization_id": p.OrganizationID,
"current_published_major": p.CurrentPublishedMajor,
"current_published_minor": p.CurrentPublishedMinor,
"write_mode": p.WriteMode,
"trust_center_visibility": p.TrustCenterVisibility,
"status": p.Status,
"archived_at": p.ArchivedAt,
@@ -675,6 +685,7 @@ base AS (
sd.current_published_major,
sd.current_published_minor,
sd.trust_center_visibility,
sd.write_mode,
sd.status,
sd.archived_at,
sd.created_at,
@@ -774,6 +785,7 @@ base AS (
sd.current_published_major,
sd.current_published_minor,
sd.trust_center_visibility,
sd.write_mode,
sd.status,
sd.archived_at,
sd.created_at,
@@ -873,6 +885,7 @@ base AS (
sd.current_published_major,
sd.current_published_minor,
sd.trust_center_visibility,
sd.write_mode,
sd.status,
sd.archived_at,
sd.created_at,

View File

@@ -28,6 +28,7 @@ type (
employeeFilterModes []EmployeeFilterMode
documentTypes []DocumentType
classifications []DocumentClassification
writeModes []DocumentWriteMode
status []DocumentStatus
}
)
@@ -71,6 +72,11 @@ func (f *DocumentFilter) WithClassifications(classifications []DocumentClassific
return f
}
func (f *DocumentFilter) WithWriteModes(writeModes []DocumentWriteMode) *DocumentFilter {
f.writeModes = writeModes
return f
}
func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter {
f.status = status
return f
@@ -101,6 +107,14 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
}
}
var writeModes []string
if f.writeModes != nil {
writeModes = make([]string, len(f.writeModes))
for i, cs := range f.writeModes {
writeModes[i] = cs.String()
}
}
var status []string
if f.status != nil {
status = make([]string, len(f.status))
@@ -122,6 +136,7 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
"employee_filter_modes": employeeFilterModes,
"document_types": documentTypes,
"classifications": classifications,
"write_modes": writeModes,
"document_status": status,
}
}
@@ -209,6 +224,12 @@ func (f *DocumentFilter) SQLFragment() string {
ELSE TRUE
END
AND
CASE
WHEN @write_modes::text[] IS NOT NULL THEN
documents.write_mode::text = ANY(@write_modes::text[])
ELSE TRUE
END
AND
CASE
WHEN @document_status::text[] IS NULL THEN TRUE
ELSE status::text = ANY(@document_status::text[])

View File

@@ -24,15 +24,16 @@ type (
)
const (
DocumentTypeOther DocumentType = "OTHER"
DocumentTypeGovernance DocumentType = "GOVERNANCE"
DocumentTypePolicy DocumentType = "POLICY"
DocumentTypeProcedure DocumentType = "PROCEDURE"
DocumentTypePlan DocumentType = "PLAN"
DocumentTypeRegister DocumentType = "REGISTER"
DocumentTypeRecord DocumentType = "RECORD"
DocumentTypeReport DocumentType = "REPORT"
DocumentTypeTemplate DocumentType = "TEMPLATE"
DocumentTypeOther DocumentType = "OTHER"
DocumentTypeGovernance DocumentType = "GOVERNANCE"
DocumentTypePolicy DocumentType = "POLICY"
DocumentTypeProcedure DocumentType = "PROCEDURE"
DocumentTypePlan DocumentType = "PLAN"
DocumentTypeRegister DocumentType = "REGISTER"
DocumentTypeRecord DocumentType = "RECORD"
DocumentTypeReport DocumentType = "REPORT"
DocumentTypeTemplate DocumentType = "TEMPLATE"
DocumentTypeStatementOfApplicability DocumentType = "STATEMENT_OF_APPLICABILITY"
)
func DocumentTypes() []DocumentType {
@@ -46,6 +47,7 @@ func DocumentTypes() []DocumentType {
DocumentTypeRecord,
DocumentTypeReport,
DocumentTypeTemplate,
DocumentTypeStatementOfApplicability,
}
}
@@ -75,6 +77,8 @@ func (dt *DocumentType) UnmarshalText(data []byte) error {
*dt = DocumentTypeReport
case DocumentTypeTemplate.String():
*dt = DocumentTypeTemplate
case DocumentTypeStatementOfApplicability.String():
*dt = DocumentTypeStatementOfApplicability
default:
return fmt.Errorf("invalid DocumentType value: %q", val)
}

View File

@@ -30,20 +30,21 @@ import (
type (
DocumentVersion struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
DocumentID gid.GID `db:"document_id"`
Title string `db:"title"`
Major int `db:"major"`
Minor int `db:"minor"`
Classification DocumentClassification `db:"classification"`
DocumentType DocumentType `db:"document_type"`
Content string `db:"content"`
Changelog string `db:"changelog"`
Status DocumentVersionStatus `db:"status"`
PublishedAt *time.Time `db:"published_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
DocumentID gid.GID `db:"document_id"`
Title string `db:"title"`
Major int `db:"major"`
Minor int `db:"minor"`
Classification DocumentClassification `db:"classification"`
DocumentType DocumentType `db:"document_type"`
Content string `db:"content"`
Changelog string `db:"changelog"`
Status DocumentVersionStatus `db:"status"`
Orientation DocumentVersionOrientation `db:"orientation"`
PublishedAt *time.Time `db:"published_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
DocumentVersions []*DocumentVersion
@@ -93,6 +94,7 @@ SELECT
content,
changelog,
status,
orientation,
published_at,
created_at,
updated_at
@@ -156,6 +158,7 @@ SELECT
content,
changelog,
status,
orientation,
published_at,
created_at,
updated_at
@@ -211,6 +214,8 @@ INSERT INTO document_versions (
content,
changelog,
status,
orientation,
published_at,
created_at,
updated_at
)
@@ -227,6 +232,8 @@ VALUES (
@content,
@changelog,
@status,
@orientation,
@published_at,
@created_at,
@updated_at
)
@@ -244,6 +251,8 @@ VALUES (
"content": dv.Content,
"changelog": dv.Changelog,
"status": dv.Status,
"orientation": dv.Orientation,
"published_at": dv.PublishedAt,
"created_at": dv.CreatedAt,
"updated_at": dv.UpdatedAt,
}
@@ -285,6 +294,7 @@ SELECT
content,
changelog,
status,
orientation,
published_at,
created_at,
updated_at
@@ -344,6 +354,7 @@ SELECT
content,
changelog,
status,
orientation,
published_at,
created_at,
updated_at
@@ -399,6 +410,7 @@ SELECT
content,
changelog,
status,
orientation,
published_at,
created_at,
updated_at
@@ -453,6 +465,7 @@ UPDATE document_versions SET
published_at = @published_at,
classification = @classification,
document_type = @document_type,
orientation = @orientation,
updated_at = @updated_at
WHERE %s
AND id = @document_version_id
@@ -471,6 +484,7 @@ WHERE %s
"published_at": dv.PublishedAt,
"classification": dv.Classification,
"document_type": dv.DocumentType,
"orientation": dv.Orientation,
"updated_at": dv.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 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 coredata
import (
"database/sql/driver"
"fmt"
)
type (
DocumentVersionOrientation string
)
const (
DocumentVersionOrientationPortrait DocumentVersionOrientation = "PORTRAIT"
DocumentVersionOrientationLandscape DocumentVersionOrientation = "LANDSCAPE"
)
func DocumentVersionOrientations() []DocumentVersionOrientation {
return []DocumentVersionOrientation{
DocumentVersionOrientationPortrait,
DocumentVersionOrientationLandscape,
}
}
func (o DocumentVersionOrientation) MarshalText() ([]byte, error) {
return []byte(o.String()), nil
}
func (o *DocumentVersionOrientation) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case DocumentVersionOrientationPortrait.String():
*o = DocumentVersionOrientationPortrait
case DocumentVersionOrientationLandscape.String():
*o = DocumentVersionOrientationLandscape
default:
return fmt.Errorf("invalid DocumentVersionOrientation value: %q", val)
}
return nil
}
func (o DocumentVersionOrientation) String() string {
return string(o)
}
func (o *DocumentVersionOrientation) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for DocumentVersionOrientation, expected string got %T", value)
}
return o.UnmarshalText([]byte(val))
}
func (o DocumentVersionOrientation) Value() (driver.Value, error) {
return o.String(), nil
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 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 coredata
import "fmt"
type (
DocumentWriteMode string
)
const (
DocumentWriteModeAuthored DocumentWriteMode = "AUTHORED"
DocumentWriteModeGenerated DocumentWriteMode = "GENERATED"
)
func (e DocumentWriteMode) IsValid() bool {
switch e {
case DocumentWriteModeAuthored, DocumentWriteModeGenerated:
return true
}
return false
}
func (e DocumentWriteMode) String() string { return string(e) }
func (e *DocumentWriteMode) UnmarshalText(text []byte) error {
*e = DocumentWriteMode(text)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid DocumentWriteMode", string(text))
}
return nil
}
func (e DocumentWriteMode) MarshalText() ([]byte, error) {
return []byte(e.String()), nil
}

View File

@@ -24,22 +24,23 @@ type (
)
const (
ElectronicSignatureDocumentTypeNDA ElectronicSignatureDocumentType = "NDA"
ElectronicSignatureDocumentTypeDPA ElectronicSignatureDocumentType = "DPA"
ElectronicSignatureDocumentTypeMSA ElectronicSignatureDocumentType = "MSA"
ElectronicSignatureDocumentTypeSOW ElectronicSignatureDocumentType = "SOW"
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
ElectronicSignatureDocumentTypeGovernance ElectronicSignatureDocumentType = "GOVERNANCE"
ElectronicSignatureDocumentTypePolicy ElectronicSignatureDocumentType = "POLICY"
ElectronicSignatureDocumentTypeProcedure ElectronicSignatureDocumentType = "PROCEDURE"
ElectronicSignatureDocumentTypePlan ElectronicSignatureDocumentType = "PLAN"
ElectronicSignatureDocumentTypeRegister ElectronicSignatureDocumentType = "REGISTER"
ElectronicSignatureDocumentTypeRecord ElectronicSignatureDocumentType = "RECORD"
ElectronicSignatureDocumentTypeReport ElectronicSignatureDocumentType = "REPORT"
ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE"
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
ElectronicSignatureDocumentTypeNDA ElectronicSignatureDocumentType = "NDA"
ElectronicSignatureDocumentTypeDPA ElectronicSignatureDocumentType = "DPA"
ElectronicSignatureDocumentTypeMSA ElectronicSignatureDocumentType = "MSA"
ElectronicSignatureDocumentTypeSOW ElectronicSignatureDocumentType = "SOW"
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
ElectronicSignatureDocumentTypeGovernance ElectronicSignatureDocumentType = "GOVERNANCE"
ElectronicSignatureDocumentTypePolicy ElectronicSignatureDocumentType = "POLICY"
ElectronicSignatureDocumentTypeProcedure ElectronicSignatureDocumentType = "PROCEDURE"
ElectronicSignatureDocumentTypePlan ElectronicSignatureDocumentType = "PLAN"
ElectronicSignatureDocumentTypeRegister ElectronicSignatureDocumentType = "REGISTER"
ElectronicSignatureDocumentTypeRecord ElectronicSignatureDocumentType = "RECORD"
ElectronicSignatureDocumentTypeReport ElectronicSignatureDocumentType = "REPORT"
ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE"
ElectronicSignatureDocumentTypeStatementOfApplicability ElectronicSignatureDocumentType = "STATEMENT_OF_APPLICABILITY"
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
)
@@ -61,6 +62,7 @@ func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
ElectronicSignatureDocumentTypeRecord,
ElectronicSignatureDocumentTypeReport,
ElectronicSignatureDocumentTypeTemplate,
ElectronicSignatureDocumentTypeStatementOfApplicability,
ElectronicSignatureDocumentTypeOther,
}
}
@@ -103,6 +105,8 @@ func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
*dt = ElectronicSignatureDocumentTypeReport
case ElectronicSignatureDocumentTypeTemplate.String():
*dt = ElectronicSignatureDocumentTypeTemplate
case ElectronicSignatureDocumentTypeStatementOfApplicability.String():
*dt = ElectronicSignatureDocumentTypeStatementOfApplicability
case ElectronicSignatureDocumentTypeOther.String():
*dt = ElectronicSignatureDocumentTypeOther
default:
@@ -161,6 +165,8 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string {
return "Report"
case ElectronicSignatureDocumentTypeTemplate:
return "Template"
case ElectronicSignatureDocumentTypeStatementOfApplicability:
return "Statement of Applicability"
default:
return string(dt)
}
@@ -199,6 +205,8 @@ func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) {
docAgreement = "I acknowledge and agree to this Report."
case ElectronicSignatureDocumentTypeTemplate:
docAgreement = "I acknowledge and agree to this Template."
case ElectronicSignatureDocumentTypeStatementOfApplicability:
docAgreement = "I acknowledge and agree to this Statement of Applicability."
case ElectronicSignatureDocumentTypeOther:
return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text")
default:
@@ -226,6 +234,8 @@ func ElectronicSignatureDocumentTypeFromDocumentType(dt DocumentType) Electronic
return ElectronicSignatureDocumentTypeReport
case DocumentTypeTemplate:
return ElectronicSignatureDocumentTypeTemplate
case DocumentTypeStatementOfApplicability:
return ElectronicSignatureDocumentTypeStatementOfApplicability
default:
return ElectronicSignatureDocumentTypeOther
}

View File

@@ -0,0 +1,45 @@
-- Copyright (c) 2026 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.
CREATE TYPE document_version_orientation AS ENUM ('PORTRAIT', 'LANDSCAPE');
CREATE TYPE document_write_mode AS ENUM ('AUTHORED', 'GENERATED');
ALTER TABLE document_versions
ADD COLUMN orientation document_version_orientation DEFAULT 'PORTRAIT';
ALTER TABLE document_versions
ALTER COLUMN orientation DROP DEFAULT;
ALTER TABLE documents
ADD COLUMN write_mode document_write_mode NOT NULL DEFAULT 'AUTHORED';
ALTER TABLE documents
ALTER COLUMN write_mode DROP DEFAULT;
ALTER TYPE document_type ADD VALUE 'STATEMENT_OF_APPLICABILITY';
ALTER TYPE electronic_signature_document_type ADD VALUE 'STATEMENT_OF_APPLICABILITY';
ALTER TABLE statements_of_applicability
ADD COLUMN document_id TEXT UNIQUE REFERENCES documents(id) ON DELETE SET NULL;
-- TODO: drop owner_profile_id column
ALTER TABLE statements_of_applicability
ALTER COLUMN owner_profile_id DROP NOT NULL;
-- TODO: drop statements_of_applicability.source_id column
-- TODO: drop statements_of_applicability.snapshot_id column
-- TODO: drop applicability_statements.snapshot_id column

View File

@@ -124,6 +124,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type != 'STATEMENTS_OF_APPLICABILITY'
AND %s
`
@@ -163,6 +164,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type != 'STATEMENTS_OF_APPLICABILITY'
AND %s
`

View File

@@ -43,7 +43,6 @@ func SnapshotsTypes() []SnapshotsType {
SnapshotsTypeFindings,
SnapshotsTypeObligations,
SnapshotsTypeProcessingActivities,
SnapshotsTypeStatementsOfApplicability,
}
}

View File

@@ -42,8 +42,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
return ProcessingActivities{}, nil
case SnapshotsTypeVendors:
return Vendors{}, nil
case SnapshotsTypeStatementsOfApplicability:
return StatementsOfApplicability{}, nil
default:
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
}

View File

@@ -33,9 +33,7 @@ type (
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Name string `db:"name"`
SourceID *gid.GID `db:"source_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
OwnerID gid.GID `db:"owner_profile_id"`
DocumentID *gid.GID `db:"document_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -79,9 +77,7 @@ SELECT
id,
organization_id,
name,
source_id,
snapshot_id,
owner_profile_id,
document_id,
created_at,
updated_at
FROM
@@ -89,6 +85,7 @@ FROM
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -121,16 +118,13 @@ func (s *StatementsOfApplicability) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[StatementOfApplicabilityOrderField],
filter *StatementOfApplicabilityFilter,
) error {
q := `
SELECT
id,
organization_id,
name,
source_id,
snapshot_id,
owner_profile_id,
document_id,
created_at,
updated_at
FROM
@@ -138,14 +132,13 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -167,7 +160,6 @@ func (s *StatementsOfApplicability) CountByOrganizationID(
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *StatementOfApplicabilityFilter,
) (int, error) {
q := `
SELECT
@@ -177,15 +169,14 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
@@ -208,9 +199,7 @@ INSERT INTO
id,
organization_id,
name,
source_id,
snapshot_id,
owner_profile_id,
document_id,
created_at,
updated_at
)
@@ -219,9 +208,7 @@ VALUES (
@statement_of_applicability_id,
@organization_id,
@name,
@source_id,
@snapshot_id,
@owner_profile_id,
@document_id,
@created_at,
@updated_at
);
@@ -232,9 +219,7 @@ VALUES (
"statement_of_applicability_id": s.ID,
"organization_id": s.OrganizationID,
"name": s.Name,
"source_id": s.SourceID,
"snapshot_id": s.SnapshotID,
"owner_profile_id": s.OwnerID,
"document_id": s.DocumentID,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
}
@@ -262,11 +247,12 @@ func (s *StatementOfApplicability) Update(
UPDATE statements_of_applicability
SET
name = @name,
owner_profile_id = @owner_profile_id,
document_id = @document_id,
updated_at = @updated_at
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -274,7 +260,7 @@ WHERE
args := pgx.StrictNamedArgs{
"statement_of_applicability_id": s.ID,
"name": s.Name,
"owner_profile_id": s.OwnerID,
"document_id": s.DocumentID,
"updated_at": s.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
@@ -307,6 +293,7 @@ DELETE FROM statements_of_applicability
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -326,139 +313,3 @@ WHERE
return nil
}
func (soas StatementsOfApplicability) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
if err := soas.insertStatementOfApplicabilitySnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot insert statement_of_applicability snapshots: %w", err)
}
if err := soas.insertStatementOfApplicabilityControlSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot insert statement_of_applicability_control snapshots: %w", err)
}
return nil
}
func (soas StatementsOfApplicability) insertStatementOfApplicabilitySnapshots(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
INSERT INTO statements_of_applicability (
id,
tenant_id,
organization_id,
name,
source_id,
snapshot_id,
owner_profile_id,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(@tenant_id), @statement_of_applicability_entity_type),
@tenant_id,
soa.organization_id,
soa.name,
soa.id,
@snapshot_id,
soa.owner_profile_id,
soa.created_at,
soa.updated_at
FROM statements_of_applicability soa
WHERE
%s
AND soa.organization_id = @organization_id
AND soa.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"statement_of_applicability_entity_type": StatementOfApplicabilityEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert statement_of_applicability snapshots: %w", err)
}
return nil
}
func (soas StatementsOfApplicability) insertStatementOfApplicabilityControlSnapshots(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH source_soa AS (
SELECT id, organization_id
FROM statements_of_applicability
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
),
snapshot_soa AS (
SELECT id, source_id
FROM statements_of_applicability
WHERE snapshot_id = @snapshot_id
)
INSERT INTO applicability_statements (
id,
statement_of_applicability_id,
control_id,
organization_id,
tenant_id,
snapshot_id,
applicability,
justification,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(@tenant_id), @applicability_statement_entity_type),
snapshot_soa.id,
soac.control_id,
soac.organization_id,
@tenant_id,
@snapshot_id,
soac.applicability,
soac.justification,
soac.created_at,
soac.updated_at
FROM applicability_statements soac
INNER JOIN source_soa
ON soac.statement_of_applicability_id = source_soa.id
INNER JOIN snapshot_soa
ON snapshot_soa.source_id = source_soa.id
WHERE soac.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"applicability_statement_entity_type": ApplicabilityStatementEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert statement_of_applicability_control snapshots: %w", err)
}
return nil
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 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 coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
StatementOfApplicabilityFilter struct {
snapshotID **gid.GID
}
)
func NewStatementOfApplicabilityFilter(snapshotID **gid.GID) *StatementOfApplicabilityFilter {
return &StatementOfApplicabilityFilter{
snapshotID: snapshotID,
}
}
func (f *StatementOfApplicabilityFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{}
if f.snapshotID == nil {
args["has_snapshot_filter"] = false
args["filter_snapshot_id"] = nil
} else if *f.snapshotID == nil {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = nil
} else {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
func (f *StatementOfApplicabilityFilter) SQLFragment() string {
return `
CASE
WHEN @has_snapshot_filter::boolean = false THEN TRUE
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
snapshot_id = @filter_snapshot_id::text
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
snapshot_id IS NULL
ELSE TRUE
END`
}

View File

@@ -44,9 +44,6 @@ var (
//go:embed transfer_impact_assessments_template.html
transferImpactAssessmentsTemplateContent string
//go:embed soa_template.html
soaTemplateContent string
templateFuncs = template.FuncMap{
"now": func() time.Time { return time.Now() },
"eq": func(a, b any) bool { return a == b },
@@ -186,8 +183,6 @@ var (
dataProtectionImpactAssessmentsTemplate = template.Must(template.New("dataProtectionImpactAssessments").Funcs(templateFuncs).Parse(dataProtectionImpactAssessmentsTemplateContent))
transferImpactAssessmentsTemplate = template.Must(template.New("transferImpactAssessments").Funcs(templateFuncs).Parse(transferImpactAssessmentsTemplateContent))
statementOfApplicabilityTemplate = template.Must(template.New("statement-of-applicability").Funcs(templateFuncs).Parse(soaTemplateContent))
)
type (
@@ -205,6 +200,7 @@ type (
Signatures []SignatureData
CompanyHorizontalLogoBase64 string
MermaidJS template.JS
Landscape bool
}
SignatureData struct {
@@ -280,37 +276,35 @@ type (
}
StatementOfApplicabilityData struct {
Title string
OrganizationName string
CreatedAt time.Time
TotalControls int
FrameworkGroups []FrameworkControlGroup
CompanyHorizontalLogoBase64 string
Version int
PublishedAt time.Time
Approver string
Title string
OrganizationName string
CreatedAt time.Time
TotalControls int
Rows []SOARow
}
FrameworkControlGroup struct {
FrameworkName string
Controls []ControlData
}
ControlData struct {
FrameworkName string
SectionTitle string
Name string
Applicability *bool
Justification *string
BestPractice *bool
Implemented *string
NotImplementedJustification *string
Regulatory *bool
Contractual *bool
RiskAssessment *bool
SOARow struct {
FrameworkName string
ControlSection string
ControlName string
Applicability string
Justification string
Implemented string
NotImplJustification string
Regulatory string
Contractual string
BestPractice string
RiskAssessment string
}
)
func BoolLabel(v bool) string {
if v {
return "Yes"
}
return "No"
}
const (
ClassificationPublic Classification = "PUBLIC"
ClassificationInternal Classification = "INTERNAL"
@@ -381,12 +375,3 @@ func RenderTransferImpactAssessmentsTableHTML(data TransferImpactAssessmentTable
return buf.Bytes(), nil
}
func RenderStatementOfApplicabilityHTML(data StatementOfApplicabilityData) ([]byte, error) {
var buf bytes.Buffer
if err := statementOfApplicabilityTemplate.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("cannot execute SOA template: %w", err)
}
return buf.Bytes(), nil
}

View File

@@ -1,523 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Statement of Applicability</title>
<style>
@page {
size: A4 landscape;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: Arial, sans-serif;
font-size: 7.5pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
background: white;
}
/* Cover page */
.cover-page {
page-break-after: always;
}
.company-header {
margin-bottom: 30px;
page-break-after: avoid;
}
.company-logo {
max-height: 50px;
max-width: 100%;
height: auto;
width: auto;
display: block;
}
.export-title {
font-size: 22pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.export-subtitle {
font-size: 18pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.document-meta {
margin: 0 0 30px 0;
font-size: 9pt;
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #333;
}
.meta-table td {
padding: 6px 8px;
border: 1px solid #333;
vertical-align: middle;
}
.meta-table td:first-child {
font-weight: 600;
width: 25%;
background: #f8f8f8;
color: #333;
}
.classification {
font-weight: bold;
text-transform: uppercase;
}
.purpose-section {
margin: 30px 0;
page-break-after: avoid;
}
.purpose-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.purpose-text {
font-size: 10pt;
color: #333;
line-height: 1.5;
text-align: justify;
}
/* Controls page */
.controls-page {
page-break-before: always;
}
.controls-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.controls-table {
width: 100%;
border-collapse: collapse;
font-size: 8pt;
margin-top: 10px;
}
.controls-table th,
.controls-table td {
padding: 5px 6px;
text-align: left;
border: 1px solid #ddd;
vertical-align: top;
}
.controls-table th {
background: #f5f5f5;
font-weight: bold;
color: #333;
}
.controls-table tr {
page-break-inside: avoid;
}
.section-tag {
display: inline-block;
background: #e0e0e0;
color: #333;
padding: 2px 5px;
border-radius: 3px;
font-size: 7pt;
font-weight: 500;
margin-right: 5px;
}
.state-tag {
display: inline-block;
padding: 2px 5px;
border-radius: 4px;
font-size: 8pt;
font-weight: 500;
}
.state-tag-success {
background: #eefadc;
color: #5d770d;
}
.state-tag-warning {
background: #fff4d5;
color: #ad5700;
}
.state-tag-danger {
background: #ffefef;
color: #cd2b31;
}
/* Annex page */
.annex-page {
page-break-before: always;
}
.annex-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.annex-section {
margin-bottom: 20px;
}
.annex-section-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin: 15px 0 10px 0;
}
.annex-subsection-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 8px 0;
}
.annex-enum-list {
margin: 10px 0;
padding-left: 20px;
}
.annex-enum-item {
margin-bottom: 8px;
font-size: 10pt;
line-height: 1.5;
}
.annex-enum-name {
font-weight: 600;
color: #333;
}
.annex-enum-description {
color: #000;
margin-left: 5px;
}
/* Prevent bad page breaks */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
page-break-inside: avoid;
}
@media print {
body {
background: white;
}
}
</style>
</head>
<body>
<div class="cover-page">
<div class="company-header">
{{- if .CompanyHorizontalLogoBase64}}
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
{{- end}}
</div>
<h1 class="export-title">Statement of Applicability</h1>
<h2 class="export-subtitle">{{.Title}}</h2>
<div class="document-meta">
<table class="meta-table">
<tr>
<td>Classification</td>
<td>
<span class="classification">CONFIDENTIAL</span>
</td>
</tr>
<tr>
<td>Approver</td>
<td>{{.Approver}}</td>
</tr>
<tr>
<td>Version</td>
<td>{{.Version}}</td>
</tr>
<tr>
<td>Published</td>
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
</tr>
</table>
</div>
<div class="purpose-section">
<div class="purpose-title">1. Purpose</div>
<div class="purpose-text">
This document provides a comprehensive overview of the statement of applicability for controls within the organization.
It serves as a record of which controls are applicable or not applicable to the organization, along with their
relationships to regulatory requirements, contractual obligations, risk assessments, and best practices.
</div>
</div>
</div>
{{- if .FrameworkGroups}}
<div class="controls-page">
<h1 class="controls-title">2. Controls</h1>
<table class="controls-table">
<thead>
<tr>
<th rowspan="2" style="width: 12%;">Framework</th>
<th rowspan="2" style="width: 24%;">Control</th>
<th rowspan="2" style="width: 8%;">Applicability</th>
<th rowspan="2" style="width: 14%;">Justification for non-applicability</th>
<th rowspan="2" style="width: 8%;">Implemented</th>
<th rowspan="2" style="width: 10%;">Justification for non-implementation</th>
<th colspan="4" style="width: 24%; text-align: center;">Justification for inclusion</th>
</tr>
<tr>
<th style="width: 6%;">Regulatory</th>
<th style="width: 6%;">Contractual</th>
<th style="width: 6%;">Best Practice</th>
<th style="width: 6%;">Risk Assessment</th>
</tr>
</thead>
<tbody>
{{- range $group := .FrameworkGroups}}
{{- range $group.Controls}}
<tr>
<td>{{$group.FrameworkName}}</td>
<td><span class="section-tag">{{.SectionTitle}}</span>{{.Name}}</td>
<td>
{{- $state := boolToYesNo .Applicability}}
{{- if eq $state "yes"}}
<span class="state-tag state-tag-success">Yes</span>
{{- else if eq $state "no"}}
<span class="state-tag state-tag-danger">No</span>
{{- else}}
<span class="state-tag">-</span>
{{- end}}
</td>
<td>
{{- $appStateJ := boolToYesNo .Applicability}}
{{- if and (eq $appStateJ "no") .Justification}}
{{.Justification}}
{{- else}}
-
{{- end}}
</td>
<td>
{{- $appState := boolToYesNo .Applicability}}
{{- if eq $appState "no"}}
<span class="state-tag">-</span>
{{- else if .Implemented}}
{{- if eq (derefString .Implemented) "IMPLEMENTED"}}
<span class="state-tag state-tag-success">Yes</span>
{{- else}}
<span class="state-tag state-tag-danger">No</span>
{{- end}}
{{- else}}
<span class="state-tag">-</span>
{{- end}}
</td>
<td>
{{- $appState2 := boolToYesNo .Applicability}}
{{- if eq $appState2 "no"}}
-
{{- else if and .Implemented (eq (derefString .Implemented) "NOT_IMPLEMENTED") .NotImplementedJustification}}
{{.NotImplementedJustification}}
{{- else}}
-
{{- end}}
</td>
<td>{{boolToYesNoDash .Regulatory}}</td>
<td>{{boolToYesNoDash .Contractual}}</td>
<td>{{boolToYesNoDash .BestPractice}}</td>
<td>{{boolToYesNoDash .RiskAssessment}}</td>
</tr>
{{- end}}
{{- end}}
</tbody>
</table>
</div>
{{- end}}
<div class="annex-page">
<h1 class="annex-title">3. Annexes</h1>
<div class="annex-section">
<div class="annex-section-title">3.1 Column Definitions</div>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Framework</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-description">The name of the compliance framework or standard to which the control belongs (e.g., ISO 27001, SOC 2, GDPR).</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Control</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-description">The specific control identifier and name within the framework, including its section reference.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Applicability</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The control is applicable to the organization.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The control is not applicable to the organization (with justification provided).</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Justification for non-applicability</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-description">Provides the rationale when a control is not applicable. This field is empty for applicable controls.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Implemented</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The control has been implemented by the organization.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The control has not been implemented (with justification provided).</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">-:</span>
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Justification for non-implementation</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-description">Provides the rationale when a control is not implemented. This field is empty for implemented controls or when the control is not applicable.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Justification for inclusion</div>
<div class="annex-enum-description" style="margin-bottom: 12px;">
For applicable controls, this section provides additional context on why the control is included, based on regulatory requirements, contractual obligations, best practices, or risk assessments.
</div>
<div style="margin-left: 20px;">
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Regulatory</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The control is linked to one or more legal or regulatory obligations.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The control is not associated with any legal or regulatory obligations.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">-:</span>
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
</li>
</ul>
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Contractual</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The control is linked to one or more contractual obligations.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The control is not associated with any contractual obligations.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">-:</span>
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
</li>
</ul>
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Best Practice</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The control is designated as a best practice recommendation.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The control is not designated as a best practice.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">-:</span>
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
</li>
</ul>
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Risk Assessment</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The control is associated with one or more identified risks through risk mitigation measures.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The control is not currently associated with any identified risks.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">-:</span>
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
</li>
</ul>
</div>
</div>
</div>
</body>
</html>

View File

@@ -8,7 +8,7 @@
<style>
/* A4 Page Setup for printing */
@page {
size: A4;
size: A4{{if .Landscape}} landscape{{end}};
margin: 2.5cm;
@bottom-right {
@@ -118,8 +118,16 @@
widows: 3;
}
.document-content hr {
border: none;
margin: 0;
padding: 0;
height: 0;
page-break-after: always;
}
.document-content h1 {
font-size: 14pt;
font-size: 18pt;
font-weight: bold;
color: #000;
margin: 20px 0 12px 0;

View File

@@ -338,12 +338,12 @@ const (
ActionRightsRequestDelete = "core:rights-request:delete"
// StatementOfApplicability actions
ActionStatementOfApplicabilityList = "core:statement-of-applicability:list"
ActionStatementOfApplicabilityGet = "core:statement-of-applicability:get"
ActionStatementOfApplicabilityCreate = "core:statement-of-applicability:create"
ActionStatementOfApplicabilityUpdate = "core:statement-of-applicability:update"
ActionStatementOfApplicabilityDelete = "core:statement-of-applicability:delete"
ActionStatementOfApplicabilityExport = "core:statement-of-applicability:export"
ActionStatementOfApplicabilityList = "core:statement-of-applicability:list"
ActionStatementOfApplicabilityGet = "core:statement-of-applicability:get"
ActionStatementOfApplicabilityCreate = "core:statement-of-applicability:create"
ActionStatementOfApplicabilityUpdate = "core:statement-of-applicability:update"
ActionStatementOfApplicabilityDelete = "core:statement-of-applicability:delete"
ActionStatementOfApplicabilityPublish = "core:statement-of-applicability:publish"
ActionApplicabilityStatementGet = "core:applicability-statement:get"
ActionApplicabilityStatementList = "core:applicability-statement:list"

View File

@@ -136,7 +136,7 @@ func (s *DocumentApprovalService) RequestApproval(
return &ErrDocumentVersionNotDraft{}
}
q, err := s.requestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog)
q, err := s.RequestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog)
if err != nil {
return err
}
@@ -158,7 +158,7 @@ func (s *DocumentApprovalService) RequestApproval(
return quorum, nil
}
func (s *DocumentApprovalService) requestApprovalInTx(
func (s *DocumentApprovalService) RequestApprovalInTx(
ctx context.Context,
tx pg.Tx,
document *coredata.Document,
@@ -264,7 +264,7 @@ func (s *DocumentApprovalService) BulkPublishMajorVersions(
approverIDs[i] = a.ApproverProfileID
}
if _, err := s.requestApprovalInTx(ctx, tx, document, dv, approverIDs, &req.Changelog); err != nil {
if _, err := s.RequestApprovalInTx(ctx, tx, document, dv, approverIDs, &req.Changelog); err != nil {
return fmt.Errorf("cannot request approval for %q: %w", documentID, err)
}
} else {

View File

@@ -77,6 +77,12 @@ type (
ErrDocumentNotArchived struct {
}
ErrDocumentGenerated struct {
}
ErrDocumentVersionGenerated struct {
}
ErrDocumentVersionSignatureAlreadySigned struct {
}
@@ -217,6 +223,14 @@ func (e ErrDocumentNotArchived) Error() string {
return "cannot unarchive a document that is not archived"
}
func (e ErrDocumentGenerated) Error() string {
return "cannot create draft for a generated document"
}
func (e ErrDocumentVersionGenerated) Error() string {
return "cannot edit a generated document version"
}
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
return "document version signature already signed"
}
@@ -587,6 +601,7 @@ func (s *DocumentService) Create(
document := &coredata.Document{
ID: documentID,
WriteMode: coredata.DocumentWriteModeAuthored,
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
Status: coredata.DocumentStatusActive,
CreatedAt: now,
@@ -616,6 +631,7 @@ func (s *DocumentService) Create(
Status: coredata.DocumentVersionStatusDraft,
Classification: req.Classification,
DocumentType: req.DocumentType,
Orientation: coredata.DocumentVersionOrientationPortrait,
CreatedAt: now,
UpdatedAt: now,
}
@@ -1100,6 +1116,7 @@ func (s *DocumentService) createDraftInTx(
Classification: latestVersion.Classification,
DocumentType: latestVersion.DocumentType,
Content: latestVersion.Content,
Orientation: latestVersion.Orientation,
Status: coredata.DocumentVersionStatusDraft,
CreatedAt: now,
UpdatedAt: now,
@@ -1677,6 +1694,10 @@ func (s *DocumentService) Update(
hasVersionChanges := req.Title != nil || req.Content != nil || req.Classification != nil || req.DocumentType != nil
if hasVersionChanges && document.WriteMode == coredata.DocumentWriteModeGenerated {
return &ErrDocumentVersionGenerated{}
}
if !hasVersionChanges {
if req.DefaultApproverIDs != nil {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
@@ -2191,6 +2212,8 @@ func exportDocumentPDF(
}
}
isLandscape := version.Orientation == coredata.DocumentVersionOrientationLandscape
docData := docgen.DocumentData{
Title: version.Title,
Content: json.RawMessage([]byte(version.Content)),
@@ -2201,6 +2224,7 @@ func exportDocumentPDF(
PublishedAt: version.PublishedAt,
Signatures: signatureData,
CompanyHorizontalLogoBase64: horizontalLogoBase64,
Landscape: isLandscape,
}
htmlContent, err := docgen.RenderHTML(docData)
@@ -2208,9 +2232,14 @@ func exportDocumentPDF(
return nil, fmt.Errorf("cannot generate HTML: %w", err)
}
orientation := html2pdf.OrientationPortrait
if isLandscape {
orientation = html2pdf.OrientationLandscape
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
Orientation: orientation,
MarginTop: html2pdf.NewMarginInches(1.0),
MarginBottom: html2pdf.NewMarginInches(1.0),
MarginLeft: html2pdf.NewMarginInches(1.0),

View File

@@ -0,0 +1,362 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"text/template"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
)
type GeneratedDocumentService struct {
svc *TenantService
}
func (s *GeneratedDocumentService) PublishStatementOfApplicability(
ctx context.Context,
statementOfApplicabilityID gid.GID,
approverIDs []gid.GID,
) (*coredata.Document, *coredata.DocumentVersion, error) {
var (
document *coredata.Document
documentVersion *coredata.DocumentVersion
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
soa := &coredata.StatementOfApplicability{}
if err := soa.LoadByID(ctx, tx, s.svc.scope, statementOfApplicabilityID); err != nil {
return fmt.Errorf("cannot load statement of applicability: %w", err)
}
documentData, err := s.buildStatementOfApplicabilityDocumentData(ctx, tx, soa)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
prosemirrorJSON, err := BuildStatementOfApplicabilityDocument(documentData)
if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err)
}
now := time.Now()
var existingDoc *coredata.Document
if soa.DocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, s.svc.scope, *soa.DocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load statement of applicability document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
soa.DocumentID = nil
soa.UpdatedAt = now
if err := soa.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot clear document reference: %w", err)
}
}
}
hasApprovers := len(approverIDs) > 0
if existingDoc == nil {
documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
document = &coredata.Document{
ID: documentID,
OrganizationID: soa.OrganizationID,
WriteMode: coredata.DocumentWriteModeGenerated,
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
Status: coredata.DocumentStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
if err := document.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
soa.DocumentID = &documentID
soa.UpdatedAt = now
if err := soa.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document reference: %w", err)
}
} else {
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
if hasApprovers {
versionStatus = coredata.DocumentVersionStatusDraft
} else {
publishedAt = &now
}
documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
documentVersion = &coredata.DocumentVersion{
ID: documentVersionID,
OrganizationID: soa.OrganizationID,
DocumentID: document.ID,
Title: soa.Name,
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
Status: versionStatus,
Classification: coredata.DocumentClassificationConfidential,
DocumentType: coredata.DocumentTypeStatementOfApplicability,
Orientation: coredata.DocumentVersionOrientationLandscape,
PublishedAt: publishedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, soa.OrganizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}
func (s *GeneratedDocumentService) buildStatementOfApplicabilityDocumentData(
ctx context.Context,
conn pg.Querier,
statementOfApplicability *coredata.StatementOfApplicability,
) (docgen.StatementOfApplicabilityData, error) {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID); err != nil {
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load organization: %w", err)
}
var applicabilityStatements coredata.ApplicabilityStatements
if err := applicabilityStatements.LoadAllByStatementOfApplicabilityID(ctx, conn, s.svc.scope, statementOfApplicability.ID); err != nil {
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load applicability statements: %w", err)
}
if len(applicabilityStatements) == 0 {
return docgen.StatementOfApplicabilityData{
Title: statementOfApplicability.Name,
OrganizationName: organization.Name,
CreatedAt: statementOfApplicability.CreatedAt,
TotalControls: 0,
}, nil
}
controlIDs := make([]gid.GID, len(applicabilityStatements))
for i, stmt := range applicabilityStatements {
controlIDs[i] = stmt.ControlID
}
var controls coredata.Controls
if err := controls.LoadByIDs(ctx, conn, s.svc.scope, controlIDs); err != nil {
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load controls: %w", err)
}
controlMap := make(map[gid.GID]*coredata.Control, len(controls))
frameworkIDSet := make(map[gid.GID]struct{})
for _, c := range controls {
controlMap[c.ID] = c
frameworkIDSet[c.FrameworkID] = struct{}{}
}
frameworkIDs := make([]gid.GID, 0, len(frameworkIDSet))
for id := range frameworkIDSet {
frameworkIDs = append(frameworkIDs, id)
}
var frameworks coredata.Frameworks
if err := frameworks.LoadByIDs(ctx, conn, s.svc.scope, frameworkIDs); err != nil {
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load frameworks: %w", err)
}
frameworkMap := make(map[gid.GID]*coredata.Framework, len(frameworks))
for _, f := range frameworks {
frameworkMap[f.ID] = f
}
controlOblTypes, err := coredata.LoadObligationTypesByControlIDs(ctx, conn, s.svc.scope, controlIDs)
if err != nil {
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load obligation types: %w", err)
}
type obligationKey struct {
controlID gid.GID
oblType coredata.ObligationType
}
oblSet := make(map[obligationKey]struct{}, len(controlOblTypes))
for _, co := range controlOblTypes {
oblSet[obligationKey{co.ControlID, co.ObligationType}] = struct{}{}
}
var controlsWithRisk coredata.ControlsWithRisk
if err := controlsWithRisk.LoadByControlIDs(ctx, conn, s.svc.scope, controlIDs); err != nil {
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load controls with risks: %w", err)
}
riskSet := make(map[gid.GID]struct{}, len(controlsWithRisk))
for _, cwr := range controlsWithRisk {
riskSet[cwr.ControlID] = struct{}{}
}
rows := make([]docgen.SOARow, 0, len(applicabilityStatements))
for _, stmt := range applicabilityStatements {
control := controlMap[stmt.ControlID]
if control == nil {
continue
}
framework := frameworkMap[control.FrameworkID]
if framework == nil {
continue
}
applicable := stmt.Applicability
justification := "-"
if !applicable && stmt.Justification != nil {
justification = *stmt.Justification
}
implemented := "-"
if applicable {
if control.Implemented == coredata.ControlImplementationStateImplemented {
implemented = "Yes"
} else {
implemented = "No"
}
}
notImplJustification := "-"
if applicable && control.Implemented != coredata.ControlImplementationStateImplemented && control.NotImplementedJustification != nil {
notImplJustification = *control.NotImplementedJustification
}
regulatory := "-"
contractual := "-"
bestPractice := "-"
riskAssessment := "-"
if applicable {
_, hasLegal := oblSet[obligationKey{stmt.ControlID, coredata.ObligationTypeLegal}]
regulatory = docgen.BoolLabel(hasLegal)
_, hasContractual := oblSet[obligationKey{stmt.ControlID, coredata.ObligationTypeContractual}]
contractual = docgen.BoolLabel(hasContractual)
bestPractice = docgen.BoolLabel(control.BestPractice)
_, hasRisk := riskSet[stmt.ControlID]
riskAssessment = docgen.BoolLabel(hasRisk)
}
rows = append(rows, docgen.SOARow{
FrameworkName: framework.Name,
ControlSection: control.SectionTitle,
ControlName: control.Name,
Applicability: docgen.BoolLabel(applicable),
Justification: justification,
Implemented: implemented,
NotImplJustification: notImplJustification,
Regulatory: regulatory,
Contractual: contractual,
BestPractice: bestPractice,
RiskAssessment: riskAssessment,
})
}
return docgen.StatementOfApplicabilityData{
Title: statementOfApplicability.Name,
OrganizationName: organization.Name,
CreatedAt: statementOfApplicability.CreatedAt,
TotalControls: len(applicabilityStatements),
Rows: rows,
}, nil
}
var soaTemplate = template.Must(
template.New("statement_of_applicability.json.tmpl").
Funcs(template.FuncMap{
"json": func(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
}).
ParseFS(Templates, "templates/statement_of_applicability.json.tmpl"),
)
func BuildStatementOfApplicabilityDocument(data docgen.StatementOfApplicabilityData) (string, error) {
var buf bytes.Buffer
if err := soaTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute soa template: %w", err)
}
return buf.String(), nil
}

View File

@@ -170,8 +170,8 @@ var AuditorPolicy = policy.NewPolicy(
).WithSID("employee-document-access").When(organizationCondition),
policy.Allow(
ActionStatementOfApplicabilityExport,
).WithSID("soa-export").When(organizationCondition),
ActionStatementOfApplicabilityPublish,
).WithSID("soa-publish").When(organizationCondition),
).WithDescription("Read-only probo access for auditors (excludes internal/employee content)")
// EmployeePolicy defines permissions for employee role.

View File

@@ -118,6 +118,7 @@ type (
DataProtectionImpactAssessments *DataProtectionImpactAssessmentService
TransferImpactAssessments *TransferImpactAssessmentService
StatementsOfApplicability *StatementOfApplicabilityService
GeneratedDocuments *GeneratedDocumentService
Files *FileService
CustomDomains *CustomDomainService
SlackMessages *slack.SlackMessageService
@@ -284,8 +285,10 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
html2pdfConverter: s.html2pdfConverter,
}
tenantService.StatementsOfApplicability = &StatementOfApplicabilityService{
svc: tenantService,
html2pdfConverter: s.html2pdfConverter,
svc: tenantService,
}
tenantService.GeneratedDocuments = &GeneratedDocumentService{
svc: tenantService,
}
tenantService.Files = &FileService{svc: tenantService}
tenantService.CustomDomains = &CustomDomainService{

View File

@@ -17,34 +17,28 @@ package probo
import (
"context"
"fmt"
"io"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type StatementOfApplicabilityService struct {
svc *TenantService
html2pdfConverter *html2pdf.Converter
svc *TenantService
}
type (
CreateStatementOfApplicabilityRequest struct {
OrganizationID gid.GID
Name string
OwnerID gid.GID
}
UpdateStatementOfApplicabilityRequest struct {
StatementOfApplicabilityID gid.GID
Name *string
OwnerID *gid.GID
}
)
@@ -53,7 +47,6 @@ func (csr *CreateStatementOfApplicabilityRequest) Validate() error {
v.Check(csr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(csr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(csr.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
return v.Error()
}
@@ -63,7 +56,6 @@ func (usr *UpdateStatementOfApplicabilityRequest) Validate() error {
v.Check(usr.StatementOfApplicabilityID, "statement_of_applicability_id", validator.Required(), validator.GID(coredata.StatementOfApplicabilityEntityType))
v.Check(usr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(usr.OwnerID, "owner_id", validator.GID(coredata.MembershipProfileEntityType))
return v.Error()
}
@@ -72,7 +64,6 @@ func (s StatementOfApplicabilityService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.StatementOfApplicabilityOrderField],
filter *coredata.StatementOfApplicabilityFilter,
) (*page.Page[*coredata.StatementOfApplicability, coredata.StatementOfApplicabilityOrderField], error) {
var statementsOfApplicability coredata.StatementsOfApplicability
organization := &coredata.Organization{}
@@ -90,7 +81,6 @@ func (s StatementOfApplicabilityService) ListForOrganizationID(
s.svc.scope,
organization.ID,
cursor,
filter,
)
if err != nil {
return fmt.Errorf("cannot load statements_of_applicability: %w", err)
@@ -110,7 +100,6 @@ func (s StatementOfApplicabilityService) ListForOrganizationID(
func (s StatementOfApplicabilityService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
filter *coredata.StatementOfApplicabilityFilter,
) (int, error) {
var count int
@@ -118,7 +107,7 @@ func (s StatementOfApplicabilityService) CountForOrganizationID(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
statementsOfApplicability := &coredata.StatementsOfApplicability{}
count, err = statementsOfApplicability.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
count, err = statementsOfApplicability.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count statements_of_applicability: %w", err)
}
@@ -180,7 +169,6 @@ func (s StatementOfApplicabilityService) Create(
ID: statementOfApplicabilityID,
OrganizationID: organization.ID,
Name: req.Name,
OwnerID: req.OwnerID,
CreatedAt: now,
UpdatedAt: now,
}
@@ -223,9 +211,6 @@ func (s StatementOfApplicabilityService) Update(
if req.Name != nil {
statementOfApplicability.Name = *req.Name
}
if req.OwnerID != nil {
statementOfApplicability.OwnerID = *req.OwnerID
}
statementOfApplicability.UpdatedAt = time.Now()
@@ -444,248 +429,3 @@ func (s StatementOfApplicabilityService) ListControlLinks(
return page.NewPage(controls, cursor), nil
}
func (s StatementOfApplicabilityService) ExportPDF(
ctx context.Context,
statementOfApplicabilityID gid.GID,
) ([]byte, error) {
var documentData docgen.StatementOfApplicabilityData
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
statementOfApplicability := &coredata.StatementOfApplicability{}
if err := statementOfApplicability.LoadByID(ctx, conn, s.svc.scope, statementOfApplicabilityID); err != nil {
return fmt.Errorf("cannot load statement of applicability: %w", err)
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
owner := &coredata.MembershipProfile{}
if err := owner.LoadByID(ctx, conn, s.svc.scope, statementOfApplicability.OwnerID); err != nil {
return fmt.Errorf("cannot load owner profile: %w", err)
}
// Load applicability statements
var applicabilityStatements coredata.ApplicabilityStatements
cursor := page.NewCursor(
10000,
nil,
page.Head,
page.OrderBy[coredata.ApplicabilityStatementOrderField]{
Field: coredata.ApplicabilityStatementOrderFieldControlSectionTitle,
Direction: page.OrderDirectionAsc,
},
)
if err := applicabilityStatements.LoadByStatementOfApplicabilityID(ctx, conn, s.svc.scope, statementOfApplicabilityID, cursor); err != nil {
return fmt.Errorf("cannot load applicability statements: %w", err)
}
if len(applicabilityStatements) == 0 {
// No linked controls, skip loading additional data
documentData = docgen.StatementOfApplicabilityData{
Title: statementOfApplicability.Name,
OrganizationName: organization.Name,
CreatedAt: statementOfApplicability.CreatedAt,
TotalControls: 0,
FrameworkGroups: []docgen.FrameworkControlGroup{},
}
return nil
}
frameworkControlsMap := make(map[string][]docgen.ControlData)
frameworkOrder := []string{}
for _, stmt := range applicabilityStatements {
// Load control
control := &coredata.Control{}
if err := control.LoadByID(ctx, conn, s.svc.scope, stmt.ControlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
// Load framework
framework := &coredata.Framework{}
if err := framework.LoadByID(ctx, conn, s.svc.scope, control.FrameworkID); err != nil {
return fmt.Errorf("cannot load framework: %w", err)
}
// Count legal obligations
var controlObligations coredata.ControlObligations
legalType := coredata.ObligationTypeLegal
legalFilter := coredata.NewControlObligationFilter(&legalType)
legalCount, err := controlObligations.CountByControlID(ctx, conn, s.svc.scope, stmt.ControlID, legalFilter)
if err != nil {
return fmt.Errorf("cannot count legal obligations: %w", err)
}
// Count contractual obligations
contractualType := coredata.ObligationTypeContractual
contractualFilter := coredata.NewControlObligationFilter(&contractualType)
contractualCount, err := controlObligations.CountByControlID(ctx, conn, s.svc.scope, stmt.ControlID, contractualFilter)
if err != nil {
return fmt.Errorf("cannot count contractual obligations: %w", err)
}
// Check if control has risk
var controlsWithRisk coredata.ControlsWithRisk
if err := controlsWithRisk.LoadByControlIDs(ctx, conn, s.svc.scope, []gid.GID{stmt.ControlID}); err != nil {
return fmt.Errorf("cannot load controls with risks: %w", err)
}
hasRisk := len(controlsWithRisk) > 0
if _, exists := frameworkControlsMap[framework.Name]; !exists {
frameworkOrder = append(frameworkOrder, framework.Name)
frameworkControlsMap[framework.Name] = []docgen.ControlData{}
}
var regulatory *bool
var contractual *bool
var bestPractice *bool
var riskAssessment *bool
if stmt.Applicability {
falseVal := false
trueVal := true
regulatory = &falseVal
contractual = &falseVal
riskAssessment = &falseVal
if legalCount > 0 {
regulatory = &trueVal
}
if contractualCount > 0 {
contractual = &trueVal
}
if hasRisk {
riskAssessment = &trueVal
}
bestPractice = &control.BestPractice
}
applicability := stmt.Applicability
implemented := control.Implemented.String()
frameworkControlsMap[framework.Name] = append(
frameworkControlsMap[framework.Name],
docgen.ControlData{
FrameworkName: framework.Name,
SectionTitle: control.SectionTitle,
Name: control.Name,
Applicability: &applicability,
Justification: stmt.Justification,
BestPractice: bestPractice,
Implemented: &implemented,
NotImplementedJustification: func() *string {
if control.Implemented == coredata.ControlImplementationStateImplemented {
return nil
}
return control.NotImplementedJustification
}(),
Regulatory: regulatory,
Contractual: contractual,
RiskAssessment: riskAssessment,
},
)
}
frameworkGroups := make([]docgen.FrameworkControlGroup, len(frameworkOrder))
for i, frameworkName := range frameworkOrder {
frameworkGroups[i] = docgen.FrameworkControlGroup{
FrameworkName: frameworkName,
Controls: frameworkControlsMap[frameworkName],
}
}
var snapshots coredata.Snapshots
snapshotType := coredata.SnapshotsTypeStatementsOfApplicability
var version int
var publishedAt time.Time
if statementOfApplicability.SnapshotID != nil {
snapshot := &coredata.Snapshot{}
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *statementOfApplicability.SnapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
publishedAt = snapshot.CreatedAt
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count states of applicability snapshots: %w", err)
}
version = snapshotCount
} else {
publishedAt = time.Now()
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count states of applicability snapshots: %w", err)
}
version = snapshotCount + 1
}
horizontalLogoBase64 := ""
if organization.HorizontalLogoFileID != nil {
fileRecord := &coredata.File{}
fileErr := fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID)
if fileErr == nil {
base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord)
if logoErr == nil {
horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
}
}
}
documentData = docgen.StatementOfApplicabilityData{
Title: statementOfApplicability.Name,
OrganizationName: organization.Name,
CreatedAt: statementOfApplicability.CreatedAt,
TotalControls: len(applicabilityStatements),
FrameworkGroups: frameworkGroups,
CompanyHorizontalLogoBase64: horizontalLogoBase64,
Version: version,
PublishedAt: publishedAt,
Approver: owner.FullName,
}
return nil
},
)
if err != nil {
return nil, err
}
htmlData, err := docgen.RenderStatementOfApplicabilityHTML(documentData)
if err != nil {
return nil, fmt.Errorf("cannot render HTML: %w", err)
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginInches(1.0),
MarginBottom: html2pdf.NewMarginInches(1.0),
MarginLeft: html2pdf.NewMarginInches(1.0),
MarginRight: html2pdf.NewMarginInches(1.0),
PrintBackground: true,
Scale: 1.0,
}
pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlData, cfg)
if err != nil {
return nil, fmt.Errorf("cannot generate PDF: %w", err)
}
pdfData, err := io.ReadAll(pdfReader)
if err != nil {
return nil, fmt.Errorf("cannot read PDF data: %w", err)
}
return pdfData, nil
}

View File

@@ -0,0 +1,189 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "1. Purpose" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "This document provides a comprehensive overview of the statement of applicability for controls within the organization. It serves as a record of which controls are applicable or not applicable to the organization, along with their relationships to regulatory requirements, contractual obligations, risk assessments, and best practices." }]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "2. Controls" }]
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Framework", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Control", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Applicability", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for non-applicability", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Implemented", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for non-implementation", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 4, "rowspan": 1 }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for inclusion", "marks": [{ "type": "bold" }] }] }] }
]
},
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Regulatory", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Contractual", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Best Practice", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Risk Assessment", "marks": [{ "type": "bold" }] }] }] }
]
}{{range .Rows}},
{
"type": "tableRow",
"content": [
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .FrameworkName}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "[%s] " .ControlSection)}}, "marks": [{ "type": "code" }] }, { "type": "text", "text": {{json .ControlName}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Applicability}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Justification}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Implemented}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .NotImplJustification}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Regulatory}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Contractual}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .BestPractice}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .RiskAssessment}} }] }] }
]
}{{end}}
]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "3. Definitions" }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Framework" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "The name of the compliance framework or standard to which the control belongs (e.g., ISO 27001, SOC 2, GDPR)." }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Control" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "The specific control identifier and name within the framework, including its section reference." }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Applicability" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is applicable to the organization." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not applicable to the organization (with justification provided)." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Justification for non-applicability" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Provides the rationale when a control is not applicable. This field is empty for applicable controls." }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Implemented" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control has been implemented by the organization." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control has not been implemented (with justification provided)." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Justification for non-implementation" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Provides the rationale when a control is not implemented. This field is empty for implemented controls or when the control is not applicable." }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Justification for inclusion" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "For applicable controls, this section provides additional context on why the control is included, based on regulatory requirements, contractual obligations, best practices, or risk assessments." }]
},
{
"type": "heading",
"attrs": { "level": 4 },
"content": [{ "type": "text", "text": "Regulatory" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is linked to one or more legal or regulatory obligations." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not associated with any legal or regulatory obligations." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 4 },
"content": [{ "type": "text", "text": "Contractual" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is linked to one or more contractual obligations." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not associated with any contractual obligations." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 4 },
"content": [{ "type": "text", "text": "Best Practice" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is designated as a best practice recommendation." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not designated as a best practice." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 4 },
"content": [{ "type": "text", "text": "Risk Assessment" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is associated with one or more identified risks through risk mitigation measures." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not currently associated with any identified risks." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
]
}
]
}

View File

@@ -7,7 +7,6 @@ package console_v1
import (
"context"
"encoding/base64"
"errors"
"fmt"
@@ -15,7 +14,6 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
@@ -229,6 +227,7 @@ func (r *controlResolver) Documents(ctx context.Context, obj *types.Control, fir
var documentFilter = coredata.NewDocumentFilter(nil)
if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query).
WithWriteModes(filter.WriteModes).
WithDocumentTypes(filter.DocumentTypes).
WithClassifications(filter.Classifications)
}
@@ -768,7 +767,6 @@ func (r *mutationResolver) CreateStatementOfApplicability(ctx context.Context, i
probo.CreateStatementOfApplicabilityRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
OwnerID: input.OwnerID,
},
)
if err != nil {
@@ -805,7 +803,6 @@ func (r *mutationResolver) UpdateStatementOfApplicability(ctx context.Context, i
probo.UpdateStatementOfApplicabilityRequest{
StatementOfApplicabilityID: input.ID,
Name: name,
OwnerID: input.OwnerID,
},
)
if err != nil {
@@ -843,28 +840,53 @@ func (r *mutationResolver) DeleteStatementOfApplicability(ctx context.Context, i
}, nil
}
// ExportStatementOfApplicabilityPDF is the resolver for the exportStatementOfApplicabilityPDF field.
func (r *mutationResolver) ExportStatementOfApplicabilityPDF(ctx context.Context, input types.ExportStatementOfApplicabilityPDFInput) (*types.ExportStatementOfApplicabilityPDFPayload, error) {
if err := r.authorize(ctx, input.StatementOfApplicabilityID, probo.ActionStatementOfApplicabilityExport); err != nil {
// PublishStatementOfApplicability is the resolver for the publishStatementOfApplicability field.
func (r *mutationResolver) PublishStatementOfApplicability(ctx context.Context, input types.PublishStatementOfApplicabilityInput) (*types.PublishStatementOfApplicabilityPayload, error) {
if err := r.authorize(ctx, input.StatementOfApplicabilityID, probo.ActionStatementOfApplicabilityPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.StatementOfApplicabilityID.TenantID())
pdfData, err := prb.StatementsOfApplicability.ExportPDF(ctx, input.StatementOfApplicabilityID)
document, documentVersion, err := prb.GeneratedDocuments.PublishStatementOfApplicability(ctx, input.StatementOfApplicabilityID, input.ApproverIds)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot export statement of applicability PDF", log.Error(err))
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot publish statement of applicability", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
base64Data := base64.StdEncoding.EncodeToString(pdfData)
dataURI := fmt.Sprintf("data:application/pdf;base64,%s", base64Data)
return &types.ExportStatementOfApplicabilityPDFPayload{
Data: dataURI,
return &types.PublishStatementOfApplicabilityPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Document is the resolver for the document field.
func (r *statementOfApplicabilityResolver) Document(ctx context.Context, obj *types.StatementOfApplicability) (*types.Document, error) {
if obj.Document == nil {
return nil, nil
}
if err := r.authorize(ctx, obj.Document.ID, probo.ActionDocumentGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.Document.ID.TenantID())
document, err := prb.Documents.Get(ctx, obj.Document.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDocument(document), nil
}
// Organization is the resolver for the organization field.
func (r *statementOfApplicabilityResolver) Organization(ctx context.Context, obj *types.StatementOfApplicability) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
@@ -885,26 +907,6 @@ func (r *statementOfApplicabilityResolver) Organization(ctx context.Context, obj
return types.NewOrganization(organization), nil
}
// Owner is the resolver for the owner field.
func (r *statementOfApplicabilityResolver) Owner(ctx context.Context, obj *types.StatementOfApplicability) (*types.Profile, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil {
return nil, err
}
loaders := dataloader.FromContext(ctx)
owner, err := loaders.Profile.Load(ctx, obj.Owner.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot load owner", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewProfile(owner), nil
}
// ApplicabilityStatements is the resolver for the applicabilityStatements field.
func (r *statementOfApplicabilityResolver) ApplicabilityStatements(ctx context.Context, obj *types.StatementOfApplicability, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ApplicabilityStatementOrderBy) (*types.ApplicabilityStatementConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionApplicabilityStatementList); err != nil {
@@ -946,7 +948,7 @@ func (r *statementOfApplicabilityConnectionResolver) TotalCount(ctx context.Cont
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := prb.StatementsOfApplicability.CountForOrganizationID(ctx, obj.ParentID, obj.Filters)
count, err := prb.StatementsOfApplicability.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count statements_of_applicability", log.Error(err))
return 0, gqlutils.Internal(ctx)

View File

@@ -78,10 +78,6 @@ input ControlFilter {
query: String
}
input StatementOfApplicabilityFilter {
snapshotId: ID
}
type Control implements Node {
id: ID!
organization: Organization @goField(forceResolver: true)
@@ -163,10 +159,8 @@ type ControlEdge {
type StatementOfApplicability implements Node {
id: ID!
name: String!
sourceId: ID
snapshotId: ID
document: Document @goField(forceResolver: true)
organization: Organization @goField(forceResolver: true)
owner: Profile! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -273,9 +267,9 @@ extend type Mutation {
deleteStatementOfApplicability(
input: DeleteStatementOfApplicabilityInput!
): DeleteStatementOfApplicabilityPayload!
exportStatementOfApplicabilityPDF(
input: ExportStatementOfApplicabilityPDFInput!
): ExportStatementOfApplicabilityPDFPayload!
publishStatementOfApplicability(
input: PublishStatementOfApplicabilityInput!
): PublishStatementOfApplicabilityPayload!
}
input CreateControlInput {
@@ -372,13 +366,11 @@ input DeleteControlSnapshotMappingInput {
input CreateStatementOfApplicabilityInput {
organizationId: ID!
name: String!
ownerId: ID!
}
input UpdateStatementOfApplicabilityInput {
id: ID!
name: String
ownerId: ID
}
input ApplicabilityStatementInput {
@@ -391,8 +383,9 @@ input DeleteStatementOfApplicabilityInput {
statementOfApplicabilityId: ID!
}
input ExportStatementOfApplicabilityPDFInput {
input PublishStatementOfApplicabilityInput {
statementOfApplicabilityId: ID!
approverIds: [ID!]
}
type CreateControlPayload {
@@ -481,6 +474,7 @@ type DeleteStatementOfApplicabilityPayload {
deletedStatementOfApplicabilityId: ID!
}
type ExportStatementOfApplicabilityPDFPayload {
data: String!
type PublishStatementOfApplicabilityPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}

View File

@@ -37,6 +37,36 @@ enum DocumentType
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
TEMPLATE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
STATEMENT_OF_APPLICABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentTypeStatementOfApplicability"
)
}
enum DocumentVersionOrientation
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionOrientation"
) {
PORTRAIT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionOrientationPortrait"
)
LANDSCAPE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionOrientationLandscape"
)
}
enum DocumentWriteMode
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentWriteMode") {
AUTHORED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentWriteModeAuthored"
)
GENERATED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentWriteModeGenerated"
)
}
enum DocumentClassification
@@ -199,6 +229,7 @@ input DocumentVersionOrder
input DocumentFilter {
query: String
writeModes: [DocumentWriteMode!]
documentTypes: [DocumentType!]
classifications: [DocumentClassification!]
status: [DocumentStatus!]
@@ -259,6 +290,7 @@ type Document implements Node {
defaultApprovers: [Profile!]! @goField(forceResolver: true)
writeMode: DocumentWriteMode!
status: DocumentStatus!
archivedAt: Datetime
@@ -279,6 +311,7 @@ type DocumentVersion implements Node {
title: String!
classification: DocumentClassification!
documentType: DocumentType!
orientation: DocumentVersionOrientation!
approvers(
first: Int
after: CursorKey

View File

@@ -60,6 +60,10 @@ enum ElectronicSignatureDocumentType
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
)
STATEMENT_OF_APPLICABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeStatementOfApplicability"
)
}
enum ElectronicSignatureEventType

View File

@@ -190,7 +190,6 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: StatementOfApplicabilityOrder
filter: StatementOfApplicabilityFilter = { snapshotId: null }
): StatementOfApplicabilityConnection! @goField(forceResolver: true)
dataProtectionImpactAssessments(

View File

@@ -174,6 +174,7 @@ func (r *measureResolver) Documents(ctx context.Context, obj *types.Measure, fir
var documentFilter = coredata.NewDocumentFilter(nil)
if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query).
WithWriteModes(filter.WriteModes).
WithDocumentTypes(filter.DocumentTypes).
WithClassifications(filter.Classifications)
}

View File

@@ -540,7 +540,7 @@ func (r *organizationResolver) Controls(ctx context.Context, obj *types.Organiza
}
// StatementsOfApplicability is the resolver for the statementsOfApplicability field.
func (r *organizationResolver) StatementsOfApplicability(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.StatementOfApplicabilityOrderBy, filter *types.StatementOfApplicabilityFilter) (*types.StatementOfApplicabilityConnection, error) {
func (r *organizationResolver) StatementsOfApplicability(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.StatementOfApplicabilityOrderBy) (*types.StatementOfApplicabilityConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionStatementOfApplicabilityList); err != nil {
return nil, err
}
@@ -560,18 +560,13 @@ func (r *organizationResolver) StatementsOfApplicability(ctx context.Context, ob
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var statementOfApplicabilityFilter = coredata.NewStatementOfApplicabilityFilter(nil)
if filter != nil {
statementOfApplicabilityFilter = coredata.NewStatementOfApplicabilityFilter(&filter.SnapshotID)
}
page, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, obj.ID, cursor, statementOfApplicabilityFilter)
page, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization statements_of_applicability", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewStatementOfApplicabilityConnection(page, r, obj.ID, statementOfApplicabilityFilter), nil
return types.NewStatementOfApplicabilityConnection(page, r, obj.ID), nil
}
// DataProtectionImpactAssessments is the resolver for the dataProtectionImpactAssessments field.
@@ -670,6 +665,7 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
var documentFilter = coredata.NewDocumentFilter(nil)
if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query).
WithWriteModes(filter.WriteModes).
WithDocumentTypes(filter.DocumentTypes).
WithClassifications(filter.Classifications).
WithStatus(filter.Status)

View File

@@ -344,6 +344,7 @@ func (r *riskResolver) Documents(ctx context.Context, obj *types.Risk, first *in
var documentFilter = coredata.NewDocumentFilter(nil)
if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query).
WithWriteModes(filter.WriteModes).
WithDocumentTypes(filter.DocumentTypes).
WithClassifications(filter.Classifications)
}

View File

@@ -81,6 +81,7 @@ func NewDocument(document *coredata.Document) *Document {
},
CurrentPublishedMajor: document.CurrentPublishedMajor,
CurrentPublishedMinor: document.CurrentPublishedMinor,
WriteMode: document.WriteMode,
TrustCenterVisibility: document.TrustCenterVisibility,
Status: document.Status,
ArchivedAt: document.ArchivedAt,

View File

@@ -83,6 +83,7 @@ func NewDocumentVersion(documentVersion *coredata.DocumentVersion) *DocumentVers
Status: documentVersion.Status,
Classification: documentVersion.Classification,
DocumentType: documentVersion.DocumentType,
Orientation: documentVersion.Orientation,
PublishedAt: documentVersion.PublishedAt,
Changelog: documentVersion.Changelog,
CreatedAt: documentVersion.CreatedAt,

View File

@@ -30,7 +30,6 @@ type (
Resolver any
ParentID gid.GID
Filters *coredata.StatementOfApplicabilityFilter
}
)
@@ -38,7 +37,6 @@ func NewStatementOfApplicabilityConnection(
p *page.Page[*coredata.StatementOfApplicability, coredata.StatementOfApplicabilityOrderField],
parentType any,
parentID gid.GID,
filters *coredata.StatementOfApplicabilityFilter,
) *StatementOfApplicabilityConnection {
var edges = make([]*StatementOfApplicabilityEdge, len(p.Data))
@@ -52,7 +50,6 @@ func NewStatementOfApplicabilityConnection(
Resolver: parentType,
ParentID: parentID,
Filters: filters,
}
}
@@ -64,18 +61,21 @@ func NewStatementOfApplicabilityEdge(soa *coredata.StatementOfApplicability, ord
}
func NewStatementOfApplicability(soa *coredata.StatementOfApplicability) *StatementOfApplicability {
return &StatementOfApplicability{
s := &StatementOfApplicability{
ID: soa.ID,
Organization: &Organization{
ID: soa.OrganizationID,
},
Owner: &Profile{
ID: soa.OwnerID,
},
Name: soa.Name,
SourceID: soa.SourceID,
SnapshotID: soa.SnapshotID,
CreatedAt: soa.CreatedAt,
UpdatedAt: soa.UpdatedAt,
Name: soa.Name,
CreatedAt: soa.CreatedAt,
UpdatedAt: soa.UpdatedAt,
}
if soa.DocumentID != nil {
s.Document = &Document{
ID: *soa.DocumentID,
}
}
return s
}

View File

@@ -6,7 +6,6 @@ package mcp_v1
import (
"context"
"encoding/base64"
"errors"
"fmt"
"time"
@@ -2045,6 +2044,7 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
}
documentFilter = coredata.NewDocumentFilter(query).
WithWriteModes(input.Filter.WriteModes).
WithDocumentTypes(input.Filter.DocumentTypes).
WithClassifications(input.Filter.Classifications).
WithStatus(input.Filter.Status)
@@ -2818,13 +2818,7 @@ func (r *Resolver) ListStatementsOfApplicabilityTool(ctx context.Context, req *m
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
filter := coredata.NewStatementOfApplicabilityFilter(&noSnapshot)
if input.Filter != nil {
filter = coredata.NewStatementOfApplicabilityFilter(&input.Filter.SnapshotID)
}
pg, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, input.OrganizationID, cursor, filter)
pg, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, input.OrganizationID, cursor)
if err != nil {
return nil, types.ListStatementsOfApplicabilityOutput{}, fmt.Errorf("failed to list statements of applicability: %w", err)
}
@@ -2855,7 +2849,6 @@ func (r *Resolver) AddStatementOfApplicabilityTool(ctx context.Context, req *mcp
soa, err := svc.StatementsOfApplicability.Create(ctx, probo.CreateStatementOfApplicabilityRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
OwnerID: input.OwnerID,
})
if err != nil {
return nil, types.AddStatementOfApplicabilityOutput{}, fmt.Errorf("failed to create statement of applicability: %w", err)
@@ -2874,7 +2867,6 @@ func (r *Resolver) UpdateStatementOfApplicabilityTool(ctx context.Context, req *
soa, err := svc.StatementsOfApplicability.Update(ctx, probo.UpdateStatementOfApplicabilityRequest{
StatementOfApplicabilityID: input.ID,
Name: input.Name,
OwnerID: input.OwnerID,
})
if err != nil {
return nil, types.UpdateStatementOfApplicabilityOutput{}, fmt.Errorf("failed to update statement of applicability: %w", err)
@@ -2900,27 +2892,6 @@ func (r *Resolver) DeleteStatementOfApplicabilityTool(ctx context.Context, req *
}, nil
}
func (r *Resolver) ExportStatementOfApplicabilityPDFTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ExportStatementOfApplicabilityPDFInput) (*mcp.CallToolResult, types.ExportStatementOfApplicabilityPDFOutput, error) {
r.MustAuthorize(ctx, input.ID, probo.ActionStatementOfApplicabilityExport)
svc := r.ProboService(ctx, input.ID)
soa, err := svc.StatementsOfApplicability.Get(ctx, input.ID)
if err != nil {
return nil, types.ExportStatementOfApplicabilityPDFOutput{}, fmt.Errorf("failed to get statement of applicability: %w", err)
}
pdfData, err := svc.StatementsOfApplicability.ExportPDF(ctx, input.ID)
if err != nil {
return nil, types.ExportStatementOfApplicabilityPDFOutput{}, fmt.Errorf("failed to export statement of applicability PDF: %w", err)
}
return nil, types.ExportStatementOfApplicabilityPDFOutput{
PdfBase64: base64.StdEncoding.EncodeToString(pdfData),
Filename: soa.Name + ".pdf",
}, nil
}
func (r *Resolver) ListApplicabilityStatementsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListApplicabilityStatementsInput) (*mcp.CallToolResult, types.ListApplicabilityStatementsOutput, error) {
r.MustAuthorize(ctx, input.StatementOfApplicabilityID, probo.ActionApplicabilityStatementList)
@@ -3935,3 +3906,19 @@ func (r *Resolver) DeleteDocumentDraftTool(ctx context.Context, req *mcp.CallToo
Document: types.NewDocument(document),
}, nil
}
func (r *Resolver) PublishStatementOfApplicabilityTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishStatementOfApplicabilityInput) (*mcp.CallToolResult, types.PublishStatementOfApplicabilityOutput, error) {
r.MustAuthorize(ctx, input.ID, probo.ActionStatementOfApplicabilityPublish)
svc := r.ProboService(ctx, input.ID)
document, documentVersion, err := svc.GeneratedDocuments.PublishStatementOfApplicability(ctx, input.ID, input.ApproverIds)
if err != nil {
return nil, types.PublishStatementOfApplicabilityOutput{}, fmt.Errorf("cannot publish statement of applicability: %w", err)
}
return nil, types.PublishStatementOfApplicabilityOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -5144,6 +5144,7 @@ components:
- RECORD
- REPORT
- TEMPLATE
- STATEMENT_OF_APPLICABILITY
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentType
DocumentClassification:
@@ -5170,6 +5171,13 @@ components:
- ARCHIVED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentStatus
DocumentWriteMode:
type: string
enum:
- AUTHORED
- GENERATED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentWriteMode
DocumentVersionSignatureState:
type: string
enum:
@@ -5244,6 +5252,7 @@ components:
- id
- organization_id
- trust_center_visibility
- write_mode
- status
- created_at
- updated_at
@@ -5267,6 +5276,9 @@ components:
trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility
write_mode:
$ref: "#/components/schemas/DocumentWriteMode"
description: Write mode (authored or generated)
status:
$ref: "#/components/schemas/DocumentStatus"
description: Document status
@@ -5419,6 +5431,11 @@ components:
query:
type: string
description: Search query
write_modes:
type: array
items:
$ref: "#/components/schemas/DocumentWriteMode"
description: Filter by write mode (AUTHORED or GENERATED)
trust_center_visibilities:
type: array
items:
@@ -6119,7 +6136,6 @@ components:
- id
- organization_id
- name
- owner_id
- created_at
- updated_at
properties:
@@ -6132,14 +6148,11 @@ components:
name:
type: string
description: Statement of applicability name
owner_id:
$ref: "#/components/schemas/GID"
description: Owner profile ID
snapshot_id:
document_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Snapshot ID
description: Associated document ID
created_at:
type: string
format: date-time
@@ -6211,7 +6224,6 @@ components:
required:
- organization_id
- name
- owner_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
@@ -6219,9 +6231,6 @@ components:
name:
type: string
description: Statement of applicability name
owner_id:
$ref: "#/components/schemas/GID"
description: Owner profile ID
AddStatementOfApplicabilityOutput:
type: object
@@ -6242,9 +6251,6 @@ components:
name:
type: string
description: Statement of applicability name
owner_id:
$ref: "#/components/schemas/GID"
description: Owner profile ID
UpdateStatementOfApplicabilityOutput:
type: object
@@ -6272,7 +6278,8 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted statement of applicability ID
ExportStatementOfApplicabilityPDFInput:
PublishStatementOfApplicabilityInput:
type: object
required:
- id
@@ -6280,19 +6287,24 @@ components:
id:
$ref: "#/components/schemas/GID"
description: Statement of applicability ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately.
ExportStatementOfApplicabilityPDFOutput:
PublishStatementOfApplicabilityOutput:
type: object
required:
- pdf_base64
- filename
- document_id
- document_version_id
properties:
pdf_base64:
type: string
description: Base64-encoded PDF content
filename:
type: string
description: Suggested filename for the PDF
document_id:
$ref: "#/components/schemas/GID"
description: Created or updated document ID
document_version_id:
$ref: "#/components/schemas/GID"
description: Created document version ID
ApplicabilityStatementOrderField:
type: string
@@ -8597,15 +8609,14 @@ tools:
$ref: "#/components/schemas/DeleteStatementOfApplicabilityInput"
outputSchema:
$ref: "#/components/schemas/DeleteStatementOfApplicabilityOutput"
- name: exportStatementOfApplicabilityPDF
description: Export a statement of applicability as a PDF document
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:
readonly: true
idempotent: true
readonly: false
inputSchema:
$ref: "#/components/schemas/ExportStatementOfApplicabilityPDFInput"
$ref: "#/components/schemas/PublishStatementOfApplicabilityInput"
outputSchema:
$ref: "#/components/schemas/ExportStatementOfApplicabilityPDFOutput"
$ref: "#/components/schemas/PublishStatementOfApplicabilityOutput"
- name: listApplicabilityStatements
description: List all applicability statements for a statement of applicability
hints:

View File

@@ -47,6 +47,7 @@ func NewDocument(d *coredata.Document) *Document {
OrganizationID: d.OrganizationID,
CurrentPublishedMajor: d.CurrentPublishedMajor,
CurrentPublishedMinor: d.CurrentPublishedMinor,
WriteMode: d.WriteMode,
TrustCenterVisibility: d.TrustCenterVisibility,
Status: d.Status,
ArchivedAt: d.ArchivedAt,

View File

@@ -23,8 +23,7 @@ func NewStatementOfApplicability(s *coredata.StatementOfApplicability) *Statemen
ID: s.ID,
OrganizationID: s.OrganizationID,
Name: s.Name,
OwnerID: s.OwnerID,
SnapshotID: s.SnapshotID,
DocumentID: s.DocumentID,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}

View File

@@ -83,6 +83,10 @@ enum DocumentType
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
TEMPLATE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
STATEMENT_OF_APPLICABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentTypeStatementOfApplicability"
)
}
type Document implements Node @nda {