Data as document: replace snapshot with publish workflow
Mirror the SOA-to-document migration for the data list. Remove data from the snapshot system and add a publish workflow that generates a ProseMirror document for the full organization data inventory. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
// 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
|
||||
@@ -12,43 +12,21 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
package datum
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/cmd/datum/publish"
|
||||
)
|
||||
|
||||
type (
|
||||
DatumFilter struct {
|
||||
snapshotID **gid.GID
|
||||
func NewCmdDatum(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "datum <command>",
|
||||
Short: "Manage data",
|
||||
}
|
||||
)
|
||||
|
||||
func NewDatumFilter(snapshotID **gid.GID) *DatumFilter {
|
||||
return &DatumFilter{
|
||||
snapshotID: snapshotID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DatumFilter) SQLArguments() pgx.NamedArgs {
|
||||
args := pgx.NamedArgs{}
|
||||
|
||||
if f.snapshotID != nil && *f.snapshotID != nil {
|
||||
args["filter_snapshot_id"] = **f.snapshotID
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (f *DatumFilter) SQLFragment() string {
|
||||
if f.snapshotID == nil {
|
||||
return "TRUE"
|
||||
}
|
||||
|
||||
if *f.snapshotID == nil {
|
||||
return "snapshot_id IS NULL"
|
||||
} else {
|
||||
return "snapshot_id = @filter_snapshot_id"
|
||||
}
|
||||
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
147
pkg/cmd/datum/publish/publish.go
Normal file
147
pkg/cmd/datum/publish/publish.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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: PublishDataListInput!) {
|
||||
publishDataList(input: $input) {
|
||||
documentEdge {
|
||||
node {
|
||||
id
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
documentVersionEdge {
|
||||
node {
|
||||
id
|
||||
title
|
||||
major
|
||||
minor
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type publishResponse struct {
|
||||
PublishDataList 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:"publishDataList"`
|
||||
}
|
||||
|
||||
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagApprover []string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "publish",
|
||||
Short: "Publish the data list as a document version",
|
||||
Example: ` # Publish the data list
|
||||
prb datum publish --org ORG_ID
|
||||
|
||||
# Publish with approvers
|
||||
prb datum publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
|
||||
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
|
||||
}
|
||||
|
||||
if flagOrg == "" {
|
||||
flagOrg = hc.Organization
|
||||
}
|
||||
if flagOrg == "" {
|
||||
return fmt.Errorf("organization is required: pass --org or run `prb auth login`")
|
||||
}
|
||||
|
||||
client := api.NewClient(
|
||||
host,
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
"organizationId": flagOrg,
|
||||
}
|
||||
|
||||
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.PublishDataList.DocumentVersionEdge.Node
|
||||
_, _ = fmt.Fprintf(
|
||||
f.IOStreams.Out,
|
||||
"Published data list %s (v%d.%d)\n",
|
||||
v.Title,
|
||||
v.Major,
|
||||
v.Minor,
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
|
||||
cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
cmdconfig "go.probo.inc/probo/pkg/cmd/config"
|
||||
cmdcontext "go.probo.inc/probo/pkg/cmd/context"
|
||||
"go.probo.inc/probo/pkg/cmd/control"
|
||||
"go.probo.inc/probo/pkg/cmd/datum"
|
||||
"go.probo.inc/probo/pkg/cmd/document"
|
||||
"go.probo.inc/probo/pkg/cmd/evidence"
|
||||
"go.probo.inc/probo/pkg/cmd/finding"
|
||||
@@ -77,6 +78,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
cmd.AddCommand(cmdcontext.NewCmdContext(f))
|
||||
cmd.AddCommand(control.NewCmdControl(f))
|
||||
cmd.AddCommand(datum.NewCmdDatum(f))
|
||||
cmd.AddCommand(document.NewCmdDocument(f))
|
||||
cmd.AddCommand(evidence.NewCmdEvidence(f))
|
||||
cmd.AddCommand(finding.NewCmdFinding(f))
|
||||
|
||||
@@ -34,17 +34,11 @@ type (
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
OwnerID gid.GID `db:"owner_profile_id"`
|
||||
DataClassification DataClassification `db:"data_classification"`
|
||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
||||
SourceID *gid.GID `db:"source_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
Data []*Datum
|
||||
|
||||
DataSnapshotter interface {
|
||||
InsertDataSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
|
||||
}
|
||||
)
|
||||
|
||||
func (d *Datum) CursorKey(field DatumOrderField) page.CursorKey {
|
||||
@@ -88,8 +82,6 @@ SELECT
|
||||
owner_profile_id,
|
||||
organization_id,
|
||||
data_classification,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -97,6 +89,7 @@ FROM
|
||||
WHERE
|
||||
%s
|
||||
AND id = @data_id
|
||||
AND snapshot_id IS NULL
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
@@ -132,8 +125,6 @@ SELECT
|
||||
owner_profile_id,
|
||||
organization_id,
|
||||
data_classification,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -141,6 +132,7 @@ FROM
|
||||
WHERE
|
||||
%s
|
||||
AND owner_profile_id = @owner_profile_id
|
||||
AND snapshot_id IS NULL
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
@@ -169,7 +161,6 @@ func (d *Data) CountByOrganizationID(
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
filter *DatumFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
@@ -179,14 +170,13 @@ 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)
|
||||
|
||||
@@ -205,7 +195,6 @@ func (d *Data) LoadByOrganizationID(
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[DatumOrderField],
|
||||
filter *DatumFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
@@ -214,8 +203,6 @@ SELECT
|
||||
organization_id,
|
||||
owner_profile_id,
|
||||
data_classification,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -223,15 +210,14 @@ 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.StrictNamedArgs{"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)
|
||||
@@ -249,6 +235,51 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Data) LoadAllByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
organization_id,
|
||||
owner_profile_id,
|
||||
data_classification,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
data
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND snapshot_id IS NULL
|
||||
ORDER BY
|
||||
name ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query data: %w", err)
|
||||
}
|
||||
|
||||
data, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Datum])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect data: %w", err)
|
||||
}
|
||||
|
||||
*d = data
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Datum) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
@@ -262,8 +293,6 @@ INSERT INTO data (
|
||||
owner_profile_id,
|
||||
organization_id,
|
||||
data_classification,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@@ -273,8 +302,6 @@ INSERT INTO data (
|
||||
@owner_profile_id,
|
||||
@organization_id,
|
||||
@data_classification,
|
||||
@snapshot_id,
|
||||
@source_id,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
@@ -287,8 +314,6 @@ INSERT INTO data (
|
||||
"owner_profile_id": d.OwnerID,
|
||||
"organization_id": d.OrganizationID,
|
||||
"data_classification": d.DataClassification,
|
||||
"snapshot_id": d.SnapshotID,
|
||||
"source_id": d.SourceID,
|
||||
"created_at": d.CreatedAt,
|
||||
"updated_at": d.UpdatedAt,
|
||||
}
|
||||
@@ -323,8 +348,6 @@ RETURNING
|
||||
owner_profile_id,
|
||||
organization_id,
|
||||
data_classification,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
created_at,
|
||||
updated_at
|
||||
`
|
||||
@@ -380,73 +403,3 @@ WHERE
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Data) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
|
||||
snapshotters := []DataSnapshotter{Data{}, Vendors{}, DatumVendors{}}
|
||||
|
||||
for _, snapshotter := range snapshotters {
|
||||
if err := snapshotter.InsertDataSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
|
||||
return fmt.Errorf("cannot create data snapshots: (%T) %w", snapshotter, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Data) InsertDataSnapshots(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
snapshotID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
WITH
|
||||
source_data AS (
|
||||
SELECT *
|
||||
FROM data
|
||||
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
|
||||
)
|
||||
INSERT INTO data (
|
||||
tenant_id,
|
||||
id,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
name,
|
||||
organization_id,
|
||||
owner_profile_id,
|
||||
data_classification,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
@tenant_id,
|
||||
generate_gid(decode_base64_unpadded(@tenant_id), @datum_entity_type),
|
||||
@snapshot_id,
|
||||
d.id,
|
||||
d.name,
|
||||
d.organization_id,
|
||||
d.owner_profile_id,
|
||||
d.data_classification,
|
||||
d.created_at,
|
||||
d.updated_at
|
||||
FROM source_data d
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"snapshot_id": snapshotID,
|
||||
"organization_id": organizationID,
|
||||
"datum_entity_type": DatumEntityType,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert data snapshots: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ package coredata
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -27,10 +26,9 @@ import (
|
||||
|
||||
type (
|
||||
DatumVendor struct {
|
||||
DatumID gid.GID `db:"datum_id"`
|
||||
VendorID gid.GID `db:"vendor_id"`
|
||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
DatumID gid.GID `db:"datum_id"`
|
||||
VendorID gid.GID `db:"vendor_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
DatumVendors []*DatumVendor
|
||||
@@ -119,61 +117,3 @@ FROM vendor_ids
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DatumVendors) InsertDataSnapshots(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
snapshotID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
WITH
|
||||
source_data AS (
|
||||
SELECT id
|
||||
FROM data
|
||||
WHERE organization_id = @organization_id AND snapshot_id IS NULL
|
||||
),
|
||||
snapshot_data AS (
|
||||
SELECT id, source_id
|
||||
FROM data
|
||||
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
|
||||
),
|
||||
snapshot_vendors AS (
|
||||
SELECT id, source_id
|
||||
FROM vendors
|
||||
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
|
||||
),
|
||||
source_data_vendors AS (
|
||||
SELECT datum_id, vendor_id, snapshot_id, created_at
|
||||
FROM data_vendors
|
||||
WHERE %s AND datum_id = ANY(SELECT id FROM source_data)
|
||||
)
|
||||
INSERT INTO data_vendors (tenant_id, datum_id, vendor_id, organization_id, snapshot_id, created_at)
|
||||
SELECT
|
||||
@tenant_id,
|
||||
sd.id,
|
||||
sv.id,
|
||||
@organization_id,
|
||||
@snapshot_id,
|
||||
dv.created_at
|
||||
FROM source_data_vendors dv
|
||||
JOIN snapshot_data sd ON sd.source_id = dv.datum_id
|
||||
JOIN snapshot_vendors sv ON sv.source_id = dv.vendor_id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"snapshot_id": snapshotID,
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert datum vendor snapshots: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
22
pkg/coredata/migrations/20260420T120000Z.sql
Normal file
22
pkg/coredata/migrations/20260420T120000Z.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- 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 TABLE generated_documents (
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
data_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (organization_id)
|
||||
);
|
||||
@@ -124,7 +124,7 @@ FROM
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND type != 'STATEMENTS_OF_APPLICABILITY'
|
||||
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
|
||||
AND %s
|
||||
`
|
||||
|
||||
@@ -164,7 +164,7 @@ FROM
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND type != 'STATEMENTS_OF_APPLICABILITY'
|
||||
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
|
||||
AND %s
|
||||
`
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ func SnapshotsTypes() []SnapshotsType {
|
||||
SnapshotsTypeRisks,
|
||||
SnapshotsTypeVendors,
|
||||
SnapshotsTypeAssets,
|
||||
SnapshotsTypeData,
|
||||
SnapshotsTypeFindings,
|
||||
SnapshotsTypeObligations,
|
||||
SnapshotsTypeProcessingActivities,
|
||||
|
||||
@@ -32,8 +32,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
|
||||
return Assets{}, nil
|
||||
case SnapshotsTypeRisks:
|
||||
return Risks{}, nil
|
||||
case SnapshotsTypeData:
|
||||
return Data{}, nil
|
||||
case SnapshotsTypeFindings:
|
||||
return Findings{}, nil
|
||||
case SnapshotsTypeObligations:
|
||||
|
||||
@@ -717,6 +717,102 @@ WHERE %s
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (vs *Vendors) LoadAllByDatumID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
datumID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH vend AS (
|
||||
SELECT
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.name,
|
||||
v.description,
|
||||
v.category,
|
||||
v.headquarter_address,
|
||||
v.legal_name,
|
||||
v.website_url,
|
||||
v.privacy_policy_url,
|
||||
v.service_level_agreement_url,
|
||||
v.data_processing_agreement_url,
|
||||
v.business_associate_agreement_url,
|
||||
v.subprocessors_list_url,
|
||||
v.certifications,
|
||||
v.countries,
|
||||
v.business_owner_profile_id,
|
||||
v.security_owner_profile_id,
|
||||
v.status_page_url,
|
||||
v.terms_of_service_url,
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.snapshot_id,
|
||||
v.source_id,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM
|
||||
vendors v
|
||||
INNER JOIN
|
||||
data_vendors dv ON v.id = dv.vendor_id
|
||||
WHERE
|
||||
dv.datum_id = @datum_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
headquarter_address,
|
||||
legal_name,
|
||||
website_url,
|
||||
privacy_policy_url,
|
||||
service_level_agreement_url,
|
||||
data_processing_agreement_url,
|
||||
business_associate_agreement_url,
|
||||
subprocessors_list_url,
|
||||
certifications,
|
||||
countries,
|
||||
business_owner_profile_id,
|
||||
security_owner_profile_id,
|
||||
status_page_url,
|
||||
terms_of_service_url,
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
vend
|
||||
WHERE %s
|
||||
ORDER BY name ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"datum_id": datumID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query vendors: %w", err)
|
||||
}
|
||||
|
||||
vendors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Vendor])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect vendors: %w", err)
|
||||
}
|
||||
|
||||
*vs = vendors
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs *Vendors) LoadByDatumID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
@@ -981,108 +1077,6 @@ ORDER BY
|
||||
return vendorMap, nil
|
||||
}
|
||||
|
||||
func (d Vendors) InsertDataSnapshots(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
snapshotID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
WITH
|
||||
source_data AS (
|
||||
SELECT id
|
||||
FROM data
|
||||
WHERE organization_id = @organization_id AND snapshot_id IS NULL
|
||||
),
|
||||
source_data_vendors AS (
|
||||
SELECT datum_id, vendor_id, snapshot_id, created_at
|
||||
FROM data_vendors
|
||||
WHERE datum_id = ANY(SELECT id FROM source_data)
|
||||
),
|
||||
source_vendors AS (
|
||||
SELECT *
|
||||
FROM vendors
|
||||
WHERE %s AND id = ANY(SELECT vendor_id FROM source_data_vendors)
|
||||
)
|
||||
INSERT INTO vendors (
|
||||
tenant_id,
|
||||
id,
|
||||
snapshot_id,
|
||||
source_id,
|
||||
organization_id,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
headquarter_address,
|
||||
legal_name,
|
||||
website_url,
|
||||
privacy_policy_url,
|
||||
service_level_agreement_url,
|
||||
data_processing_agreement_url,
|
||||
business_associate_agreement_url,
|
||||
subprocessors_list_url,
|
||||
certifications,
|
||||
countries,
|
||||
business_owner_profile_id,
|
||||
security_owner_profile_id,
|
||||
status_page_url,
|
||||
terms_of_service_url,
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
@tenant_id,
|
||||
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type),
|
||||
@snapshot_id,
|
||||
v.id,
|
||||
v.organization_id,
|
||||
v.name,
|
||||
v.description,
|
||||
v.category,
|
||||
v.headquarter_address,
|
||||
v.legal_name,
|
||||
v.website_url,
|
||||
v.privacy_policy_url,
|
||||
v.service_level_agreement_url,
|
||||
v.data_processing_agreement_url,
|
||||
v.business_associate_agreement_url,
|
||||
v.subprocessors_list_url,
|
||||
v.certifications,
|
||||
v.countries,
|
||||
v.business_owner_profile_id,
|
||||
v.security_owner_profile_id,
|
||||
v.status_page_url,
|
||||
v.terms_of_service_url,
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM source_vendors v
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"snapshot_id": snapshotID,
|
||||
"organization_id": organizationID,
|
||||
"vendor_entity_type": VendorEntityType,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert vendor snapshots: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs Vendors) InsertAssetSnapshots(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
|
||||
@@ -296,6 +296,21 @@ type (
|
||||
BestPractice string
|
||||
RiskAssessment string
|
||||
}
|
||||
|
||||
DataListData struct {
|
||||
Title string
|
||||
OrganizationName string
|
||||
CreatedAt time.Time
|
||||
TotalData int
|
||||
Rows []DataListRow
|
||||
}
|
||||
|
||||
DataListRow struct {
|
||||
Name string
|
||||
Classification string
|
||||
Owner string
|
||||
Vendors string
|
||||
}
|
||||
)
|
||||
|
||||
func BoolLabel(v bool) string {
|
||||
|
||||
@@ -234,11 +234,12 @@ const (
|
||||
ActionAssetDelete = "core:asset:delete"
|
||||
|
||||
// Datum actions
|
||||
ActionDatumGet = "core:datum:get"
|
||||
ActionDatumList = "core:datum:list"
|
||||
ActionDatumCreate = "core:datum:create"
|
||||
ActionDatumUpdate = "core:datum:update"
|
||||
ActionDatumDelete = "core:datum:delete"
|
||||
ActionDatumGet = "core:datum:get"
|
||||
ActionDatumList = "core:datum:list"
|
||||
ActionDatumCreate = "core:datum:create"
|
||||
ActionDatumUpdate = "core:datum:update"
|
||||
ActionDatumDelete = "core:datum:delete"
|
||||
ActionDatumPublish = "core:datum:publish"
|
||||
|
||||
// Audit actions
|
||||
ActionAuditGet = "core:audit:get"
|
||||
|
||||
@@ -119,7 +119,6 @@ func (s DatumService) GetByOwnerID(
|
||||
func (s DatumService) CountForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
filter *coredata.DatumFilter,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
@@ -127,7 +126,7 @@ func (s DatumService) CountForOrganizationID(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||
data := coredata.Data{}
|
||||
count, err = data.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
|
||||
count, err = data.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count data: %w", err)
|
||||
}
|
||||
@@ -147,7 +146,6 @@ func (s DatumService) ListForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[coredata.DatumOrderField],
|
||||
filter *coredata.DatumFilter,
|
||||
) (*page.Page[*coredata.Datum, coredata.DatumOrderField], error) {
|
||||
var data coredata.Data
|
||||
|
||||
@@ -160,7 +158,6 @@ func (s DatumService) ListForOrganizationID(
|
||||
s.svc.scope,
|
||||
organizationID,
|
||||
cursor,
|
||||
filter,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,9 +20,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/docgen"
|
||||
@@ -339,6 +341,322 @@ func (s *GeneratedDocumentService) buildStatementOfApplicabilityDocumentData(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *GeneratedDocumentService) PublishDataList(
|
||||
ctx context.Context,
|
||||
organizationID 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 {
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
documentData, err := s.buildDataListDocumentData(ctx, tx, organization)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build document data: %w", err)
|
||||
}
|
||||
|
||||
prosemirrorJSON, err := BuildDataListDocument(documentData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build prosemirror document: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
var dataDocumentID *gid.GID
|
||||
err = tx.QueryRow(
|
||||
ctx,
|
||||
`SELECT data_document_id FROM generated_documents WHERE organization_id = @organization_id`,
|
||||
pgx.NamedArgs{"organization_id": organizationID},
|
||||
).Scan(&dataDocumentID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("cannot query generated documents: %w", err)
|
||||
}
|
||||
|
||||
var existingDoc *coredata.Document
|
||||
if dataDocumentID != nil {
|
||||
doc := &coredata.Document{}
|
||||
err = doc.LoadByID(ctx, tx, s.svc.scope, *dataDocumentID)
|
||||
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return fmt.Errorf("cannot load data list document: %w", err)
|
||||
}
|
||||
|
||||
if err == nil && doc.ArchivedAt == nil {
|
||||
existingDoc = doc
|
||||
} else {
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
`UPDATE generated_documents SET data_document_id = NULL, updated_at = @updated_at WHERE organization_id = @organization_id`,
|
||||
pgx.NamedArgs{"organization_id": organizationID, "updated_at": now},
|
||||
)
|
||||
if 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: 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)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
`INSERT INTO generated_documents (organization_id, tenant_id, data_document_id, created_at, updated_at)
|
||||
VALUES (@organization_id, @tenant_id, @data_document_id, @created_at, @updated_at)
|
||||
ON CONFLICT (organization_id) DO UPDATE SET data_document_id = @data_document_id, updated_at = @updated_at`,
|
||||
pgx.NamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"tenant_id": s.svc.scope.GetTenantID(),
|
||||
"data_document_id": documentID,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot upsert generated documents: %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: organizationID,
|
||||
DocumentID: document.ID,
|
||||
Title: "Data List",
|
||||
Major: newMajor,
|
||||
Minor: 0,
|
||||
Content: prosemirrorJSON,
|
||||
Status: versionStatus,
|
||||
Classification: coredata.DocumentClassificationConfidential,
|
||||
DocumentType: coredata.DocumentTypeRegister,
|
||||
Orientation: coredata.DocumentVersionOrientationPortrait,
|
||||
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, 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) GetDataListDocumentID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
) (*gid.GID, error) {
|
||||
var dataDocumentID *gid.GID
|
||||
|
||||
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return conn.QueryRow(
|
||||
ctx,
|
||||
`SELECT data_document_id FROM generated_documents WHERE organization_id = @organization_id`,
|
||||
pgx.NamedArgs{"organization_id": organizationID},
|
||||
).Scan(&dataDocumentID)
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get data list document ID: %w", err)
|
||||
}
|
||||
|
||||
return dataDocumentID, nil
|
||||
}
|
||||
|
||||
func (s *GeneratedDocumentService) buildDataListDocumentData(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
organization *coredata.Organization,
|
||||
) (docgen.DataListData, error) {
|
||||
var data coredata.Data
|
||||
if err := data.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
|
||||
return docgen.DataListData{}, fmt.Errorf("cannot load data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return docgen.DataListData{
|
||||
Title: "Data List",
|
||||
OrganizationName: organization.Name,
|
||||
CreatedAt: time.Now(),
|
||||
TotalData: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ownerIDs := make([]gid.GID, 0, len(data))
|
||||
ownerIDSet := make(map[gid.GID]struct{})
|
||||
for _, d := range data {
|
||||
if _, ok := ownerIDSet[d.OwnerID]; !ok {
|
||||
ownerIDs = append(ownerIDs, d.OwnerID)
|
||||
ownerIDSet[d.OwnerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var profiles coredata.MembershipProfiles
|
||||
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
|
||||
return docgen.DataListData{}, fmt.Errorf("cannot load profiles: %w", err)
|
||||
}
|
||||
|
||||
profileMap := make(map[gid.GID]*coredata.MembershipProfile, len(profiles))
|
||||
for _, p := range profiles {
|
||||
profileMap[p.ID] = p
|
||||
}
|
||||
|
||||
rows := make([]docgen.DataListRow, 0, len(data))
|
||||
for _, d := range data {
|
||||
ownerName := "-"
|
||||
if p, ok := profileMap[d.OwnerID]; ok {
|
||||
ownerName = p.FullName
|
||||
}
|
||||
|
||||
var vendors coredata.Vendors
|
||||
if err := vendors.LoadAllByDatumID(ctx, conn, s.svc.scope, d.ID); err != nil {
|
||||
return docgen.DataListData{}, fmt.Errorf("cannot load vendors for datum %s: %w", d.ID, err)
|
||||
}
|
||||
|
||||
vendorNames := make([]string, 0, len(vendors))
|
||||
for _, v := range vendors {
|
||||
vendorNames = append(vendorNames, v.Name)
|
||||
}
|
||||
|
||||
vendorStr := "-"
|
||||
if len(vendorNames) > 0 {
|
||||
vendorStr = strings.Join(vendorNames, ", ")
|
||||
}
|
||||
|
||||
rows = append(rows, docgen.DataListRow{
|
||||
Name: d.Name,
|
||||
Classification: formatClassification(d.DataClassification),
|
||||
Owner: ownerName,
|
||||
Vendors: vendorStr,
|
||||
})
|
||||
}
|
||||
|
||||
return docgen.DataListData{
|
||||
Title: "Data List",
|
||||
OrganizationName: organization.Name,
|
||||
CreatedAt: time.Now(),
|
||||
TotalData: len(data),
|
||||
Rows: rows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatClassification(c coredata.DataClassification) string {
|
||||
switch c {
|
||||
case coredata.DataClassificationPublic:
|
||||
return "Public"
|
||||
case coredata.DataClassificationInternal:
|
||||
return "Internal"
|
||||
case coredata.DataClassificationConfidential:
|
||||
return "Confidential"
|
||||
case coredata.DataClassificationSecret:
|
||||
return "Secret"
|
||||
default:
|
||||
return string(c)
|
||||
}
|
||||
}
|
||||
|
||||
var dataListTemplate = template.Must(
|
||||
template.New("data_list.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/data_list.json.tmpl"),
|
||||
)
|
||||
|
||||
func BuildDataListDocument(data docgen.DataListData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := dataListTemplate.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("cannot execute data list template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
var soaTemplate = template.Must(
|
||||
template.New("statement_of_applicability.json.tmpl").
|
||||
Funcs(template.FuncMap{
|
||||
|
||||
81
pkg/probo/templates/data_list.json.tmpl
Normal file
81
pkg/probo/templates/data_list.json.tmpl
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"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 inventory of data assets managed by the organization. It serves as a record of all data items, their classification levels, ownership, and associated vendors." }]
|
||||
},
|
||||
{ "type": "horizontalRule" },
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "2. Data Inventory" }]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"content": [
|
||||
{
|
||||
"type": "tableRow",
|
||||
"content": [
|
||||
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Name", "marks": [{ "type": "bold" }] }] }] },
|
||||
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Classification", "marks": [{ "type": "bold" }] }] }] },
|
||||
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [180] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Owner", "marks": [{ "type": "bold" }] }] }] },
|
||||
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Vendors", "marks": [{ "type": "bold" }] }] }] }
|
||||
]
|
||||
}{{range .Rows}},
|
||||
{
|
||||
"type": "tableRow",
|
||||
"content": [
|
||||
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Name}} }] }] },
|
||||
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Classification}} }] }] },
|
||||
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [180] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Owner}} }] }] },
|
||||
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Vendors}} }] }] }
|
||||
]
|
||||
}{{end}}
|
||||
]
|
||||
},
|
||||
{ "type": "horizontalRule" },
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "3. Definitions" }]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 3 },
|
||||
"content": [{ "type": "text", "text": "Classification" }]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"content": [
|
||||
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Public: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Data intended for public disclosure with no confidentiality requirements." }] }] },
|
||||
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Internal: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Data intended for internal use only, not meant for public disclosure." }] }] },
|
||||
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Confidential: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Sensitive data requiring protection, accessible only to authorized personnel." }] }] },
|
||||
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Secret: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Highly sensitive data requiring the strictest access controls and protection measures." }] }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 3 },
|
||||
"content": [{ "type": "text", "text": "Owner" }]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "The individual responsible for the data asset, including its accuracy, security, and compliance with applicable policies." }]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 3 },
|
||||
"content": [{ "type": "text", "text": "Vendors" }]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "Third-party vendors that process or have access to the data asset." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -220,12 +220,7 @@ func (r *datumConnectionResolver) TotalCount(ctx context.Context, obj *types.Dat
|
||||
|
||||
switch obj.Resolver.(type) {
|
||||
case *organizationResolver:
|
||||
datumFilter := coredata.NewDatumFilter(nil)
|
||||
if obj.Filter != nil {
|
||||
datumFilter = coredata.NewDatumFilter(&obj.Filter.SnapshotID)
|
||||
}
|
||||
|
||||
count, err := prb.Data.CountForOrganizationID(ctx, obj.ParentID, datumFilter)
|
||||
count, err := prb.Data.CountForOrganizationID(ctx, obj.ParentID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot count data", log.Error(err))
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
@@ -405,6 +400,29 @@ func (r *mutationResolver) DeleteDatum(ctx context.Context, input types.DeleteDa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublishDataList is the resolver for the publishDataList field.
|
||||
func (r *mutationResolver) PublishDataList(ctx context.Context, input types.PublishDataListInput) (*types.PublishDataListPayload, error) {
|
||||
if err := r.authorize(ctx, input.OrganizationID, probo.ActionDatumPublish); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
|
||||
document, documentVersion, err := prb.GeneratedDocuments.PublishDataList(ctx, input.OrganizationID, input.ApproverIds)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||
return nil, gqlutils.Conflict(ctx, err)
|
||||
}
|
||||
r.logger.ErrorCtx(ctx, "cannot publish data list", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.PublishDataListPayload{
|
||||
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
|
||||
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Asset returns schema.AssetResolver implementation.
|
||||
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }
|
||||
|
||||
|
||||
@@ -66,10 +66,6 @@ input AssetFilter {
|
||||
snapshotId: ID
|
||||
}
|
||||
|
||||
input DatumFilter {
|
||||
snapshotId: ID
|
||||
}
|
||||
|
||||
type Asset implements Node {
|
||||
id: ID!
|
||||
snapshotId: ID
|
||||
@@ -97,7 +93,6 @@ type Datum implements Node
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.Datum"
|
||||
) {
|
||||
id: ID!
|
||||
snapshotId: ID
|
||||
name: String!
|
||||
dataClassification: DataClassification!
|
||||
owner: Profile! @goField(forceResolver: true)
|
||||
@@ -150,6 +145,9 @@ extend type Mutation {
|
||||
createDatum(input: CreateDatumInput!): CreateDatumPayload!
|
||||
updateDatum(input: UpdateDatumInput!): UpdateDatumPayload!
|
||||
deleteDatum(input: DeleteDatumInput!): DeleteDatumPayload!
|
||||
publishDataList(
|
||||
input: PublishDataListInput!
|
||||
): PublishDataListPayload!
|
||||
}
|
||||
|
||||
input CreateAssetInput {
|
||||
@@ -219,3 +217,13 @@ type UpdateDatumPayload {
|
||||
type DeleteDatumPayload {
|
||||
deletedDatumId: ID!
|
||||
}
|
||||
|
||||
input PublishDataListInput {
|
||||
organizationId: ID!
|
||||
approverIds: [ID!]
|
||||
}
|
||||
|
||||
type PublishDataListPayload {
|
||||
documentEdge: DocumentEdge!
|
||||
documentVersionEdge: DocumentVersionEdge!
|
||||
}
|
||||
|
||||
@@ -130,13 +130,14 @@ type Organization implements Node {
|
||||
filter: AssetFilter = { snapshotId: null }
|
||||
): AssetConnection! @goField(forceResolver: true)
|
||||
|
||||
dataListDocument: Document @goField(forceResolver: true)
|
||||
|
||||
data(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DatumOrder
|
||||
filter: DatumFilter = { snapshotId: null }
|
||||
): DatumConnection! @goField(forceResolver: true)
|
||||
|
||||
audits(
|
||||
|
||||
@@ -4,7 +4,6 @@ enum SnapshotsType
|
||||
VENDORS
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
|
||||
ASSETS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeAssets")
|
||||
DATA @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeData")
|
||||
FINDINGS
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"
|
||||
|
||||
@@ -256,8 +256,32 @@ func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organizati
|
||||
return types.NewAssetConnection(page, r, obj.ID, filter), nil
|
||||
}
|
||||
|
||||
// Assets is the resolver for the assets field.
|
||||
func (r *organizationResolver) Data(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DatumOrderBy, filter *types.DatumFilter) (*types.DatumConnection, error) {
|
||||
// DataListDocument is the resolver for the dataListDocument field.
|
||||
func (r *organizationResolver) DataListDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
dataDocumentID, err := prb.GeneratedDocuments.GetDataListDocumentID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get data export document ID: %w", err)
|
||||
}
|
||||
if dataDocumentID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
doc, err := prb.Documents.Get(ctx, *dataDocumentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get data export document: %w", err)
|
||||
}
|
||||
|
||||
return types.NewDocument(doc), nil
|
||||
}
|
||||
|
||||
// Data is the resolver for the data field.
|
||||
func (r *organizationResolver) Data(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DatumOrderBy) (*types.DatumConnection, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionDatumList); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -277,18 +301,13 @@ func (r *organizationResolver) Data(ctx context.Context, obj *types.Organization
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
datumFilter := coredata.NewDatumFilter(nil)
|
||||
if filter != nil {
|
||||
datumFilter = coredata.NewDatumFilter(&filter.SnapshotID)
|
||||
}
|
||||
|
||||
page, err := prb.Data.ListForOrganizationID(ctx, obj.ID, cursor, datumFilter)
|
||||
page, err := prb.Data.ListForOrganizationID(ctx, obj.ID, cursor)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot list organization data", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return types.NewDataConnection(page, r, obj.ID, filter), nil
|
||||
return types.NewDataConnection(page, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// Audits is the resolver for the audits field.
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
type Datum struct {
|
||||
ID gid.GID `json:"id"`
|
||||
OrganizationID gid.GID `json:"-"`
|
||||
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
DataClassification coredata.DataClassification `json:"dataClassification"`
|
||||
Owner *Profile `json:"owner"`
|
||||
@@ -48,7 +47,6 @@ type (
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
Filter *DatumFilter
|
||||
}
|
||||
)
|
||||
|
||||
@@ -56,7 +54,6 @@ func NewDataConnection(
|
||||
p *page.Page[*coredata.Datum, coredata.DatumOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
filter *DatumFilter,
|
||||
) *DatumConnection {
|
||||
edges := make([]*DatumEdge, len(p.Data))
|
||||
for i, datum := range p.Data {
|
||||
@@ -69,7 +66,6 @@ func NewDataConnection(
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
Filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +80,6 @@ func NewDatum(d *coredata.Datum) *Datum {
|
||||
},
|
||||
OrganizationID: d.OrganizationID,
|
||||
Name: d.Name,
|
||||
SnapshotID: d.SnapshotID,
|
||||
DataClassification: d.DataClassification,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
|
||||
@@ -658,13 +658,7 @@ func (r *Resolver) ListDataTool(ctx context.Context, req *mcp.CallToolRequest, i
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
noSnapshot := (*gid.GID)(nil)
|
||||
datumFilter := coredata.NewDatumFilter(&noSnapshot)
|
||||
if input.Filter != nil {
|
||||
datumFilter = coredata.NewDatumFilter(&input.Filter.SnapshotID)
|
||||
}
|
||||
|
||||
page, err := prb.Data.ListForOrganizationID(ctx, input.OrganizationID, cursor, datumFilter)
|
||||
page, err := prb.Data.ListForOrganizationID(ctx, input.OrganizationID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list organization data: %w", err))
|
||||
}
|
||||
@@ -4136,3 +4130,19 @@ func (r *Resolver) GetDocumentVersionApprovalDecisionTool(ctx context.Context, r
|
||||
ApprovalDecision: types.NewDocumentVersionApprovalDecision(decision),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) PublishDataListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishDataListInput) (*mcp.CallToolResult, types.PublishDataListOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionDatumPublish)
|
||||
|
||||
svc := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
document, documentVersion, err := svc.GeneratedDocuments.PublishDataList(ctx, input.OrganizationID, input.ApproverIds)
|
||||
if err != nil {
|
||||
return nil, types.PublishDataListOutput{}, fmt.Errorf("cannot publish data list: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.PublishDataListOutput{
|
||||
DocumentID: document.ID,
|
||||
DocumentVersionID: documentVersion.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2218,13 +2218,6 @@ components:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
snapshot_id:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/GID"
|
||||
description: Snapshot ID
|
||||
- type: "null"
|
||||
description: No snapshot
|
||||
description: Snapshot ID
|
||||
name:
|
||||
type: string
|
||||
description: Datum name
|
||||
@@ -2260,16 +2253,6 @@ components:
|
||||
cursor:
|
||||
$ref: "#/components/schemas/CursorKey"
|
||||
description: Page cursor
|
||||
filter:
|
||||
type: object
|
||||
properties:
|
||||
snapshot_id:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/GID"
|
||||
- type: "null"
|
||||
description: Filter by snapshot ID. Defaults to null, which returns only data with no snapshot (current live data). Pass a specific snapshot ID to retrieve data as it was at that snapshot.
|
||||
default: null
|
||||
|
||||
ListDataOutput:
|
||||
type: object
|
||||
required:
|
||||
@@ -5017,7 +5000,6 @@ components:
|
||||
- RISKS
|
||||
- VENDORS
|
||||
- ASSETS
|
||||
- DATA
|
||||
- NONCONFORMITIES
|
||||
- OBLIGATIONS
|
||||
- CONTINUAL_IMPROVEMENTS
|
||||
@@ -6794,6 +6776,33 @@ components:
|
||||
description: Deleted statement of applicability ID
|
||||
|
||||
|
||||
PublishDataListInput:
|
||||
type: object
|
||||
required:
|
||||
- organization_id
|
||||
properties:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization 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.
|
||||
|
||||
PublishDataListOutput:
|
||||
type: object
|
||||
required:
|
||||
- document_id
|
||||
- document_version_id
|
||||
properties:
|
||||
document_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Created or updated document ID
|
||||
document_version_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Created document version ID
|
||||
|
||||
PublishStatementOfApplicabilityInput:
|
||||
type: object
|
||||
required:
|
||||
@@ -8860,7 +8869,7 @@ tools:
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/GetSnapshotOutput"
|
||||
- name: takeSnapshot
|
||||
description: Take a snapshot of a collection of objects (risks, vendors, assets, data, findings, obligations, or processing activities)
|
||||
description: Take a snapshot of a collection of objects (risks, vendors, assets, findings, obligations, or processing activities)
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
@@ -9124,6 +9133,14 @@ tools:
|
||||
$ref: "#/components/schemas/DeleteStatementOfApplicabilityInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/DeleteStatementOfApplicabilityOutput"
|
||||
- name: publishDataList
|
||||
description: Publish the data list for an organization as a document. If a document already exists, a new version is created.
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/PublishDataListInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/PublishDataListOutput"
|
||||
- name: publishStatementOfApplicability
|
||||
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
||||
hints:
|
||||
|
||||
@@ -25,7 +25,6 @@ func NewDatum(d *coredata.Datum) *Datum {
|
||||
Name: d.Name,
|
||||
OwnerID: d.OwnerID,
|
||||
OrganizationID: d.OrganizationID,
|
||||
SnapshotID: d.SnapshotID,
|
||||
DataClassification: d.DataClassification,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
|
||||
Reference in New Issue
Block a user