From b603d04d8d53bb870ea9a0357a970e93749c4b33 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 21 Apr 2026 18:40:49 +0200 Subject: [PATCH] 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 --- .../components/assets/ReadOnlyAssetsTable.tsx | 10 +- apps/console/src/hooks/graph/AssetGraph.ts | 40 +- .../organizations/assets/AssetDetailsPage.tsx | 54 +-- .../pages/organizations/assets/AssetsPage.tsx | 67 ++- .../assets/dialogs/PublishAssetListDialog.tsx | 159 +++++++ apps/console/src/routes/assetRoutes.ts | 28 +- .../main.go | 434 ++++++++++++++++++ contrib/claude/coredata.md | 36 +- e2e/console/asset_publish_test.go | 406 ++++++++++++++++ e2e/console/snapshot_test.go | 6 +- packages/helpers/src/snapshots.ts | 5 - .../nodes/Probo/actions/asset/index.ts | 10 +- .../Probo/actions/asset/publish.operation.ts | 101 ++++ .../asset_filter.go => cmd/asset/asset.go} | 46 +- pkg/cmd/asset/publish/publish.go | 147 ++++++ pkg/cmd/root/root.go | 2 + pkg/coredata/asset.go | 214 ++++++--- pkg/coredata/asset_vendor.go | 72 +-- pkg/coredata/datum.go | 107 +++++ pkg/coredata/migrations/20260420T140000Z.sql | 16 + pkg/coredata/snapshot.go | 4 +- pkg/coredata/snapshots_type.go | 1 - pkg/coredata/snapshottable.go | 2 - pkg/coredata/statement_of_applicability.go | 33 ++ pkg/coredata/vendor.go | 126 +++-- pkg/docgen/generator.go | 17 + pkg/probo/actions.go | 11 +- pkg/probo/asset_service.go | 5 +- pkg/probo/document_service.go | 43 ++ pkg/probo/generated_document_service.go | 334 ++++++++++++-- pkg/probo/templates/asset_list.json.tmpl | 83 ++++ pkg/server/api/console/v1/asset_resolvers.go | 30 +- .../api/console/v1/graphql/asset.graphql | 18 +- .../console/v1/graphql/organization.graphql | 3 +- .../api/console/v1/graphql/snapshot.graphql | 1 - .../api/console/v1/organization_resolvers.go | 35 +- pkg/server/api/console/v1/types/asset.go | 10 +- pkg/server/api/mcp/v1/schema.resolvers.go | 24 +- pkg/server/api/mcp/v1/specification.yaml | 52 ++- pkg/server/api/mcp/v1/types/asset.go | 9 +- 40 files changed, 2315 insertions(+), 486 deletions(-) create mode 100644 apps/console/src/pages/organizations/assets/dialogs/PublishAssetListDialog.tsx create mode 100644 cmd/migrate-asset-snapshots-to-documents/main.go create mode 100644 e2e/console/asset_publish_test.go create mode 100644 packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts rename pkg/{coredata/asset_filter.go => cmd/asset/asset.go} (51%) create mode 100644 pkg/cmd/asset/publish/publish.go create mode 100644 pkg/coredata/migrations/20260420T140000Z.sql create mode 100644 pkg/probo/templates/asset_list.json.tmpl diff --git a/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx b/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx index a87440559..fabf299b2 100644 --- a/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx +++ b/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx @@ -16,7 +16,6 @@ import { faviconUrl, getAssetTypeVariant } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { Avatar, Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui"; import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment"; -import { useParams } from "react-router"; import type { OperationType } from "relay-runtime"; import type { @@ -66,17 +65,10 @@ export function ReadOnlyAssetsTable(props: Props) { function AssetRow({ entry }: { entry: AssetEntry }) { const organizationId = useOrganizationId(); const { __ } = useTranslate(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); const vendors = entry.vendors?.edges.map(edge => edge.node) ?? []; return ( - + {entry.name} diff --git a/apps/console/src/hooks/graph/AssetGraph.ts b/apps/console/src/hooks/graph/AssetGraph.ts index 727b816f5..88a497619 100644 --- a/apps/console/src/hooks/graph/AssetGraph.ts +++ b/apps/console/src/hooks/graph/AssetGraph.ts @@ -18,16 +18,28 @@ import { useConfirm } from "@probo/ui"; import { useMutation } from "react-relay"; import { graphql } from "relay-runtime"; -import { useMutationWithToasts } from "../useMutationWithToasts"; +import type { + AssetGraphCreateMutation, + AssetType, +} from "#/__generated__/core/AssetGraphCreateMutation.graphql"; +import type { AssetGraphDeleteMutation } from "#/__generated__/core/AssetGraphDeleteMutation.graphql"; +import type { AssetGraphUpdateMutation } from "#/__generated__/core/AssetGraphUpdateMutation.graphql"; /* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */ export const assetsQuery = graphql` - query AssetGraphListQuery($organizationId: ID!, $snapshotId: ID) { + query AssetGraphListQuery($organizationId: ID!) { node(id: $organizationId) { ... on Organization { canCreateAsset: permission(action: "core:asset:create") - ...AssetsPageFragment @arguments(snapshotId: $snapshotId) + canPublishAssets: permission(action: "core:asset:publish") + assetListDocument { + id + defaultApprovers { + id + } + } + ...AssetsPageFragment } } } @@ -38,7 +50,6 @@ export const assetNodeQuery = graphql` node(id: $assetId) { ... on Asset { id - snapshotId name amount assetType @@ -75,7 +86,6 @@ export const createAssetMutation = graphql` assetEdge @appendEdge(connections: $connections) { node { id - snapshotId name amount assetType @@ -107,7 +117,6 @@ export const updateAssetMutation = graphql` updateAsset(input: $input) { asset { id - snapshotId name amount assetType @@ -146,8 +155,7 @@ export const useDeleteAsset = ( asset: { id?: string; name?: string }, connectionId: string, ) => { - // eslint-disable-next-line relay/generated-typescript-types - const [mutate] = useMutation(deleteAssetMutation); + const [mutate] = useMutation(deleteAssetMutation); const confirm = useConfirm(); const { __ } = useTranslate(); @@ -160,7 +168,7 @@ export const useDeleteAsset = ( promisifyMutation(mutate)({ variables: { input: { - assetId: asset.id, + assetId: asset.id!, }, connections: [connectionId], }, @@ -178,15 +186,14 @@ export const useDeleteAsset = ( }; export const useCreateAsset = (connectionId: string) => { - // eslint-disable-next-line relay/generated-typescript-types - const [mutate, isMutating] = useMutation(createAssetMutation); + const [mutate, isMutating] = useMutation(createAssetMutation); const { __ } = useTranslate(); return [ (input: { name: string; amount: number; - assetType: string; + assetType: AssetType; ownerId: string; organizationId: string; vendorIds?: string[]; @@ -228,16 +235,13 @@ export const useCreateAsset = (connectionId: string) => { export const useUpdateAsset = () => { const { __ } = useTranslate(); - const [mutate] = useMutationWithToasts(updateAssetMutation, { - successMessage: __("Asset updated successfully"), - errorMessage: __("Failed to update asset"), - }); + const [mutate] = useMutation(updateAssetMutation); return (input: { id: string; name?: string; amount?: number; - assetType?: string; + assetType?: AssetType; dataTypesStored?: string; ownerId?: string; vendorIds?: string[]; @@ -246,7 +250,7 @@ export const useUpdateAsset = () => { return alert(__("Failed to update asset: asset ID is required")); } - return mutate({ + return promisifyMutation(mutate)({ variables: { input, }, diff --git a/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx b/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx index 99039d94a..07f41736b 100644 --- a/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx +++ b/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx @@ -12,10 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { - getAssetTypeVariant, - validateSnapshotConsistency, -} from "@probo/helpers"; +import { getAssetTypeVariant } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { ActionDropdown, @@ -32,14 +29,12 @@ import { type PreloadedQuery, usePreloadedQuery, } from "react-relay"; -import { useParams } from "react-router"; import { z } from "zod"; import type { AssetGraphNodeQuery } from "#/__generated__/core/AssetGraphNodeQuery.graphql"; import { ControlledField } from "#/components/form/ControlledField"; import { PeopleSelectField } from "#/components/form/PeopleSelectField"; import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField"; -import { SnapshotBanner } from "#/components/SnapshotBanner"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useOrganizationId } from "#/hooks/useOrganizationId"; @@ -70,15 +65,10 @@ export default function AssetDetailsPage(props: Props) { const assetEntry = asset.node; const { __ } = useTranslate(); const organizationId = useOrganizationId(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); - - validateSnapshotConsistency(assetEntry, snapshotId); const connectionId = ConnectionHandler.getConnectionID( organizationId, "AssetsPage_assets", - { filter: { snapshotId: snapshotId || null } }, ); const deleteAsset = useDeleteAsset(assetEntry, connectionId); @@ -107,25 +97,19 @@ export default function AssetDetailsPage(props: Props) { reset(formData); }); - const breadcrumbAssetsUrl - = isSnapshotMode && snapshotId - ? `/organizations/${organizationId}/snapshots/${snapshotId}/assets` - : `/organizations/${organizationId}/assets`; + const breadcrumbItems = [ + { + label: __("Assets"), + to: `/organizations/${organizationId}/assets`, + }, + { + label: assetEntry?.name ?? "", + }, + ]; return (
- {snapshotId && } - +
@@ -138,7 +122,7 @@ export default function AssetDetailsPage(props: Props) { : __("Virtual")}
- {!isSnapshotMode && asset.node.canDelete && ( + {asset.node.canDelete && ( @@ -181,7 +165,7 @@ export default function AssetDetailsPage(props: Props) { label={__("Data Types Stored")} {...register("dataTypesStored")} type="text" - disabled={isSnapshotMode} + disabled={!assetEntry.canUpdate} />
- {formState.isDirty && !isSnapshotMode && asset.node.canUpdate && ( + {formState.isDirty && assetEntry.canUpdate && ( diff --git a/apps/console/src/pages/organizations/assets/AssetsPage.tsx b/apps/console/src/pages/organizations/assets/AssetsPage.tsx index 4c543db2c..a6d57bcea 100644 --- a/apps/console/src/pages/organizations/assets/AssetsPage.tsx +++ b/apps/console/src/pages/organizations/assets/AssetsPage.tsx @@ -14,19 +14,24 @@ import { usePageTitle } from "@probo/hooks"; import { useTranslate } from "@probo/i18n"; -import { Button, IconPlusLarge, PageHeader } from "@probo/ui"; +import { + Button, + IconPageTextLine, + IconPlusLarge, + IconUpload, + PageHeader, +} from "@probo/ui"; import { graphql, type PreloadedQuery, usePaginationFragment, usePreloadedQuery, } from "react-relay"; -import { useParams } from "react-router"; +import { Link, useNavigate } from "react-router"; import type { AssetGraphListQuery } from "#/__generated__/core/AssetGraphListQuery.graphql"; import type { AssetsListQuery } from "#/__generated__/core/AssetsListQuery.graphql"; import type { AssetsPageFragment$key } from "#/__generated__/core/AssetsPageFragment.graphql"; -import { SnapshotBanner } from "#/components/SnapshotBanner"; import { assetsQuery } from "#/hooks/graph/AssetGraph"; import { useOrganizationId } from "#/hooks/useOrganizationId"; @@ -34,6 +39,7 @@ import { AssetsTable } from "../../../components/assets/AssetsTable"; import { ReadOnlyAssetsTable } from "../../../components/assets/ReadOnlyAssetsTable"; import { CreateAssetDialog } from "./dialogs/CreateAssetDialog"; +import { PublishAssetListDialog } from "./dialogs/PublishAssetListDialog"; const paginatedAssetsFragment = graphql` fragment AssetsPageFragment on Organization @@ -44,7 +50,6 @@ const paginatedAssetsFragment = graphql` after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } last: { type: "Int", defaultValue: null } - snapshotId: { type: "ID", defaultValue: null } ) { assets( first: $first @@ -52,14 +57,12 @@ const paginatedAssetsFragment = graphql` last: $last before: $before orderBy: $orderBy - filter: { snapshotId: $snapshotId } - ) @connection(key: "AssetsPage_assets", filters: ["filter"]) { + ) @connection(key: "AssetsPage_assets") { __id edges { node { # eslint-disable-next-line relay/unused-fields id - snapshotId # eslint-disable-next-line relay/unused-fields name # eslint-disable-next-line relay/unused-fields @@ -102,8 +105,7 @@ type Props = { export default function AssetsPage(props: Props) { const { __ } = useTranslate(); const organizationId = useOrganizationId(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); + const navigate = useNavigate(); const data = usePreloadedQuery( assetsQuery, @@ -115,29 +117,56 @@ export default function AssetsPage(props: Props) { ); const assets = pagination.data.assets?.edges.map(edge => edge.node); const connectionId = pagination.data.assets.__id; + const defaultApproverIds = (data.node.assetListDocument?.defaultApprovers ?? []).map(a => a.id); const canWrite = assets.some(asset => asset.canDelete || asset.canUpdate); usePageTitle(__("Assets")); return (
- {snapshotId && } - {!isSnapshotMode && data.node.canCreateAsset && ( - - - - )} +
+ {data.node.assetListDocument?.id && ( + + )} + {data.node.canPublishAssets && ( + { + void navigate( + `/organizations/${organizationId}/documents/${documentId}`, + ); + }} + > + + + )} + {data.node.canCreateAsset && ( + + + + )} +
- {isSnapshotMode || !canWrite + {!canWrite ? ( ) diff --git a/apps/console/src/pages/organizations/assets/dialogs/PublishAssetListDialog.tsx b/apps/console/src/pages/organizations/assets/dialogs/PublishAssetListDialog.tsx new file mode 100644 index 000000000..84d06531d --- /dev/null +++ b/apps/console/src/pages/organizations/assets/dialogs/PublishAssetListDialog.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +import { formatError, type GraphQLError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + IconSend, + IconUpload, + useDialogRef, + useToast, +} from "@probo/ui"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { PublishAssetListDialogMutation } from "#/__generated__/core/PublishAssetListDialogMutation.graphql"; +import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const publishMutation = graphql` + mutation PublishAssetListDialogMutation( + $input: PublishAssetListInput! + ) { + publishAssetList(input: $input) { + documentEdge { + node { + id + } + } + } + } +`; + +interface PublishAssetListDialogProps { + children: ReactNode; + organizationId: string; + defaultApproverIds?: string[]; + onPublished?: (documentId: string) => void; +} + +export function PublishAssetListDialog({ + children, + organizationId, + defaultApproverIds, + onPublished, +}: PublishAssetListDialogProps) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + + const schema = useMemo(() => z.object({ + approverIds: z.array(z.string()), + }), []); + + const { + control, + handleSubmit, + reset, + watch, + } = useFormWithSchema(schema, { + defaultValues: { + approverIds: defaultApproverIds ?? [], + }, + }); + + const [publish, isPublishing] + = useMutation(publishMutation); + + const approverIds = watch("approverIds"); + const hasApprovers = approverIds.length > 0; + + const onSubmit = (data: z.infer) => { + publish({ + variables: { + input: { + organizationId, + approverIds: data.approverIds.length > 0 ? data.approverIds : undefined, + }, + }, + onCompleted(response) { + const documentId = response.publishAssetList?.documentEdge?.node?.id; + if (documentId) { + toast({ + title: __("Success"), + description: hasApprovers + ? __("Approval requested successfully.") + : __("Asset list published successfully."), + variant: "success", + }); + dialogRef.current?.close(); + reset(); + onPublished?.(documentId); + } + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to publish asset list"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + return ( + +
void handleSubmit(onSubmit)(e)}> + +
+

+ {__("Select approvers to request approval before publishing, or publish directly without approvers.")} +

+ +
+
+ + + +
+
+ ); +} diff --git a/apps/console/src/routes/assetRoutes.ts b/apps/console/src/routes/assetRoutes.ts index 902c9f6ed..2ec843ea1 100644 --- a/apps/console/src/routes/assetRoutes.ts +++ b/apps/console/src/routes/assetRoutes.ts @@ -33,21 +33,7 @@ export const assetRoutes = [ Fallback: PageSkeleton, loader: loaderFromQueryLoader(({ organizationId }) => loadQuery(coreEnvironment, assetsQuery, { - organizationId: organizationId, - snapshotId: null, - }), - ), - Component: withQueryRef( - lazy(() => import("#/pages/organizations/assets/AssetsPage")), - ), - }, - { - path: "snapshots/:snapshotId/assets", - Fallback: PageSkeleton, - loader: loaderFromQueryLoader(({ organizationId, snapshotId }) => - loadQuery(coreEnvironment, assetsQuery, { - organizationId: organizationId, - snapshotId: snapshotId, + organizationId, }), ), Component: withQueryRef( @@ -66,16 +52,4 @@ export const assetRoutes = [ lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")), ), }, - { - path: "snapshots/:snapshotId/assets/:assetId", - Fallback: PageSkeleton, - loader: loaderFromQueryLoader(({ assetId }) => - loadQuery(coreEnvironment, assetNodeQuery, { - assetId, - }), - ), - Component: withQueryRef( - lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")), - ), - }, ] satisfies AppRoute[]; diff --git a/cmd/migrate-asset-snapshots-to-documents/main.go b/cmd/migrate-asset-snapshots-to-documents/main.go new file mode 100644 index 000000000..5dd34b72e --- /dev/null +++ b/cmd/migrate-asset-snapshots-to-documents/main.go @@ -0,0 +1,434 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Command migrate-asset-snapshots-to-documents creates documents and document +// versions from existing asset snapshots. For each organization that has asset +// snapshots, it generates an asset list document using the same ProseMirror +// builder as the publish flow. +package main + +import ( + "context" + "flag" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/docgen" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/probo" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + var ( + pgDSN string + dryRun bool + ) + + flag.StringVar( + &pgDSN, + "pg-dsn", + os.Getenv("DATABASE_URL"), + "PostgreSQL connection URL (default: DATABASE_URL env)", + ) + flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing") + flag.Parse() + + if pgDSN == "" { + return fmt.Errorf("set -pg-dsn or DATABASE_URL") + } + + ctx := context.Background() + + pgClient, err := newPgClientFromDSN(pgDSN) + if err != nil { + return fmt.Errorf("cannot create pg client: %w", err) + } + + return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return migrate(ctx, tx, dryRun) + }) +} + +type orgWithAssetSnapshots struct { + organizationID gid.GID + tenantID gid.TenantID + organizationName string +} + +type assetSnapshot struct { + snapshotID string + publishedAt time.Time +} + +func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error { + orgs, err := loadOrgsWithAssetSnapshots(ctx, tx) + if err != nil { + return err + } + + if len(orgs) == 0 { + fmt.Println("no organizations with asset snapshots to migrate") + return nil + } + + var stats struct { + documents, versions int + } + + for _, org := range orgs { + snapshots, err := loadAssetSnapshots(ctx, tx, org.organizationID) + if err != nil { + return err + } + + if dryRun { + fmt.Printf("would migrate org %s (%s) — %d asset snapshot(s)\n", + org.organizationID, org.organizationName, len(snapshots)) + continue + } + + documentID := gid.New(org.tenantID, coredata.DocumentEntityType) + now := time.Now() + + _, err = tx.Exec( + ctx, + ` +INSERT INTO documents ( + id, tenant_id, organization_id, write_mode, + current_published_major, current_published_minor, + trust_center_visibility, status, created_at, updated_at +) VALUES ( + @id, @tenant_id, @organization_id, + 'GENERATED'::document_write_mode, + @current_published_major, 0, + 'NONE'::trust_center_visibility, + 'ACTIVE'::document_status, + @created_at, @updated_at +)`, + pgx.NamedArgs{ + "id": documentID, + "tenant_id": org.tenantID, + "organization_id": org.organizationID, + "current_published_major": len(snapshots), + "created_at": now, + "updated_at": now, + }, + ) + if err != nil { + return fmt.Errorf("cannot insert document for org %s: %w", org.organizationID, err) + } + stats.documents++ + + _, err = tx.Exec( + ctx, + `INSERT INTO generated_documents (organization_id, tenant_id, asset_list_document_id, created_at, updated_at) +VALUES (@organization_id, @tenant_id, @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": org.organizationID, + "tenant_id": org.tenantID, + "asset_list_document_id": documentID, + "created_at": now, + "updated_at": now, + }, + ) + if err != nil { + return fmt.Errorf("cannot link document to org %s: %w", org.organizationID, err) + } + + for major, snap := range snapshots { + content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt) + if err != nil { + return fmt.Errorf("cannot build content for snapshot %s of org %s: %w", + snap.snapshotID, org.organizationID, err) + } + + versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType) + + _, err = tx.Exec( + ctx, + ` +INSERT INTO document_versions ( + id, tenant_id, organization_id, document_id, + title, major, minor, classification, document_type, + content, changelog, status, orientation, + published_at, created_at, updated_at +) VALUES ( + @id, @tenant_id, @organization_id, @document_id, + @title, @major, 0, + 'CONFIDENTIAL'::document_classification, + 'REGISTER'::document_type, + @content, '', + 'PUBLISHED'::document_version_status, + 'PORTRAIT'::document_version_orientation, + @published_at, @published_at, @published_at +)`, + pgx.NamedArgs{ + "id": versionID, + "tenant_id": org.tenantID, + "organization_id": org.organizationID, + "document_id": documentID, + "title": "Asset List", + "major": major + 1, + "content": content, + "published_at": snap.publishedAt, + }, + ) + if err != nil { + return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err) + } + stats.versions++ + } + + fmt.Printf("migrated org %s (%s) — %d version(s)\n", + org.organizationID, org.organizationName, len(snapshots)) + } + + if dryRun { + fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs)) + return nil + } + + fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions) + + return nil +} + +func loadOrgsWithAssetSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithAssetSnapshots, error) { + rows, err := tx.Query( + ctx, + ` +SELECT DISTINCT + o.id, + o.tenant_id, + o.name, + o.created_at +FROM organizations o +WHERE NOT EXISTS ( + SELECT 1 FROM generated_documents gd + WHERE gd.organization_id = o.id AND gd.asset_list_document_id IS NOT NULL + ) + AND EXISTS ( + SELECT 1 FROM assets a + WHERE a.organization_id = o.id AND a.snapshot_id IS NOT NULL + ) +ORDER BY o.created_at; +`, + ) + if err != nil { + return nil, fmt.Errorf("cannot query organizations with asset snapshots: %w", err) + } + defer rows.Close() + + var result []orgWithAssetSnapshots + for rows.Next() { + var o orgWithAssetSnapshots + var createdAt time.Time + if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil { + return nil, fmt.Errorf("cannot scan organization: %w", err) + } + result = append(result, o) + } + + return result, rows.Err() +} + +func loadAssetSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]assetSnapshot, error) { + rows, err := tx.Query( + ctx, + ` +SELECT DISTINCT + s.id, + s.created_at +FROM snapshots s +WHERE s.organization_id = @organization_id + AND s.type = 'ASSETS' +ORDER BY s.created_at ASC; +`, + pgx.NamedArgs{"organization_id": organizationID}, + ) + if err != nil { + return nil, fmt.Errorf("cannot query asset snapshots for org %s: %w", organizationID, err) + } + defer rows.Close() + + var result []assetSnapshot + for rows.Next() { + var s assetSnapshot + if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil { + return nil, fmt.Errorf("cannot scan snapshot: %w", err) + } + result = append(result, s) + } + + return result, rows.Err() +} + +func buildSnapshotContent( + ctx context.Context, + tx pg.Tx, + snapshotID string, + orgName string, + publishedAt time.Time, +) (string, error) { + rows, err := tx.Query( + ctx, + ` +SELECT + a.id, + a.name, + a.asset_type, + a.amount, + a.data_types_stored, + COALESCE(p.full_name, '-') +FROM assets a +LEFT JOIN iam_membership_profiles p ON p.id = a.owner_profile_id +WHERE a.snapshot_id = @snapshot_id +ORDER BY a.name ASC; +`, + pgx.NamedArgs{"snapshot_id": snapshotID}, + ) + if err != nil { + return "", fmt.Errorf("cannot load snapshot assets: %w", err) + } + defer rows.Close() + + type assetInfo struct { + id string + name string + assetType string + amount int + dataTypesStored string + ownerName string + } + + var assets []assetInfo + for rows.Next() { + var a assetInfo + if err := rows.Scan(&a.id, &a.name, &a.assetType, &a.amount, &a.dataTypesStored, &a.ownerName); err != nil { + return "", fmt.Errorf("cannot scan asset: %w", err) + } + assets = append(assets, a) + } + if err := rows.Err(); err != nil { + return "", err + } + + vendorRows, err := tx.Query( + ctx, + ` +SELECT + av.asset_id, + v.name +FROM asset_vendors av +JOIN vendors v ON v.id = av.vendor_id +WHERE av.snapshot_id = @snapshot_id +ORDER BY v.name ASC; +`, + pgx.NamedArgs{"snapshot_id": snapshotID}, + ) + if err != nil { + return "", fmt.Errorf("cannot load snapshot asset vendors: %w", err) + } + defer vendorRows.Close() + + vendorsByAsset := make(map[string][]string) + for vendorRows.Next() { + var assetID, vendorName string + if err := vendorRows.Scan(&assetID, &vendorName); err != nil { + return "", fmt.Errorf("cannot scan vendor: %w", err) + } + vendorsByAsset[assetID] = append(vendorsByAsset[assetID], vendorName) + } + if err := vendorRows.Err(); err != nil { + return "", err + } + + assetRows := make([]docgen.AssetListRow, len(assets)) + for i, a := range assets { + vendors := "-" + if v, ok := vendorsByAsset[a.id]; ok && len(v) > 0 { + vendors = strings.Join(v, ", ") + } + + assetRows[i] = docgen.AssetListRow{ + Name: a.name, + AssetType: formatAssetTypeString(a.assetType), + Amount: a.amount, + DataTypesStored: a.dataTypesStored, + Owner: a.ownerName, + Vendors: vendors, + } + } + + docData := docgen.AssetListData{ + Title: "Asset List", + OrganizationName: orgName, + CreatedAt: publishedAt, + TotalAssets: len(assetRows), + Rows: assetRows, + } + + return probo.BuildAssetListDocument(docData) +} + +func formatAssetTypeString(t string) string { + switch t { + case "PHYSICAL": + return "Physical" + case "VIRTUAL": + return "Virtual" + default: + return t + } +} + +func newPgClientFromDSN(dsn string) (*pg.Client, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("cannot parse DSN") + } + + var opts []pg.Option + + if u.Host != "" { + opts = append(opts, pg.WithAddr(u.Host)) + } + + if u.User != nil { + opts = append(opts, pg.WithUser(u.User.Username())) + if password, ok := u.User.Password(); ok { + opts = append(opts, pg.WithPassword(password)) + } + } + + if len(u.Path) > 1 { + opts = append(opts, pg.WithDatabase(u.Path[1:])) + } + + return pg.NewClient(opts...) +} diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md index 7a18f19a9..80bc5c6b3 100644 --- a/contrib/claude/coredata.md +++ b/contrib/claude/coredata.md @@ -155,32 +155,30 @@ Map `pgx.ErrNoRows` to `ErrResourceNotFound`. Check unique constraint violations Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals. ```go -type AssetFilter struct { - snapshotID **gid.GID +type CookieBannerFilter struct { + state *CookieBannerState } -func NewAssetFilter(snapshotID **gid.GID) *AssetFilter { - return &AssetFilter{snapshotID: snapshotID} +func NewCookieBannerFilter(state *CookieBannerState) *CookieBannerFilter { + return &CookieBannerFilter{state: state} } -func (f *AssetFilter) SQLFragment() string { - if f.snapshotID == nil { - return "TRUE" - } - - if *f.snapshotID == nil { - return "snapshot_id IS NULL" - } - - return "snapshot_id = @filter_snapshot_id" +func (f *CookieBannerFilter) SQLFragment() string { + return `( + CASE + WHEN @filter_state::text IS NOT NULL THEN + state = @filter_state::cookie_banner_state + ELSE TRUE + END + )` } -func (f *AssetFilter) SQLArguments() pgx.NamedArgs { - if f.snapshotID == nil || *f.snapshotID == nil { - return pgx.NamedArgs{} +func (f *CookieBannerFilter) SQLArguments() pgx.StrictNamedArgs { + args := pgx.StrictNamedArgs{"filter_state": nil} + if f.state != nil { + args["filter_state"] = string(*f.state) } - - return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID} + return args } ``` diff --git a/e2e/console/asset_publish_test.go b/e2e/console/asset_publish_test.go new file mode 100644 index 000000000..b1634cbe8 --- /dev/null +++ b/e2e/console/asset_publish_test.go @@ -0,0 +1,406 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestAsset_PublishAssetList(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "publish without approvers publishes immediately", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + profileID := factory.CreateUser(owner) + + createAssetForPublish(t, owner, profileID, "Test Asset") + + const query = ` + mutation($input: PublishAssetListInput!) { + publishAssetList(input: $input) { + documentEdge { + node { + id + writeMode + status + } + } + documentVersionEdge { + node { + id + title + documentType + status + major + minor + content + } + } + } + } + ` + + var result struct { + PublishAssetList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + WriteMode string `json:"writeMode"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + Content string `json:"content"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishAssetList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + &result, + ) + + require.NoError(t, err) + + doc := result.PublishAssetList.DocumentEdge.Node + assert.NotEmpty(t, doc.ID) + assert.Equal(t, "GENERATED", doc.WriteMode) + assert.Equal(t, "ACTIVE", doc.Status) + + ver := result.PublishAssetList.DocumentVersionEdge.Node + assert.NotEmpty(t, ver.ID) + assert.Equal(t, "REGISTER", ver.DocumentType) + assert.Equal(t, "PUBLISHED", ver.Status) + assert.Equal(t, 1, ver.Major) + assert.Equal(t, 0, ver.Minor) + assert.Contains(t, ver.Content, "Purpose") + assert.Contains(t, ver.Content, "Test Asset") + }, + ) + + t.Run( + "publish with approvers creates draft with quorum", + func(t *testing.T) { + t.Parallel() + + const query = ` + mutation($input: PublishAssetListInput!) { + publishAssetList(input: $input) { + documentEdge { + node { + id + writeMode + } + } + documentVersionEdge { + node { + id + status + major + } + } + } + } + ` + + var result struct { + PublishAssetList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + WriteMode string `json:"writeMode"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Status string `json:"status"` + Major int `json:"major"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishAssetList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + "approverIds": []string{owner.GetProfileID().String()}, + }, + }, + &result, + ) + + require.NoError(t, err) + + doc := result.PublishAssetList.DocumentEdge.Node + assert.NotEmpty(t, doc.ID) + assert.Equal(t, "GENERATED", doc.WriteMode) + + ver := result.PublishAssetList.DocumentVersionEdge.Node + assert.NotEmpty(t, ver.ID) + assert.Equal(t, "PENDING_APPROVAL", ver.Status) + }, + ) + + t.Run( + "creating second document reuses existing document", + func(t *testing.T) { + t.Parallel() + + secondOwner := testutil.NewClient(t, testutil.RoleOwner) + profileID := factory.CreateUser(secondOwner) + + createAssetForPublish(t, secondOwner, profileID, "Reuse Test Asset") + + const query = ` + mutation($input: PublishAssetListInput!) { + publishAssetList(input: $input) { + documentEdge { + node { id } + } + documentVersionEdge { + node { id major } + } + } + } + ` + + var result1, result2 struct { + PublishAssetList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Major int `json:"major"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishAssetList"` + } + + input := map[string]any{ + "input": map[string]any{ + "organizationId": secondOwner.GetOrganizationID(), + }, + } + + err := secondOwner.Execute(query, input, &result1) + require.NoError(t, err) + + err = secondOwner.Execute(query, input, &result2) + require.NoError(t, err) + + doc1 := result1.PublishAssetList.DocumentEdge.Node.ID + doc2 := result2.PublishAssetList.DocumentEdge.Node.ID + assert.Equal(t, doc1, doc2, "should reuse same document") + + ver1Major := result1.PublishAssetList.DocumentVersionEdge.Node.Major + ver2Major := result2.PublishAssetList.DocumentVersionEdge.Node.Major + assert.Equal(t, 1, ver1Major) + assert.Equal(t, 2, ver2Major) + }, + ) + + t.Run( + "document linked back to organization", + func(t *testing.T) { + t.Parallel() + + thirdOwner := testutil.NewClient(t, testutil.RoleOwner) + profileID := factory.CreateUser(thirdOwner) + + createAssetForPublish(t, thirdOwner, profileID, "Link Test Asset") + + const publishQuery = ` + mutation($input: PublishAssetListInput!) { + publishAssetList(input: $input) { + documentEdge { + node { id } + } + documentVersionEdge { + node { id } + } + } + } + ` + + var publishResult struct { + PublishAssetList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishAssetList"` + } + + err := thirdOwner.Execute( + publishQuery, + map[string]any{ + "input": map[string]any{ + "organizationId": thirdOwner.GetOrganizationID(), + }, + }, + &publishResult, + ) + require.NoError(t, err) + + docID := publishResult.PublishAssetList.DocumentEdge.Node.ID + + const orgQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + id + assetListDocument { id } + } + } + } + ` + + var orgResult struct { + Node struct { + ID string `json:"id"` + AssetListDocument *struct { + ID string `json:"id"` + } `json:"assetListDocument"` + } `json:"node"` + } + + err = thirdOwner.Execute( + orgQuery, + map[string]any{"id": thirdOwner.GetOrganizationID()}, + &orgResult, + ) + require.NoError(t, err) + require.NotNil(t, orgResult.Node.AssetListDocument) + assert.Equal(t, docID, orgResult.Node.AssetListDocument.ID) + }, + ) +} + +func TestAsset_PublishAssetList_RBAC(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + profileID := factory.CreateUser(owner) + createAssetForPublish(t, owner, profileID, "RBAC Test Asset") + + const query = ` + mutation($input: PublishAssetListInput!) { + publishAssetList(input: $input) { + documentEdge { + node { id } + } + documentVersionEdge { + node { id } + } + } + } + ` + + t.Run( + "viewer cannot publish asset list", + func(t *testing.T) { + t.Parallel() + + err := viewer.ExecuteShouldFail( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + ) + testutil.RequireForbiddenError(t, err) + }, + ) +} + +func createAssetForPublish(t *testing.T, client *testutil.Client, ownerProfileID string, name string) string { + t.Helper() + + const query = ` + mutation($input: CreateAssetInput!) { + createAsset(input: $input) { + assetEdge { + node { + id + } + } + } + } + ` + + var result struct { + CreateAsset struct { + AssetEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"assetEdge"` + } `json:"createAsset"` + } + + err := client.Execute(query, map[string]any{ + "input": map[string]any{ + "organizationId": client.GetOrganizationID().String(), + "name": name, + "amount": 1, + "ownerId": ownerProfileID, + "assetType": "VIRTUAL", + "dataTypesStored": "Test data types", + }, + }, &result) + require.NoError(t, err) + + return result.CreateAsset.AssetEdge.Node.ID +} diff --git a/e2e/console/snapshot_test.go b/e2e/console/snapshot_test.go index 4b3525eb2..ed52a5081 100644 --- a/e2e/console/snapshot_test.go +++ b/e2e/console/snapshot_test.go @@ -104,7 +104,7 @@ func TestSnapshot_Delete(t *testing.T) { "input": map[string]any{ "organizationId": owner.GetOrganizationID().String(), "name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()), - "type": "ASSETS", + "type": "VENDORS", }, }, &createResult) require.NoError(t, err) @@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) { owner := testutil.NewClient(t, testutil.RoleOwner) // Create multiple snapshots - snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"} + snapshotTypes := []string{"RISKS", "VENDORS"} for i, snapshotType := range snapshotTypes { query := ` mutation CreateSnapshot($input: CreateSnapshotInput!) { @@ -219,7 +219,7 @@ func TestSnapshot_Types(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) - snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"} + snapshotTypes := []string{"RISKS", "VENDORS"} for _, snapshotType := range snapshotTypes { t.Run(snapshotType, func(t *testing.T) { diff --git a/packages/helpers/src/snapshots.ts b/packages/helpers/src/snapshots.ts index de686269c..f96d76ba9 100644 --- a/packages/helpers/src/snapshots.ts +++ b/packages/helpers/src/snapshots.ts @@ -17,7 +17,6 @@ type Translator = (s: string) => string; export const snapshotTypes = [ "RISKS", "VENDORS", - "ASSETS", "FINDINGS", "OBLIGATIONS", "PROCESSING_ACTIVITIES", @@ -33,8 +32,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef return __("Risks"); case "VENDORS": return __("Vendors"); - case "ASSETS": - return __("Assets"); case "FINDINGS": case "NONCONFORMITIES": case "CONTINUAL_IMPROVEMENTS": @@ -54,8 +51,6 @@ export function getSnapshotTypeUrlPath(type?: string): string { return "/risks"; case "VENDORS": return "/vendors"; - case "ASSETS": - return "/assets"; case "FINDINGS": case "NONCONFORMITIES": case "CONTINUAL_IMPROVEMENTS": diff --git a/packages/n8n-node/nodes/Probo/actions/asset/index.ts b/packages/n8n-node/nodes/Probo/actions/asset/index.ts index 142d29819..4ef5c77d5 100644 --- a/packages/n8n-node/nodes/Probo/actions/asset/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/asset/index.ts @@ -18,6 +18,7 @@ import * as updateOp from './update.operation'; import * as deleteOp from './delete.operation'; import * as getOp from './get.operation'; import * as getAllOp from './getAll.operation'; +import * as publishOp from './publish.operation'; export const description: INodeProperties[] = [ { @@ -55,6 +56,12 @@ export const description: INodeProperties[] = [ description: 'Get many assets', action: 'Get many assets', }, + { + name: 'Publish', + value: 'publish', + description: 'Publish the asset list as a document', + action: 'Publish the asset list', + }, { name: 'Update', value: 'update', @@ -69,6 +76,7 @@ export const description: INodeProperties[] = [ ...deleteOp.description, ...getOp.description, ...getAllOp.description, + ...publishOp.description, ]; -export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll }; +export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll, publishOp as publish }; diff --git a/packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts new file mode 100644 index 000000000..d70cef013 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['asset'], + operation: ['publish'], + }, + }, + default: '', + description: 'The ID of the organization whose asset list to publish', + required: true, + }, + { + displayName: 'Approver IDs', + name: 'approverIds', + type: 'string', + displayOptions: { + show: { + resource: ['asset'], + operation: ['publish'], + }, + }, + default: '', + description: 'Comma-separated list of approver profile IDs', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string; + + const query = ` + mutation PublishAssetList($input: PublishAssetListInput!) { + publishAssetList(input: $input) { + documentEdge { + node { + id + status + currentPublishedMajor + currentPublishedMinor + createdAt + updatedAt + } + } + documentVersionEdge { + node { + id + title + major + minor + status + classification + documentType + publishedAt + createdAt + updatedAt + } + } + } + } + `; + + const input: Record = { organizationId }; + + if (approverIds) { + input.approverIds = approverIds + .split(',') + .map(id => id.trim()) + .filter(Boolean); + } + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/coredata/asset_filter.go b/pkg/cmd/asset/asset.go similarity index 51% rename from pkg/coredata/asset_filter.go rename to pkg/cmd/asset/asset.go index 908dce6c6..b11522a92 100644 --- a/pkg/coredata/asset_filter.go +++ b/pkg/cmd/asset/asset.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025-2026 Probo Inc . +// Copyright (c) 2026 Probo Inc . // // 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 ", + 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 } diff --git a/pkg/cmd/asset/publish/publish.go b/pkg/cmd/asset/publish/publish.go new file mode 100644 index 000000000..8c6b99bb3 --- /dev/null +++ b/pkg/cmd/asset/publish/publish.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 3e53fd81d..d1d71204e 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -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)) diff --git a/pkg/coredata/asset.go b/pkg/coredata/asset.go index 05bc5f642..b1cde4f2b 100644 --- a/pkg/coredata/asset.go +++ b/pkg/coredata/asset.go @@ -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 diff --git a/pkg/coredata/asset_vendor.go b/pkg/coredata/asset_vendor.go index 47d647532..7dfb6289b 100644 --- a/pkg/coredata/asset_vendor.go +++ b/pkg/coredata/asset_vendor.go @@ -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 -} diff --git a/pkg/coredata/datum.go b/pkg/coredata/datum.go index 78a78b480..d33851643 100644 --- a/pkg/coredata/datum.go +++ b/pkg/coredata/datum.go @@ -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 +} diff --git a/pkg/coredata/migrations/20260420T140000Z.sql b/pkg/coredata/migrations/20260420T140000Z.sql new file mode 100644 index 000000000..c2c7b0750 --- /dev/null +++ b/pkg/coredata/migrations/20260420T140000Z.sql @@ -0,0 +1,16 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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; diff --git a/pkg/coredata/snapshot.go b/pkg/coredata/snapshot.go index 0289be9d8..2a8e9d677 100644 --- a/pkg/coredata/snapshot.go +++ b/pkg/coredata/snapshot.go @@ -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 ` diff --git a/pkg/coredata/snapshots_type.go b/pkg/coredata/snapshots_type.go index e46deb7b1..4972714e1 100644 --- a/pkg/coredata/snapshots_type.go +++ b/pkg/coredata/snapshots_type.go @@ -38,7 +38,6 @@ func SnapshotsTypes() []SnapshotsType { return []SnapshotsType{ SnapshotsTypeRisks, SnapshotsTypeVendors, - SnapshotsTypeAssets, SnapshotsTypeFindings, SnapshotsTypeObligations, SnapshotsTypeProcessingActivities, diff --git a/pkg/coredata/snapshottable.go b/pkg/coredata/snapshottable.go index 073b931a5..91855a7ac 100644 --- a/pkg/coredata/snapshottable.go +++ b/pkg/coredata/snapshottable.go @@ -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: diff --git a/pkg/coredata/statement_of_applicability.go b/pkg/coredata/statement_of_applicability.go index 7854e4d57..ccb51dce6 100644 --- a/pkg/coredata/statement_of_applicability.go +++ b/pkg/coredata/statement_of_applicability.go @@ -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 +} diff --git a/pkg/coredata/vendor.go b/pkg/coredata/vendor.go index a5b2b0cfd..9b77ddaba 100644 --- a/pkg/coredata/vendor.go +++ b/pkg/coredata/vendor.go @@ -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 } diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go index 8e4d56de1..10d84d478 100644 --- a/pkg/docgen/generator.go +++ b/pkg/docgen/generator.go @@ -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 { diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index b6bb9efcf..8d6d7ae3d 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -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" diff --git a/pkg/probo/asset_service.go b/pkg/probo/asset_service.go index 952ff556d..795810479 100644 --- a/pkg/probo/asset_service.go +++ b/pkg/probo/asset_service.go @@ -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, ) }, ) diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 2536886ae..4da2c0168 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -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 diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go index 87273cc1a..99eb15fcb 100644 --- a/pkg/probo/generated_document_service.go +++ b/pkg/probo/generated_document_service.go @@ -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{ diff --git a/pkg/probo/templates/asset_list.json.tmpl b/pkg/probo/templates/asset_list.json.tmpl new file mode 100644 index 000000000..84f1d439b --- /dev/null +++ b/pkg/probo/templates/asset_list.json.tmpl @@ -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." }] + } + ] +} diff --git a/pkg/server/api/console/v1/asset_resolvers.go b/pkg/server/api/console/v1/asset_resolvers.go index ff11d067b..c2de7610e 100644 --- a/pkg/server/api/console/v1/asset_resolvers.go +++ b/pkg/server/api/console/v1/asset_resolvers.go @@ -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} } diff --git a/pkg/server/api/console/v1/graphql/asset.graphql b/pkg/server/api/console/v1/graphql/asset.graphql index 9876e5db6..eb48c630e 100644 --- a/pkg/server/api/console/v1/graphql/asset.graphql +++ b/pkg/server/api/console/v1/graphql/asset.graphql @@ -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! +} diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index 1db67b72a..0db78280c 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -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) diff --git a/pkg/server/api/console/v1/graphql/snapshot.graphql b/pkg/server/api/console/v1/graphql/snapshot.graphql index 5d7c06c6a..858fcc1c0 100644 --- a/pkg/server/api/console/v1/graphql/snapshot.graphql +++ b/pkg/server/api/console/v1/graphql/snapshot.graphql @@ -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" diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index 8cf97b17b..4bbab9c0b 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -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. diff --git a/pkg/server/api/console/v1/types/asset.go b/pkg/server/api/console/v1/types/asset.go index 439e7e403..5da879d2e 100644 --- a/pkg/server/api/console/v1/types/asset.go +++ b/pkg/server/api/console/v1/types/asset.go @@ -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, }, diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index e6cdba84c..96855578e 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -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 +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index e03e1949c..5377601da 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -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: diff --git a/pkg/server/api/mcp/v1/types/asset.go b/pkg/server/api/mcp/v1/types/asset.go index 447efdf0c..a0e029c11 100644 --- a/pkg/server/api/mcp/v1/types/asset.go +++ b/pkg/server/api/mcp/v1/types/asset.go @@ -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 {