Assets as document: replace snapshot with publish workflow

Remove assets from the snapshot system and replace with a publish-based
document workflow that generates versioned ProseMirror documents.

- Remove snapshot_id/source_id from asset and asset_vendor models
- Delete AssetFilter (no longer needed without snapshot filtering)
- Add PublishAssetList service, GraphQL mutation, MCP tool, CLI command,
  and n8n operation
- Add asset_list_document_id column to generated_documents table
- Generate ProseMirror documents with asset inventory tables
  (name, type, amount, data types stored, owner, vendors)
- Add AssetListDocument resolver on Organization type
- Update frontend to remove snapshot routes/params and add publish dialog
- Add e2e tests for asset publish (immediate, with approvers, reuse, RBAC)
- Add migration script for converting legacy asset snapshots to documents
- Exclude ASSETS from snapshot type lists and e2e snapshot tests
- Move generated_documents SQL to coredata methods on Datum and Asset
- Clear generated document and SOA references on soft delete and archive

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-21 18:40:49 +02:00
parent 565b71526c
commit b603d04d8d
40 changed files with 2315 additions and 486 deletions

View File

@@ -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 asset
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/asset/publish"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
type (
AssetFilter struct {
snapshotID **gid.GID
func NewCmdAsset(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "asset <command>",
Short: "Manage assets",
}
)
func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
return &AssetFilter{
snapshotID: snapshotID,
}
}
func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{}
if f.snapshotID != nil && *f.snapshotID != nil {
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
func (f *AssetFilter) 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
}

View 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: PublishAssetListInput!) {
publishAssetList(input: $input) {
documentEdge {
node {
id
status
createdAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
}
}
}
}
`
type publishResponse struct {
PublishAssetList 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:"publishAssetList"`
}
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagApprover []string
)
cmd := &cobra.Command{
Use: "publish",
Short: "Publish the asset list as a document version",
Example: ` # Publish the asset list
prb asset publish --org ORG_ID
# Publish with approvers
prb asset 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.PublishAssetList.DocumentVersionEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Published asset 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
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/spf13/cobra"
accessreview "go.probo.inc/probo/pkg/cmd/access-review"
cmdapi "go.probo.inc/probo/pkg/cmd/api"
"go.probo.inc/probo/pkg/cmd/asset"
"go.probo.inc/probo/pkg/cmd/auditlog"
"go.probo.inc/probo/pkg/cmd/auth"
"go.probo.inc/probo/pkg/cmd/browse"
@@ -71,6 +72,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(accessreview.NewCmdAccessReview(f))
cmd.AddCommand(cmdapi.NewCmdAPI(f))
cmd.AddCommand(asset.NewCmdAsset(f))
cmd.AddCommand(auditlog.NewCmdAuditLog(f))
cmd.AddCommand(auth.NewCmdAuth(f))
cmd.AddCommand(browse.NewCmdBrowse(f))

View File

@@ -30,8 +30,6 @@ import (
type (
Asset struct {
ID gid.GID `db:"id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
Name string `db:"name"`
Amount int `db:"amount"`
OwnerID gid.GID `db:"owner_profile_id"`
@@ -80,8 +78,6 @@ func (a *Asset) LoadByID(
q := `
SELECT
id,
snapshot_id,
source_id,
name,
organization_id,
owner_profile_id,
@@ -95,6 +91,7 @@ FROM
WHERE
%s
AND id = @asset_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -130,8 +127,6 @@ func (a *Asset) LoadByOwnerID(
q := `
SELECT
id,
snapshot_id,
source_id,
name,
organization_id,
owner_profile_id,
@@ -145,6 +140,7 @@ FROM
WHERE
%s
AND owner_profile_id = @owner_profile_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -177,7 +173,6 @@ func (a *Assets) CountByOrganizationID(
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *AssetFilter,
) (int, error) {
q := `
SELECT
@@ -187,14 +182,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)
@@ -212,13 +206,10 @@ func (a *Assets) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[AssetOrderField],
filter *AssetFilter,
) error {
q := `
SELECT
id,
snapshot_id,
source_id,
name,
organization_id,
owner_profile_id,
@@ -232,15 +223,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)
@@ -258,6 +248,53 @@ WHERE
return nil
}
func (a *Assets) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
id,
name,
organization_id,
owner_profile_id,
amount,
asset_type,
data_types_stored,
created_at,
updated_at
FROM
assets
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 assets: %w", err)
}
assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
if err != nil {
return fmt.Errorf("cannot collect assets: %w", err)
}
*a = assets
return nil
}
func (a *Asset) Insert(
ctx context.Context,
conn pg.Tx,
@@ -330,8 +367,6 @@ WHERE
AND snapshot_id IS NULL
RETURNING
id,
snapshot_id,
source_id,
name,
organization_id,
owner_profile_id,
@@ -396,75 +431,108 @@ WHERE
return nil
}
func (assets Assets) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
snapshotters := []AssetSnapshotter{Assets{}, Vendors{}, AssetVendors{}}
func (a Asset) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
for _, snapshotter := range snapshotters {
if err := snapshotter.InsertAssetSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot create asset snapshots: (%T) %w", snapshotter, err)
}
err := conn.QueryRow(
ctx,
`
SELECT
asset_list_document_id
FROM
generated_documents
WHERE
organization_id = @organization_id
`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&documentID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get asset list document ID: %w", err)
}
return nil
return documentID, nil
}
func (assets Assets) InsertAssetSnapshots(
func (a Asset) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
tenantID gid.TenantID,
documentID gid.GID,
) error {
query := `
WITH
source_assets AS (
SELECT *
FROM assets
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
)
INSERT INTO assets (
tenant_id,
id,
snapshot_id,
source_id,
name,
now := time.Now()
_, err := conn.Exec(
ctx,
`
INSERT INTO generated_documents (
organization_id,
owner_profile_id,
amount,
asset_type,
data_types_stored,
tenant_id,
asset_list_document_id,
created_at,
updated_at
)
SELECT
) VALUES (
@organization_id,
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @asset_entity_type),
@snapshot_id,
a.id,
a.name,
a.organization_id,
a.owner_profile_id,
a.amount,
a.asset_type,
a.data_types_stored,
a.created_at,
a.updated_at
FROM source_assets a
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"asset_entity_type": AssetEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
@asset_list_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
asset_list_document_id = @asset_list_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"asset_list_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert asset snapshots: %w", err)
return fmt.Errorf("cannot upsert asset list document ID: %w", err)
}
return nil
}
func (a Asset) ClearGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
generated_documents
SET
asset_list_document_id = NULL,
updated_at = @now
WHERE
asset_list_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear asset list document references: %w", err)
}
return nil

