From 16ced9638d0074130079e838313f3ecae42bd9bb Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 2 Sep 2025 18:52:07 +0200 Subject: [PATCH] Add risk snapshots Signed-off-by: Sacha Al Himdani --- .../console/src/components/SnapshotBanner.tsx | 8 +- apps/console/src/hooks/graph/RiskGraph.ts | 14 +- .../RiskGraphFragment.graphql.ts | 29 +++- .../RiskGraphListQuery.graphql.ts | 51 +++++-- .../RiskGraphNodeQuery.graphql.ts | 136 ++++++++++-------- .../__generated__/RisksListQuery.graphql.ts | 80 +++++++---- .../measures/tabs/MeasureEvidencesTab.tsx | 88 +++++++----- .../organizations/risks/RiskDetailPage.tsx | 107 ++++++++------ .../pages/organizations/risks/RisksPage.tsx | 77 ++++++---- apps/console/src/routes/riskRoutes.ts | 38 ++++- packages/helpers/src/snapshots.ts | 3 + pkg/coredata/migrations/20250902T093819Z.sql | 11 ++ pkg/coredata/risk.go | 100 ++++++++++++- pkg/coredata/risk_filter.go | 54 +++++-- pkg/coredata/snapshottable.go | 2 + pkg/probo/framework_service.go | 6 +- pkg/probo/risk_service.go | 3 - pkg/server/api/console/v1/schema.graphql | 4 +- pkg/server/api/console/v1/schema/schema.go | 68 ++++++++- pkg/server/api/console/v1/types/risk.go | 1 + pkg/server/api/console/v1/types/types.go | 4 +- pkg/server/api/console/v1/v1_resolver.go | 8 +- 22 files changed, 639 insertions(+), 253 deletions(-) create mode 100644 pkg/coredata/migrations/20250902T093819Z.sql diff --git a/apps/console/src/components/SnapshotBanner.tsx b/apps/console/src/components/SnapshotBanner.tsx index e8a30c43a..96532d709 100644 --- a/apps/console/src/components/SnapshotBanner.tsx +++ b/apps/console/src/components/SnapshotBanner.tsx @@ -3,7 +3,7 @@ import { useTranslate } from "@probo/i18n"; import { useLazyLoadQuery, graphql } from "react-relay"; import { useLocation } from "react-router"; import type { SnapshotBannerQuery } from "./__generated__/SnapshotBannerQuery.graphql"; -import { getSnapshotTypeUrlPath } from "@probo/helpers"; +import { getSnapshotTypeUrlPath, getSnapshotTypeLabel, sprintf } from "@probo/helpers"; const snapshotQuery = graphql` query SnapshotBannerQuery($snapshotId: ID!) { @@ -50,7 +50,11 @@ export function SnapshotBanner({ snapshotId }: Props) { {__("Snapshot")} {snapshot.name}

- {__("You are viewing a snapshot from")} {dateFormat(snapshot.createdAt, { year: "numeric", month: "short", day: "numeric" })} + {sprintf( + __("You are viewing a %s snapshot from %s"), + getSnapshotTypeLabel(__, snapshot.type).toLocaleLowerCase(), + dateFormat(snapshot.createdAt, { year: "numeric", month: "short", day: "numeric" }) + )}

diff --git a/apps/console/src/hooks/graph/RiskGraph.ts b/apps/console/src/hooks/graph/RiskGraph.ts index 6a2382705..4715469f8 100644 --- a/apps/console/src/hooks/graph/RiskGraph.ts +++ b/apps/console/src/hooks/graph/RiskGraph.ts @@ -31,10 +31,10 @@ export function useDeleteRiskMutation() { } export const risksQuery = graphql` - query RiskGraphListQuery($organizationId: ID!) { + query RiskGraphListQuery($organizationId: ID!, $snapshotId: ID) { organization: node(id: $organizationId) { id - ...RiskGraphFragment + ...RiskGraphFragment @arguments(snapshotId: $snapshotId) } } `; @@ -48,6 +48,7 @@ const risksFragment = graphql` after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } last: { type: "Int", defaultValue: null } + snapshotId: { type: "ID", defaultValue: null } ) { risks( first: $first @@ -55,10 +56,12 @@ const risksFragment = graphql` last: $last before: $before orderBy: $order - ) @connection(key: "RisksListQuery_risks") { + filter: { snapshotId: $snapshotId } + ) @connection(key: "RisksListQuery_risks", filters: ["filter"]) { edges { node { id + snapshotId name category treatment @@ -77,7 +80,7 @@ const risksFragment = graphql` export const RisksConnectionKey = "RisksListQuery_risks"; -export function useRisksQuery(queryRef: PreloadedQuery) { +export function useRisksQuery(queryRef: PreloadedQuery, snapshotId?: string | null) { const data = usePreloadedQuery(risksQuery, queryRef); const [dataFragment, refetch] = useRefetchableFragment( risksFragment, @@ -90,6 +93,7 @@ export function useRisksQuery(queryRef: PreloadedQuery) { connectionId: ConnectionHandler.getConnectionID( data.organization.id, RisksConnectionKey, + { filter: { snapshotId: snapshotId || null } } ), }; } @@ -98,6 +102,8 @@ export const riskNodeQuery = graphql` query RiskGraphNodeQuery($riskId: ID!) { node(id: $riskId) { ... on Risk { + id + snapshotId name description treatment diff --git a/apps/console/src/hooks/graph/__generated__/RiskGraphFragment.graphql.ts b/apps/console/src/hooks/graph/__generated__/RiskGraphFragment.graphql.ts index 4588db048..1174eeef2 100644 --- a/apps/console/src/hooks/graph/__generated__/RiskGraphFragment.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/RiskGraphFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<2b964bdee12b49230932678f583c7401>> * @lightSyntaxTransform * @nogrep */ @@ -25,6 +25,7 @@ export type RiskGraphFragment$data = { readonly residualImpact: number; readonly residualLikelihood: number; readonly residualRiskScore: number; + readonly snapshotId: string | null | undefined; readonly treatment: RiskTreatment; readonly " $fragmentSpreads": FragmentRefs<"useRiskFormFragment">; }; @@ -76,6 +77,11 @@ return { "defaultValue": null, "kind": "LocalArgument", "name": "order" + }, + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "snapshotId" } ], "kind": "Fragment", @@ -116,9 +122,15 @@ return { "alias": "risks", "args": [ { - "kind": "Variable", - "name": "orderBy", - "variableName": "order" + "fields": [ + { + "kind": "Variable", + "name": "snapshotId", + "variableName": "snapshotId" + } + ], + "kind": "ObjectValue", + "name": "filter" } ], "concreteType": "RiskConnection", @@ -143,6 +155,13 @@ return { "plural": false, "selections": [ (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "snapshotId", + "storageKey": null + }, { "alias": null, "args": null, @@ -280,6 +299,6 @@ return { }; })(); -(node as any).hash = "dc662098b9aceaa2400f4656ed1b2d01"; +(node as any).hash = "95c68a022cbf075eede9b7417393a369"; export default node; diff --git a/apps/console/src/hooks/graph/__generated__/RiskGraphListQuery.graphql.ts b/apps/console/src/hooks/graph/__generated__/RiskGraphListQuery.graphql.ts index 412c806ef..34bb750e7 100644 --- a/apps/console/src/hooks/graph/__generated__/RiskGraphListQuery.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/RiskGraphListQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<8ae6d82b30ec5657f8a55bd47fab4e7e>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -12,6 +12,7 @@ import { ConcreteRequest } from 'relay-runtime'; import { FragmentRefs } from "relay-runtime"; export type RiskGraphListQuery$variables = { organizationId: string; + snapshotId?: string | null | undefined; }; export type RiskGraphListQuery$data = { readonly organization: { @@ -30,6 +31,11 @@ var v0 = [ "defaultValue": null, "kind": "LocalArgument", "name": "organizationId" + }, + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "snapshotId" } ], v1 = [ @@ -46,14 +52,26 @@ v2 = { "name": "id", "storageKey": null }, -v3 = { +v3 = [ + { + "kind": "Variable", + "name": "snapshotId", + "variableName": "snapshotId" + } +], +v4 = { "alias": null, "args": null, "kind": "ScalarField", "name": "__typename", "storageKey": null }, -v4 = [ +v5 = [ + { + "fields": (v3/*: any*/), + "kind": "ObjectValue", + "name": "filter" + }, { "kind": "Literal", "name": "first", @@ -77,7 +95,7 @@ return { "selections": [ (v2/*: any*/), { - "args": null, + "args": (v3/*: any*/), "kind": "FragmentSpread", "name": "RiskGraphFragment" } @@ -102,14 +120,14 @@ return { "name": "node", "plural": false, "selections": [ - (v3/*: any*/), + (v4/*: any*/), (v2/*: any*/), { "kind": "InlineFragment", "selections": [ { "alias": null, - "args": (v4/*: any*/), + "args": (v5/*: any*/), "concreteType": "RiskConnection", "kind": "LinkedField", "name": "risks", @@ -132,6 +150,13 @@ return { "plural": false, "selections": [ (v2/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "snapshotId", + "storageKey": null + }, { "alias": null, "args": null, @@ -221,7 +246,7 @@ return { ], "storageKey": null }, - (v3/*: any*/) + (v4/*: any*/) ], "storageKey": null }, @@ -275,13 +300,13 @@ return { "storageKey": null } ], - "storageKey": "risks(first:50)" + "storageKey": null }, { "alias": null, - "args": (v4/*: any*/), + "args": (v5/*: any*/), "filters": [ - "orderBy" + "filter" ], "handle": "connection", "key": "RisksListQuery_risks", @@ -298,16 +323,16 @@ return { ] }, "params": { - "cacheID": "36e237e142f201c74ebc23ff46d7345a", + "cacheID": "b2114c476ed7e095fc9d2317b6b1c554", "id": null, "metadata": {}, "name": "RiskGraphListQuery", "operationKind": "query", - "text": "query RiskGraphListQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...RiskGraphFragment\n }\n}\n\nfragment RiskGraphFragment on Organization {\n risks(first: 50) {\n edges {\n node {\n id\n name\n category\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n ...useRiskFormFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n" + "text": "query RiskGraphListQuery(\n $organizationId: ID!\n $snapshotId: ID\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...RiskGraphFragment_3iomuz\n }\n}\n\nfragment RiskGraphFragment_3iomuz on Organization {\n risks(first: 50, filter: {snapshotId: $snapshotId}) {\n edges {\n node {\n id\n snapshotId\n name\n category\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n ...useRiskFormFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n" } }; })(); -(node as any).hash = "5d41ad11b598f63829f481ff0ae8aec7"; +(node as any).hash = "a6e146d7d0f9d2713796935d0d615311"; export default node; diff --git a/apps/console/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts b/apps/console/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts index f4c38691b..6353a896e 100644 --- a/apps/console/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/RiskGraphNodeQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<494ff8512e4a50c2bbc2cb3453dbab1c>> * @lightSyntaxTransform * @nogrep */ @@ -23,6 +23,7 @@ export type RiskGraphNodeQuery$data = { readonly documentsInfo?: { readonly totalCount: number; }; + readonly id?: string; readonly inherentRiskScore?: number; readonly measuresInfo?: { readonly totalCount: number; @@ -34,6 +35,7 @@ export type RiskGraphNodeQuery$data = { readonly id: string; } | null | undefined; readonly residualRiskScore?: number; + readonly snapshotId?: string | null | undefined; readonly treatment?: RiskTreatment; readonly " $fragmentSpreads": FragmentRefs<"RiskControlsTabFragment" | "RiskDocumentsTabFragment" | "RiskMeasuresTabFragment" | "RiskOverviewTabFragment" | "useRiskFormFragment">; }; @@ -62,31 +64,38 @@ v2 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "name", + "name": "id", "storageKey": null }, v3 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "description", + "name": "snapshotId", "storageKey": null }, v4 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "treatment", + "name": "name", "storageKey": null }, v5 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "id", + "name": "description", "storageKey": null }, v6 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "treatment", + "storageKey": null +}, +v7 = { "alias": null, "args": null, "concreteType": "People", @@ -94,7 +103,7 @@ v6 = { "name": "owner", "plural": false, "selections": [ - (v5/*: any*/), + (v2/*: any*/), { "alias": null, "args": null, @@ -105,35 +114,35 @@ v6 = { ], "storageKey": null }, -v7 = { +v8 = { "alias": null, "args": null, "kind": "ScalarField", "name": "note", "storageKey": null }, -v8 = { +v9 = { "alias": null, "args": null, "kind": "ScalarField", "name": "inherentRiskScore", "storageKey": null }, -v9 = { +v10 = { "alias": null, "args": null, "kind": "ScalarField", "name": "residualRiskScore", "storageKey": null }, -v10 = [ +v11 = [ { "kind": "Literal", "name": "first", "value": 0 } ], -v11 = [ +v12 = [ { "alias": null, "args": null, @@ -142,72 +151,72 @@ v11 = [ "storageKey": null } ], -v12 = { +v13 = { "alias": "measuresInfo", - "args": (v10/*: any*/), + "args": (v11/*: any*/), "concreteType": "MeasureConnection", "kind": "LinkedField", "name": "measures", "plural": false, - "selections": (v11/*: any*/), + "selections": (v12/*: any*/), "storageKey": "measures(first:0)" }, -v13 = { +v14 = { "alias": "documentsInfo", - "args": (v10/*: any*/), + "args": (v11/*: any*/), "concreteType": "DocumentConnection", "kind": "LinkedField", "name": "documents", "plural": false, - "selections": (v11/*: any*/), + "selections": (v12/*: any*/), "storageKey": "documents(first:0)" }, -v14 = { +v15 = { "alias": "controlsInfo", - "args": (v10/*: any*/), + "args": (v11/*: any*/), "concreteType": "ControlConnection", "kind": "LinkedField", "name": "controls", "plural": false, - "selections": (v11/*: any*/), + "selections": (v12/*: any*/), "storageKey": "controls(first:0)" }, -v15 = { +v16 = { "alias": null, "args": null, "kind": "ScalarField", "name": "__typename", "storageKey": null }, -v16 = [ +v17 = [ { "kind": "Literal", "name": "first", "value": 100 } ], -v17 = { +v18 = { "alias": null, "args": null, "kind": "ScalarField", "name": "cursor", "storageKey": null }, -v18 = { +v19 = { "alias": null, "args": null, "kind": "ScalarField", "name": "endCursor", "storageKey": null }, -v19 = { +v20 = { "alias": null, "args": null, "kind": "ScalarField", "name": "hasNextPage", "storageKey": null }, -v20 = { +v21 = { "alias": null, "args": null, "concreteType": "PageInfo", @@ -215,12 +224,12 @@ v20 = { "name": "pageInfo", "plural": false, "selections": [ - (v18/*: any*/), - (v19/*: any*/) + (v19/*: any*/), + (v20/*: any*/) ], "storageKey": null }, -v21 = { +v22 = { "kind": "ClientExtension", "selections": [ { @@ -232,7 +241,7 @@ v21 = { } ] }, -v22 = [ +v23 = [ { "kind": "Literal", "name": "first", @@ -260,13 +269,15 @@ return { (v2/*: any*/), (v3/*: any*/), (v4/*: any*/), + (v5/*: any*/), (v6/*: any*/), (v7/*: any*/), (v8/*: any*/), (v9/*: any*/), - (v12/*: any*/), + (v10/*: any*/), (v13/*: any*/), (v14/*: any*/), + (v15/*: any*/), { "args": null, "kind": "FragmentSpread", @@ -317,21 +328,22 @@ return { "name": "node", "plural": false, "selections": [ - (v15/*: any*/), - (v5/*: any*/), + (v16/*: any*/), + (v2/*: any*/), { "kind": "InlineFragment", "selections": [ - (v2/*: any*/), (v3/*: any*/), (v4/*: any*/), + (v5/*: any*/), (v6/*: any*/), (v7/*: any*/), (v8/*: any*/), (v9/*: any*/), - (v12/*: any*/), + (v10/*: any*/), (v13/*: any*/), (v14/*: any*/), + (v15/*: any*/), { "alias": null, "args": null, @@ -369,7 +381,7 @@ return { }, { "alias": null, - "args": (v16/*: any*/), + "args": (v17/*: any*/), "concreteType": "MeasureConnection", "kind": "LinkedField", "name": "measures", @@ -391,8 +403,8 @@ return { "name": "node", "plural": false, "selections": [ - (v5/*: any*/), (v2/*: any*/), + (v4/*: any*/), { "alias": null, "args": null, @@ -400,22 +412,22 @@ return { "name": "state", "storageKey": null }, - (v15/*: any*/) + (v16/*: any*/) ], "storageKey": null }, - (v17/*: any*/) + (v18/*: any*/) ], "storageKey": null }, - (v20/*: any*/), - (v21/*: any*/) + (v21/*: any*/), + (v22/*: any*/) ], "storageKey": "measures(first:100)" }, { "alias": null, - "args": (v16/*: any*/), + "args": (v17/*: any*/), "filters": null, "handle": "connection", "key": "Risk__measures", @@ -424,7 +436,7 @@ return { }, { "alias": null, - "args": (v16/*: any*/), + "args": (v17/*: any*/), "concreteType": "DocumentConnection", "kind": "LinkedField", "name": "documents", @@ -446,7 +458,7 @@ return { "name": "node", "plural": false, "selections": [ - (v5/*: any*/), + (v2/*: any*/), { "alias": null, "args": null, @@ -498,7 +510,7 @@ return { "name": "node", "plural": false, "selections": [ - (v5/*: any*/), + (v2/*: any*/), { "alias": null, "args": null, @@ -515,22 +527,22 @@ return { ], "storageKey": "versions(first:1)" }, - (v15/*: any*/) + (v16/*: any*/) ], "storageKey": null }, - (v17/*: any*/) + (v18/*: any*/) ], "storageKey": null }, - (v20/*: any*/), - (v21/*: any*/) + (v21/*: any*/), + (v22/*: any*/) ], "storageKey": "documents(first:100)" }, { "alias": null, - "args": (v16/*: any*/), + "args": (v17/*: any*/), "filters": null, "handle": "connection", "key": "Risk__documents", @@ -539,7 +551,7 @@ return { }, { "alias": null, - "args": (v22/*: any*/), + "args": (v23/*: any*/), "concreteType": "ControlConnection", "kind": "LinkedField", "name": "controls", @@ -561,7 +573,7 @@ return { "name": "node", "plural": false, "selections": [ - (v5/*: any*/), + (v2/*: any*/), { "alias": null, "args": null, @@ -569,7 +581,7 @@ return { "name": "sectionTitle", "storageKey": null }, - (v2/*: any*/), + (v4/*: any*/), { "alias": null, "args": null, @@ -578,16 +590,16 @@ return { "name": "framework", "plural": false, "selections": [ - (v5/*: any*/), - (v2/*: any*/) + (v2/*: any*/), + (v4/*: any*/) ], "storageKey": null }, - (v15/*: any*/) + (v16/*: any*/) ], "storageKey": null }, - (v17/*: any*/) + (v18/*: any*/) ], "storageKey": null }, @@ -599,8 +611,8 @@ return { "name": "pageInfo", "plural": false, "selections": [ - (v18/*: any*/), (v19/*: any*/), + (v20/*: any*/), { "alias": null, "args": null, @@ -623,7 +635,7 @@ return { }, { "alias": null, - "args": (v22/*: any*/), + "args": (v23/*: any*/), "filters": [ "orderBy", "filter" @@ -643,16 +655,16 @@ return { ] }, "params": { - "cacheID": "5cb3fdf2f48c4b0d2714a06ac2a8fc4d", + "cacheID": "03ef938803c52b572e23e691d50c4188", "id": null, "metadata": {}, "name": "RiskGraphNodeQuery", "operationKind": "query", - "text": "query RiskGraphNodeQuery(\n $riskId: ID!\n) {\n node(id: $riskId) {\n __typename\n ... on Risk {\n name\n description\n treatment\n owner {\n id\n fullName\n }\n note\n inherentRiskScore\n residualRiskScore\n measuresInfo: measures(first: 0) {\n totalCount\n }\n documentsInfo: documents(first: 0) {\n totalCount\n }\n controlsInfo: controls(first: 0) {\n totalCount\n }\n ...useRiskFormFragment\n ...RiskOverviewTabFragment\n ...RiskMeasuresTabFragment\n ...RiskDocumentsTabFragment\n ...RiskControlsTabFragment\n }\n id\n }\n}\n\nfragment LinkedDocumentsCardFragment on Document {\n id\n title\n createdAt\n documentType\n versions(first: 1) {\n edges {\n node {\n id\n status\n }\n }\n }\n}\n\nfragment LinkedMeasuresCardFragment on Measure {\n id\n name\n state\n}\n\nfragment RiskControlsTabFragment on Risk {\n id\n controls(first: 20) {\n edges {\n node {\n id\n sectionTitle\n name\n framework {\n id\n name\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment RiskDocumentsTabFragment on Risk {\n id\n documents(first: 100) {\n edges {\n node {\n id\n ...LinkedDocumentsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment RiskMeasuresTabFragment on Risk {\n id\n measures(first: 100) {\n edges {\n node {\n id\n ...LinkedMeasuresCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment RiskOverviewTabFragment on Risk {\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n" + "text": "query RiskGraphNodeQuery(\n $riskId: ID!\n) {\n node(id: $riskId) {\n __typename\n ... on Risk {\n id\n snapshotId\n name\n description\n treatment\n owner {\n id\n fullName\n }\n note\n inherentRiskScore\n residualRiskScore\n measuresInfo: measures(first: 0) {\n totalCount\n }\n documentsInfo: documents(first: 0) {\n totalCount\n }\n controlsInfo: controls(first: 0) {\n totalCount\n }\n ...useRiskFormFragment\n ...RiskOverviewTabFragment\n ...RiskMeasuresTabFragment\n ...RiskDocumentsTabFragment\n ...RiskControlsTabFragment\n }\n id\n }\n}\n\nfragment LinkedDocumentsCardFragment on Document {\n id\n title\n createdAt\n documentType\n versions(first: 1) {\n edges {\n node {\n id\n status\n }\n }\n }\n}\n\nfragment LinkedMeasuresCardFragment on Measure {\n id\n name\n state\n}\n\nfragment RiskControlsTabFragment on Risk {\n id\n controls(first: 20) {\n edges {\n node {\n id\n sectionTitle\n name\n framework {\n id\n name\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment RiskDocumentsTabFragment on Risk {\n id\n documents(first: 100) {\n edges {\n node {\n id\n ...LinkedDocumentsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment RiskMeasuresTabFragment on Risk {\n id\n measures(first: 100) {\n edges {\n node {\n id\n ...LinkedMeasuresCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment RiskOverviewTabFragment on Risk {\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n" } }; })(); -(node as any).hash = "a190e4ead5b381da07e69f00650d8372"; +(node as any).hash = "43dcdf60f1d1b28414650fe5f9294faa"; export default node; diff --git a/apps/console/src/hooks/graph/__generated__/RisksListQuery.graphql.ts b/apps/console/src/hooks/graph/__generated__/RisksListQuery.graphql.ts index 809571f09..baab98945 100644 --- a/apps/console/src/hooks/graph/__generated__/RisksListQuery.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/RisksListQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<6369aba92b58ca2a67d57110a829bde4>> + * @generated SignedSource<<4a0922eabd4e20c574db91e75f45a562>> * @lightSyntaxTransform * @nogrep */ @@ -23,6 +23,7 @@ export type RisksListQuery$variables = { id: string; last?: number | null | undefined; order?: RiskOrder | null | undefined; + snapshotId?: string | null | undefined; }; export type RisksListQuery$data = { readonly node: { @@ -65,52 +66,69 @@ v5 = { "kind": "LocalArgument", "name": "order" }, -v6 = [ +v6 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "snapshotId" +}, +v7 = [ { "kind": "Variable", "name": "id", "variableName": "id" } ], -v7 = { +v8 = { "kind": "Variable", "name": "after", "variableName": "after" }, -v8 = { +v9 = { "kind": "Variable", "name": "before", "variableName": "before" }, -v9 = { +v10 = { "kind": "Variable", "name": "first", "variableName": "first" }, -v10 = { +v11 = { "kind": "Variable", "name": "last", "variableName": "last" }, -v11 = { +v12 = { + "kind": "Variable", + "name": "snapshotId", + "variableName": "snapshotId" +}, +v13 = { "alias": null, "args": null, "kind": "ScalarField", "name": "__typename", "storageKey": null }, -v12 = { +v14 = { "alias": null, "args": null, "kind": "ScalarField", "name": "id", "storageKey": null }, -v13 = [ - (v7/*: any*/), +v15 = [ (v8/*: any*/), (v9/*: any*/), + { + "fields": [ + (v12/*: any*/) + ], + "kind": "ObjectValue", + "name": "filter" + }, (v10/*: any*/), + (v11/*: any*/), { "kind": "Variable", "name": "orderBy", @@ -125,7 +143,8 @@ return { (v2/*: any*/), (v3/*: any*/), (v4/*: any*/), - (v5/*: any*/) + (v5/*: any*/), + (v6/*: any*/) ], "kind": "Fragment", "metadata": null, @@ -133,7 +152,7 @@ return { "selections": [ { "alias": null, - "args": (v6/*: any*/), + "args": (v7/*: any*/), "concreteType": null, "kind": "LinkedField", "name": "node", @@ -141,15 +160,16 @@ return { "selections": [ { "args": [ - (v7/*: any*/), (v8/*: any*/), (v9/*: any*/), (v10/*: any*/), + (v11/*: any*/), { "kind": "Variable", "name": "order", "variableName": "order" - } + }, + (v12/*: any*/) ], "kind": "FragmentSpread", "name": "RiskGraphFragment" @@ -169,6 +189,7 @@ return { (v2/*: any*/), (v4/*: any*/), (v5/*: any*/), + (v6/*: any*/), (v3/*: any*/) ], "kind": "Operation", @@ -176,20 +197,20 @@ return { "selections": [ { "alias": null, - "args": (v6/*: any*/), + "args": (v7/*: any*/), "concreteType": null, "kind": "LinkedField", "name": "node", "plural": false, "selections": [ - (v11/*: any*/), - (v12/*: any*/), + (v13/*: any*/), + (v14/*: any*/), { "kind": "InlineFragment", "selections": [ { "alias": null, - "args": (v13/*: any*/), + "args": (v15/*: any*/), "concreteType": "RiskConnection", "kind": "LinkedField", "name": "risks", @@ -211,7 +232,14 @@ return { "name": "node", "plural": false, "selections": [ - (v12/*: any*/), + (v14/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "snapshotId", + "storageKey": null + }, { "alias": null, "args": null, @@ -297,11 +325,11 @@ return { "name": "owner", "plural": false, "selections": [ - (v12/*: any*/) + (v14/*: any*/) ], "storageKey": null }, - (v11/*: any*/) + (v13/*: any*/) ], "storageKey": null }, @@ -359,9 +387,9 @@ return { }, { "alias": null, - "args": (v13/*: any*/), + "args": (v15/*: any*/), "filters": [ - "orderBy" + "filter" ], "handle": "connection", "key": "RisksListQuery_risks", @@ -378,16 +406,16 @@ return { ] }, "params": { - "cacheID": "ca8ad189a3d134eecaaf9adfe090277e", + "cacheID": "161eb78d1d5100120c68db5d8106aa7e", "id": null, "metadata": {}, "name": "RisksListQuery", "operationKind": "query", - "text": "query RisksListQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 50\n $last: Int = null\n $order: RiskOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...RiskGraphFragment_16fISc\n id\n }\n}\n\nfragment RiskGraphFragment_16fISc on Organization {\n risks(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n name\n category\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n ...useRiskFormFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n" + "text": "query RisksListQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 50\n $last: Int = null\n $order: RiskOrder = null\n $snapshotId: ID = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...RiskGraphFragment_25MC8O\n id\n }\n}\n\nfragment RiskGraphFragment_25MC8O on Organization {\n risks(first: $first, after: $after, last: $last, before: $before, orderBy: $order, filter: {snapshotId: $snapshotId}) {\n edges {\n node {\n id\n snapshotId\n name\n category\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n inherentRiskScore\n residualRiskScore\n ...useRiskFormFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment useRiskFormFragment on Risk {\n id\n name\n category\n description\n treatment\n inherentLikelihood\n inherentImpact\n residualLikelihood\n residualImpact\n note\n owner {\n id\n }\n}\n" } }; })(); -(node as any).hash = "dc662098b9aceaa2400f4656ed1b2d01"; +(node as any).hash = "95c68a022cbf075eede9b7417393a369"; export default node; diff --git a/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx b/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx index 4d1dadc90..1e65b7029 100644 --- a/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx +++ b/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx @@ -91,7 +91,7 @@ export default function MeasureEvidencesTab() { const { measure } = useOutletContext<{ measure: MeasureEvidencesTabFragment$key & { id: string; name: string }; }>(); - const { evidenceId } = useParams<{ evidenceId: string }>(); + const { evidenceId, snapshotId } = useParams<{ evidenceId: string; snapshotId?: string }>(); const pagination = usePaginationFragment(evidencesFragment, measure); const connectionId = pagination.data.evidences.__id; const evidences = @@ -101,6 +101,7 @@ export default function MeasureEvidencesTab() { const evidence = evidences.find((e) => e.id === evidenceId); const organizationId = useOrganizationId(); const dialogRef = useDialogRef(); + const isSnapshotMode = Boolean(snapshotId); usePageTitle(measure.name + " - " + __("Evidences")); @@ -124,34 +125,41 @@ export default function MeasureEvidencesTab() { measureId={measure.id} organizationId={organizationId} connectionId={connectionId} + hideActions={isSnapshotMode} + snapshotId={snapshotId} /> ))} - dialogRef.current?.open()} - icon={IconPlusLarge} - > - {__("Add evidence")} - + {!isSnapshotMode && ( + dialogRef.current?.open()} + icon={IconPlusLarge} + > + {__("Add evidence")} + + )} {evidence && ( - navigate( - `/organizations/${organizationId}/measures/${measure.id}/evidences` - ) - } + onClose={() => { + const baseUrl = isSnapshotMode + ? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/measures/${measure.id}/evidences` + : `/organizations/${organizationId}/measures/${measure.id}/evidences`; + navigate(baseUrl); + }} evidenceId={evidence.id} filename={evidence.filename} /> )} - + {!isSnapshotMode && ( + + )} ); } @@ -161,6 +169,8 @@ function EvidenceRow(props: { measureId: string; organizationId: string; connectionId: string; + hideActions?: boolean; + snapshotId?: string; }) { const evidence = useFragment(evidenceFragment, props.evidenceKey); const { __, dateFormat } = useTranslate(); @@ -203,6 +213,10 @@ function EvidenceRow(props: { ); }; + const evidenceUrl = props.snapshotId + ? `/organizations/${props.organizationId}/snapshots/${props.snapshotId}/risks/measures/${props.measureId}/evidences/${evidence.id}` + : `/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`; + return ( <> {isDownloading && ( @@ -211,30 +225,30 @@ function EvidenceRow(props: { onClose={() => setIsDownloading(false)} /> )} - + {evidence.filename} {fileType(__, evidence)} {fileSize(__, evidence.size)} {dateFormat(evidence.createdAt)} -
- - setIsDownloading(true)}> - - {__("Download")} - - - {__("Delete")} - - -
+ {!props.hideActions && ( +
+ + setIsDownloading(true)}> + + {__("Download")} + + + {__("Delete")} + + +
+ )} diff --git a/apps/console/src/pages/organizations/risks/RiskDetailPage.tsx b/apps/console/src/pages/organizations/risks/RiskDetailPage.tsx index 748ae3512..3a5294eb2 100644 --- a/apps/console/src/pages/organizations/risks/RiskDetailPage.tsx +++ b/apps/console/src/pages/organizations/risks/RiskDetailPage.tsx @@ -17,7 +17,7 @@ import { } from "@probo/ui"; import { Outlet, useNavigate, useParams } from "react-router"; import { useTranslate } from "@probo/i18n"; -import { getTreatment, sprintf } from "@probo/helpers"; +import { getTreatment, sprintf, validateSnapshotConsistency } from "@probo/helpers"; import { ConnectionHandler } from "relay-runtime"; import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; import FormRiskDialog from "./FormRiskDialog"; @@ -29,23 +29,27 @@ import { useDeleteRiskMutation, } from "/hooks/graph/RiskGraph"; import type { RiskGraphNodeQuery } from "/hooks/graph/__generated__/RiskGraphNodeQuery.graphql"; +import { SnapshotBanner } from "/components/SnapshotBanner"; type Props = { queryRef: PreloadedQuery; }; export default function RiskDetailPage(props: Props) { - const { riskId } = useParams<{ riskId: string }>(); + const { riskId, snapshotId } = useParams<{ riskId: string; snapshotId?: string }>(); const organizationId = useOrganizationId(); const navigate = useNavigate(); + const isSnapshotMode = Boolean(snapshotId); if (!riskId) { throw new Error("Cannot load risk detail page without riskId parameter"); } const { __ } = useTranslate(); - const data = usePreloadedQuery(riskNodeQuery, props.queryRef); + const data = usePreloadedQuery(riskNodeQuery, props.queryRef); const risk = data.node; + + validateSnapshotConsistency(risk, snapshotId); const [deleteRisk] = useDeleteRiskMutation(); usePageTitle(risk.name ?? "Risk detail"); @@ -54,7 +58,8 @@ export default function RiskDetailPage(props: Props) { const onDelete = () => { const connectionId = ConnectionHandler.getConnectionID( organizationId, - RisksConnectionKey + RisksConnectionKey, + { filter: { snapshotId: snapshotId || null } } ); confirm( () => @@ -65,7 +70,10 @@ export default function RiskDetailPage(props: Props) { connections: [connectionId], }, onSuccess() { - navigate(`/organizations/${organizationId}/risks`); + const risksUrl = isSnapshotMode && snapshotId + ? `/organizations/${organizationId}/snapshots/${snapshotId}/risks` + : `/organizations/${organizationId}/risks`; + navigate(risksUrl); resolve(); }, }); @@ -85,68 +93,75 @@ export default function RiskDetailPage(props: Props) { const measuresCount = risk.measuresInfo?.totalCount ?? 0; const controlsCount = risk.controlsInfo?.totalCount ?? 0; + const risksUrl = isSnapshotMode && snapshotId + ? `/organizations/${organizationId}/snapshots/${snapshotId}/risks` + : `/organizations/${organizationId}/risks`; + + const baseTabUrl = isSnapshotMode && snapshotId + ? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${riskId}` + : `/organizations/${organizationId}/risks/${riskId}`; + return (
+ {snapshotId && } {/* Header */}
-
- - {__("Edit")} - - } - risk={{ id: riskId, ...risk }} - /> - - - {__("Delete")} - - -
+ {!isSnapshotMode && ( +
+ + {__("Edit")} + + } + risk={{ id: riskId, ...risk }} + /> + + + {__("Delete")} + + +
+ )}
- + {__("Overview")} - - {__("Measures")} - {measuresCount} - - - {__("Documents")} - {documentsCount} - - - {__("Controls")} - {controlsCount} - + {!isSnapshotMode && ( + <> + + {__("Measures")} + {measuresCount} + + + {__("Documents")} + {documentsCount} + + + {__("Controls")} + {controlsCount} + + + )} diff --git a/apps/console/src/pages/organizations/risks/RisksPage.tsx b/apps/console/src/pages/organizations/risks/RisksPage.tsx index d49ec6239..1524b05e2 100644 --- a/apps/console/src/pages/organizations/risks/RisksPage.tsx +++ b/apps/console/src/pages/organizations/risks/RisksPage.tsx @@ -27,6 +27,8 @@ import { SortableTable, SortableTh } from "/components/SortableTable"; import type { PreloadedQuery } from "react-relay"; import type { RiskGraphListQuery } from "/hooks/graph/__generated__/RiskGraphListQuery.graphql"; import type { RiskGraphFragment$data } from "/hooks/graph/__generated__/RiskGraphFragment.graphql"; +import { useParams } from "react-router"; +import { SnapshotBanner } from "/components/SnapshotBanner"; type Props = { queryRef: PreloadedQuery; @@ -35,18 +37,28 @@ type Props = { export default function RisksPage(props: Props) { const { __ } = useTranslate(); const organizationId = useOrganizationId(); + const { snapshotId } = useParams<{ snapshotId?: string }>(); + const isSnapshotMode = Boolean(snapshotId); - const { connectionId, risks, refetch } = useRisksQuery(props.queryRef); + const { connectionId, risks, refetch } = useRisksQuery(props.queryRef, snapshotId); usePageTitle(__("Risks")); return (
- - {__("New Risk")}} - /> + {snapshotId && } + + {!isSnapshotMode && ( + {__("New Risk")}} + /> + )}
@@ -100,6 +112,8 @@ type RowProps = { function RiskRow(props: RowProps) { const { __ } = useTranslate(); const { risk, connectionId, organizationId } = props; + const { snapshotId } = useParams<{ snapshotId?: string }>(); + const isSnapshotMode = Boolean(snapshotId); const [deleteRisk] = useDeleteRiskMutation(); const confirm = useConfirm(); const onDelete = () => { @@ -125,14 +139,21 @@ function RiskRow(props: RowProps) { ); }; const formDialogRef = useDialogRef(); + + const riskUrl = isSnapshotMode && snapshotId + ? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${risk.id}/overview` + : `/organizations/${organizationId}/risks/${risk.id}/overview`; + return ( <> - - + {!isSnapshotMode && ( + + )} + {risk.name} {risk.category} {getTreatment(__, risk.treatment)} @@ -143,22 +164,24 @@ function RiskRow(props: RowProps) { - - formDialogRef.current?.open()} - > - {__("Edit")} - + {!isSnapshotMode && ( + + formDialogRef.current?.open()} + > + {__("Edit")} + - - {__("Delete")} - - + + {__("Delete")} + + + )} diff --git a/apps/console/src/routes/riskRoutes.ts b/apps/console/src/routes/riskRoutes.ts index 8b93ef30a..990e4de0c 100644 --- a/apps/console/src/routes/riskRoutes.ts +++ b/apps/console/src/routes/riskRoutes.ts @@ -14,7 +14,20 @@ export const riskRoutes = [ path: "risks", fallback: RisksPageSkeleton, queryLoader: ({ organizationId }) => - loadQuery(relayEnvironment, risksQuery, { organizationId }), + loadQuery(relayEnvironment, risksQuery, { + organizationId, + snapshotId: null + }), + Component: lazy(() => import("/pages/organizations/risks/RisksPage")), + }, + { + path: "snapshots/:snapshotId/risks", + fallback: RisksPageSkeleton, + queryLoader: ({ organizationId, snapshotId }) => + loadQuery(relayEnvironment, risksQuery, { + organizationId, + snapshotId + }), Component: lazy(() => import("/pages/organizations/risks/RisksPage")), }, { @@ -61,4 +74,27 @@ export const riskRoutes = [ }, ], }, + { + path: "snapshots/:snapshotId/risks/:riskId", + fallback: PageSkeleton, + queryLoader: ({ riskId }) => + loadQuery(relayEnvironment, riskNodeQuery, { riskId }), + Component: lazy(() => import("/pages/organizations/risks/RiskDetailPage")), + children: [ + { + path: "", + loader: () => { + throw redirect("overview"); + }, + Component: Fragment, + }, + { + path: "overview", + fallback: LinkCardSkeleton, + Component: lazy( + () => import("/pages/organizations/risks/tabs/RiskOverviewTab.tsx") + ), + }, + ], + }, ] satisfies AppRoute[]; diff --git a/packages/helpers/src/snapshots.ts b/packages/helpers/src/snapshots.ts index 3cb628424..21bad5ca1 100644 --- a/packages/helpers/src/snapshots.ts +++ b/packages/helpers/src/snapshots.ts @@ -1,6 +1,7 @@ type Translator = (s: string) => string; export const snapshotTypes = [ + "RISKS", "VENDORS", "ASSETS", "DATA", @@ -39,6 +40,8 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef export function getSnapshotTypeUrlPath(type?: string): string { switch (type) { + case "RISKS": + return "/risks"; case "VENDORS": return "/vendors"; case "ASSETS": diff --git a/pkg/coredata/migrations/20250902T093819Z.sql b/pkg/coredata/migrations/20250902T093819Z.sql new file mode 100644 index 000000000..6e1a6b210 --- /dev/null +++ b/pkg/coredata/migrations/20250902T093819Z.sql @@ -0,0 +1,11 @@ +ALTER TABLE risks ADD COLUMN snapshot_id TEXT; +ALTER TABLE risks ADD COLUMN source_id TEXT; + +ALTER TABLE risks ADD CONSTRAINT risks_snapshot_id_fkey + FOREIGN KEY (snapshot_id) + REFERENCES snapshots(id) + ON UPDATE CASCADE + ON DELETE CASCADE; + +ALTER TABLE risks ADD CONSTRAINT risks_source_id_snapshot_id_key + UNIQUE (source_id, snapshot_id); diff --git a/pkg/coredata/risk.go b/pkg/coredata/risk.go index acf66cc95..0114f58e2 100644 --- a/pkg/coredata/risk.go +++ b/pkg/coredata/risk.go @@ -42,11 +42,17 @@ type ( ResidualLikelihood int `db:"residual_likelihood"` ResidualImpact int `db:"residual_impact"` ResidualRiskScore int `db:"residual_risk_score"` + SnapshotID *gid.GID `db:"snapshot_id"` + SourceID *gid.GID `db:"source_id"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } Risks []*Risk + + RiskSnapshotter interface { + InsertRiskSnapshots(ctx context.Context, conn pg.Conn, scope Scoper, organizationID, snapshotID gid.GID) error + } ) func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey { @@ -79,7 +85,9 @@ func (r *Risks) CountByMeasureID( WITH rsks AS ( SELECT r.id, - r.tenant_id + r.tenant_id, + r.search_vector, + r.snapshot_id FROM risks r INNER JOIN @@ -136,6 +144,8 @@ WITH rsks AS ( r.residual_likelihood, r.residual_impact, r.residual_risk_score, + r.snapshot_id, + r.source_id, r.search_vector, r.created_at, r.updated_at @@ -161,6 +171,8 @@ SELECT residual_likelihood, residual_impact, residual_risk_score, + snapshot_id, + source_id, created_at, updated_at FROM @@ -246,6 +258,8 @@ SELECT residual_impact, residual_risk_score, category, + snapshot_id, + source_id, created_at, updated_at FROM risks @@ -298,6 +312,8 @@ SELECT residual_likelihood, residual_impact, residual_risk_score, + snapshot_id, + source_id, created_at, updated_at FROM risks @@ -378,6 +394,7 @@ SET updated_at = @updated_at WHERE %s AND id = @risk_id + AND snapshot_id IS NULL ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -408,7 +425,7 @@ func (r *Risk) Delete( riskID gid.GID, ) error { q := ` -DELETE FROM risks WHERE %s AND id = @id +DELETE FROM risks WHERE %s AND id = @id AND snapshot_id IS NULL ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -430,7 +447,9 @@ func (r *Risks) CountByDocumentID( WITH rsks AS ( SELECT r.id, - r.tenant_id + r.tenant_id, + r.search_vector, + r.snapshot_id FROM risks r INNER JOIN @@ -460,3 +479,78 @@ WHERE %s return count, nil } + +func (r Risks) Snapshot(ctx context.Context, conn pg.Conn, scope Scoper, organizationID, snapshotID gid.GID) error { + if err := r.InsertRiskSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil { + return fmt.Errorf("cannot create risk snapshots: %w", err) + } + + return nil +} + +func (r Risks) InsertRiskSnapshots( + ctx context.Context, + conn pg.Conn, + scope Scoper, + organizationID gid.GID, + snapshotID gid.GID, +) error { + query := ` +INSERT INTO risks ( + tenant_id, + id, + snapshot_id, + source_id, + organization_id, + name, + description, + category, + treatment, + note, + owner_id, + inherent_likelihood, + inherent_impact, + residual_likelihood, + residual_impact, + created_at, + updated_at +) +SELECT + @tenant_id, + generate_gid(decode_base64_unpadded(@tenant_id), @risk_entity_type), + @snapshot_id, + r.id, + r.organization_id, + r.name, + r.description, + r.category, + r.treatment, + r.note, + r.owner_id, + r.inherent_likelihood, + r.inherent_impact, + r.residual_likelihood, + r.residual_impact, + r.created_at, + r.updated_at +FROM risks r +WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL + ` + + query = fmt.Sprintf(query, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "snapshot_id": snapshotID, + "organization_id": organizationID, + "risk_entity_type": RiskEntityType, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, query, args) + if err != nil { + return fmt.Errorf("cannot insert risk snapshots: %w", err) + } + + return nil +} diff --git a/pkg/coredata/risk_filter.go b/pkg/coredata/risk_filter.go index 0017b7266..b4f75ef82 100644 --- a/pkg/coredata/risk_filter.go +++ b/pkg/coredata/risk_filter.go @@ -15,36 +15,62 @@ package coredata import ( + "github.com/getprobo/probo/pkg/gid" "github.com/jackc/pgx/v5" ) type ( RiskFilter struct { - query *string + query *string + snapshotID **gid.GID } ) -func NewRiskFilter(query *string) *RiskFilter { +func NewRiskFilter(query *string, snapshotID **gid.GID) *RiskFilter { return &RiskFilter{ - query: query, + query: query, + snapshotID: snapshotID, } } -func (f *RiskFilter) SQLArguments() pgx.NamedArgs { - return pgx.NamedArgs{ +func (f *RiskFilter) SQLArguments() pgx.StrictNamedArgs { + args := pgx.StrictNamedArgs{ "query": f.query, } + + if f.snapshotID == nil { + args["has_snapshot_filter"] = false + args["filter_snapshot_id"] = nil + } else if *f.snapshotID == nil { + args["has_snapshot_filter"] = true + args["filter_snapshot_id"] = nil + } else { + args["has_snapshot_filter"] = true + args["filter_snapshot_id"] = **f.snapshotID + } + + return args } func (f *RiskFilter) SQLFragment() string { - if f.query == nil || *f.query == "" { - return "TRUE" - } - return ` - search_vector @@ ( - SELECT to_tsquery('simple', string_agg(lexeme || ':*', ' & ')) - FROM unnest(regexp_split_to_array(trim(@query), '\s+')) AS lexeme - ) - ` +( + CASE + WHEN @query::text IS NOT NULL AND @query::text != '' THEN + search_vector @@ ( + SELECT to_tsquery('simple', string_agg(lexeme || ':*', ' & ')) + FROM unnest(regexp_split_to_array(trim(@query), '\s+')) AS lexeme + ) + ELSE TRUE + END + AND + CASE + WHEN @has_snapshot_filter::boolean = false THEN TRUE + WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN + snapshot_id = @filter_snapshot_id::text + WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN + snapshot_id IS NULL + ELSE TRUE + END +)` } diff --git a/pkg/coredata/snapshottable.go b/pkg/coredata/snapshottable.go index 6aed421ee..afd9392dd 100644 --- a/pkg/coredata/snapshottable.go +++ b/pkg/coredata/snapshottable.go @@ -30,6 +30,8 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) { switch snapshotType { case SnapshotsTypeAssets: return Assets{}, nil + case SnapshotsTypeRisks: + return Risks{}, nil case SnapshotsTypeData: return Data{}, nil case SnapshotsTypeNonConformityRegistries: diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 80958a0d6..b27ba92e8 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -364,12 +364,13 @@ func (s FrameworkService) StateOfApplicability(ctx context.Context, frameworkID for _, measure := range measures { risks := coredata.Risks{} + var nilSnapshotID *gid.GID = nil risksCount, err := risks.CountByMeasureID( ctx, conn, s.svc.scope, measure.ID, - coredata.NewRiskFilter(nil), + coredata.NewRiskFilter(nil, &nilSnapshotID), ) if err != nil { return fmt.Errorf("cannot count risks: %w", err) @@ -405,12 +406,13 @@ func (s FrameworkService) StateOfApplicability(ctx context.Context, frameworkID for _, document := range documents { risks := coredata.Risks{} + var nilSnapshotID *gid.GID = nil risksCount, err := risks.CountByDocumentID( ctx, conn, s.svc.scope, document.ID, - coredata.NewRiskFilter(nil), + coredata.NewRiskFilter(nil, &nilSnapshotID), ) if err != nil { return fmt.Errorf("cannot count risks: %w", err) diff --git a/pkg/probo/risk_service.go b/pkg/probo/risk_service.go index b3d4f1e11..0994708ca 100644 --- a/pkg/probo/risk_service.go +++ b/pkg/probo/risk_service.go @@ -186,7 +186,6 @@ func (s RiskService) CreateDocumentMapping( riskDocument := &coredata.RiskDocument{ RiskID: risk.ID, DocumentID: document.ID, - TenantID: s.svc.scope.GetTenantID(), CreatedAt: time.Now(), } @@ -254,7 +253,6 @@ func (s RiskService) CreateMeasureMapping( riskMeasure := &coredata.RiskMeasure{ RiskID: risk.ID, MeasureID: measure.ID, - TenantID: s.svc.scope.GetTenantID(), CreatedAt: time.Now(), } @@ -291,7 +289,6 @@ func (s RiskService) DeleteMeasureMapping( riskMeasure := &coredata.RiskMeasure{ RiskID: riskID, MeasureID: measureID, - TenantID: s.svc.scope.GetTenantID(), CreatedAt: time.Now(), } diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 172cb496b..5b835ca01 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -1100,6 +1100,7 @@ input MeasureFilter { input RiskFilter { query: String + snapshotId: ID } input PeopleFilter { @@ -1236,7 +1237,7 @@ type Organization implements Node { last: Int before: CursorKey orderBy: RiskOrder - filter: RiskFilter + filter: RiskFilter = { snapshotId: null } ): RiskConnection! @goField(forceResolver: true) tasks( @@ -1664,6 +1665,7 @@ type Document implements Node { type Risk implements Node { id: ID! + snapshotId: ID name: String! description: String! category: String! diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index a27a4dcb8..8905074af 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -1027,6 +1027,7 @@ type ComplexityRoot struct { ResidualImpact func(childComplexity int) int ResidualLikelihood func(childComplexity int) int ResidualRiskScore func(childComplexity int) int + SnapshotID func(childComplexity int) int Treatment func(childComplexity int) int UpdatedAt func(childComplexity int) int } @@ -6224,6 +6225,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Risk.ResidualRiskScore(childComplexity), true + case "Risk.snapshotId": + if e.complexity.Risk.SnapshotID == nil { + break + } + + return e.complexity.Risk.SnapshotID(childComplexity), true + case "Risk.treatment": if e.complexity.Risk.Treatment == nil { break @@ -9061,6 +9069,7 @@ input MeasureFilter { input RiskFilter { query: String + snapshotId: ID } input PeopleFilter { @@ -9197,7 +9206,7 @@ type Organization implements Node { last: Int before: CursorKey orderBy: RiskOrder - filter: RiskFilter + filter: RiskFilter = { snapshotId: null } ): RiskConnection! @goField(forceResolver: true) tasks( @@ -9625,6 +9634,7 @@ type Document implements Node { type Risk implements Node { id: ID! + snapshotId: ID name: String! description: String! category: String! @@ -45984,6 +45994,47 @@ func (ec *executionContext) fieldContext_Risk_id(_ context.Context, field graphq return fc, nil } +func (ec *executionContext) _Risk_snapshotId(ctx context.Context, field graphql.CollectedField, obj *types.Risk) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Risk_snapshotId(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.SnapshotID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*gid.GID) + fc.Result = res + return ec.marshalOID2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Risk_snapshotId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Risk", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Risk_name(ctx context.Context, field graphql.CollectedField, obj *types.Risk) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Risk_name(ctx, field) if err != nil { @@ -47135,6 +47186,8 @@ func (ec *executionContext) fieldContext_RiskEdge_node(_ context.Context, field switch field.Name { case "id": return ec.fieldContext_Risk_id(ctx, field) + case "snapshotId": + return ec.fieldContext_Risk_snapshotId(ctx, field) case "name": return ec.fieldContext_Risk_name(ctx, field) case "description": @@ -51015,6 +51068,8 @@ func (ec *executionContext) fieldContext_UpdateRiskPayload_risk(_ context.Contex switch field.Name { case "id": return ec.fieldContext_Risk_id(ctx, field) + case "snapshotId": + return ec.fieldContext_Risk_snapshotId(ctx, field) case "name": return ec.fieldContext_Risk_name(ctx, field) case "description": @@ -63958,7 +64013,7 @@ func (ec *executionContext) unmarshalInputRiskFilter(ctx context.Context, obj an asMap[k] = v } - fieldsInOrder := [...]string{"query"} + fieldsInOrder := [...]string{"query", "snapshotId"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -63972,6 +64027,13 @@ func (ec *executionContext) unmarshalInputRiskFilter(ctx context.Context, obj an return it, err } it.Query = data + case "snapshotId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("snapshotId")) + data, err := ec.unmarshalOID2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.SnapshotID = data } } @@ -76125,6 +76187,8 @@ func (ec *executionContext) _Risk(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "snapshotId": + out.Values[i] = ec._Risk_snapshotId(ctx, field, obj) case "name": out.Values[i] = ec._Risk_name(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/pkg/server/api/console/v1/types/risk.go b/pkg/server/api/console/v1/types/risk.go index 784bb8fba..34dd971ed 100644 --- a/pkg/server/api/console/v1/types/risk.go +++ b/pkg/server/api/console/v1/types/risk.go @@ -67,6 +67,7 @@ func NewRisk(r *coredata.Risk) *Risk { return &Risk{ ID: r.ID, Name: r.Name, + SnapshotID: r.SnapshotID, Description: r.Description, Treatment: r.Treatment, InherentLikelihood: r.InherentLikelihood, diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index 8b8209104..cfd6e5b37 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -1336,6 +1336,7 @@ type RequestSignaturePayload struct { type Risk struct { ID gid.GID `json:"id"` + SnapshotID *gid.GID `json:"snapshotId,omitempty"` Name string `json:"name"` Description string `json:"description"` Category string `json:"category"` @@ -1365,7 +1366,8 @@ type RiskEdge struct { } type RiskFilter struct { - Query *string `json:"query,omitempty"` + Query *string `json:"query,omitempty"` + SnapshotID *gid.GID `json:"snapshotId,omitempty"` } type SendSigningNotificationsInput struct { diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 5a9c0f22c..ad6d0aadb 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1034,9 +1034,9 @@ func (r *measureResolver) Risks(ctx context.Context, obj *types.Measure, first * cursor := types.NewCursor(first, after, last, before, pageOrderBy) - var riskFilter = coredata.NewRiskFilter(nil) + var riskFilter = coredata.NewRiskFilter(nil, nil) if filter != nil { - riskFilter = coredata.NewRiskFilter(filter.Query) + riskFilter = coredata.NewRiskFilter(filter.Query, &filter.SnapshotID) } page, err := prb.Risks.ListForMeasureID(ctx, obj.ID, cursor, riskFilter) @@ -3595,9 +3595,9 @@ func (r *organizationResolver) Risks(ctx context.Context, obj *types.Organizatio cursor := types.NewCursor(first, after, last, before, pageOrderBy) - var riskFilter = coredata.NewRiskFilter(nil) + var riskFilter = coredata.NewRiskFilter(nil, nil) if filter != nil { - riskFilter = coredata.NewRiskFilter(filter.Query) + riskFilter = coredata.NewRiskFilter(filter.Query, &filter.SnapshotID) } page, err := prb.Risks.ListForOrganizationID(ctx, obj.ID, cursor, riskFilter)