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

@@ -0,0 +1,538 @@
// 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.
// Command migrate-soa-snapshots-to-documents creates documents, document
// versions, approval quorums, and approval decisions from existing SOA
// snapshots. For each snapshot it generates the ProseMirror content using
// the same builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net/url"
"os"
"time"
"github.com/jackc/pgx/v5"
"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/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrate(ctx, tx, dryRun)
})
}
type originalSOA struct {
id string
tenantID gid.TenantID
organizationID gid.GID
name string
ownerProfileID *string
}
type snapshotSOA struct {
id string
snapshotID string
ownerProfileID *string
publishedAt time.Time
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
originals, err := loadOriginalSOAs(ctx, tx)
if err != nil {
return err
}
if len(originals) == 0 {
fmt.Println("no SOAs with snapshots to migrate")
return nil
}
var stats struct {
documents, versions, quorums, decisions, defaultApprovers int
}
for _, orig := range originals {
snapshots, err := loadSnapshots(ctx, tx, orig.id)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate SOA %s (%s) — %d snapshot(s)\n", orig.id, orig.name, len(snapshots))
continue
}
documentID := gid.New(orig.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document for SOA %s: %w", orig.id, err)
}
stats.documents++
_, err = tx.Exec(
ctx,
`UPDATE statements_of_applicability SET document_id = @document_id WHERE id = @id`,
pgx.NamedArgs{
"document_id": documentID,
"id": orig.id,
},
)
if err != nil {
return fmt.Errorf("cannot link document to SOA %s: %w", orig.id, err)
}
if orig.ownerProfileID != nil {
_, err = tx.Exec(
ctx,
`
INSERT INTO document_default_approvers (
document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at
) VALUES (
@document_id, @approver_profile_id, @tenant_id, @organization_id, @created_at, @created_at
)`,
pgx.NamedArgs{
"document_id": documentID,
"approver_profile_id": *orig.ownerProfileID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"created_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert default approver for SOA %s: %w", orig.id, err)
}
stats.defaultApprovers++
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.id, orig.name)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s of SOA %s: %w", snap.snapshotID, orig.id, err)
}
versionID := gid.New(orig.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'STATEMENT_OF_APPLICABILITY'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'LANDSCAPE'::document_version_orientation,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"document_id": documentID,
"title": orig.name,
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
stats.versions++
if snap.ownerProfileID != nil {
quorumID := gid.New(orig.tenantID, coredata.DocumentVersionApprovalQuorumEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_version_approval_quorums (
id, tenant_id, organization_id, version_id, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @version_id,
'APPROVED'::document_version_approval_quorum_status,
@created_at, @created_at
)`,
pgx.NamedArgs{
"id": quorumID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"version_id": versionID,
"created_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert quorum for snapshot %s: %w", snap.snapshotID, err)
}
stats.quorums++
decisionID := gid.New(orig.tenantID, coredata.DocumentVersionApprovalDecisionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_version_approval_decisions (
id, tenant_id, organization_id, quorum_id,
approver_id, state, decided_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @quorum_id,
@approver_id,
'APPROVED'::document_version_approval_decision_state,
@decided_at, @decided_at, @decided_at
)`,
pgx.NamedArgs{
"id": decisionID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"quorum_id": quorumID,
"approver_id": *snap.ownerProfileID,
"decided_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert decision for snapshot %s: %w", snap.snapshotID, err)
}
stats.decisions++
}
}
fmt.Printf("migrated SOA %s (%s) — %d version(s)\n", orig.id, orig.name, len(snapshots))
}
if dryRun {
fmt.Printf("\n%d SOA(s) would be migrated\n", len(originals))
return nil
}
fmt.Printf("\ncreated %d document(s), %d version(s), %d quorum(s), %d decision(s), %d default approver(s)\n",
stats.documents, stats.versions, stats.quorums, stats.decisions, stats.defaultApprovers)
return nil
}
func loadOriginalSOAs(ctx context.Context, tx pg.Tx) ([]originalSOA, error) {
rows, err := tx.Query(
ctx,
`
SELECT
soa.id,
soa.tenant_id,
soa.organization_id,
soa.name,
soa.owner_profile_id
FROM statements_of_applicability soa
WHERE soa.snapshot_id IS NULL
AND soa.document_id IS NULL
AND EXISTS (
SELECT 1 FROM statements_of_applicability snap
WHERE snap.source_id = soa.id AND snap.snapshot_id IS NOT NULL
)
ORDER BY soa.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query original SOAs: %w", err)
}
defer rows.Close()
var result []originalSOA
for rows.Next() {
var o originalSOA
if err := rows.Scan(&o.id, &o.tenantID, &o.organizationID, &o.name, &o.ownerProfileID); err != nil {
return nil, fmt.Errorf("cannot scan original SOA: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadSnapshots(ctx context.Context, tx pg.Tx, originalSOAID string) ([]snapshotSOA, error) {
rows, err := tx.Query(
ctx,
`
SELECT
snap.id,
snap.snapshot_id,
snap.owner_profile_id,
snap_record.created_at
FROM statements_of_applicability snap
JOIN snapshots snap_record ON snap_record.id = snap.snapshot_id
WHERE snap.source_id = @source_id
AND snap.snapshot_id IS NOT NULL
ORDER BY snap_record.created_at ASC;
`,
pgx.NamedArgs{"source_id": originalSOAID},
)
if err != nil {
return nil, fmt.Errorf("cannot query snapshots for SOA %s: %w", originalSOAID, err)
}
defer rows.Close()
var result []snapshotSOA
for rows.Next() {
var s snapshotSOA
if err := rows.Scan(&s.id, &s.snapshotID, &s.ownerProfileID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
type snapshotControl struct {
frameworkName string
sectionTitle string
controlName string
applicability bool
justification *string
bestPractice bool
implemented string
notImplementedJustification *string
hasLegal bool
hasContractual bool
hasRisk bool
}
func buildSnapshotContent(ctx context.Context, tx pg.Tx, snapshotSOAID string, soaName string) (string, error) {
rows, err := tx.Query(
ctx,
`
WITH control_risks_via_measures AS (
SELECT DISTINCT cm.control_id
FROM controls_measures cm
INNER JOIN risks_measures rm ON cm.measure_id = rm.measure_id
),
control_risks_via_documents AS (
SELECT DISTINCT cd.control_id
FROM controls_documents cd
INNER JOIN risks_documents rd ON cd.document_id = rd.document_id
),
control_risks AS (
SELECT control_id FROM control_risks_via_measures
UNION
SELECT control_id FROM control_risks_via_documents
)
SELECT
f.name AS framework_name,
c.section_title,
c.name AS control_name,
stmt.applicability,
stmt.justification,
c.best_practice,
c.implemented,
c.not_implemented_justification,
EXISTS (
SELECT 1 FROM controls_obligations co
JOIN obligations o ON o.id = co.obligation_id
WHERE co.control_id = c.id AND o.type = 'LEGAL'
) AS has_legal,
EXISTS (
SELECT 1 FROM controls_obligations co
JOIN obligations o ON o.id = co.obligation_id
WHERE co.control_id = c.id AND o.type = 'CONTRACTUAL'
) AS has_contractual,
EXISTS (
SELECT 1 FROM control_risks cr WHERE cr.control_id = c.id
) AS has_risk
FROM applicability_statements stmt
JOIN controls c ON c.id = stmt.control_id
JOIN frameworks f ON f.id = c.framework_id
WHERE stmt.statement_of_applicability_id = @soa_id
ORDER BY f.name, c.section_title;
`,
pgx.NamedArgs{"soa_id": snapshotSOAID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot controls: %w", err)
}
defer rows.Close()
var soaRows []docgen.SOARow
for rows.Next() {
var sc snapshotControl
if err := rows.Scan(
&sc.frameworkName,
&sc.sectionTitle,
&sc.controlName,
&sc.applicability,
&sc.justification,
&sc.bestPractice,
&sc.implemented,
&sc.notImplementedJustification,
&sc.hasLegal,
&sc.hasContractual,
&sc.hasRisk,
); err != nil {
return "", fmt.Errorf("cannot scan control: %w", err)
}
applicable := sc.applicability
justification := "-"
if !applicable && sc.justification != nil {
justification = *sc.justification
}
implemented := "-"
if applicable {
if sc.implemented == "IMPLEMENTED" {
implemented = "Yes"
} else {
implemented = "No"
}
}
notImplJustification := "-"
if applicable && sc.implemented != "IMPLEMENTED" && sc.notImplementedJustification != nil {
notImplJustification = *sc.notImplementedJustification
}
regulatory := "-"
contractual := "-"
bestPractice := "-"
riskAssessment := "-"
if applicable {
regulatory = docgen.BoolLabel(sc.hasLegal)
contractual = docgen.BoolLabel(sc.hasContractual)
bestPractice = docgen.BoolLabel(sc.bestPractice)
riskAssessment = docgen.BoolLabel(sc.hasRisk)
}
soaRows = append(soaRows, docgen.SOARow{
FrameworkName: sc.frameworkName,
ControlSection: sc.sectionTitle,
ControlName: sc.controlName,
Applicability: docgen.BoolLabel(applicable),
Justification: justification,
Implemented: implemented,
NotImplJustification: notImplJustification,
Regulatory: regulatory,
Contractual: contractual,
BestPractice: bestPractice,
RiskAssessment: riskAssessment,
})
}
if err := rows.Err(); err != nil {
return "", err
}
data := docgen.StatementOfApplicabilityData{
Title: soaName,
TotalControls: len(soaRows),
Rows: soaRows,
}
return probo.BuildStatementOfApplicabilityDocument(data)
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
opts = append(opts, pg.WithAddr(u.Host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}