View File

@@ -17,7 +17,6 @@ package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
@@ -27,18 +26,13 @@ import (
type (
AssetVendor struct {
AssetID gid.GID `db:"asset_id"`
VendorID gid.GID `db:"vendor_id"`
TenantID gid.TenantID `db:"tenant_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
CreatedAt time.Time `db:"created_at"`
AssetID gid.GID `db:"asset_id"`
VendorID gid.GID `db:"vendor_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
AssetVendors []*AssetVendor
AssetSnapshotter interface {
InsertAssetSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
)
func (av AssetVendors) Merge(
@@ -124,61 +118,3 @@ FROM vendor_ids
return nil
}
func (av AssetVendors) InsertAssetSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
source_assets AS (
SELECT id
FROM assets
WHERE organization_id = @organization_id AND snapshot_id IS NULL
),
snapshot_assets AS (
SELECT id, source_id
FROM assets
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_asset_vendors AS (
SELECT asset_id, vendor_id, snapshot_id, created_at
FROM asset_vendors
WHERE %s AND asset_id = ANY(SELECT id FROM source_assets) AND snapshot_id IS NULL
)
INSERT INTO asset_vendors (tenant_id, asset_id, vendor_id, organization_id, snapshot_id, created_at)
SELECT
@tenant_id,
sa.id,
sv.id,
@organization_id,
@snapshot_id,
av.created_at
FROM source_asset_vendors av
JOIN snapshot_assets sa ON sa.source_id = av.asset_id
JOIN snapshot_vendors sv ON sv.source_id = av.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 asset vendor snapshots: %w", err)
}
return nil
}

View File

@@ -403,3 +403,110 @@ WHERE
return nil
}
func (d Datum) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
data_document_id
FROM
generated_documents
WHERE
organization_id = @organization_id
`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&documentID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get data document ID: %w", err)
}
return documentID, nil
}
func (d Datum) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
organizationID gid.GID,
tenantID gid.TenantID,
documentID gid.GID,
) error {
now := time.Now()
_, err := conn.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": tenantID,
"data_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert data document ID: %w", err)
}
return nil
}
func (d Datum) ClearGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
generated_documents
SET
data_document_id = NULL,
updated_at = @now
WHERE
data_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear data document references: %w", err)
}
return nil
}

View File

@@ -0,0 +1,16 @@
-- 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.
ALTER TABLE generated_documents
ADD COLUMN asset_list_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;

View File

@@ -124,7 +124,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND %s
`
@@ -164,7 +164,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND %s
`

View File

@@ -38,7 +38,6 @@ func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
SnapshotsTypeVendors,
SnapshotsTypeAssets,
SnapshotsTypeFindings,
SnapshotsTypeObligations,
SnapshotsTypeProcessingActivities,

View File

@@ -28,8 +28,6 @@ type Snapshottable interface {
func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeAssets:
return Assets{}, nil
case SnapshotsTypeRisks:
return Risks{}, nil
case SnapshotsTypeFindings:

View File

@@ -313,3 +313,36 @@ WHERE
return nil
}
func (s StatementOfApplicability) ClearDocumentIDByDocumentIDs(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
statements_of_applicability
SET
document_id = NULL,
updated_at = @now
WHERE
document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear statement of applicability document references: %w", err)
}
return nil
}

View File

@@ -1077,35 +1077,52 @@ ORDER BY
return vendorMap, nil
}
func (vs Vendors) InsertAssetSnapshots(
func (vs *Vendors) LoadAllByAssetID(
ctx context.Context,
conn pg.Tx,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
assetID gid.GID,
) error {
query := `
WITH
source_assets AS (
SELECT id
FROM assets
WHERE organization_id = @organization_id AND snapshot_id IS NULL
),
source_asset_vendors AS (
SELECT asset_id, vendor_id, snapshot_id, created_at
FROM asset_vendors
WHERE asset_id = ANY(SELECT id FROM source_assets)
),
source_vendors AS (
SELECT *
FROM vendors
WHERE %s AND id = ANY(SELECT vendor_id FROM source_asset_vendors)
)
INSERT INTO vendors (
tenant_id,
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
asset_vendors av ON v.id = av.vendor_id
WHERE
av.asset_id = @asset_id
)
SELECT
id,
snapshot_id,
source_id,
tenant_id,
organization_id,
name,
description,
@@ -1127,55 +1144,32 @@ INSERT INTO vendors (
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
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
`
FROM
vend
WHERE %s
ORDER BY name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_entity_type": VendorEntityType,
}
args := pgx.StrictNamedArgs{"asset_id": assetID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert vendor snapshots for assets: %w", err)
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
}

View File

@@ -311,6 +311,23 @@ type (
Owner string
Vendors string
}
AssetListData struct {
Title string
OrganizationName string
CreatedAt time.Time
TotalAssets int
Rows []AssetListRow
}
AssetListRow struct {
Name string
AssetType string
Amount int
DataTypesStored string
Owner string
Vendors string
}
)
func BoolLabel(v bool) string {

View File

@@ -227,11 +227,12 @@ const (
ActionRiskObligationMappingDelete = "core:risk:delete-obligation-mapping"
// Asset actions
ActionAssetGet = "core:asset:get"
ActionAssetList = "core:asset:list"
ActionAssetCreate = "core:asset:create"
ActionAssetUpdate = "core:asset:update"
ActionAssetDelete = "core:asset:delete"
ActionAssetGet = "core:asset:get"
ActionAssetList = "core:asset:list"
ActionAssetCreate = "core:asset:create"
ActionAssetUpdate = "core:asset:update"
ActionAssetDelete = "core:asset:delete"
ActionAssetPublish = "core:asset:publish"
// Datum actions
ActionDatumGet = "core:datum:get"

View File

@@ -125,7 +125,6 @@ func (s AssetService) GetByOwnerID(
func (s AssetService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
filter *coredata.AssetFilter,
) (int, error) {
var count int
@@ -133,7 +132,7 @@ func (s AssetService) CountForOrganizationID(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
assets := coredata.Assets{}
count, err = assets.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
count, err = assets.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count assets: %w", err)
}
@@ -153,7 +152,6 @@ func (s AssetService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.AssetOrderField],
filter *coredata.AssetFilter,
) (*page.Page[*coredata.Asset, coredata.AssetOrderField], error) {
var assets coredata.Assets
@@ -166,7 +164,6 @@ func (s AssetService) ListForOrganizationID(
s.svc.scope,
organizationID,
cursor,
filter,
)
},
)

View File

@@ -1150,6 +1150,10 @@ func (s *DocumentService) SoftDelete(
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := s.clearDocumentReferences(ctx, tx, []gid.GID{documentID}); err != nil {
return err
}
return document.SoftDelete(ctx, tx, s.svc.scope)
},
)
@@ -1168,6 +1172,10 @@ func (s *DocumentService) BulkSoftDelete(
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := s.clearDocumentReferences(ctx, tx, documentIDs); err != nil {
return err
}
return documents.BulkSoftDelete(ctx, tx, s.svc.scope)
},
)
@@ -1201,6 +1209,10 @@ func (s *DocumentService) BulkArchive(
return fmt.Errorf("cannot delete measure mappings: %w", err)
}
if err := s.clearDocumentReferences(ctx, tx, documentIDs); err != nil {
return err
}
return documents.BulkArchive(ctx, tx, s.svc.scope)
},
)
@@ -1224,6 +1236,33 @@ func (s *DocumentService) BulkUnarchive(
)
}
// clearDocumentReferences nullifies references to the given document IDs in
// generated_documents and statements_of_applicability. This must be called
// inside a transaction before soft-deleting or archiving documents, because
// those operations are UPDATEs and do not trigger ON DELETE SET NULL.
func (s *DocumentService) clearDocumentReferences(
ctx context.Context,
tx pg.Tx,
documentIDs []gid.GID,
) error {
datum := coredata.Datum{}
if err := datum.ClearGeneratedDocumentID(ctx, tx, documentIDs); err != nil {
return err
}
asset := coredata.Asset{}
if err := asset.ClearGeneratedDocumentID(ctx, tx, documentIDs); err != nil {
return err
}
soa := coredata.StatementOfApplicability{}
if err := soa.ClearDocumentIDByDocumentIDs(ctx, tx, documentIDs); err != nil {
return err
}
return nil
}
func (s *DocumentService) RequestExport(
ctx context.Context,
documentIDs []gid.GID,
@@ -1849,6 +1888,10 @@ func (s *DocumentService) Archive(
return fmt.Errorf("cannot delete measure mappings: %w", err)
}
if err := s.clearDocumentReferences(ctx, tx, []gid.GID{documentID}); err != nil {
return err
}
document.Status = coredata.DocumentStatusArchived
document.ArchivedAt = &now
document.UpdatedAt = now

View File

@@ -24,7 +24,6 @@ import (
"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"
@@ -367,13 +366,9 @@ func (s *GeneratedDocumentService) PublishDataList(
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) {
datum := coredata.Datum{}
dataDocumentID, err := datum.GetGeneratedDocumentID(ctx, tx, organizationID)
if err != nil {
return fmt.Errorf("cannot query generated documents: %w", err)
}
@@ -388,12 +383,7 @@ func (s *GeneratedDocumentService) PublishDataList(
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 {
if err := datum.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*dataDocumentID}); err != nil {
return fmt.Errorf("cannot clear document reference: %w", err)
}
}
@@ -418,20 +408,7 @@ func (s *GeneratedDocumentService) PublishDataList(
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 {
if err := datum.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
return fmt.Errorf("cannot upsert generated documents: %w", err)
}
} else {
@@ -523,15 +500,11 @@ func (s *GeneratedDocumentService) GetDataListDocumentID(
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)
datum := coredata.Datum{}
var err error
dataDocumentID, err = datum.GetGeneratedDocumentID(ctx, conn, organizationID)
return err
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get data list document ID: %w", err)
}
@@ -653,6 +626,295 @@ func BuildDataListDocument(data docgen.DataListData) (string, error) {
return buf.String(), nil
}
func (s *GeneratedDocumentService) PublishAssetList(
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.buildAssetListDocumentData(ctx, tx, organization)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
prosemirrorJSON, err := BuildAssetListDocument(documentData)
if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err)
}
now := time.Now()
asset := coredata.Asset{}
assetDocumentID, err := asset.GetGeneratedDocumentID(ctx, tx, organizationID)
if err != nil {
return fmt.Errorf("cannot query generated documents: %w", err)
}
var existingDoc *coredata.Document
if assetDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, s.svc.scope, *assetDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load asset list document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
if err := asset.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*assetDocumentID}); 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)
}
if err := asset.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); 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: "Asset 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) GetAssetListDocumentID(
ctx context.Context,
organizationID gid.GID,
) (*gid.GID, error) {
var assetDocumentID *gid.GID
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
asset := coredata.Asset{}
var err error
assetDocumentID, err = asset.GetGeneratedDocumentID(ctx, conn, organizationID)
return err
})
if err != nil {
return nil, fmt.Errorf("cannot get asset list document ID: %w", err)
}
return assetDocumentID, nil
}
func (s *GeneratedDocumentService) buildAssetListDocumentData(
ctx context.Context,
conn pg.Querier,
organization *coredata.Organization,
) (docgen.AssetListData, error) {
var assets coredata.Assets
if err := assets.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
return docgen.AssetListData{}, fmt.Errorf("cannot load assets: %w", err)
}
if len(assets) == 0 {
return docgen.AssetListData{
Title: "Asset List",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalAssets: 0,
}, nil
}
ownerIDs := make([]gid.GID, 0, len(assets))
ownerIDSet := make(map[gid.GID]struct{})
for _, a := range assets {
if _, ok := ownerIDSet[a.OwnerID]; !ok {
ownerIDs = append(ownerIDs, a.OwnerID)
ownerIDSet[a.OwnerID] = struct{}{}
}
}
var profiles coredata.MembershipProfiles
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
return docgen.AssetListData{}, 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.AssetListRow, 0, len(assets))
for _, a := range assets {
ownerName := "-"
if p, ok := profileMap[a.OwnerID]; ok {
ownerName = p.FullName
}
var vendors coredata.Vendors
if err := vendors.LoadAllByAssetID(ctx, conn, s.svc.scope, a.ID); err != nil {
return docgen.AssetListData{}, fmt.Errorf("cannot load vendors for asset %s: %w", a.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.AssetListRow{
Name: a.Name,
AssetType: formatAssetType(a.AssetType),
Amount: a.Amount,
DataTypesStored: a.DataTypesStored,
Owner: ownerName,
Vendors: vendorStr,
})
}
return docgen.AssetListData{
Title: "Asset List",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalAssets: len(assets),
Rows: rows,
}, nil
}
func formatAssetType(t coredata.AssetType) string {
switch t {
case coredata.AssetTypePhysical:
return "Physical"
case coredata.AssetTypeVirtual:
return "Virtual"
default:
return string(t)
}
}
var assetListTemplate = template.Must(
template.New("asset_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
},
"printf": fmt.Sprintf,
}).
ParseFS(Templates, "templates/asset_list.json.tmpl"),
)
func BuildAssetListDocument(data docgen.AssetListData) (string, error) {
var buf bytes.Buffer
if err := assetListTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute asset list template: %w", err)
}
return buf.String(), nil
}
var soaTemplate = template.Must(
template.New("statement_of_applicability.json.tmpl").
Funcs(template.FuncMap{

View File

@@ -0,0 +1,83 @@
{
"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 assets managed by the organization. It serves as a record of all assets, their types, quantities, data stored, ownership, and associated vendors." }]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "2. Asset Inventory" }]
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Name", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [100] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Type", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Amount", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Data Types Stored", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Owner", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [200] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Vendors", "marks": [{ "type": "bold" }] }] }] }
]
}{{range .Rows}},
{
"type": "tableRow",
"content": [
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Name}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [100] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .AssetType}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d" .Amount)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .DataTypesStored}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Owner}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [200] }, "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": "Asset Type" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Physical: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Tangible hardware assets such as servers, workstations, networking equipment, and storage devices." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Virtual: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Software-based assets such as cloud services, virtual machines, SaaS applications, and databases." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Owner" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "The individual responsible for the asset, including its maintenance, 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 provide or support the asset." }]
}
]
}

View File

@@ -117,12 +117,7 @@ func (r *assetConnectionResolver) TotalCount(ctx context.Context, obj *types.Ass
switch obj.Resolver.(type) {
case *organizationResolver:
assetFilter := coredata.NewAssetFilter(nil)
if obj.Filter != nil {
assetFilter = coredata.NewAssetFilter(&obj.Filter.SnapshotID)
}
count, err := prb.Assets.CountForOrganizationID(ctx, obj.ParentID, assetFilter)
count, err := prb.Assets.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count assets", log.Error(err))
return 0, gqlutils.Internal(ctx)
@@ -423,6 +418,29 @@ func (r *mutationResolver) PublishDataList(ctx context.Context, input types.Publ
}, nil
}
// PublishAssetList is the resolver for the publishAssetList field.
func (r *mutationResolver) PublishAssetList(ctx context.Context, input types.PublishAssetListInput) (*types.PublishAssetListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionAssetPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishAssetList(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 asset list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishAssetListPayload{
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} }

View File

@@ -62,13 +62,8 @@ input DatumOrder
field: DatumOrderField!
}
input AssetFilter {
snapshotId: ID
}
type Asset implements Node {
id: ID!
snapshotId: ID
name: String!
amount: Int!
owner: Profile! @goField(forceResolver: true)
@@ -148,6 +143,9 @@ extend type Mutation {
publishDataList(
input: PublishDataListInput!
): PublishDataListPayload!
publishAssetList(
input: PublishAssetListInput!
): PublishAssetListPayload!
}
input CreateAssetInput {
@@ -227,3 +225,13 @@ type PublishDataListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input PublishAssetListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishAssetListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}

View File

@@ -121,13 +121,14 @@ type Organization implements Node {
orderBy: AccessReviewCampaignOrder
): AccessReviewCampaignConnection! @goField(forceResolver: true)
assetListDocument: Document @goField(forceResolver: true)
assets(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AssetOrder
filter: AssetFilter = { snapshotId: null }
): AssetConnection! @goField(forceResolver: true)
dataListDocument: Document @goField(forceResolver: true)

View File

@@ -3,7 +3,6 @@ enum SnapshotsType
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
VENDORS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
ASSETS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeAssets")
FINDINGS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"

View File

@@ -221,8 +221,32 @@ func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *t
return types.NewAccessReviewCampaignConnection(p, r, obj.ID), nil
}
// AssetListDocument is the resolver for the assetListDocument field.
func (r *organizationResolver) AssetListDocument(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())
assetDocumentID, err := prb.GeneratedDocuments.GetAssetListDocumentID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot get asset list document ID: %w", err)
}
if assetDocumentID == nil {
return nil, nil
}
doc, err := prb.Documents.Get(ctx, *assetDocumentID)
if err != nil {
return nil, fmt.Errorf("cannot get asset list document: %w", err)
}
return types.NewDocument(doc), nil
}
// Assets is the resolver for the assets field.
func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AssetOrderBy, filter *types.AssetFilter) (*types.AssetConnection, error) {
func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AssetOrderBy) (*types.AssetConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionAssetList); err != nil {
return nil, err
}
@@ -242,18 +266,13 @@ func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organizati
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
assetFilter := coredata.NewAssetFilter(nil)
if filter != nil {
assetFilter = coredata.NewAssetFilter(&filter.SnapshotID)
}
page, err := prb.Assets.ListForOrganizationID(ctx, obj.ID, cursor, assetFilter)
page, err := prb.Assets.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization assets", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAssetConnection(page, r, obj.ID, filter), nil
return types.NewAssetConnection(page, r, obj.ID), nil
}
// DataListDocument is the resolver for the dataListDocument field.

View File

@@ -30,7 +30,6 @@ type (
Resolver any
ParentID gid.GID
Filter *AssetFilter
}
)
@@ -38,7 +37,6 @@ func NewAssetConnection(
p *page.Page[*coredata.Asset, coredata.AssetOrderField],
resolver any,
parentID gid.GID,
filter *AssetFilter,
) *AssetConnection {
edges := make([]*AssetEdge, len(p.Data))
for i, asset := range p.Data {
@@ -51,7 +49,6 @@ func NewAssetConnection(
Resolver: resolver,
ParentID: parentID,
Filter: filter,
}
}
@@ -64,10 +61,9 @@ func NewAssetEdge(asset *coredata.Asset, orderField coredata.AssetOrderField) *A
func NewAsset(asset *coredata.Asset) *Asset {
return &Asset{
ID: asset.ID,
SnapshotID: asset.SnapshotID,
Name: asset.Name,
Amount: asset.Amount,
ID: asset.ID,
Name: asset.Name,
Amount: asset.Amount,
Owner: &Profile{
ID: asset.OwnerID,
},

View File

@@ -559,13 +559,7 @@ func (r *Resolver) ListAssetsTool(ctx context.Context, req *mcp.CallToolRequest,
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
assetFilter := coredata.NewAssetFilter(&noSnapshot)
if input.Filter != nil {
assetFilter = coredata.NewAssetFilter(&input.Filter.SnapshotID)
}
page, err := prb.Assets.ListForOrganizationID(ctx, input.OrganizationID, cursor, assetFilter)
page, err := prb.Assets.ListForOrganizationID(ctx, input.OrganizationID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization assets: %w", err))
}
@@ -4022,3 +4016,19 @@ func (r *Resolver) PublishDataListTool(ctx context.Context, req *mcp.CallToolReq
DocumentVersionID: documentVersion.ID,
}, nil
}
func (r *Resolver) PublishAssetListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishAssetListInput) (*mcp.CallToolResult, types.PublishAssetListOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionAssetPublish)
svc := r.ProboService(ctx, input.OrganizationID)
document, documentVersion, err := svc.GeneratedDocuments.PublishAssetList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
return nil, types.PublishAssetListOutput{}, fmt.Errorf("cannot publish asset list: %w", err)
}
return nil, types.PublishAssetListOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -2003,11 +2003,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
snapshot_id:
type:
- string
- "null"
description: Snapshot ID
name:
type: string
description: Asset name
@@ -2049,15 +2044,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 assets with no snapshot (current live data). Pass a specific snapshot ID to retrieve assets as they were at that snapshot.
default: null
ListAssetsOutput:
type: object
@@ -4999,7 +4985,6 @@ components:
enum:
- RISKS
- VENDORS
- ASSETS
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
@@ -6584,6 +6569,33 @@ components:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishAssetListInput:
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.
PublishAssetListOutput:
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:
@@ -8650,7 +8662,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/GetSnapshotOutput"
- name: takeSnapshot
description: Take a snapshot of a collection of objects (risks, vendors, assets, findings, obligations, or processing activities)
description: Take a snapshot of a collection of objects (risks, vendors, findings, obligations, or processing activities)
hints:
readonly: false
inputSchema:
@@ -8870,6 +8882,14 @@ tools:
$ref: "#/components/schemas/PublishDataListInput"
outputSchema:
$ref: "#/components/schemas/PublishDataListOutput"
- name: publishAssetList
description: Publish the asset list for an organization as a document. If a document already exists, a new version is created.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishAssetListInput"
outputSchema:
$ref: "#/components/schemas/PublishAssetListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:

View File

@@ -20,7 +20,7 @@ import (
)
func NewAsset(a *coredata.Asset) *Asset {
asset := &Asset{
return &Asset{
ID: a.ID,
Name: a.Name,
Amount: a.Amount,
@@ -31,13 +31,6 @@ func NewAsset(a *coredata.Asset) *Asset {
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
if a.SnapshotID != nil {
s := a.SnapshotID.String()
asset.SnapshotID = &s
}
return asset
}
func NewListAssetsOutput(assetPage *page.Page[*coredata.Asset, coredata.AssetOrderField]) ListAssetsOutput {