diff --git a/apps/console/src/hooks/graph/RiskGraph.ts b/apps/console/src/hooks/graph/RiskGraph.ts deleted file mode 100644 index 0fbae1444..000000000 --- a/apps/console/src/hooks/graph/RiskGraph.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) 2025-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 { useTranslate } from "@probo/i18n"; -import { - type PreloadedQuery, - usePaginationFragment, - usePreloadedQuery, -} from "react-relay"; -import { graphql } from "relay-runtime"; - -import type { RiskGraphDeleteMutation } from "#/__generated__/core/RiskGraphDeleteMutation.graphql"; -import type { RiskGraphFragment$key } from "#/__generated__/core/RiskGraphFragment.graphql"; -import type { RiskGraphListQuery } from "#/__generated__/core/RiskGraphListQuery.graphql"; -import type { RisksListQuery } from "#/__generated__/core/RisksListQuery.graphql"; - -import { useMutationWithToasts } from "../useMutationWithToasts"; - -/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */ - -const deleteRiskMutation = graphql` - mutation RiskGraphDeleteMutation( - $input: DeleteRiskInput! - $connections: [ID!]! - ) { - deleteRisk(input: $input) { - deletedRiskId @deleteEdge(connections: $connections) - } - } -`; - -export function useDeleteRiskMutation() { - const { __ } = useTranslate(); - - return useMutationWithToasts(deleteRiskMutation, { - successMessage: __("Risk deleted successfully."), - errorMessage: __("Failed to delete risk"), - }); -} - -export const risksQuery = graphql` - query RiskGraphListQuery($organizationId: ID!) { - organization: node(id: $organizationId) { - id - ...RiskGraphFragment - } - } -`; - -const risksFragment = graphql` - fragment RiskGraphFragment on Organization - @refetchable(queryName: "RisksListQuery") - @argumentDefinitions( - first: { type: "Int", defaultValue: 50 } - order: { - type: "RiskOrder" - defaultValue: { direction: DESC, field: CREATED_AT } - } - after: { type: "CursorKey", defaultValue: null } - before: { type: "CursorKey", defaultValue: null } - last: { type: "Int", defaultValue: null } - ) { - canCreateRisk: permission(action: "core:risk:create") - canPublishRisk: permission(action: "core:risk:publish") - risksDocument { - id - currentPublishedMajor - currentPublishedMinor - defaultApprovers { - id - } - } - risks( - first: $first - after: $after - last: $last - before: $before - orderBy: $order - ) @connection(key: "RisksListQuery_risks", filters: []) { - __id - edges { - node { - id - name - category - treatment - owner { - id - fullName - } - inherentLikelihood - inherentImpact - residualLikelihood - residualImpact - inherentRiskScore - residualRiskScore - canUpdate: permission(action: "core:risk:update") - canDelete: permission(action: "core:risk:delete") - ...useRiskFormFragment - } - } - } - } -`; - -export const RisksConnectionKey = "RisksListQuery_risks"; - -export function useRisksQuery(queryRef: PreloadedQuery) { - const data = usePreloadedQuery(risksQuery, queryRef); - const pagination = usePaginationFragment( - risksFragment, - data.organization as RiskGraphFragment$key, - ); - const risks = pagination.data?.risks?.edges.map(edge => edge.node); - - return { - ...pagination, - risks, - connectionId: pagination.data.risks.__id, - }; -} - -export const riskNodeQuery = graphql` - query RiskGraphNodeQuery($riskId: ID!) { - node(id: $riskId) { - ... on Risk { - id - name - description - treatment - owner { - id - fullName - } - note - inherentRiskScore - residualRiskScore - measuresInfo: measures(first: 0) { - totalCount - } - documentsInfo: documents(first: 0) { - totalCount - } - controlsInfo: controls(first: 0) { - totalCount - } - obligationsInfo: obligations(first: 0) { - totalCount - } - canUpdate: permission(action: "core:risk:update") - canDelete: permission(action: "core:risk:delete") - canCreateDocumentMapping: permission( - action: "core:risk:create-document-mapping" - ) - canDeleteDocumentMapping: permission( - action: "core:risk:delete-document-mapping" - ) - canCreateMeasureMapping: permission( - action: "core:risk:create-measure-mapping" - ) - canDeleteMeasureMapping: permission( - action: "core:risk:delete-measure-mapping" - ) - canCreateObligationMapping: permission( - action: "core:risk:create-obligation-mapping" - ) - canDeleteObligationMapping: permission( - action: "core:risk:delete-obligation-mapping" - ) - ...useRiskFormFragment - ...RiskOverviewTabFragment - ...RiskMeasuresTabFragment - ...RiskDocumentsTabFragment - ...RiskControlsTabFragment - ...RiskObligationsTabFragment - } - } - } -`; diff --git a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx index 5e6e821b4..cc33e0a25 100644 --- a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx +++ b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx @@ -48,6 +48,7 @@ const fragment = graphql` canListTasks: permission(action: "core:task:list") canListMeasures: permission(action: "core:measure:list") canListRisks: permission(action: "core:risk:list") + canListFrameworks: permission(action: "core:framework:list") canListMembers: permission(action: "iam:membership:list") canListThirdParties: permission(action: "core:thirdParty:list") @@ -113,6 +114,7 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) { to={`${prefix}/risks`} /> )} + {organization.canListFrameworks && ( . +// +// 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 { formatDate, formatError, type GraphQLError } from "@probo/helpers"; +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Breadcrumb, + Card, + DropdownItem, + IconTrashCan, + PageHeader, + useConfirm, + useToast, +} from "@probo/ui"; +import { + ConnectionHandler, + graphql, + type PreloadedQuery, + useMutation, + usePreloadedQuery, +} from "react-relay"; +import { useNavigate } from "react-router"; + +import type { RiskAssessmentDetailPageDeleteMutation } from "#/__generated__/core/RiskAssessmentDetailPageDeleteMutation.graphql"; +import type { RiskAssessmentDetailPageQuery } from "#/__generated__/core/RiskAssessmentDetailPageQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { CreateScopeDialog } from "./_components/CreateScopeDialog"; +import { ScopeCard } from "./_components/ScopeCard"; + +export const riskAssessmentDetailPageQuery = graphql` + query RiskAssessmentDetailPageQuery($riskAssessmentId: ID!) { + node(id: $riskAssessmentId) { + ... on RiskAssessment { + id + name + description + createdAt + updatedAt + canDelete: permission(action: "core:risk-assessment:delete") + scopes(first: 50) + @connection(key: "RiskAssessmentDetailPage_scopes", filters: []) { + __id + edges { + node { + id + ...ScopeCardFragment + } + } + } + } + } + } +`; + +const deleteMutation = graphql` + mutation RiskAssessmentDetailPageDeleteMutation( + $input: DeleteRiskAssessmentInput! + $connections: [ID!]! + ) { + deleteRiskAssessment(input: $input) { + deletedRiskAssessmentId @deleteEdge(connections: $connections) + } + } +`; + +const RiskAssessmentsConnectionKey = "RiskAssessmentsPage_riskAssessments"; + +interface RiskAssessmentDetailPageProps { + queryRef: PreloadedQuery; +} + +export default function RiskAssessmentDetailPage({ queryRef }: RiskAssessmentDetailPageProps) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const navigate = useNavigate(); + const confirm = useConfirm(); + const { toast } = useToast(); + const data = usePreloadedQuery(riskAssessmentDetailPageQuery, queryRef); + const ra = data.node; + const [deleteRiskAssessment] = useMutation(deleteMutation); + + usePageTitle(ra?.name ?? __("Risk Assessment")); + + if (!ra?.id) { + return null; + } + + const raId = ra.id; + const scopes = ra.scopes?.edges.map(e => e.node) ?? []; + const scopesConnectionId = ra.scopes?.__id ?? ""; + const listConnectionId = ConnectionHandler.getConnectionID( + organizationId, + RiskAssessmentsConnectionKey, + ); + const listUrl = `/organizations/${organizationId}/risk-assessments`; + + const handleDelete = () => { + confirm( + () => + new Promise((resolve, reject) => { + deleteRiskAssessment({ + variables: { + input: { riskAssessmentId: raId }, + connections: [listConnectionId], + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0].message, + variant: "error", + }); + reject(new Error(errors[0].message)); + return; + } + void navigate(listUrl); + resolve(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError(__("Failed to delete risk assessment"), error as GraphQLError), + variant: "error", + }); + reject(error); + }, + }); + }), + { message: __("This will permanently delete this risk assessment and all its scopes, nodes, processes, and threats. This action cannot be undone.") }, + ); + }; + + return ( +
+ + + + {ra.canDelete && ( + + + {__("Delete")} + + + )} + + +
+

{__("Details")}

+ + {ra.description && ( +
{ra.description}
+ )} +
+
+
+ {__("Created at")} +
+
+ {formatDate(ra.createdAt)} +
+
+
+
+ {__("Updated at")} +
+
+ {formatDate(ra.updatedAt)} +
+
+
+
+
+ +
+
+

{__("Scopes")}

+ +
+ + {scopes.length === 0 && ( + +
+ {__("No scopes yet. Create a scope to start defining nodes, processes, and threats.")} +
+
+ )} + + {scopes.map(scope => ( + + ))} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentDetailPageLoader.tsx b/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentDetailPageLoader.tsx new file mode 100644 index 000000000..eda8a30b9 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentDetailPageLoader.tsx @@ -0,0 +1,44 @@ +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { RiskAssessmentDetailPageQuery } from "#/__generated__/core/RiskAssessmentDetailPageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; + +import RiskAssessmentDetailPage, { riskAssessmentDetailPageQuery } from "./RiskAssessmentDetailPage"; + +export default function RiskAssessmentDetailPageLoader() { + const { riskAssessmentId } = useParams<{ riskAssessmentId: string }>(); + const [queryRef, loadQuery] + = useQueryLoader(riskAssessmentDetailPageQuery); + + useEffect(() => { + if (riskAssessmentId) { + loadQuery({ riskAssessmentId }); + } + }, [loadQuery, riskAssessmentId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentsPage.tsx b/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentsPage.tsx new file mode 100644 index 000000000..6558e2d0b --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentsPage.tsx @@ -0,0 +1,169 @@ +// 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 { formatDate } from "@probo/helpers"; +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + PageHeader, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@probo/ui"; +import { + graphql, + type PreloadedQuery, + usePaginationFragment, + usePreloadedQuery, +} from "react-relay"; + +import type { RiskAssessmentsPageFragment$key } from "#/__generated__/core/RiskAssessmentsPageFragment.graphql"; +import type { RiskAssessmentsPageQuery } from "#/__generated__/core/RiskAssessmentsPageQuery.graphql"; +import type { RiskAssessmentsPageRefetchQuery } from "#/__generated__/core/RiskAssessmentsPageRefetchQuery.graphql"; +import { SortableTable, SortableTh } from "#/components/SortableTable"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { CreateRiskAssessmentDialog } from "./_components/CreateRiskAssessmentDialog"; + +export const riskAssessmentsPageQuery = graphql` + query RiskAssessmentsPageQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ...RiskAssessmentsPageFragment + } + } +`; + +const riskAssessmentsFragment = graphql` + fragment RiskAssessmentsPageFragment on Organization + @refetchable(queryName: "RiskAssessmentsPageRefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { + type: "RiskAssessmentOrder" + defaultValue: { direction: DESC, field: CREATED_AT } + } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + canCreateRiskAssessment: permission( + action: "core:risk-assessment:create" + ) + riskAssessments( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + ) + @connection( + key: "RiskAssessmentsPage_riskAssessments" + filters: [] + ) { + __id + edges { + node { + id + name + description + createdAt + } + } + } + } +`; + +interface RiskAssessmentsPageProps { + queryRef: PreloadedQuery; +} + +export default function RiskAssessmentsPage({ queryRef }: RiskAssessmentsPageProps) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + + const data = usePreloadedQuery(riskAssessmentsPageQuery, queryRef); + const { data: fragmentData, ...pagination } = usePaginationFragment< + RiskAssessmentsPageRefetchQuery, + RiskAssessmentsPageFragment$key + >(riskAssessmentsFragment, data.organization); + + const riskAssessments + = fragmentData.riskAssessments?.edges.map(edge => edge.node) ?? []; + const connectionId = fragmentData.riskAssessments.__id; + const canCreate = fragmentData.canCreateRiskAssessment; + + const refetch = ({ + order, + }: { + order: { direction: string; field: string }; + }) => { + pagination.refetch( + { + order: { + direction: order.direction as "ASC" | "DESC", + field: order.field as "NAME" | "CREATED_AT", + }, + }, + { fetchPolicy: "network-only" }, + ); + }; + + usePageTitle(__("Risk Assessments")); + + return ( +
+ + {canCreate && ( + + )} + + + + + + {__("Name")} + {__("Description")} + {__("Created")} + + + + {riskAssessments.map(ra => ( + + {ra.name} + + {ra.description || "—"} + + + {formatDate(ra.createdAt)} + + + ))} + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentsPageLoader.tsx b/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentsPageLoader.tsx new file mode 100644 index 000000000..72688be56 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/RiskAssessmentsPageLoader.tsx @@ -0,0 +1,42 @@ +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { RiskAssessmentsPageQuery } from "#/__generated__/core/RiskAssessmentsPageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import RiskAssessmentsPage, { riskAssessmentsPageQuery } from "./RiskAssessmentsPage"; + +export default function RiskAssessmentsPageLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] + = useQueryLoader(riskAssessmentsPageQuery); + + useEffect(() => { + loadQuery({ organizationId }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateNodeDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateNodeDialog.tsx new file mode 100644 index 000000000..bffde534e --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateNodeDialog.tsx @@ -0,0 +1,90 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconPlusLarge, + Option, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { CreateNodeDialogMutation } from "#/__generated__/core/CreateNodeDialogMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; + +const createNodeMutation = graphql` + mutation CreateNodeDialogMutation( + $input: CreateRiskAssessmentNodeInput! + $connections: [ID!]! + ) { + createRiskAssessmentNode(input: $input) { + riskAssessmentNodeEdge @appendEdge(connections: $connections) { + node { id nodeType name } + } + } + } +`; + +export function CreateNodeDialog(props: { scopeId: string; connectionId: string }) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [createNode, isCreating] = useMutation(createNodeMutation); + const { register, control, handleSubmit, reset, formState } = useForm({ + defaultValues: { name: "", nodeType: "ASSET" }, + }); + const onSubmit = (data: { name: string; nodeType: string }) => { + createNode({ + variables: { + input: { + riskAssessmentScopeId: props.scopeId, + nodeType: data.nodeType as "ENTITY" | "BOUNDARY" | "ASSET" | "DATA", + name: data.name, + }, + connections: [props.connectionId], + }, + onCompleted: () => { + reset(); + dialogRef.current?.close(); + }, + }); + }; + return ( + {__("Add")}} + title={} + > +
void handleSubmit(onSubmit)(e)}> + + + + + + + + + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateProcessDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateProcessDialog.tsx new file mode 100644 index 000000000..bffdb8e02 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateProcessDialog.tsx @@ -0,0 +1,97 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconPlusLarge, + Option, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { CreateProcessDialogMutation } from "#/__generated__/core/CreateProcessDialogMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; + +const createProcessMutation = graphql` + mutation CreateProcessDialogMutation( + $input: CreateRiskAssessmentProcessInput! + $connections: [ID!]! + ) { + createRiskAssessmentProcess(input: $input) { + riskAssessmentProcessEdge @appendEdge(connections: $connections) { + node { id sourceNodeId targetNodeId name } + } + } + } +`; + +export function CreateProcessDialog(props: { + scopeId: string; + nodes: { id: string; name: string }[]; + connectionId: string; +}) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [createProcess, isCreating] = useMutation(createProcessMutation); + const { register, control, handleSubmit, reset, formState } = useForm({ + defaultValues: { name: "", sourceNodeId: "", targetNodeId: "" }, + }); + const onSubmit = (data: { name: string; sourceNodeId: string; targetNodeId: string }) => { + createProcess({ + variables: { + input: { + riskAssessmentScopeId: props.scopeId, + sourceNodeId: data.sourceNodeId, + targetNodeId: data.targetNodeId, + name: data.name, + }, + connections: [props.connectionId], + }, + onCompleted: () => { + reset(); + dialogRef.current?.close(); + }, + }); + }; + return ( + {__("Add")}} + title={} + > +
void handleSubmit(onSubmit)(e)}> + + + {props.nodes.map(n => )} + + + {props.nodes.map(n => )} + + + + + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateRiskAssessmentDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateRiskAssessmentDialog.tsx new file mode 100644 index 000000000..46ea9ec37 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateRiskAssessmentDialog.tsx @@ -0,0 +1,118 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconPlusLarge, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { CreateRiskAssessmentDialogCreateMutation } from "#/__generated__/core/CreateRiskAssessmentDialogCreateMutation.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +const createMutation = graphql` + mutation CreateRiskAssessmentDialogCreateMutation( + $input: CreateRiskAssessmentInput! + $connections: [ID!]! + ) { + createRiskAssessment(input: $input) { + riskAssessmentEdge @prependEdge(connections: $connections) { + node { + id + name + description + createdAt + } + } + } + } +`; + +export function CreateRiskAssessmentDialog(props: { + connectionId: string; +}) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const dialogRef = useDialogRef(); + const [createRiskAssessment, isCreating] = useMutation(createMutation); + const { register, handleSubmit, reset, formState } = useForm({ + defaultValues: { name: "", description: "" }, + }); + + const onSubmit = (data: { name: string; description: string }) => { + createRiskAssessment({ + variables: { + input: { + organizationId, + name: data.name, + description: data.description || null, + }, + connections: [props.connectionId], + }, + onCompleted: () => { + reset(); + dialogRef.current?.close(); + }, + }); + }; + + return ( + + {__("New Risk Assessment")} + + )} + title={( + + )} + > +
void handleSubmit(onSubmit)(e)}> + + + + + + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateScenarioInScopeDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateScenarioInScopeDialog.tsx new file mode 100644 index 000000000..e09cb3aaf --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateScenarioInScopeDialog.tsx @@ -0,0 +1,180 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Badge, + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconCrossLargeX, + IconPlusLarge, + Option, + Select, + useDialogRef, +} from "@probo/ui"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { CreateScenarioInScopeDialogLinkThreatMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkThreatMutation.graphql"; +import type { CreateScenarioInScopeDialogMutation } from "#/__generated__/core/CreateScenarioInScopeDialogMutation.graphql"; + +const createScenarioMutation = graphql` + mutation CreateScenarioInScopeDialogMutation( + $input: CreateRiskAssessmentScenarioInput! + $connections: [ID!]! + ) { + createRiskAssessmentScenario(input: $input) { + riskAssessmentScenarioEdge @appendEdge(connections: $connections) { + node { + id name description + risks(first: 10) { edges { node { id name } } } + threats(first: 10) { edges { node { id name } } } + } + } + } + } +`; + +const linkThreatMutation = graphql` + mutation CreateScenarioInScopeDialogLinkThreatMutation( + $input: LinkRiskAssessmentScenarioThreatInput! + ) { + linkRiskAssessmentScenarioThreat(input: $input) { + riskAssessmentScenario { id } + } + } +`; + +export function CreateScenarioInScopeDialog(props: { + scopeId: string; + threats: { id: string; name: string }[]; + connectionId: string; +}) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [selectedThreats, setSelectedThreats] = useState>(new Map()); + const [createScenario, isCreating] = useMutation(createScenarioMutation); + const [linkThreat] = useMutation(linkThreatMutation); + const { register, handleSubmit, reset, formState } = useForm({ + defaultValues: { name: "", description: "" }, + }); + + const availableThreats = props.threats.filter(t => !selectedThreats.has(t.id)); + + const onSubmit = (data: { name: string; description: string }) => { + createScenario({ + variables: { + input: { + riskAssessmentScopeId: props.scopeId, + name: data.name, + description: data.description || null, + }, + connections: [props.connectionId], + }, + onCompleted(response) { + const scenarioId = response.createRiskAssessmentScenario.riskAssessmentScenarioEdge.node.id; + for (const threatId of selectedThreats.keys()) { + linkThreat({ + variables: { + input: { riskAssessmentScenarioId: scenarioId, threatId }, + }, + }); + } + reset(); + setSelectedThreats(new Map()); + dialogRef.current?.close(); + }, + }); + }; + + return ( + {__("Add")}} + title={} + > +
void handleSubmit(onSubmit)(e)}> + + + + {props.threats.length > 0 && ( +
+
{__("Threats")}
+ {selectedThreats.size > 0 && ( +
+ {[...selectedThreats.entries()].map(([id, name]) => ( + + {name} + + + ))} +
+ )} + {availableThreats.length > 0 && ( + + )} +
+ )} +
+ +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateScopeDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateScopeDialog.tsx new file mode 100644 index 000000000..76cf1b440 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateScopeDialog.tsx @@ -0,0 +1,84 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconPlusLarge, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; +import { useParams } from "react-router"; + +import type { CreateScopeDialogMutation } from "#/__generated__/core/CreateScopeDialogMutation.graphql"; + +const createScopeMutation = graphql` + mutation CreateScopeDialogMutation( + $input: CreateRiskAssessmentScopeInput! + $connections: [ID!]! + ) { + createRiskAssessmentScope(input: $input) { + riskAssessmentScopeEdge @appendEdge(connections: $connections) { + node { + id + ...ScopeCardFragment + } + } + } + } +`; + +export function CreateScopeDialog(props: { connectionId: string }) { + const { riskAssessmentId } = useParams<{ riskAssessmentId: string }>(); + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [createScope, isCreating] = useMutation(createScopeMutation); + const { register, handleSubmit, reset, formState } = useForm({ + defaultValues: { name: "" }, + }); + const onSubmit = (data: { name: string }) => { + if (!riskAssessmentId) return; + createScope({ + variables: { + input: { riskAssessmentId, name: data.name }, + connections: [props.connectionId], + }, + onCompleted: () => { + reset(); + dialogRef.current?.close(); + }, + }); + }; + return ( + {__("Add Scope")}} + title={} + > +
void handleSubmit(onSubmit)(e)}> + + + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateThreatDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateThreatDialog.tsx new file mode 100644 index 000000000..4ecc72dd6 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateThreatDialog.tsx @@ -0,0 +1,93 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconPlusLarge, + Option, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { CreateThreatDialogMutation } from "#/__generated__/core/CreateThreatDialogMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; + +const createThreatMutation = graphql` + mutation CreateThreatDialogMutation( + $input: CreateRiskAssessmentThreatInput! + $connections: [ID!]! + ) { + createRiskAssessmentThreat(input: $input) { + riskAssessmentThreatEdge @appendEdge(connections: $connections) { + node { id processId name category } + } + } + } +`; + +export function CreateThreatDialog(props: { + scopeId: string; + processes: { id: string; name: string }[]; + connectionId: string; +}) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [createThreat, isCreating] = useMutation(createThreatMutation); + const { register, control, handleSubmit, reset, formState } = useForm({ + defaultValues: { name: "", processId: "", category: "Confidentiality" }, + }); + const onSubmit = (data: { name: string; processId: string; category: string }) => { + createThreat({ + variables: { + input: { + riskAssessmentScopeId: props.scopeId, + processId: data.processId, + name: data.name, + category: data.category, + }, + connections: [props.connectionId], + }, + onCompleted: () => { + reset(); + dialogRef.current?.close(); + }, + }); + }; + return ( + {__("Add")}} + title={} + > +
void handleSubmit(onSubmit)(e)}> + + + {props.processes.map(p => )} + + + + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/NodeActions.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/NodeActions.tsx new file mode 100644 index 000000000..193971703 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/NodeActions.tsx @@ -0,0 +1,115 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + DropdownItem, + Field, + IconPencil, + IconTrashCan, + Option, + useConfirm, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { NodeActionsDeleteMutation } from "#/__generated__/core/NodeActionsDeleteMutation.graphql"; +import type { NodeActionsUpdateMutation } from "#/__generated__/core/NodeActionsUpdateMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; + +const updateNodeMutation = graphql` + mutation NodeActionsUpdateMutation($input: UpdateRiskAssessmentNodeInput!) { + updateRiskAssessmentNode(input: $input) { + riskAssessmentNode { id nodeType name } + } + } +`; + +const deleteNodeMutation = graphql` + mutation NodeActionsDeleteMutation( + $input: DeleteRiskAssessmentNodeInput! + $connections: [ID!]! + ) { + deleteRiskAssessmentNode(input: $input) { + deletedRiskAssessmentNodeId @deleteEdge(connections: $connections) + } + } +`; + +export function NodeActions(props: { + node: { id: string; name: string; nodeType: string }; + connectionId: string; +}) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const dialogRef = useDialogRef(); + const [updateNode] = useMutation(updateNodeMutation); + const [deleteNode] = useMutation(deleteNodeMutation); + const { register, control, handleSubmit } = useForm({ + values: { name: props.node.name, nodeType: props.node.nodeType }, + }); + return ( + <> + + dialogRef.current?.open()}> + {__("Edit")} + + confirm( + () => { + deleteNode({ + variables: { + input: { riskAssessmentNodeId: props.node.id }, + connections: [props.connectionId], + }, + }); + }, + { message: __("Delete this node?") }, + )} + > + {__("Delete")} + + + }> +
void handleSubmit((d) => { + updateNode({ + variables: { input: { id: props.node.id, name: d.name, nodeType: d.nodeType as "ENTITY" | "BOUNDARY" | "ASSET" | "DATA" } }, + onCompleted: () => { dialogRef.current?.close(); }, + }); + })(e)} + > + + + + + + + + + + +
+
+ + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/ProcessActions.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/ProcessActions.tsx new file mode 100644 index 000000000..f032b4550 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/ProcessActions.tsx @@ -0,0 +1,127 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + DropdownItem, + Field, + IconPencil, + IconTrashCan, + Option, + useConfirm, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { ProcessActionsDeleteMutation } from "#/__generated__/core/ProcessActionsDeleteMutation.graphql"; +import type { ProcessActionsUpdateMutation } from "#/__generated__/core/ProcessActionsUpdateMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; + +const updateProcessMutation = graphql` + mutation ProcessActionsUpdateMutation($input: UpdateRiskAssessmentProcessInput!) { + updateRiskAssessmentProcess(input: $input) { + riskAssessmentProcess { id sourceNodeId targetNodeId name } + } + } +`; + +const deleteProcessMutation = graphql` + mutation ProcessActionsDeleteMutation( + $input: DeleteRiskAssessmentProcessInput! + $connections: [ID!]! + ) { + deleteRiskAssessmentProcess(input: $input) { + deletedRiskAssessmentProcessId @deleteEdge(connections: $connections) + } + } +`; + +export function ProcessActions(props: { + process: { id: string; name: string; sourceNodeId: string; targetNodeId: string }; + nodes: { id: string; name: string }[]; + connectionId: string; +}) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const dialogRef = useDialogRef(); + const [updateProcess] = useMutation(updateProcessMutation); + const [deleteProcess] = useMutation(deleteProcessMutation); + const { register, control, handleSubmit } = useForm({ + values: { + name: props.process.name, + sourceNodeId: props.process.sourceNodeId, + targetNodeId: props.process.targetNodeId, + }, + }); + return ( + <> + + dialogRef.current?.open()}> + {__("Edit")} + + confirm( + () => { + deleteProcess({ + variables: { + input: { riskAssessmentProcessId: props.process.id }, + connections: [props.connectionId], + }, + }); + }, + { message: __("Delete this process?") }, + )} + > + {__("Delete")} + + + }> +
void handleSubmit((d) => { + updateProcess({ + variables: { + input: { + id: props.process.id, + name: d.name, + sourceNodeId: d.sourceNodeId, + targetNodeId: d.targetNodeId, + }, + }, + onCompleted: () => { dialogRef.current?.close(); }, + }); + })(e)} + > + + + {props.nodes.map(n => )} + + + {props.nodes.map(n => )} + + + + +
+
+ + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/ScenarioInScopeActions.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/ScenarioInScopeActions.tsx new file mode 100644 index 000000000..9ebb23665 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/ScenarioInScopeActions.tsx @@ -0,0 +1,303 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Badge, + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + DropdownItem, + Field, + IconCrossLargeX, + IconPencil, + IconTrashCan, + Option, + Select, + useConfirm, + useDialogRef, +} from "@probo/ui"; +import { Suspense } from "react"; +import { useForm } from "react-hook-form"; +import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; + +import type { ScenarioInScopeActionsDeleteMutation } from "#/__generated__/core/ScenarioInScopeActionsDeleteMutation.graphql"; +import type { ScenarioInScopeActionsLinkRiskMutation } from "#/__generated__/core/ScenarioInScopeActionsLinkRiskMutation.graphql"; +import type { ScenarioInScopeActionsLinkThreatMutation } from "#/__generated__/core/ScenarioInScopeActionsLinkThreatMutation.graphql"; +import type { ScenarioInScopeActionsRisksQuery } from "#/__generated__/core/ScenarioInScopeActionsRisksQuery.graphql"; +import type { ScenarioInScopeActionsUnlinkRiskMutation } from "#/__generated__/core/ScenarioInScopeActionsUnlinkRiskMutation.graphql"; +import type { ScenarioInScopeActionsUnlinkThreatMutation } from "#/__generated__/core/ScenarioInScopeActionsUnlinkThreatMutation.graphql"; +import type { ScenarioInScopeActionsUpdateMutation } from "#/__generated__/core/ScenarioInScopeActionsUpdateMutation.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +const updateScenarioMutation = graphql` + mutation ScenarioInScopeActionsUpdateMutation($input: UpdateRiskAssessmentScenarioInput!) { + updateRiskAssessmentScenario(input: $input) { + riskAssessmentScenario { + id name description + risks(first: 10) { edges { node { id name } } } + threats(first: 10) { edges { node { id name } } } + } + } + } +`; + +const deleteScenarioMutation = graphql` + mutation ScenarioInScopeActionsDeleteMutation( + $input: DeleteRiskAssessmentScenarioInput! + $connections: [ID!]! + ) { + deleteRiskAssessmentScenario(input: $input) { + deletedRiskAssessmentScenarioId @deleteEdge(connections: $connections) + } + } +`; + +const linkThreatMutation = graphql` + mutation ScenarioInScopeActionsLinkThreatMutation($input: LinkRiskAssessmentScenarioThreatInput!) { + linkRiskAssessmentScenarioThreat(input: $input) { + riskAssessmentScenario { + id + threats(first: 10) { edges { node { id name } } } + } + } + } +`; + +const unlinkThreatMutation = graphql` + mutation ScenarioInScopeActionsUnlinkThreatMutation($input: UnlinkRiskAssessmentScenarioThreatInput!) { + unlinkRiskAssessmentScenarioThreat(input: $input) { + riskAssessmentScenario { + id + threats(first: 10) { edges { node { id name } } } + } + } + } +`; + +const linkRiskMutation = graphql` + mutation ScenarioInScopeActionsLinkRiskMutation($input: LinkRiskAssessmentScenarioRiskInput!) { + linkRiskAssessmentScenarioRisk(input: $input) { + riskAssessmentScenario { + id + risks(first: 10) { edges { node { id name } } } + } + riskAssessmentScenarioEdge { node { id } } + } + } +`; + +const unlinkRiskMutation = graphql` + mutation ScenarioInScopeActionsUnlinkRiskMutation($input: UnlinkRiskAssessmentScenarioRiskInput!) { + unlinkRiskAssessmentScenarioRisk(input: $input) { + riskAssessmentScenario { + id + risks(first: 10) { edges { node { id name } } } + } + deletedRiskAssessmentScenarioId + } + } +`; + +const risksQuery = graphql` + query ScenarioInScopeActionsRisksQuery($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + risks(first: 100) { + edges { node { id name } } + } + } + } + } +`; + +function RiskSelector(props: { + scenarioId: string; + linkedRiskIds: Set; +}) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const [linkRisk] = useMutation(linkRiskMutation); + const data = useLazyLoadQuery( + risksQuery, + { organizationId }, + { fetchPolicy: "store-or-network" }, + ); + const allRisks = data.node?.risks?.edges?.map(e => e.node) ?? []; + const availableRisks = allRisks.filter(r => !props.linkedRiskIds.has(r.id)); + + if (availableRisks.length === 0) { + return

{__("No more risks available.")}

; + } + + return ( + + ); +} + +export function ScenarioInScopeActions(props: { + scenario: { + id: string; + name: string; + description: string | null; + risks: readonly { id: string; name: string }[]; + threats: readonly { id: string; name: string }[]; + }; + scopeThreats: readonly { id: string; name: string }[]; + connectionId: string; +}) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const dialogRef = useDialogRef(); + const [updateScenario] = useMutation(updateScenarioMutation); + const [deleteScenario] = useMutation(deleteScenarioMutation); + const [linkThreat] = useMutation(linkThreatMutation); + const [unlinkThreat] = useMutation(unlinkThreatMutation); + const [unlinkRisk] = useMutation(unlinkRiskMutation); + const { register, handleSubmit } = useForm({ + values: { name: props.scenario.name, description: props.scenario.description ?? "" }, + }); + + const linkedThreatIds = new Set(props.scenario.threats.map(t => t.id)); + const linkedRiskIds = new Set(props.scenario.risks.map(r => r.id)); + const availableThreats = props.scopeThreats.filter(t => !linkedThreatIds.has(t.id)); + + return ( + <> + + dialogRef.current?.open()}> + {__("Edit")} + + confirm( + () => { + deleteScenario({ + variables: { + input: { riskAssessmentScenarioId: props.scenario.id }, + connections: [props.connectionId], + }, + }); + }, + { message: __("Delete this scenario?") }, + )} + > + {__("Delete")} + + + }> +
void handleSubmit((d) => { + updateScenario({ + variables: { input: { id: props.scenario.id, name: d.name, description: d.description || null } }, + onCompleted: () => { dialogRef.current?.close(); }, + }); + })(e)} + > + + + + +
+
{__("Threats")}
+ {props.scenario.threats.length > 0 && ( +
+ {props.scenario.threats.map(threat => ( + + {threat.name} + + + ))} +
+ )} + {availableThreats.length > 0 && ( + + )} +
+ +
+
{__("Risks")}
+ {props.scenario.risks.length > 0 && ( +
+ {props.scenario.risks.map(risk => ( + + {risk.name} + + + ))} +
+ )} + {__("Loading risks...")}

}> + +
+
+
+ +
+
+ + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/ScopeActions.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/ScopeActions.tsx new file mode 100644 index 000000000..1972d3566 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/ScopeActions.tsx @@ -0,0 +1,131 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + DropdownItem, + Field, + IconPencil, + IconTrashCan, + useConfirm, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { ScopeActionsDeleteMutation } from "#/__generated__/core/ScopeActionsDeleteMutation.graphql"; +import type { ScopeActionsUpdateMutation } from "#/__generated__/core/ScopeActionsUpdateMutation.graphql"; + +const updateScopeMutation = graphql` + mutation ScopeActionsUpdateMutation( + $input: UpdateRiskAssessmentScopeInput! + ) { + updateRiskAssessmentScope(input: $input) { + riskAssessmentScope { id name } + } + } +`; + +const deleteScopeMutation = graphql` + mutation ScopeActionsDeleteMutation( + $input: DeleteRiskAssessmentScopeInput! + $connections: [ID!]! + ) { + deleteRiskAssessmentScope(input: $input) { + deletedRiskAssessmentScopeId @deleteEdge(connections: $connections) + } + } +`; + +export function ScopeActions(props: { + scope: { id: string; name: string }; + connectionId: string; +}) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const dialogRef = useDialogRef(); + const [updateScope] = useMutation(updateScopeMutation); + const [deleteScope] = useMutation(deleteScopeMutation); + const { register, handleSubmit, formState } = useForm({ + values: { + name: props.scope.name, + }, + }); + + const onEdit = (data: { name: string }) => { + updateScope({ + variables: { + input: { + id: props.scope.id, + name: data.name, + }, + }, + onCompleted: () => { + dialogRef.current?.close(); + }, + }); + }; + + const onDelete = () => { + confirm( + () => { + deleteScope({ + variables: { + input: { riskAssessmentScopeId: props.scope.id }, + connections: [props.connectionId], + }, + }); + }, + { message: __("Delete this scope and all its nodes, processes, and threats?") }, + ); + }; + + return ( + <> + + dialogRef.current?.open()}> + {__("Edit")} + + + {__("Delete")} + + + } + > +
void handleSubmit(onEdit)(e)}> + + + + + + +
+
+ + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/ScopeCard.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/ScopeCard.tsx new file mode 100644 index 000000000..d21346687 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/ScopeCard.tsx @@ -0,0 +1,376 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Badge, + Card, + IconChevronDown, + IconChevronRight, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@probo/ui"; +import { type ReactNode, useState } from "react"; +import { graphql, useFragment } from "react-relay"; +import { Link } from "react-router"; + +import type { ScopeCardFragment$key } from "#/__generated__/core/ScopeCardFragment.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { CreateNodeDialog } from "./CreateNodeDialog"; +import { CreateProcessDialog } from "./CreateProcessDialog"; +import { CreateScenarioInScopeDialog } from "./CreateScenarioInScopeDialog"; +import { CreateThreatDialog } from "./CreateThreatDialog"; +import { NodeActions } from "./NodeActions"; +import { ProcessActions } from "./ProcessActions"; +import { ScenarioInScopeActions } from "./ScenarioInScopeActions"; +import { ScopeActions } from "./ScopeActions"; +import { ThreatActions } from "./ThreatActions"; + +export const scopeCardFragment = graphql` + fragment ScopeCardFragment on RiskAssessmentScope { + id + name + nodes(first: 100) + @connection(key: "RiskAssessmentScope_nodes", filters: []) { + __id + edges { + node { id nodeType name } + } + } + processes(first: 100) + @connection(key: "RiskAssessmentScope_processes", filters: []) { + __id + edges { + node { id sourceNodeId targetNodeId name } + } + } + threats(first: 100) + @connection(key: "RiskAssessmentScope_threats", filters: []) { + __id + edges { + node { id processId name category } + } + } + scenarios(first: 100) + @connection(key: "RiskAssessmentScope_scenarios", filters: []) { + __id + edges { + node { + id name description + risks(first: 10) { + edges { node { id name } } + } + threats(first: 10) { + edges { node { id name } } + } + } + } + } + } +`; + +function SectionHeader(props: { title: string; hint?: string; children: ReactNode }) { + return ( +
+
+

{props.title}

+ {props.children} +
+ {props.hint && ( +

{props.hint}

+ )} +
+ ); +} + +export function ScopeCard(props: { + scopeRef: ScopeCardFragment$key; + scopesConnectionId: string; +}) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const [isOpen, setIsOpen] = useState(true); + const scope = useFragment(scopeCardFragment, props.scopeRef); + const { scopesConnectionId } = props; + + const nodes = scope.nodes?.edges.map(e => e.node) ?? []; + const processes = scope.processes?.edges.map(e => e.node) ?? []; + const threats = scope.threats?.edges.map(e => e.node) ?? []; + const scenarios = scope.scenarios?.edges.map(e => e.node) ?? []; + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const nodesConnId = scope.nodes?.__id ?? ""; + const processesConnId = scope.processes?.__id ?? ""; + const threatsConnId = scope.threats?.__id ?? ""; + const scenariosConnId = scope.scenarios?.__id ?? ""; + + const ChevronIcon = isOpen ? IconChevronDown : IconChevronRight; + + return ( + + + + {isOpen && ( +
+
+
+ + + + + + + + + + + + {nodes.map(node => ( + + + + + + ))} + {nodes.length === 0 && ( + + + + )} + +
{__("Name")}{__("Type")} +
{node.name}{node.nodeType} + +
{__("No nodes")}
+
+ +
+ + ({ id: n.id, name: n.name }))} + connectionId={processesConnId} + /> + + + + + + + + + + + {processes.map(process => ( + + + + + + + ))} + {processes.length === 0 && ( + + + + )} + +
{__("Name")}{__("From")}{__("To")} +
{process.name}{nodeMap.get(process.sourceNodeId)?.name ?? "—"}{nodeMap.get(process.targetNodeId)?.name ?? "—"} + ({ id: n.id, name: n.name }))} + connectionId={processesConnId} + /> +
{__("No processes")}
+
+
+ +
+ + ({ id: p.id, name: p.name }))} + connectionId={threatsConnId} + /> + + + + + + + + + + + {threats.map((threat) => { + const process = processes.find(p => p.id === threat.processId); + return ( + + + + + + + ); + })} + {threats.length === 0 && ( + + + + )} + +
{__("Threat")}{__("Category")}{__("Process")} +
{threat.name}{threat.category}{process?.name ?? "—"} + +
{__("No threats")}
+
+ +
+ + ({ id: t.id, name: t.name }))} + connectionId={scenariosConnId} + /> + + + + + + + + + + + {scenarios.map((scenario) => { + const scenarioRisks = scenario.risks?.edges.map(e => e.node) ?? []; + const scenarioThreats = scenario.threats?.edges.map(e => e.node) ?? []; + return ( + + + + + + + ); + })} + {scenarios.length === 0 && ( + + + + )} + +
{__("Scenario")}{__("Risks")}{__("Threats")} +
{scenario.name} + {scenarioRisks.length > 0 + ? scenarioRisks.map((risk, i) => ( + + {i > 0 && ", "} + + {risk.name} + + + )) + : "—"} + + {scenarioThreats.length > 0 + ? scenarioThreats.map(t => t.name).join(", ") + : "—"} + + ({ id: t.id, name: t.name }))} + connectionId={scenariosConnId} + /> +
{__("No scenarios")}
+
+
+ )} +
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/ThreatActions.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/ThreatActions.tsx new file mode 100644 index 000000000..28200186f --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/ThreatActions.tsx @@ -0,0 +1,113 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + DropdownItem, + Field, + IconPencil, + IconTrashCan, + useConfirm, + useDialogRef, +} from "@probo/ui"; +import { useForm } from "react-hook-form"; +import { graphql, useMutation } from "react-relay"; + +import type { ThreatActionsDeleteMutation } from "#/__generated__/core/ThreatActionsDeleteMutation.graphql"; +import type { ThreatActionsUpdateMutation } from "#/__generated__/core/ThreatActionsUpdateMutation.graphql"; + +const updateThreatMutation = graphql` + mutation ThreatActionsUpdateMutation($input: UpdateRiskAssessmentThreatInput!) { + updateRiskAssessmentThreat(input: $input) { + riskAssessmentThreat { id processId name category } + } + } +`; + +const deleteThreatMutation = graphql` + mutation ThreatActionsDeleteMutation( + $input: DeleteRiskAssessmentThreatInput! + $connections: [ID!]! + ) { + deleteRiskAssessmentThreat(input: $input) { + deletedRiskAssessmentThreatId @deleteEdge(connections: $connections) + } + } +`; + +export function ThreatActions(props: { + threat: { id: string; name: string; category: string }; + connectionId: string; +}) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const dialogRef = useDialogRef(); + const [updateThreat] = useMutation(updateThreatMutation); + const [deleteThreat] = useMutation(deleteThreatMutation); + const { register, handleSubmit } = useForm({ + values: { name: props.threat.name, category: props.threat.category }, + }); + return ( + <> + + dialogRef.current?.open()}> + {__("Edit")} + + confirm( + () => { + deleteThreat({ + variables: { + input: { riskAssessmentThreatId: props.threat.id }, + connections: [props.connectionId], + }, + }); + }, + { message: __("Delete this threat?") }, + )} + > + {__("Delete")} + + + }> +
void handleSubmit((d) => { + updateThreat({ + variables: { input: { id: props.threat.id, name: d.name, category: d.category } }, + onCompleted: () => { dialogRef.current?.close(); }, + }); + })(e)} + > + + + + + +
+
+ + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/routes.ts b/apps/console/src/pages/organizations/risk-assessments/routes.ts new file mode 100644 index 000000000..c43402da4 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/routes.ts @@ -0,0 +1,37 @@ +// 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 { lazy } from "@probo/react-lazy"; +import type { AppRoute } from "@probo/routes"; + +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; + +export const riskAssessmentRoutes = [ + { + path: "risk-assessments", + Fallback: PageSkeleton, + Component: lazy( + () => + import("./RiskAssessmentsPageLoader"), + ), + }, + { + path: "risk-assessments/:riskAssessmentId", + Fallback: PageSkeleton, + Component: lazy( + () => + import("./RiskAssessmentDetailPageLoader"), + ), + }, +] satisfies AppRoute[]; diff --git a/apps/console/src/pages/organizations/risks/RiskDetailPage.tsx b/apps/console/src/pages/organizations/risks/RiskDetailLayout.tsx similarity index 64% rename from apps/console/src/pages/organizations/risks/RiskDetailPage.tsx rename to apps/console/src/pages/organizations/risks/RiskDetailLayout.tsx index df3bcd0ac..d979922a1 100644 --- a/apps/console/src/pages/organizations/risks/RiskDetailPage.tsx +++ b/apps/console/src/pages/organizations/risks/RiskDetailLayout.tsx @@ -32,25 +32,97 @@ import { Tabs, useConfirm, } from "@probo/ui"; -import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; +import { graphql, type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay"; import { Outlet, useNavigate, useParams } from "react-router"; import { ConnectionHandler } from "relay-runtime"; -import type { RiskGraphNodeQuery } from "#/__generated__/core/RiskGraphNodeQuery.graphql"; -import { - riskNodeQuery, - RisksConnectionKey, - useDeleteRiskMutation, -} from "#/hooks/graph/RiskGraph"; +import type { RiskDetailLayoutDeleteMutation } from "#/__generated__/core/RiskDetailLayoutDeleteMutation.graphql"; +import type { RiskDetailLayoutQuery } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; +import { RisksConnectionKey } from "#/pages/organizations/risks/RisksPage"; import FormRiskDialog from "./FormRiskDialog"; +/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */ + +export const riskDetailLayoutQuery = graphql` + query RiskDetailLayoutQuery($riskId: ID!) { + node(id: $riskId) { + ... on Risk { + id + name + description + treatment + owner { + id + fullName + } + note + inherentRiskScore + residualRiskScore + measuresInfo: measures(first: 0) { + totalCount + } + documentsInfo: documents(first: 0) { + totalCount + } + controlsInfo: controls(first: 0) { + totalCount + } + obligationsInfo: obligations(first: 0) { + totalCount + } + scenariosInfo: scenarios(first: 0) { + totalCount + } + canUpdate: permission(action: "core:risk:update") + canDelete: permission(action: "core:risk:delete") + canCreateDocumentMapping: permission( + action: "core:risk:create-document-mapping" + ) + canDeleteDocumentMapping: permission( + action: "core:risk:delete-document-mapping" + ) + canCreateMeasureMapping: permission( + action: "core:risk:create-measure-mapping" + ) + canDeleteMeasureMapping: permission( + action: "core:risk:delete-measure-mapping" + ) + canCreateObligationMapping: permission( + action: "core:risk:create-obligation-mapping" + ) + canDeleteObligationMapping: permission( + action: "core:risk:delete-obligation-mapping" + ) + ...useRiskFormFragment + ...RiskOverviewTabFragment + ...RiskMeasuresTabFragment + ...RiskDocumentsTabFragment + ...RiskControlsTabFragment + ...RiskObligationsTabFragment + ...RiskScenariosPageFragment + } + } + } +`; + +const deleteRiskMutation = graphql` + mutation RiskDetailLayoutDeleteMutation( + $input: DeleteRiskInput! + $connections: [ID!]! + ) { + deleteRisk(input: $input) { + deletedRiskId @deleteEdge(connections: $connections) + } + } +`; + type Props = { - queryRef: PreloadedQuery; + queryRef: PreloadedQuery; }; -export default function RiskDetailPage(props: Props) { +export default function RiskDetailLayout(props: Props) { const { riskId } = useParams<{ riskId: string; }>(); @@ -62,12 +134,12 @@ export default function RiskDetailPage(props: Props) { } const { __ } = useTranslate(); - const { node: risk } = usePreloadedQuery( - riskNodeQuery, + const { node: risk } = usePreloadedQuery( + riskDetailLayoutQuery, props.queryRef, ); - const [deleteRisk] = useDeleteRiskMutation(); + const [deleteRisk] = useMutation(deleteRiskMutation); usePageTitle(risk.name ?? "Risk detail"); const confirm = useConfirm(); @@ -79,16 +151,19 @@ export default function RiskDetailPage(props: Props) { ); confirm( () => - new Promise((resolve) => { + new Promise((resolve, reject) => { void deleteRisk({ variables: { input: { riskId }, connections: [connectionId], }, - onSuccess() { + onCompleted() { void navigate(`/organizations/${organizationId}/risks`); resolve(); }, + onError(error) { + reject(error); + }, }); }), { @@ -106,6 +181,7 @@ export default function RiskDetailPage(props: Props) { const measuresCount = risk.measuresInfo?.totalCount ?? 0; const controlsCount = risk.controlsInfo?.totalCount ?? 0; const obligationsCount = risk.obligationsInfo?.totalCount ?? 0; + const scenariosCount = risk.scenariosInfo?.totalCount ?? 0; const risksUrl = `/organizations/${organizationId}/risks`; const baseTabUrl = `/organizations/${organizationId}/risks/${riskId}`; @@ -169,6 +245,10 @@ export default function RiskDetailPage(props: Props) { {__("Obligations")} {obligationsCount} + + {__("Scenarios")} + {scenariosCount} + diff --git a/apps/console/src/pages/organizations/risks/RiskDetailLayoutLoader.tsx b/apps/console/src/pages/organizations/risks/RiskDetailLayoutLoader.tsx new file mode 100644 index 000000000..cb1ea5e6c --- /dev/null +++ b/apps/console/src/pages/organizations/risks/RiskDetailLayoutLoader.tsx @@ -0,0 +1,44 @@ +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { RiskDetailLayoutQuery } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; + +import RiskDetailLayout, { riskDetailLayoutQuery } from "./RiskDetailLayout"; + +export default function RiskDetailLayoutLoader() { + const { riskId } = useParams<{ riskId: string }>(); + const [queryRef, loadQuery] + = useQueryLoader(riskDetailLayoutQuery); + + useEffect(() => { + if (riskId) { + loadQuery({ riskId }); + } + }, [loadQuery, riskId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/RisksPage.tsx b/apps/console/src/pages/organizations/risks/RisksPage.tsx index 474acf5b9..e89a100fa 100644 --- a/apps/console/src/pages/organizations/risks/RisksPage.tsx +++ b/apps/console/src/pages/organizations/risks/RisksPage.tsx @@ -35,21 +35,108 @@ import { useConfirm, useDialogRef, } from "@probo/ui"; -import type { PreloadedQuery } from "react-relay"; +import { + graphql, + type PreloadedQuery, + useMutation, + usePaginationFragment, + usePreloadedQuery, +} from "react-relay"; import { useNavigate } from "react-router"; -import type { RiskGraphFragment$data } from "#/__generated__/core/RiskGraphFragment.graphql"; -import type { RiskGraphListQuery } from "#/__generated__/core/RiskGraphListQuery.graphql"; +import type { RisksPageDeleteMutation } from "#/__generated__/core/RisksPageDeleteMutation.graphql"; +import type { RisksPageFragment$data, RisksPageFragment$key } from "#/__generated__/core/RisksPageFragment.graphql"; +import type { RisksPageQuery } from "#/__generated__/core/RisksPageQuery.graphql"; +import type { RisksPageRefetchQuery } from "#/__generated__/core/RisksPageRefetchQuery.graphql"; import { SortableTable, SortableTh } from "#/components/SortableTable"; -import { useDeleteRiskMutation, useRisksQuery } from "#/hooks/graph/RiskGraph"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import type { NodeOf } from "#/types"; import { PublishRiskListDialog } from "./dialogs/PublishRiskListDialog"; import FormRiskDialog from "./FormRiskDialog"; +/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */ + +export const risksPageQuery = graphql` + query RisksPageQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ...RisksPageFragment + } + } +`; + +const risksFragment = graphql` + fragment RisksPageFragment on Organization + @refetchable(queryName: "RisksPageRefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { + type: "RiskOrder" + defaultValue: { direction: DESC, field: CREATED_AT } + } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + canCreateRisk: permission(action: "core:risk:create") + canPublishRisk: permission(action: "core:risk:publish") + risksDocument { + id + currentPublishedMajor + currentPublishedMinor + defaultApprovers { + id + } + } + risks( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + ) @connection(key: "RisksPage_risks", filters: []) { + __id + edges { + node { + id + name + category + treatment + owner { + id + fullName + } + inherentLikelihood + inherentImpact + residualLikelihood + residualImpact + inherentRiskScore + residualRiskScore + canUpdate: permission(action: "core:risk:update") + canDelete: permission(action: "core:risk:delete") + ...useRiskFormFragment + } + } + } + } +`; + +const deleteRiskMutation = graphql` + mutation RisksPageDeleteMutation( + $input: DeleteRiskInput! + $connections: [ID!]! + ) { + deleteRisk(input: $input) { + deletedRiskId @deleteEdge(connections: $connections) + } + } +`; + +export const RisksConnectionKey = "RisksPage_risks"; + type Props = { - queryRef: PreloadedQuery; + queryRef: PreloadedQuery; }; export default function RisksPage(props: Props) { @@ -57,12 +144,17 @@ export default function RisksPage(props: Props) { const organizationId = useOrganizationId(); const navigate = useNavigate(); - const { - data: { canCreateRisk, canPublishRisk, risksDocument }, - connectionId, - risks, - ...pagination - } = useRisksQuery(props.queryRef); + const queryData = usePreloadedQuery(risksPageQuery, props.queryRef); + const { data: fragmentData, ...pagination } = usePaginationFragment< + RisksPageRefetchQuery, + RisksPageFragment$key + >(risksFragment, queryData.organization); + + const canCreateRisk = fragmentData.canCreateRisk; + const canPublishRisk = fragmentData.canPublishRisk; + const risksDocument = fragmentData.risksDocument; + const risks = fragmentData.risks?.edges.map(edge => edge.node) ?? []; + const connectionId = fragmentData.risks.__id; const refetch = ({ order, @@ -185,7 +277,7 @@ export default function RisksPage(props: Props) { } type RowProps = { - risk: NodeOf; + risk: NodeOf; connectionId: string; organizationId: string; hasAnyAction: boolean; @@ -194,7 +286,7 @@ type RowProps = { function RiskRow(props: RowProps) { const { __ } = useTranslate(); const { risk, connectionId, organizationId } = props; - const [deleteRisk] = useDeleteRiskMutation(); + const [deleteRisk] = useMutation(deleteRiskMutation); const confirm = useConfirm(); const onDelete = () => { confirm( diff --git a/apps/console/src/pages/organizations/risks/RisksPageLoader.tsx b/apps/console/src/pages/organizations/risks/RisksPageLoader.tsx new file mode 100644 index 000000000..085ddca66 --- /dev/null +++ b/apps/console/src/pages/organizations/risks/RisksPageLoader.tsx @@ -0,0 +1,42 @@ +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { RisksPageQuery } from "#/__generated__/core/RisksPageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import RisksPage, { risksPageQuery } from "./RisksPage"; + +export default function RisksPageLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] + = useQueryLoader(risksPageQuery); + + useEffect(() => { + loadQuery({ organizationId }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/risks/_components/LinkScenarioDialog.tsx b/apps/console/src/pages/organizations/risks/_components/LinkScenarioDialog.tsx new file mode 100644 index 000000000..ea69d7679 --- /dev/null +++ b/apps/console/src/pages/organizations/risks/_components/LinkScenarioDialog.tsx @@ -0,0 +1,202 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + IconMagnifyingGlass, + IconPlusLarge, + IconTrashCan, + InfiniteScrollTrigger, + Input, + Spinner, +} from "@probo/ui"; +import { type ReactNode, Suspense, useMemo, useState } from "react"; +import { useLazyLoadQuery, usePaginationFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { + LinkScenarioDialogFragment$data, + LinkScenarioDialogFragment$key, +} from "#/__generated__/core/LinkScenarioDialogFragment.graphql"; +import type { LinkScenarioDialogQuery } from "#/__generated__/core/LinkScenarioDialogQuery.graphql"; +import type { LinkScenarioDialogQuery_fragment } from "#/__generated__/core/LinkScenarioDialogQuery_fragment.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; +import type { NodeOf } from "#/types"; + +const scenariosQuery = graphql` + query LinkScenarioDialogQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ... on Organization { + ...LinkScenarioDialogFragment + } + } + } +`; + +const scenariosFragment = graphql` + fragment LinkScenarioDialogFragment on Organization + @refetchable(queryName: "LinkScenarioDialogQuery_fragment") + @argumentDefinitions( + first: { type: "Int", defaultValue: 20 } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + riskAssessmentScenarios(first: $first, after: $after, last: $last, before: $before) + @connection(key: "LinkScenarioDialogQuery_riskAssessmentScenarios") { + edges { + node { + id + name + description + } + } + } + } +`; + +type Props = { + children: ReactNode; + connectionId: string; + disabled?: boolean; + linkedScenarios?: { id: string }[]; + onLink: (scenarioId: string) => void; + onUnlink: (scenarioId: string) => void; +}; + +export function LinkScenarioDialog({ children, ...props }: Props) { + const { __ } = useTranslate(); + + return ( + + + }> + + + + + + ); +} + +function LinkScenarioDialogContent(props: Omit) { + const organizationId = useOrganizationId(); + const query = useLazyLoadQuery( + scenariosQuery, + { + organizationId, + }, + { fetchPolicy: "network-only" }, + ); + const { data, loadNext, hasNext, isLoadingNext } + = usePaginationFragment( + scenariosFragment, + query.organization as LinkScenarioDialogFragment$key, + ); + const { __ } = useTranslate(); + const [search, setSearch] = useState(""); + const scenarios = useMemo( + () => data.riskAssessmentScenarios?.edges?.map(edge => edge.node) ?? [], + [data.riskAssessmentScenarios], + ); + const linkedIds = useMemo(() => { + return new Set(props.linkedScenarios?.map(s => s.id) ?? []); + }, [props.linkedScenarios]); + + const filteredScenarios = useMemo(() => { + return scenarios.filter( + scenario => + scenario.name?.toLowerCase().includes(search.toLowerCase()) + || scenario.description?.toLowerCase().includes(search.toLowerCase()), + ); + }, [scenarios, search]); + + return ( + <> +
+ +
+
+ {filteredScenarios.map(scenario => ( + + ))} + {hasNext && ( + loadNext(20)} + /> + )} +
+ + ); +} + +type Scenario = NodeOf; + +function ScenarioRow(props: { + scenario: Scenario; + linkedScenarios: Set; + onLink: (scenarioId: string) => void; + onUnlink: (scenarioId: string) => void; + disabled?: boolean; +}) { + const { __ } = useTranslate(); + const isLinked = props.linkedScenarios.has(props.scenario.id); + + const onToggle = () => { + if (isLinked) { + props.onUnlink(props.scenario.id); + } else { + props.onLink(props.scenario.id); + } + }; + + return ( +
+
+
+ {props.scenario.name} +
+
+ {props.scenario.description || __("No description")} +
+
+ +
+ ); +} diff --git a/apps/console/src/pages/organizations/risks/_components/ScenarioActions.tsx b/apps/console/src/pages/organizations/risks/_components/ScenarioActions.tsx new file mode 100644 index 000000000..ebbfdc787 --- /dev/null +++ b/apps/console/src/pages/organizations/risks/_components/ScenarioActions.tsx @@ -0,0 +1,76 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + DropdownItem, + IconTrashCan, + useConfirm, +} from "@probo/ui"; +import { graphql, useMutation } from "react-relay"; + +import type { ScenarioActionsUnlinkMutation } from "#/__generated__/core/ScenarioActionsUnlinkMutation.graphql"; + +const unlinkMutation = graphql` + mutation ScenarioActionsUnlinkMutation( + $input: UnlinkRiskAssessmentScenarioRiskInput! + $connections: [ID!]! + ) { + unlinkRiskAssessmentScenarioRisk(input: $input) { + deletedRiskAssessmentScenarioId @deleteEdge(connections: $connections) + } + } +`; + +export function ScenarioActions(props: { + scenarioId: string; + riskId: string; + connectionId: string; +}) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const [unlinkScenario] = useMutation(unlinkMutation); + + const handleUnlink = () => { + confirm( + () => { + unlinkScenario({ + variables: { + input: { + riskAssessmentScenarioId: props.scenarioId, + riskId: props.riskId, + }, + connections: [props.connectionId], + }, + }); + }, + { + message: __("Remove this scenario from the risk?"), + }, + ); + }; + + return ( + + + {__("Remove")} + + + ); +} diff --git a/apps/console/src/routes/riskRoutes.ts b/apps/console/src/pages/organizations/risks/routes.ts similarity index 58% rename from apps/console/src/routes/riskRoutes.ts rename to apps/console/src/pages/organizations/risks/routes.ts index 3cef992b5..b35ef874b 100644 --- a/apps/console/src/routes/riskRoutes.ts +++ b/apps/console/src/pages/organizations/risks/routes.ts @@ -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 @@ -13,47 +13,24 @@ // PERFORMANCE OF THIS SOFTWARE. import { lazy } from "@probo/react-lazy"; -import { - type AppRoute, - loaderFromQueryLoader, - withQueryRef, -} from "@probo/routes"; +import type { AppRoute } from "@probo/routes"; import { Fragment } from "react"; -import { loadQuery } from "react-relay"; import { redirect } from "react-router"; -import type { RiskGraphListQuery } from "#/__generated__/core/RiskGraphListQuery.graphql"; -import type { RiskGraphNodeQuery } from "#/__generated__/core/RiskGraphNodeQuery.graphql"; import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton"; import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; import { RisksPageSkeleton } from "#/components/skeletons/RisksPageSkeleton"; -import { coreEnvironment } from "#/environments"; -import { riskNodeQuery, risksQuery } from "#/hooks/graph/RiskGraph"; export const riskRoutes = [ { path: "risks", Fallback: RisksPageSkeleton, - loader: loaderFromQueryLoader(({ organizationId }) => - loadQuery(coreEnvironment, risksQuery, { - organizationId: organizationId, - }), - ), - Component: withQueryRef( - lazy(() => import("#/pages/organizations/risks/RisksPage")), - ), + Component: lazy(() => import("./RisksPageLoader")), }, { path: "risks/:riskId", Fallback: PageSkeleton, - loader: loaderFromQueryLoader(({ riskId }) => - loadQuery(coreEnvironment, riskNodeQuery, { - riskId: riskId, - }), - ), - Component: withQueryRef( - lazy(() => import("#/pages/organizations/risks/RiskDetailPage")), - ), + Component: lazy(() => import("./RiskDetailLayoutLoader")), children: [ { path: "", @@ -67,36 +44,42 @@ export const riskRoutes = [ path: "overview", Fallback: LinkCardSkeleton, Component: lazy( - () => import("#/pages/organizations/risks/tabs/RiskOverviewTab"), + () => import("./tabs/RiskOverviewTab"), ), }, { path: "measures", Fallback: LinkCardSkeleton, Component: lazy( - () => import("#/pages/organizations/risks/tabs/RiskMeasuresTab"), + () => import("./tabs/RiskMeasuresTab"), ), }, { path: "documents", Fallback: LinkCardSkeleton, Component: lazy( - () => import("#/pages/organizations/risks/tabs/RiskDocumentsTab"), + () => import("./tabs/RiskDocumentsTab"), ), }, { path: "controls", Fallback: LinkCardSkeleton, Component: lazy( - () => import("#/pages/organizations/risks/tabs/RiskControlsTab"), + () => import("./tabs/RiskControlsTab"), ), }, { path: "obligations", Fallback: LinkCardSkeleton, Component: lazy( - () => - import("#/pages/organizations/risks/tabs/RiskObligationsTab"), + () => import("./tabs/RiskObligationsTab"), + ), + }, + { + path: "scenarios", + Fallback: LinkCardSkeleton, + Component: lazy( + () => import("./scenarios/RiskScenariosPage"), ), }, ], diff --git a/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPage.tsx b/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPage.tsx new file mode 100644 index 000000000..d97ce734b --- /dev/null +++ b/apps/console/src/pages/organizations/risks/scenarios/RiskScenariosPage.tsx @@ -0,0 +1,194 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Button, + IconPlusLarge, + IconTrashCan, + Table, + Tbody, + Td, + Th, + Thead, + Tr, + TrButton, +} from "@probo/ui"; +import { graphql, useFragment } from "react-relay"; +import { useOutletContext } from "react-router"; + +import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; +import type { RiskScenariosPageFragment$key } from "#/__generated__/core/RiskScenariosPageFragment.graphql"; +import type { RiskScenariosPageLinkMutation } from "#/__generated__/core/RiskScenariosPageLinkMutation.graphql"; +import type { RiskScenariosPageUnlinkMutation } from "#/__generated__/core/RiskScenariosPageUnlinkMutation.graphql"; +import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { LinkScenarioDialog } from "../_components/LinkScenarioDialog"; + +const fragment = graphql` + fragment RiskScenariosPageFragment on Risk { + id + scenarios(first: 100) + @connection(key: "RiskScenariosPage_scenarios", filters: []) { + __id + edges { + node { + id + name + description + scope { riskAssessmentId } + } + } + } + } +`; + +const linkMutation = graphql` + mutation RiskScenariosPageLinkMutation( + $input: LinkRiskAssessmentScenarioRiskInput! + $connections: [ID!]! + ) { + linkRiskAssessmentScenarioRisk(input: $input) { + riskAssessmentScenarioEdge @appendEdge(connections: $connections) { + node { + id + name + description + scope { riskAssessmentId } + } + } + } + } +`; + +const unlinkMutation = graphql` + mutation RiskScenariosPageUnlinkMutation( + $input: UnlinkRiskAssessmentScenarioRiskInput! + $connections: [ID!]! + ) { + unlinkRiskAssessmentScenarioRisk(input: $input) { + deletedRiskAssessmentScenarioId @deleteEdge(connections: $connections) + } + } +`; + +export default function RiskScenariosPage() { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const { risk } = useOutletContext<{ + risk: RiskDetailLayoutQuery$data["node"]; + }>(); + const data = useFragment(fragment, risk); + const scenarios = data.scenarios.edges.map(e => e.node); + const connectionId = data.scenarios.__id; + const riskId = data.id; + + const incrementOptions = { + id: riskId, + node: "scenarios(first:0)", + }; + + const [linkScenario, isLinking] = useMutationWithIncrement( + linkMutation, + { + ...incrementOptions, + value: 1, + }, + ); + + const [unlinkScenario, isUnlinking] = useMutationWithIncrement( + unlinkMutation, + { + ...incrementOptions, + value: -1, + }, + ); + + const isLoading = isLinking || isUnlinking; + + const onLink = (scenarioId: string) => { + linkScenario({ + variables: { + input: { + riskAssessmentScenarioId: scenarioId, + riskId, + }, + connections: [connectionId], + }, + }); + }; + + const onUnlink = (scenarioId: string) => { + unlinkScenario({ + variables: { + input: { + riskAssessmentScenarioId: scenarioId, + riskId, + }, + connections: [connectionId], + }, + }); + }; + + return ( + + + + + + + + + {scenarios.length === 0 && ( + + + + )} + {scenarios.map(scenario => ( + + + + + + ))} + + + {__("Link Scenario")} + + + +
{__("Scenario")}{__("Description")} +
+ {__("No scenarios linked to this risk yet.")} +
{scenario.name} + {scenario.description || "—"} + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskDocumentsTab.tsx b/apps/console/src/pages/organizations/risks/tabs/RiskDocumentsTab.tsx index aa70be947..86cbde920 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskDocumentsTab.tsx +++ b/apps/console/src/pages/organizations/risks/tabs/RiskDocumentsTab.tsx @@ -15,8 +15,8 @@ import { graphql, useFragment } from "react-relay"; import { useOutletContext } from "react-router"; +import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; import type { RiskDocumentsTabFragment$key } from "#/__generated__/core/RiskDocumentsTabFragment.graphql"; -import type { RiskGraphNodeQuery$data } from "#/__generated__/core/RiskGraphNodeQuery.graphql"; import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard"; import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; @@ -64,7 +64,7 @@ export const detachDocumentMutation = graphql` export default function RiskDocumentsTab() { const { risk } = useOutletContext<{ - risk: RiskGraphNodeQuery$data["node"]; + risk: RiskDetailLayoutQuery$data["node"]; }>(); const data = useFragment( documentsFragment, diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx b/apps/console/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx index 7e5416889..9d0320dfd 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx +++ b/apps/console/src/pages/organizations/risks/tabs/RiskMeasuresTab.tsx @@ -15,7 +15,7 @@ import { graphql, useFragment } from "react-relay"; import { useOutletContext } from "react-router"; -import type { RiskGraphNodeQuery$data } from "#/__generated__/core/RiskGraphNodeQuery.graphql"; +import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; import type { RiskMeasuresTabFragment$key } from "#/__generated__/core/RiskMeasuresTabFragment.graphql"; import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard"; import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; @@ -64,7 +64,7 @@ export const detachMeasureMutation = graphql` export default function RiskMeasuresTab() { const { risk } = useOutletContext<{ - risk: RiskGraphNodeQuery$data["node"]; + risk: RiskDetailLayoutQuery$data["node"]; }>(); const data = useFragment(measuresFragment, risk); const connectionId = data.measures.__id; diff --git a/apps/console/src/pages/organizations/risks/tabs/RiskObligationsTab.tsx b/apps/console/src/pages/organizations/risks/tabs/RiskObligationsTab.tsx index 02f6dd74c..6bcb832f5 100644 --- a/apps/console/src/pages/organizations/risks/tabs/RiskObligationsTab.tsx +++ b/apps/console/src/pages/organizations/risks/tabs/RiskObligationsTab.tsx @@ -15,7 +15,7 @@ import { graphql, useFragment } from "react-relay"; import { useOutletContext } from "react-router"; -import type { RiskGraphNodeQuery$data } from "#/__generated__/core/RiskGraphNodeQuery.graphql"; +import type { RiskDetailLayoutQuery$data } from "#/__generated__/core/RiskDetailLayoutQuery.graphql"; import type { RiskObligationsTabFragment$key } from "#/__generated__/core/RiskObligationsTabFragment.graphql"; import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard"; import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; @@ -64,7 +64,7 @@ export const detachObligationMutation = graphql` export default function RiskObligationsTab() { const { risk } = useOutletContext<{ - risk: RiskGraphNodeQuery$data["node"]; + risk: RiskDetailLayoutQuery$data["node"]; }>(); const data = useFragment( obligationsFragment, diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 099fb52e1..d0ff003e4 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -31,6 +31,8 @@ import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading import { peopleRoutes } from "./pages/iam/organizations/people/routes"; import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes"; import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes"; +import { riskAssessmentRoutes } from "./pages/organizations/risk-assessments/routes"; +import { riskRoutes } from "./pages/organizations/risks/routes"; import { CurrentUser } from "./providers/CurrentUser"; import { accessReviewRoutes } from "./routes/accessReviewRoutes"; import { assetRoutes } from "./routes/assetRoutes"; @@ -44,7 +46,6 @@ import { measureRoutes } from "./routes/measureRoutes"; import { obligationRoutes } from "./routes/obligationRoutes"; import { processingActivityRoutes } from "./routes/processingActivityRoutes"; import { rightsRequestRoutes } from "./routes/rightsRequestRoutes"; -import { riskRoutes } from "./routes/riskRoutes"; import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes"; import { taskRoutes } from "./routes/taskRoutes"; import { thirdPartyRoutes } from "./routes/thirdPartyRoutes"; @@ -289,6 +290,7 @@ const routes = [ }, ...peopleRoutes, ...riskRoutes, + ...riskAssessmentRoutes, ...measureRoutes, ...documentsRoutes, ...thirdPartyRoutes, diff --git a/e2e/console/risk_assessment_test.go b/e2e/console/risk_assessment_test.go new file mode 100644 index 000000000..b0f9ae5a6 --- /dev/null +++ b/e2e/console/risk_assessment_test.go @@ -0,0 +1,762 @@ +// 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 TestRiskAssessment_Create(t *testing.T) { + t.Parallel() + + t.Run("with required fields", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + var result struct { + CreateRiskAssessment struct { + RiskAssessmentEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"riskAssessmentEdge"` + } `json:"createRiskAssessment"` + } + err := owner.Execute(` + mutation($input: CreateRiskAssessmentInput!) { + createRiskAssessment(input: $input) { + riskAssessmentEdge { node { id name } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": "Platform Threat Model", + }, + }, &result) + + require.NoError(t, err) + assert.NotEmpty(t, result.CreateRiskAssessment.RiskAssessmentEdge.Node.ID) + assert.Equal(t, "Platform Threat Model", result.CreateRiskAssessment.RiskAssessmentEdge.Node.Name) + }) +} + +func TestRiskAssessment_Delete(t *testing.T) { + t.Parallel() + + t.Run("cascades to scopes", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + + _, err := owner.Do(` + mutation($input: DeleteRiskAssessmentInput!) { + deleteRiskAssessment(input: $input) { deletedRiskAssessmentId } + } + `, map[string]any{"input": map[string]any{"riskAssessmentId": raID}}) + require.NoError(t, err) + + var result struct { + Node *struct { + ID string `json:"id"` + } `json:"node"` + } + err = owner.Execute(`query($id: ID!) { node(id: $id) { ... on RiskAssessmentScope { id } } }`, + map[string]any{"id": scopeID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "RiskAssessmentScope") + }) +} + +func TestRiskAssessmentScope_CRUD(t *testing.T) { + t.Parallel() + + t.Run("create and list via assessment", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + raID := factory.CreateRiskAssessment(owner) + factory.CreateRiskAssessmentScope(owner, raID, factory.Attrs{"name": "API scope"}) + factory.CreateRiskAssessmentScope(owner, raID, factory.Attrs{"name": "Infra scope"}) + + var result struct { + Node struct { + Scopes struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"scopes"` + } `json:"node"` + } + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on RiskAssessment { + scopes(first: 10) { + totalCount + edges { node { id name } } + } + } + } + } + `, map[string]any{"id": raID}, &result) + + require.NoError(t, err) + assert.Equal(t, 2, result.Node.Scopes.TotalCount) + assert.Len(t, result.Node.Scopes.Edges, 2) + }) +} + +func TestRiskAssessmentNode_Create(t *testing.T) { + t.Parallel() + + for _, nodeType := range []string{"ENTITY", "BOUNDARY", "ASSET", "DATA"} { + t.Run("nodeType="+nodeType, func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + + var result struct { + CreateRiskAssessmentNode struct { + RiskAssessmentNodeEdge struct { + Node struct { + ID string `json:"id"` + NodeType string `json:"nodeType"` + } `json:"node"` + } `json:"riskAssessmentNodeEdge"` + } `json:"createRiskAssessmentNode"` + } + err := owner.Execute(` + mutation($input: CreateRiskAssessmentNodeInput!) { + createRiskAssessmentNode(input: $input) { + riskAssessmentNodeEdge { node { id nodeType } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScopeId": scopeID, + "nodeType": nodeType, + "name": "Node-" + nodeType, + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, nodeType, result.CreateRiskAssessmentNode.RiskAssessmentNodeEdge.Node.NodeType) + }) + } +} + +func TestRiskAssessmentProcess_Create(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + src := factory.CreateRiskAssessmentNode(owner, scopeID, factory.Attrs{"nodeType": "ENTITY"}) + dst := factory.CreateRiskAssessmentNode(owner, scopeID, factory.Attrs{"nodeType": "ASSET"}) + + var result struct { + CreateRiskAssessmentProcess struct { + RiskAssessmentProcessEdge struct { + Node struct { + ID string `json:"id"` + SourceNodeID string `json:"sourceNodeId"` + TargetNodeID string `json:"targetNodeId"` + Name string `json:"name"` + } `json:"node"` + } `json:"riskAssessmentProcessEdge"` + } `json:"createRiskAssessmentProcess"` + } + err := owner.Execute(` + mutation($input: CreateRiskAssessmentProcessInput!) { + createRiskAssessmentProcess(input: $input) { + riskAssessmentProcessEdge { node { id sourceNodeId targetNodeId name } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScopeId": scopeID, + "sourceNodeId": src, + "targetNodeId": dst, + "name": "User → API", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, src, result.CreateRiskAssessmentProcess.RiskAssessmentProcessEdge.Node.SourceNodeID) + assert.Equal(t, dst, result.CreateRiskAssessmentProcess.RiskAssessmentProcessEdge.Node.TargetNodeID) +} + +func TestRiskAssessmentThreat_Create(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + src := factory.CreateRiskAssessmentNode(owner, scopeID) + dst := factory.CreateRiskAssessmentNode(owner, scopeID) + processID := factory.CreateRiskAssessmentProcess(owner, scopeID, src, dst) + + var result struct { + CreateRiskAssessmentThreat struct { + RiskAssessmentThreatEdge struct { + Node struct { + ID string `json:"id"` + ProcessID string `json:"processId"` + Category string `json:"category"` + } `json:"node"` + } `json:"riskAssessmentThreatEdge"` + } `json:"createRiskAssessmentThreat"` + } + err := owner.Execute(` + mutation($input: CreateRiskAssessmentThreatInput!) { + createRiskAssessmentThreat(input: $input) { + riskAssessmentThreatEdge { node { id processId category } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScopeId": scopeID, + "processId": processID, + "name": "SQL injection", + "category": "Confidentiality", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, processID, result.CreateRiskAssessmentThreat.RiskAssessmentThreatEdge.Node.ProcessID) + assert.Equal(t, "Confidentiality", result.CreateRiskAssessmentThreat.RiskAssessmentThreatEdge.Node.Category) +} + +func TestRiskAssessmentScenario_Create(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + + var result struct { + CreateRiskAssessmentScenario struct { + RiskAssessmentScenarioEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"riskAssessmentScenarioEdge"` + } `json:"createRiskAssessmentScenario"` + } + err := owner.Execute(` + mutation($input: CreateRiskAssessmentScenarioInput!) { + createRiskAssessmentScenario(input: $input) { + riskAssessmentScenarioEdge { node { id name } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScopeId": scopeID, + "name": "SQL injection impacts data breach risk", + }, + }, &result) + + require.NoError(t, err) + assert.NotEmpty(t, result.CreateRiskAssessmentScenario.RiskAssessmentScenarioEdge.Node.ID) + assert.Equal(t, "SQL injection impacts data breach risk", result.CreateRiskAssessmentScenario.RiskAssessmentScenarioEdge.Node.Name) +} + +func TestRiskAssessmentScenario_ListViaRisk(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + riskID := factory.CreateRisk(owner) + s1 := factory.CreateRiskAssessmentScenario(owner, scopeID, factory.Attrs{"name": "S1"}) + s2 := factory.CreateRiskAssessmentScenario(owner, scopeID, factory.Attrs{"name": "S2"}) + + factory.LinkRiskAssessmentScenarioRisk(owner, s1, riskID) + factory.LinkRiskAssessmentScenarioRisk(owner, s2, riskID) + + var result struct { + Node struct { + Scenarios struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"scenarios"` + } `json:"node"` + } + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Risk { + scenarios(first: 10) { + totalCount + edges { node { id name } } + } + } + } + } + `, map[string]any{"id": riskID}, &result) + + require.NoError(t, err) + assert.Equal(t, 2, result.Node.Scenarios.TotalCount) + assert.Len(t, result.Node.Scenarios.Edges, 2) +} + +func TestRiskAssessmentScenario_ListViaScope(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + factory.CreateRiskAssessmentScenario(owner, scopeID, factory.Attrs{"name": "Scenario A"}) + factory.CreateRiskAssessmentScenario(owner, scopeID, factory.Attrs{"name": "Scenario B"}) + + var result struct { + Node struct { + Scenarios struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"scenarios"` + } `json:"node"` + } + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on RiskAssessmentScope { + scenarios(first: 10) { + totalCount + edges { node { id name } } + } + } + } + } + `, map[string]any{"id": scopeID}, &result) + + require.NoError(t, err) + assert.Equal(t, 2, result.Node.Scenarios.TotalCount) + assert.Len(t, result.Node.Scenarios.Edges, 2) +} + +func TestRiskAssessment_Update(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner, factory.Attrs{"name": "Original"}) + + var result struct { + UpdateRiskAssessment struct { + RiskAssessment struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + } `json:"riskAssessment"` + } `json:"updateRiskAssessment"` + } + err := owner.Execute(` + mutation($input: UpdateRiskAssessmentInput!) { + updateRiskAssessment(input: $input) { + riskAssessment { id name description } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": raID, + "name": "Updated", + "description": "New description", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "Updated", result.UpdateRiskAssessment.RiskAssessment.Name) + require.NotNil(t, result.UpdateRiskAssessment.RiskAssessment.Description) + assert.Equal(t, "New description", *result.UpdateRiskAssessment.RiskAssessment.Description) +} + +func TestRiskAssessmentScope_Update(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID, factory.Attrs{"name": "Original"}) + + var result struct { + UpdateRiskAssessmentScope struct { + RiskAssessmentScope struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"riskAssessmentScope"` + } `json:"updateRiskAssessmentScope"` + } + err := owner.Execute(` + mutation($input: UpdateRiskAssessmentScopeInput!) { + updateRiskAssessmentScope(input: $input) { + riskAssessmentScope { id name } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": scopeID, + "name": "Updated scope", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "Updated scope", result.UpdateRiskAssessmentScope.RiskAssessmentScope.Name) +} + +func TestRiskAssessmentNode_Update(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + nodeID := factory.CreateRiskAssessmentNode(owner, scopeID, factory.Attrs{"nodeType": "ENTITY", "name": "Original"}) + + var result struct { + UpdateRiskAssessmentNode struct { + RiskAssessmentNode struct { + ID string `json:"id"` + Name string `json:"name"` + NodeType string `json:"nodeType"` + } `json:"riskAssessmentNode"` + } `json:"updateRiskAssessmentNode"` + } + err := owner.Execute(` + mutation($input: UpdateRiskAssessmentNodeInput!) { + updateRiskAssessmentNode(input: $input) { + riskAssessmentNode { id name nodeType } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": nodeID, + "name": "Updated node", + "nodeType": "BOUNDARY", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "Updated node", result.UpdateRiskAssessmentNode.RiskAssessmentNode.Name) + assert.Equal(t, "BOUNDARY", result.UpdateRiskAssessmentNode.RiskAssessmentNode.NodeType) +} + +func TestRiskAssessmentProcess_Update(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + src := factory.CreateRiskAssessmentNode(owner, scopeID, factory.Attrs{"nodeType": "ENTITY"}) + dst := factory.CreateRiskAssessmentNode(owner, scopeID, factory.Attrs{"nodeType": "ASSET"}) + processID := factory.CreateRiskAssessmentProcess(owner, scopeID, src, dst) + + var result struct { + UpdateRiskAssessmentProcess struct { + RiskAssessmentProcess struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"riskAssessmentProcess"` + } `json:"updateRiskAssessmentProcess"` + } + err := owner.Execute(` + mutation($input: UpdateRiskAssessmentProcessInput!) { + updateRiskAssessmentProcess(input: $input) { + riskAssessmentProcess { id name } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": processID, + "name": "Updated process", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "Updated process", result.UpdateRiskAssessmentProcess.RiskAssessmentProcess.Name) +} + +func TestRiskAssessmentThreat_Update(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + src := factory.CreateRiskAssessmentNode(owner, scopeID) + dst := factory.CreateRiskAssessmentNode(owner, scopeID) + processID := factory.CreateRiskAssessmentProcess(owner, scopeID, src, dst) + threatID := factory.CreateRiskAssessmentThreat(owner, scopeID, processID, factory.Attrs{"name": "Original", "category": "Confidentiality"}) + + var result struct { + UpdateRiskAssessmentThreat struct { + RiskAssessmentThreat struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + } `json:"riskAssessmentThreat"` + } `json:"updateRiskAssessmentThreat"` + } + err := owner.Execute(` + mutation($input: UpdateRiskAssessmentThreatInput!) { + updateRiskAssessmentThreat(input: $input) { + riskAssessmentThreat { id name category } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": threatID, + "name": "Updated threat", + "category": "Integrity", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "Updated threat", result.UpdateRiskAssessmentThreat.RiskAssessmentThreat.Name) + assert.Equal(t, "Integrity", result.UpdateRiskAssessmentThreat.RiskAssessmentThreat.Category) +} + +func TestRiskAssessmentScenario_Update(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + scenarioID := factory.CreateRiskAssessmentScenario(owner, scopeID, factory.Attrs{"name": "Original"}) + + var result struct { + UpdateRiskAssessmentScenario struct { + RiskAssessmentScenario struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + } `json:"riskAssessmentScenario"` + } `json:"updateRiskAssessmentScenario"` + } + err := owner.Execute(` + mutation($input: UpdateRiskAssessmentScenarioInput!) { + updateRiskAssessmentScenario(input: $input) { + riskAssessmentScenario { id name description } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": scenarioID, + "name": "Updated scenario", + "description": "Scenario desc", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "Updated scenario", result.UpdateRiskAssessmentScenario.RiskAssessmentScenario.Name) + require.NotNil(t, result.UpdateRiskAssessmentScenario.RiskAssessmentScenario.Description) + assert.Equal(t, "Scenario desc", *result.UpdateRiskAssessmentScenario.RiskAssessmentScenario.Description) +} + +func TestRiskAssessmentScenario_LinkUnlinkThreat(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + src := factory.CreateRiskAssessmentNode(owner, scopeID) + dst := factory.CreateRiskAssessmentNode(owner, scopeID) + processID := factory.CreateRiskAssessmentProcess(owner, scopeID, src, dst) + threatID := factory.CreateRiskAssessmentThreat(owner, scopeID, processID) + scenarioID := factory.CreateRiskAssessmentScenario(owner, scopeID) + + factory.LinkRiskAssessmentScenarioThreat(owner, scenarioID, threatID) + + var result struct { + Node struct { + Threats struct { + TotalCount int `json:"totalCount"` + } `json:"threats"` + } `json:"node"` + } + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on RiskAssessmentScenario { + threats(first: 10) { totalCount } + } + } + } + `, map[string]any{"id": scenarioID}, &result) + require.NoError(t, err) + assert.Equal(t, 1, result.Node.Threats.TotalCount) + + _, err = owner.Do(` + mutation($input: UnlinkRiskAssessmentScenarioThreatInput!) { + unlinkRiskAssessmentScenarioThreat(input: $input) { riskAssessmentScenario { id } } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScenarioId": scenarioID, + "threatId": threatID, + }, + }) + require.NoError(t, err) + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on RiskAssessmentScenario { + threats(first: 10) { totalCount } + } + } + } + `, map[string]any{"id": scenarioID}, &result) + require.NoError(t, err) + assert.Equal(t, 0, result.Node.Threats.TotalCount) +} + +func TestRiskAssessmentScenario_LinkUnlinkRisk(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + riskID := factory.CreateRisk(owner) + scenarioID := factory.CreateRiskAssessmentScenario(owner, scopeID) + + factory.LinkRiskAssessmentScenarioRisk(owner, scenarioID, riskID) + + var result struct { + Node struct { + Risks struct { + TotalCount int `json:"totalCount"` + } `json:"risks"` + } `json:"node"` + } + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on RiskAssessmentScenario { + risks(first: 10) { totalCount } + } + } + } + `, map[string]any{"id": scenarioID}, &result) + require.NoError(t, err) + assert.Equal(t, 1, result.Node.Risks.TotalCount) + + _, err = owner.Do(` + mutation($input: UnlinkRiskAssessmentScenarioRiskInput!) { + unlinkRiskAssessmentScenarioRisk(input: $input) { riskAssessmentScenario { id } } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScenarioId": scenarioID, + "riskId": riskID, + }, + }) + require.NoError(t, err) + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on RiskAssessmentScenario { + risks(first: 10) { totalCount } + } + } + } + `, map[string]any{"id": scenarioID}, &result) + require.NoError(t, err) + assert.Equal(t, 0, result.Node.Risks.TotalCount) +} + +func TestRiskAssessment_RBAC(t *testing.T) { + t.Parallel() + + t.Run("viewer cannot create", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + _, err := viewer.Do(` + mutation($input: CreateRiskAssessmentInput!) { + createRiskAssessment(input: $input) { riskAssessmentEdge { node { id } } } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": viewer.GetOrganizationID().String(), + "name": "test", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer cannot create risk assessment") + }) + + t.Run("viewer can read", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + raID := factory.CreateRiskAssessment(owner, factory.Attrs{"name": "Visible"}) + + var result struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } + err := viewer.Execute(` + query($id: ID!) { node(id: $id) { ... on RiskAssessment { id name } } } + `, map[string]any{"id": raID}, &result) + require.NoError(t, err) + assert.Equal(t, "Visible", result.Node.Name) + }) +} + +func TestRiskAssessment_TenantIsolation(t *testing.T) { + t.Parallel() + + owner1 := testutil.NewClient(t, testutil.RoleOwner) + owner2 := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner1) + + var result struct { + Node *struct { + ID string `json:"id"` + } `json:"node"` + } + err := owner2.Execute(` + query($id: ID!) { node(id: $id) { ... on RiskAssessment { id } } } + `, map[string]any{"id": raID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "RiskAssessment") +} diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index 77ab29883..d2779b427 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -1449,3 +1449,236 @@ func ReportDetectedResources(c *testutil.Client, bannerID string, count int) { defer func() { _ = resp.Body.Close() }() require.Equal(c.T, http.StatusNoContent, resp.StatusCode, "report detected resources unexpected status") } + +func CreateRiskAssessment(c *testutil.Client, attrs ...Attrs) string { + c.T.Helper() + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + const query = ` + mutation($input: CreateRiskAssessmentInput!) { + createRiskAssessment(input: $input) { + riskAssessmentEdge { node { id } } + } + } + ` + input := map[string]any{ + "organizationId": c.GetOrganizationID().String(), + "name": a.getString("name", SafeName("Risk Assessment")), + } + if desc := a.getStringPtr("description"); desc != nil { + input["description"] = *desc + } + var result struct { + CreateRiskAssessment struct { + RiskAssessmentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"riskAssessmentEdge"` + } `json:"createRiskAssessment"` + } + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createRiskAssessment mutation failed") + return result.CreateRiskAssessment.RiskAssessmentEdge.Node.ID +} + +func CreateRiskAssessmentScope(c *testutil.Client, riskAssessmentID string, attrs ...Attrs) string { + c.T.Helper() + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + const query = ` + mutation($input: CreateRiskAssessmentScopeInput!) { + createRiskAssessmentScope(input: $input) { + riskAssessmentScopeEdge { node { id } } + } + } + ` + input := map[string]any{ + "riskAssessmentId": riskAssessmentID, + "name": a.getString("name", SafeName("Scope")), + } + var result struct { + CreateRiskAssessmentScope struct { + RiskAssessmentScopeEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"riskAssessmentScopeEdge"` + } `json:"createRiskAssessmentScope"` + } + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createRiskAssessmentScope mutation failed") + return result.CreateRiskAssessmentScope.RiskAssessmentScopeEdge.Node.ID +} + +func CreateRiskAssessmentNode(c *testutil.Client, scopeID string, attrs ...Attrs) string { + c.T.Helper() + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + const query = ` + mutation($input: CreateRiskAssessmentNodeInput!) { + createRiskAssessmentNode(input: $input) { + riskAssessmentNodeEdge { node { id } } + } + } + ` + input := map[string]any{ + "riskAssessmentScopeId": scopeID, + "nodeType": a.getString("nodeType", "ASSET"), + "name": a.getString("name", SafeName("Node")), + } + var result struct { + CreateRiskAssessmentNode struct { + RiskAssessmentNodeEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"riskAssessmentNodeEdge"` + } `json:"createRiskAssessmentNode"` + } + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createRiskAssessmentNode mutation failed") + return result.CreateRiskAssessmentNode.RiskAssessmentNodeEdge.Node.ID +} + +func CreateRiskAssessmentProcess(c *testutil.Client, scopeID, sourceNodeID, targetNodeID string, attrs ...Attrs) string { + c.T.Helper() + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + const query = ` + mutation($input: CreateRiskAssessmentProcessInput!) { + createRiskAssessmentProcess(input: $input) { + riskAssessmentProcessEdge { node { id } } + } + } + ` + input := map[string]any{ + "riskAssessmentScopeId": scopeID, + "sourceNodeId": sourceNodeID, + "targetNodeId": targetNodeID, + "name": a.getString("name", SafeName("Process")), + } + var result struct { + CreateRiskAssessmentProcess struct { + RiskAssessmentProcessEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"riskAssessmentProcessEdge"` + } `json:"createRiskAssessmentProcess"` + } + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createRiskAssessmentProcess mutation failed") + return result.CreateRiskAssessmentProcess.RiskAssessmentProcessEdge.Node.ID +} + +func CreateRiskAssessmentThreat(c *testutil.Client, scopeID, processID string, attrs ...Attrs) string { + c.T.Helper() + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + const query = ` + mutation($input: CreateRiskAssessmentThreatInput!) { + createRiskAssessmentThreat(input: $input) { + riskAssessmentThreatEdge { node { id } } + } + } + ` + input := map[string]any{ + "riskAssessmentScopeId": scopeID, + "processId": processID, + "name": a.getString("name", SafeName("Threat")), + "category": a.getString("category", "Confidentiality"), + } + var result struct { + CreateRiskAssessmentThreat struct { + RiskAssessmentThreatEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"riskAssessmentThreatEdge"` + } `json:"createRiskAssessmentThreat"` + } + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createRiskAssessmentThreat mutation failed") + return result.CreateRiskAssessmentThreat.RiskAssessmentThreatEdge.Node.ID +} + +func CreateRiskAssessmentScenario(c *testutil.Client, scopeID string, attrs ...Attrs) string { + c.T.Helper() + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + const query = ` + mutation($input: CreateRiskAssessmentScenarioInput!) { + createRiskAssessmentScenario(input: $input) { + riskAssessmentScenarioEdge { node { id } } + } + } + ` + input := map[string]any{ + "riskAssessmentScopeId": scopeID, + "name": a.getString("name", SafeName("Scenario")), + } + if desc := a.getStringPtr("description"); desc != nil { + input["description"] = *desc + } + var result struct { + CreateRiskAssessmentScenario struct { + RiskAssessmentScenarioEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"riskAssessmentScenarioEdge"` + } `json:"createRiskAssessmentScenario"` + } + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createRiskAssessmentScenario mutation failed") + return result.CreateRiskAssessmentScenario.RiskAssessmentScenarioEdge.Node.ID +} + +func LinkRiskAssessmentScenarioThreat(c *testutil.Client, scenarioID, threatID string) { + c.T.Helper() + const query = ` + mutation($input: LinkRiskAssessmentScenarioThreatInput!) { + linkRiskAssessmentScenarioThreat(input: $input) { + riskAssessmentScenario { id } + } + } + ` + _, err := c.Do(query, map[string]any{ + "input": map[string]any{ + "riskAssessmentScenarioId": scenarioID, + "threatId": threatID, + }, + }) + require.NoError(c.T, err, "linkRiskAssessmentScenarioThreat mutation failed") +} + +func LinkRiskAssessmentScenarioRisk(c *testutil.Client, scenarioID, riskID string) { + c.T.Helper() + const query = ` + mutation($input: LinkRiskAssessmentScenarioRiskInput!) { + linkRiskAssessmentScenarioRisk(input: $input) { + riskAssessmentScenario { id } + } + } + ` + _, err := c.Do(query, map[string]any{ + "input": map[string]any{ + "riskAssessmentScenarioId": scenarioID, + "riskId": riskID, + }, + }) + require.NoError(c.T, err, "linkRiskAssessmentScenarioRisk mutation failed") +} diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index ab8c71f53..a4340e699 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -118,6 +118,12 @@ const ( CommonThirdPartyEntityType uint16 = 92 CommonThirdPartyDomainEntityType uint16 = 93 CommonTrackerPatternEntityType uint16 = 94 + RiskAssessmentEntityType uint16 = 95 + RiskAssessmentNodeEntityType uint16 = 96 + RiskAssessmentProcessEntityType uint16 = 97 + RiskAssessmentThreatEntityType uint16 = 98 + RiskAssessmentScopeEntityType uint16 = 99 + RiskAssessmentScenarioEntityType uint16 = 100 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -296,6 +302,18 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &CommonThirdPartyDomain{ID: id}, true case CommonTrackerPatternEntityType: return &CommonTrackerPattern{ID: id}, true + case RiskAssessmentEntityType: + return &RiskAssessment{ID: id}, true + case RiskAssessmentNodeEntityType: + return &RiskAssessmentNode{ID: id}, true + case RiskAssessmentProcessEntityType: + return &RiskAssessmentProcess{ID: id}, true + case RiskAssessmentThreatEntityType: + return &RiskAssessmentThreat{ID: id}, true + case RiskAssessmentScopeEntityType: + return &RiskAssessmentScope{ID: id}, true + case RiskAssessmentScenarioEntityType: + return &RiskAssessmentScenario{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260421T140001Z.sql b/pkg/coredata/migrations/20260421T140001Z.sql new file mode 100644 index 000000000..81dda4e2c --- /dev/null +++ b/pkg/coredata/migrations/20260421T140001Z.sql @@ -0,0 +1,100 @@ +-- Copyright (c) 2025-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. + +CREATE TABLE risk_assessments ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE TABLE risk_assessment_scopes ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL, + risk_assessment_id TEXT NOT NULL REFERENCES risk_assessments(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE TYPE risk_assessment_node_type AS ENUM ('ENTITY', 'BOUNDARY', 'ASSET', 'DATA'); + +CREATE TABLE risk_assessment_nodes ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL, + risk_assessment_scope_id TEXT NOT NULL REFERENCES risk_assessment_scopes(id) ON DELETE CASCADE, + node_type risk_assessment_node_type NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT risk_assessment_nodes_unique_name UNIQUE (risk_assessment_scope_id, name) +); + +CREATE TABLE risk_assessment_processes ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL, + risk_assessment_scope_id TEXT NOT NULL REFERENCES risk_assessment_scopes(id) ON DELETE CASCADE, + source_node_id TEXT NOT NULL REFERENCES risk_assessment_nodes(id) ON DELETE CASCADE, + target_node_id TEXT NOT NULL REFERENCES risk_assessment_nodes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT risk_assessment_processes_unique_name UNIQUE (risk_assessment_scope_id, name) +); + +CREATE TABLE risk_assessment_threats ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL, + risk_assessment_scope_id TEXT NOT NULL REFERENCES risk_assessment_scopes(id) ON DELETE CASCADE, + process_id TEXT NOT NULL REFERENCES risk_assessment_processes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + category TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT risk_assessment_threats_unique_name UNIQUE (risk_assessment_scope_id, name) +); + +CREATE TABLE risk_assessment_scenarios ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL, + risk_assessment_scope_id TEXT NOT NULL REFERENCES risk_assessment_scopes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE TABLE risk_assessment_scenario_threats ( + tenant_id TEXT NOT NULL, + risk_assessment_scenario_id TEXT NOT NULL REFERENCES risk_assessment_scenarios(id) ON DELETE CASCADE, + risk_assessment_threat_id TEXT NOT NULL REFERENCES risk_assessment_threats(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (risk_assessment_scenario_id, risk_assessment_threat_id) +); + +CREATE TABLE risk_assessment_scenario_risks ( + tenant_id TEXT NOT NULL, + risk_assessment_scenario_id TEXT NOT NULL REFERENCES risk_assessment_scenarios(id) ON DELETE CASCADE, + risk_id TEXT NOT NULL REFERENCES risks(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (risk_assessment_scenario_id, risk_id) +); diff --git a/pkg/coredata/risk_assessment.go b/pkg/coredata/risk_assessment.go new file mode 100644 index 000000000..0bd508806 --- /dev/null +++ b/pkg/coredata/risk_assessment.go @@ -0,0 +1,254 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessment struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + Name string `db:"name"` + Description *string `db:"description"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + RiskAssessments []*RiskAssessment +) + +func (ra *RiskAssessment) CursorKey(orderBy RiskAssessmentOrderField) page.CursorKey { + switch orderBy { + case RiskAssessmentOrderFieldCreatedAt: + return page.CursorKey{ID: ra.ID, Value: ra.CreatedAt} + case RiskAssessmentOrderFieldName: + return page.CursorKey{ID: ra.ID, Value: ra.Name} + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (ra *RiskAssessment) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM risk_assessments WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, ra.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query risk assessment authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (ra *RiskAssessments) CountByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, +) (int, error) { + q := ` +SELECT COUNT(id) +FROM risk_assessments +WHERE %s + AND organization_id = @organization_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk assessments: %w", err) + } + + return count, nil +} + +func (ra *RiskAssessments) LoadByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + cursor *page.Cursor[RiskAssessmentOrderField], +) error { + q := ` +SELECT id, organization_id, name, description, created_at, updated_at +FROM risk_assessments +WHERE %s + AND organization_id = @organization_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessments: %w", err) + } + + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessment]) + if err != nil { + return fmt.Errorf("cannot collect risk assessments: %w", err) + } + + *ra = results + + return nil +} + +func (ra *RiskAssessment) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + id gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + name, + description, + created_at, + updated_at +FROM risk_assessments +WHERE %s + AND id = @id +LIMIT 1; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment: %w", err) + } + + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[RiskAssessment]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect risk assessment: %w", err) + } + + *ra = result + + return nil +} + +func (ra *RiskAssessment) Insert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO risk_assessments (id, tenant_id, organization_id, name, description, created_at, updated_at) +VALUES (@id, @tenant_id, @organization_id, @name, @description, @created_at, @updated_at) +` + + args := pgx.StrictNamedArgs{ + "id": ra.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": ra.OrganizationID, + "name": ra.Name, + "description": ra.Description, + "created_at": ra.CreatedAt, + "updated_at": ra.UpdatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk assessment: %w", err) + } + return nil +} + +func (ra *RiskAssessment) Update( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE risk_assessments +SET + name = @name, + description = @description, + updated_at = @updated_at +WHERE %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": ra.ID, + "name": ra.Name, + "description": ra.Description, + "updated_at": ra.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update risk assessment: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +func (ra *RiskAssessment) Delete( + ctx context.Context, + conn pg.Tx, + scope Scoper, + id gid.GID, +) error { + q := ` +DELETE FROM risk_assessments WHERE %s AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + return err +} diff --git a/pkg/coredata/risk_assessment_node.go b/pkg/coredata/risk_assessment_node.go new file mode 100644 index 000000000..9fc6acdd6 --- /dev/null +++ b/pkg/coredata/risk_assessment_node.go @@ -0,0 +1,257 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentNode struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + RiskAssessmentScopeID gid.GID `db:"risk_assessment_scope_id"` + NodeType RiskAssessmentNodeType `db:"node_type"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + RiskAssessmentNodes []*RiskAssessmentNode +) + +func (n *RiskAssessmentNode) CursorKey(orderBy RiskAssessmentNodeOrderField) page.CursorKey { + switch orderBy { + case RiskAssessmentNodeOrderFieldCreatedAt: + return page.CursorKey{ID: n.ID, Value: n.CreatedAt} + case RiskAssessmentNodeOrderFieldName: + return page.CursorKey{ID: n.ID, Value: n.Name} + } + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (n *RiskAssessmentNode) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM risk_assessment_nodes WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, n.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query risk assessment node authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (ns *RiskAssessmentNodes) LoadByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, + cursor *page.Cursor[RiskAssessmentNodeOrderField], +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + node_type, + name, + created_at, + updated_at +FROM + risk_assessment_nodes +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment nodes: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentNode]) + if err != nil { + return fmt.Errorf("cannot collect risk assessment nodes: %w", err) + } + *ns = results + return nil +} + +func (ns *RiskAssessmentNodes) CountByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + risk_assessment_nodes +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk assessment nodes: %w", err) + } + return count, nil +} + +func (n *RiskAssessmentNode) LoadByID(ctx context.Context, conn pg.Querier, scope Scoper, id gid.GID) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + node_type, + name, + created_at, + updated_at +FROM + risk_assessment_nodes +WHERE + %s + AND id = @id +LIMIT 1; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment node: %w", err) + } + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[RiskAssessmentNode]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect risk assessment node: %w", err) + } + *n = result + return nil +} + +func (n *RiskAssessmentNode) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_nodes ( + id, + tenant_id, + organization_id, + risk_assessment_scope_id, + node_type, + name, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @risk_assessment_scope_id, + @node_type, + @name, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "id": n.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": n.OrganizationID, + "risk_assessment_scope_id": n.RiskAssessmentScopeID, + "node_type": n.NodeType, + "name": n.Name, + "created_at": n.CreatedAt, + "updated_at": n.UpdatedAt, + } + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk assessment node: %w", err) + } + return nil +} + +func (n *RiskAssessmentNode) Update(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +UPDATE risk_assessment_nodes +SET + node_type = @node_type, + name = @name, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "id": n.ID, + "node_type": n.NodeType, + "name": n.Name, + "updated_at": n.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update risk assessment node: %w", err) + } + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + return nil +} + +func (n *RiskAssessmentNode) Delete(ctx context.Context, conn pg.Tx, scope Scoper, id gid.GID) error { + q := ` +DELETE FROM risk_assessment_nodes +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + return err +} diff --git a/pkg/coredata/risk_assessment_node_order_field.go b/pkg/coredata/risk_assessment_node_order_field.go new file mode 100644 index 000000000..59a5b4eb9 --- /dev/null +++ b/pkg/coredata/risk_assessment_node_order_field.go @@ -0,0 +1,34 @@ +// 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 coredata + +type RiskAssessmentNodeOrderField string + +const ( + RiskAssessmentNodeOrderFieldCreatedAt RiskAssessmentNodeOrderField = "CREATED_AT" + RiskAssessmentNodeOrderFieldName RiskAssessmentNodeOrderField = "NAME" +) + +func (p RiskAssessmentNodeOrderField) Column() string { return string(p) } +func (p RiskAssessmentNodeOrderField) String() string { return string(p) } + +func (p RiskAssessmentNodeOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *RiskAssessmentNodeOrderField) UnmarshalText(text []byte) error { + *p = RiskAssessmentNodeOrderField(text) + return nil +} diff --git a/pkg/coredata/risk_assessment_node_type.go b/pkg/coredata/risk_assessment_node_type.go new file mode 100644 index 000000000..12be517e2 --- /dev/null +++ b/pkg/coredata/risk_assessment_node_type.go @@ -0,0 +1,78 @@ +// 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 coredata + +import ( + "database/sql/driver" + "fmt" +) + +type RiskAssessmentNodeType string + +const ( + RiskAssessmentNodeTypeEntity RiskAssessmentNodeType = "ENTITY" + RiskAssessmentNodeTypeBoundary RiskAssessmentNodeType = "BOUNDARY" + RiskAssessmentNodeTypeAsset RiskAssessmentNodeType = "ASSET" + RiskAssessmentNodeTypeData RiskAssessmentNodeType = "DATA" +) + +func RiskAssessmentNodeTypes() []RiskAssessmentNodeType { + return []RiskAssessmentNodeType{ + RiskAssessmentNodeTypeEntity, + RiskAssessmentNodeTypeBoundary, + RiskAssessmentNodeTypeAsset, + RiskAssessmentNodeTypeData, + } +} + +func (t RiskAssessmentNodeType) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +func (t *RiskAssessmentNodeType) UnmarshalText(data []byte) error { + val := string(data) + + switch val { + case RiskAssessmentNodeTypeEntity.String(): + *t = RiskAssessmentNodeTypeEntity + case RiskAssessmentNodeTypeBoundary.String(): + *t = RiskAssessmentNodeTypeBoundary + case RiskAssessmentNodeTypeAsset.String(): + *t = RiskAssessmentNodeTypeAsset + case RiskAssessmentNodeTypeData.String(): + *t = RiskAssessmentNodeTypeData + default: + return fmt.Errorf("invalid RiskAssessmentNodeType value: %q", val) + } + + return nil +} + +func (t RiskAssessmentNodeType) String() string { + return string(t) +} + +func (t *RiskAssessmentNodeType) Scan(value any) error { + val, ok := value.(string) + if !ok { + return fmt.Errorf("invalid scan source for RiskAssessmentNodeType, expected string got %T", value) + } + + return t.UnmarshalText([]byte(val)) +} + +func (t RiskAssessmentNodeType) Value() (driver.Value, error) { + return t.String(), nil +} diff --git a/pkg/coredata/risk_assessment_order_field.go b/pkg/coredata/risk_assessment_order_field.go new file mode 100644 index 000000000..8c14a44ba --- /dev/null +++ b/pkg/coredata/risk_assessment_order_field.go @@ -0,0 +1,39 @@ +// 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 coredata + +type RiskAssessmentOrderField string + +const ( + RiskAssessmentOrderFieldCreatedAt RiskAssessmentOrderField = "CREATED_AT" + RiskAssessmentOrderFieldName RiskAssessmentOrderField = "NAME" +) + +func (p RiskAssessmentOrderField) Column() string { + return string(p) +} + +func (p RiskAssessmentOrderField) String() string { + return string(p) +} + +func (p RiskAssessmentOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *RiskAssessmentOrderField) UnmarshalText(text []byte) error { + *p = RiskAssessmentOrderField(text) + return nil +} diff --git a/pkg/coredata/risk_assessment_process.go b/pkg/coredata/risk_assessment_process.go new file mode 100644 index 000000000..c7ef03acb --- /dev/null +++ b/pkg/coredata/risk_assessment_process.go @@ -0,0 +1,262 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentProcess struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + RiskAssessmentScopeID gid.GID `db:"risk_assessment_scope_id"` + SourceNodeID gid.GID `db:"source_node_id"` + TargetNodeID gid.GID `db:"target_node_id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + RiskAssessmentProcesses []*RiskAssessmentProcess +) + +func (p *RiskAssessmentProcess) CursorKey(orderBy RiskAssessmentProcessOrderField) page.CursorKey { + switch orderBy { + case RiskAssessmentProcessOrderFieldCreatedAt: + return page.CursorKey{ID: p.ID, Value: p.CreatedAt} + case RiskAssessmentProcessOrderFieldName: + return page.CursorKey{ID: p.ID, Value: p.Name} + } + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (p *RiskAssessmentProcess) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM risk_assessment_processes WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, p.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query risk assessment process authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (ps *RiskAssessmentProcesses) LoadByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, + cursor *page.Cursor[RiskAssessmentProcessOrderField], +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + source_node_id, + target_node_id, + name, + created_at, + updated_at +FROM + risk_assessment_processes +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment processes: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentProcess]) + if err != nil { + return fmt.Errorf("cannot collect risk assessment processes: %w", err) + } + *ps = results + return nil +} + +func (ps *RiskAssessmentProcesses) CountByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + risk_assessment_processes +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk assessment processes: %w", err) + } + return count, nil +} + +func (p *RiskAssessmentProcess) LoadByID(ctx context.Context, conn pg.Querier, scope Scoper, id gid.GID) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + source_node_id, + target_node_id, + name, + created_at, + updated_at +FROM + risk_assessment_processes +WHERE + %s + AND id = @id +LIMIT 1; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment process: %w", err) + } + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[RiskAssessmentProcess]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect risk assessment process: %w", err) + } + *p = result + return nil +} + +func (p *RiskAssessmentProcess) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_processes ( + id, + tenant_id, + organization_id, + risk_assessment_scope_id, + source_node_id, + target_node_id, + name, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @risk_assessment_scope_id, + @source_node_id, + @target_node_id, + @name, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "id": p.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": p.OrganizationID, + "risk_assessment_scope_id": p.RiskAssessmentScopeID, + "source_node_id": p.SourceNodeID, + "target_node_id": p.TargetNodeID, + "name": p.Name, + "created_at": p.CreatedAt, + "updated_at": p.UpdatedAt, + } + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk assessment process: %w", err) + } + return nil +} + +func (p *RiskAssessmentProcess) Update(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +UPDATE risk_assessment_processes +SET + source_node_id = @source_node_id, + target_node_id = @target_node_id, + name = @name, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "id": p.ID, + "source_node_id": p.SourceNodeID, + "target_node_id": p.TargetNodeID, + "name": p.Name, + "updated_at": p.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update risk assessment process: %w", err) + } + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + return nil +} + +func (p *RiskAssessmentProcess) Delete(ctx context.Context, conn pg.Tx, scope Scoper, id gid.GID) error { + q := ` +DELETE FROM risk_assessment_processes +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + _, err := conn.Exec(ctx, q, args) + return err +} diff --git a/pkg/coredata/risk_assessment_process_order_field.go b/pkg/coredata/risk_assessment_process_order_field.go new file mode 100644 index 000000000..f25278be1 --- /dev/null +++ b/pkg/coredata/risk_assessment_process_order_field.go @@ -0,0 +1,34 @@ +// 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 coredata + +type RiskAssessmentProcessOrderField string + +const ( + RiskAssessmentProcessOrderFieldCreatedAt RiskAssessmentProcessOrderField = "CREATED_AT" + RiskAssessmentProcessOrderFieldName RiskAssessmentProcessOrderField = "NAME" +) + +func (p RiskAssessmentProcessOrderField) Column() string { return string(p) } +func (p RiskAssessmentProcessOrderField) String() string { return string(p) } + +func (p RiskAssessmentProcessOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *RiskAssessmentProcessOrderField) UnmarshalText(text []byte) error { + *p = RiskAssessmentProcessOrderField(text) + return nil +} diff --git a/pkg/coredata/risk_assessment_scenario.go b/pkg/coredata/risk_assessment_scenario.go new file mode 100644 index 000000000..80247a4e3 --- /dev/null +++ b/pkg/coredata/risk_assessment_scenario.go @@ -0,0 +1,398 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentScenario struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + RiskAssessmentScopeID gid.GID `db:"risk_assessment_scope_id"` + Name string `db:"name"` + Description *string `db:"description"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + RiskAssessmentScenarios []*RiskAssessmentScenario +) + +func (s *RiskAssessmentScenario) CursorKey(orderBy RiskAssessmentScenarioOrderField) page.CursorKey { + switch orderBy { + case RiskAssessmentScenarioOrderFieldCreatedAt: + return page.CursorKey{ID: s.ID, Value: s.CreatedAt} + case RiskAssessmentScenarioOrderFieldName: + return page.CursorKey{ID: s.ID, Value: s.Name} + } + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (s *RiskAssessmentScenario) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM risk_assessment_scenarios WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query risk scenario authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (ss *RiskAssessmentScenarios) LoadByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + cursor *page.Cursor[RiskAssessmentScenarioOrderField], +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + name, + description, + created_at, + updated_at +FROM + risk_assessment_scenarios +WHERE + %s + AND organization_id = @organization_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk scenarios: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentScenario]) + if err != nil { + return fmt.Errorf("cannot collect risk scenarios: %w", err) + } + *ss = results + return nil +} + +func (ss *RiskAssessmentScenarios) CountByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + risk_assessment_scenarios +WHERE + %s + AND organization_id = @organization_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk scenarios: %w", err) + } + return count, nil +} + +func (ss *RiskAssessmentScenarios) LoadByRiskID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskID gid.GID, + cursor *page.Cursor[RiskAssessmentScenarioOrderField], +) error { + q := ` +WITH linked_scenarios AS ( + SELECT + risk_assessment_scenario_id + FROM + risk_assessment_scenario_risks + WHERE + %s + AND risk_id = @risk_id +) +SELECT + id, + organization_id, + risk_assessment_scope_id, + name, + description, + created_at, + updated_at +FROM + risk_assessment_scenarios +WHERE + %s + AND id IN (SELECT risk_assessment_scenario_id FROM linked_scenarios) + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"risk_id": riskID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk scenarios: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentScenario]) + if err != nil { + return fmt.Errorf("cannot collect risk scenarios: %w", err) + } + *ss = results + return nil +} + +func (ss *RiskAssessmentScenarios) CountByRiskID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskID gid.GID, +) (int, error) { + q := ` +WITH linked_scenarios AS ( + SELECT + risk_assessment_scenario_id + FROM + risk_assessment_scenario_risks + WHERE + %s + AND risk_id = @risk_id +) +SELECT + COUNT(id) +FROM + risk_assessment_scenarios +WHERE + %s + AND id IN (SELECT risk_assessment_scenario_id FROM linked_scenarios) +` + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment()) + args := pgx.NamedArgs{"risk_id": riskID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk scenarios: %w", err) + } + return count, nil +} + +func (ss *RiskAssessmentScenarios) LoadByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, + cursor *page.Cursor[RiskAssessmentScenarioOrderField], +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + name, + description, + created_at, + updated_at +FROM + risk_assessment_scenarios +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk scenarios: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentScenario]) + if err != nil { + return fmt.Errorf("cannot collect risk scenarios: %w", err) + } + *ss = results + return nil +} + +func (ss *RiskAssessmentScenarios) CountByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + risk_assessment_scenarios +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk scenarios: %w", err) + } + return count, nil +} + +func (s *RiskAssessmentScenario) LoadByID(ctx context.Context, conn pg.Querier, scope Scoper, id gid.GID) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + name, + description, + created_at, + updated_at +FROM + risk_assessment_scenarios +WHERE + %s + AND id = @id +LIMIT 1 +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk scenario: %w", err) + } + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[RiskAssessmentScenario]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect risk scenario: %w", err) + } + *s = result + return nil +} + +func (s *RiskAssessmentScenario) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_scenarios ( + id, + tenant_id, + organization_id, + risk_assessment_scope_id, + name, + description, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @risk_assessment_scope_id, + @name, + @description, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "id": s.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": s.OrganizationID, + "risk_assessment_scope_id": s.RiskAssessmentScopeID, + "name": s.Name, + "description": s.Description, + "created_at": s.CreatedAt, + "updated_at": s.UpdatedAt, + } + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk scenario: %w", err) + } + return nil +} + +func (s *RiskAssessmentScenario) Update(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +UPDATE risk_assessment_scenarios +SET + name = @name, + description = @description, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": s.ID, "name": s.Name, "description": s.Description, "updated_at": s.UpdatedAt} + maps.Copy(args, scope.SQLArguments()) + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update risk scenario: %w", err) + } + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + return nil +} + +func (s *RiskAssessmentScenario) Delete(ctx context.Context, conn pg.Tx, scope Scoper, id gid.GID) error { + q := ` +DELETE FROM risk_assessment_scenarios +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + _, err := conn.Exec(ctx, q, args) + return err +} diff --git a/pkg/coredata/risk_assessment_scenario_order_field.go b/pkg/coredata/risk_assessment_scenario_order_field.go new file mode 100644 index 000000000..8dc26c403 --- /dev/null +++ b/pkg/coredata/risk_assessment_scenario_order_field.go @@ -0,0 +1,34 @@ +// 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 coredata + +type RiskAssessmentScenarioOrderField string + +const ( + RiskAssessmentScenarioOrderFieldCreatedAt RiskAssessmentScenarioOrderField = "CREATED_AT" + RiskAssessmentScenarioOrderFieldName RiskAssessmentScenarioOrderField = "NAME" +) + +func (p RiskAssessmentScenarioOrderField) Column() string { return string(p) } +func (p RiskAssessmentScenarioOrderField) String() string { return string(p) } + +func (p RiskAssessmentScenarioOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *RiskAssessmentScenarioOrderField) UnmarshalText(text []byte) error { + *p = RiskAssessmentScenarioOrderField(text) + return nil +} diff --git a/pkg/coredata/risk_assessment_scenario_risk.go b/pkg/coredata/risk_assessment_scenario_risk.go new file mode 100644 index 000000000..653472416 --- /dev/null +++ b/pkg/coredata/risk_assessment_scenario_risk.go @@ -0,0 +1,181 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentScenarioRisk struct { + RiskAssessmentScenarioID gid.GID `db:"risk_assessment_scenario_id"` + RiskID gid.GID `db:"risk_id"` + CreatedAt time.Time `db:"created_at"` + } + + RiskAssessmentScenarioRisks []*RiskAssessmentScenarioRisk +) + +func (sr *RiskAssessmentScenarioRisk) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_scenario_risks ( + tenant_id, + risk_assessment_scenario_id, + risk_id, + created_at +) VALUES ( + @tenant_id, + @risk_assessment_scenario_id, + @risk_id, + @created_at +) +` + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "risk_assessment_scenario_id": sr.RiskAssessmentScenarioID, + "risk_id": sr.RiskID, + "created_at": sr.CreatedAt, + } + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk scenario risk: %w", err) + } + return nil +} + +func (sr *RiskAssessmentScenarioRisk) Delete(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +DELETE FROM risk_assessment_scenario_risks +WHERE + %s + AND risk_assessment_scenario_id = @risk_assessment_scenario_id + AND risk_id = @risk_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "risk_assessment_scenario_id": sr.RiskAssessmentScenarioID, + "risk_id": sr.RiskID, + } + maps.Copy(args, scope.SQLArguments()) + _, err := conn.Exec(ctx, q, args) + return err +} + +func (rs *Risks) LoadByScenarioID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + scenarioID gid.GID, + cursor *page.Cursor[RiskOrderField], +) error { + q := ` +WITH linked_risks AS ( + SELECT + risk_id + FROM + risk_assessment_scenario_risks + WHERE + %s + AND risk_assessment_scenario_id = @scenario_id +) +SELECT + id, + organization_id, + name, + description, + category, + treatment, + inherent_likelihood, + inherent_impact, + inherent_risk_score, + residual_likelihood, + residual_impact, + residual_risk_score, + owner_profile_id, + NULL AS owner_full_name, + note, + created_at, + updated_at +FROM + risks +WHERE + %s + AND id IN (SELECT risk_id FROM linked_risks) + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"scenario_id": scenarioID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk scenario risks: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Risk]) + if err != nil { + return fmt.Errorf("cannot collect risk scenario risks: %w", err) + } + *rs = results + return nil +} + +func (rs *Risks) CountByScenarioID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + scenarioID gid.GID, +) (int, error) { + q := ` +WITH linked_risks AS ( + SELECT + risk_id + FROM + risk_assessment_scenario_risks + WHERE + %s + AND risk_assessment_scenario_id = @scenario_id +) +SELECT + COUNT(id) +FROM + risks +WHERE + %s + AND id IN (SELECT risk_id FROM linked_risks) +` + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment()) + args := pgx.NamedArgs{"scenario_id": scenarioID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk scenario risks: %w", err) + } + return count, nil +} diff --git a/pkg/coredata/risk_assessment_scenario_threat.go b/pkg/coredata/risk_assessment_scenario_threat.go new file mode 100644 index 000000000..df3d8448e --- /dev/null +++ b/pkg/coredata/risk_assessment_scenario_threat.go @@ -0,0 +1,172 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentScenarioThreat struct { + RiskAssessmentScenarioID gid.GID `db:"risk_assessment_scenario_id"` + RiskAssessmentThreatID gid.GID `db:"risk_assessment_threat_id"` + CreatedAt time.Time `db:"created_at"` + } + + RiskAssessmentScenarioThreats []*RiskAssessmentScenarioThreat +) + +func (st *RiskAssessmentScenarioThreat) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_scenario_threats ( + tenant_id, + risk_assessment_scenario_id, + risk_assessment_threat_id, + created_at +) VALUES ( + @tenant_id, + @risk_assessment_scenario_id, + @risk_assessment_threat_id, + @created_at +) +` + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "risk_assessment_scenario_id": st.RiskAssessmentScenarioID, + "risk_assessment_threat_id": st.RiskAssessmentThreatID, + "created_at": st.CreatedAt, + } + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk scenario threat: %w", err) + } + return nil +} + +func (st *RiskAssessmentScenarioThreat) Delete(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +DELETE FROM risk_assessment_scenario_threats +WHERE + %s + AND risk_assessment_scenario_id = @risk_assessment_scenario_id + AND risk_assessment_threat_id = @risk_assessment_threat_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "risk_assessment_scenario_id": st.RiskAssessmentScenarioID, + "risk_assessment_threat_id": st.RiskAssessmentThreatID, + } + maps.Copy(args, scope.SQLArguments()) + _, err := conn.Exec(ctx, q, args) + return err +} + +func (ts *RiskAssessmentThreats) LoadByScenarioID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + scenarioID gid.GID, + cursor *page.Cursor[RiskAssessmentThreatOrderField], +) error { + q := ` +WITH linked_threats AS ( + SELECT + risk_assessment_threat_id + FROM + risk_assessment_scenario_threats + WHERE + %s + AND risk_assessment_scenario_id = @scenario_id +) +SELECT + id, + organization_id, + risk_assessment_scope_id, + process_id, + name, + category, + created_at, + updated_at +FROM + risk_assessment_threats +WHERE + %s + AND id IN (SELECT risk_assessment_threat_id FROM linked_threats) + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"scenario_id": scenarioID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk scenario threats: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentThreat]) + if err != nil { + return fmt.Errorf("cannot collect risk scenario threats: %w", err) + } + *ts = results + return nil +} + +func (ts *RiskAssessmentThreats) CountByScenarioID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + scenarioID gid.GID, +) (int, error) { + q := ` +WITH linked_threats AS ( + SELECT + risk_assessment_threat_id + FROM + risk_assessment_scenario_threats + WHERE + %s + AND risk_assessment_scenario_id = @scenario_id +) +SELECT + COUNT(id) +FROM + risk_assessment_threats +WHERE + %s + AND id IN (SELECT risk_assessment_threat_id FROM linked_threats) +` + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment()) + args := pgx.NamedArgs{"scenario_id": scenarioID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk scenario threats: %w", err) + } + return count, nil +} diff --git a/pkg/coredata/risk_assessment_scope.go b/pkg/coredata/risk_assessment_scope.go new file mode 100644 index 000000000..926751f09 --- /dev/null +++ b/pkg/coredata/risk_assessment_scope.go @@ -0,0 +1,242 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentScope struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + RiskAssessmentID gid.GID `db:"risk_assessment_id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + RiskAssessmentScopes []*RiskAssessmentScope +) + +func (s *RiskAssessmentScope) CursorKey(orderBy RiskAssessmentScopeOrderField) page.CursorKey { + switch orderBy { + case RiskAssessmentScopeOrderFieldCreatedAt: + return page.CursorKey{ID: s.ID, Value: s.CreatedAt} + case RiskAssessmentScopeOrderFieldName: + return page.CursorKey{ID: s.ID, Value: s.Name} + } + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (s *RiskAssessmentScope) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM risk_assessment_scopes WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query risk assessment scope authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (ss *RiskAssessmentScopes) LoadByRiskAssessmentID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentID gid.GID, + cursor *page.Cursor[RiskAssessmentScopeOrderField], +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_id, + name, + created_at, + updated_at +FROM + risk_assessment_scopes +WHERE + %s + AND risk_assessment_id = @risk_assessment_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_id": riskAssessmentID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment scopes: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentScope]) + if err != nil { + return fmt.Errorf("cannot collect risk assessment scopes: %w", err) + } + *ss = results + return nil +} + +func (ss *RiskAssessmentScopes) CountByRiskAssessmentID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + risk_assessment_scopes +WHERE + %s + AND risk_assessment_id = @risk_assessment_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_id": riskAssessmentID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk assessment scopes: %w", err) + } + return count, nil +} + +func (s *RiskAssessmentScope) LoadByID(ctx context.Context, conn pg.Querier, scope Scoper, id gid.GID) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_id, + name, + created_at, + updated_at +FROM + risk_assessment_scopes +WHERE + %s + AND id = @id +LIMIT 1 +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment scope: %w", err) + } + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[RiskAssessmentScope]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect risk assessment scope: %w", err) + } + *s = result + return nil +} + +func (s *RiskAssessmentScope) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_scopes ( + id, + tenant_id, + organization_id, + risk_assessment_id, + name, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @risk_assessment_id, + @name, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "id": s.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": s.OrganizationID, + "risk_assessment_id": s.RiskAssessmentID, + "name": s.Name, + "created_at": s.CreatedAt, + "updated_at": s.UpdatedAt, + } + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk assessment scope: %w", err) + } + return nil +} + +func (s *RiskAssessmentScope) Update(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +UPDATE risk_assessment_scopes +SET + name = @name, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": s.ID, "name": s.Name, "updated_at": s.UpdatedAt} + maps.Copy(args, scope.SQLArguments()) + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update risk assessment scope: %w", err) + } + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + return nil +} + +func (s *RiskAssessmentScope) Delete(ctx context.Context, conn pg.Tx, scope Scoper, id gid.GID) error { + q := ` +DELETE FROM risk_assessment_scopes +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + _, err := conn.Exec(ctx, q, args) + return err +} diff --git a/pkg/coredata/risk_assessment_scope_order_field.go b/pkg/coredata/risk_assessment_scope_order_field.go new file mode 100644 index 000000000..1aa41a2e7 --- /dev/null +++ b/pkg/coredata/risk_assessment_scope_order_field.go @@ -0,0 +1,34 @@ +// 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 coredata + +type RiskAssessmentScopeOrderField string + +const ( + RiskAssessmentScopeOrderFieldCreatedAt RiskAssessmentScopeOrderField = "CREATED_AT" + RiskAssessmentScopeOrderFieldName RiskAssessmentScopeOrderField = "NAME" +) + +func (p RiskAssessmentScopeOrderField) Column() string { return string(p) } +func (p RiskAssessmentScopeOrderField) String() string { return string(p) } + +func (p RiskAssessmentScopeOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *RiskAssessmentScopeOrderField) UnmarshalText(text []byte) error { + *p = RiskAssessmentScopeOrderField(text) + return nil +} diff --git a/pkg/coredata/risk_assessment_threat.go b/pkg/coredata/risk_assessment_threat.go new file mode 100644 index 000000000..f63cb4921 --- /dev/null +++ b/pkg/coredata/risk_assessment_threat.go @@ -0,0 +1,262 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentThreat struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + RiskAssessmentScopeID gid.GID `db:"risk_assessment_scope_id"` + ProcessID gid.GID `db:"process_id"` + Name string `db:"name"` + Category string `db:"category"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + RiskAssessmentThreats []*RiskAssessmentThreat +) + +func (t *RiskAssessmentThreat) CursorKey(orderBy RiskAssessmentThreatOrderField) page.CursorKey { + switch orderBy { + case RiskAssessmentThreatOrderFieldCreatedAt: + return page.CursorKey{ID: t.ID, Value: t.CreatedAt} + case RiskAssessmentThreatOrderFieldName: + return page.CursorKey{ID: t.ID, Value: t.Name} + } + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (t *RiskAssessmentThreat) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM risk_assessment_threats WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, t.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query risk assessment threat authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (ts *RiskAssessmentThreats) LoadByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, + cursor *page.Cursor[RiskAssessmentThreatOrderField], +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + process_id, + name, + category, + created_at, + updated_at +FROM + risk_assessment_threats +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk threats: %w", err) + } + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentThreat]) + if err != nil { + return fmt.Errorf("cannot collect risk threats: %w", err) + } + *ts = results + return nil +} + +func (ts *RiskAssessmentThreats) CountByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + risk_assessment_threats +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count risk threats: %w", err) + } + return count, nil +} + +func (t *RiskAssessmentThreat) LoadByID(ctx context.Context, conn pg.Querier, scope Scoper, id gid.GID) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + process_id, + name, + category, + created_at, + updated_at +FROM + risk_assessment_threats +WHERE + %s + AND id = @id +LIMIT 1; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk threat: %w", err) + } + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[RiskAssessmentThreat]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect risk threat: %w", err) + } + *t = result + return nil +} + +func (t *RiskAssessmentThreat) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_threats ( + id, + tenant_id, + organization_id, + risk_assessment_scope_id, + process_id, + name, + category, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @risk_assessment_scope_id, + @process_id, + @name, + @category, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "id": t.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": t.OrganizationID, + "risk_assessment_scope_id": t.RiskAssessmentScopeID, + "process_id": t.ProcessID, + "name": t.Name, + "category": t.Category, + "created_at": t.CreatedAt, + "updated_at": t.UpdatedAt, + } + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + return fmt.Errorf("cannot insert risk threat: %w", err) + } + return nil +} + +func (t *RiskAssessmentThreat) Update(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +UPDATE risk_assessment_threats +SET + process_id = @process_id, + name = @name, + category = @category, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "id": t.ID, + "process_id": t.ProcessID, + "name": t.Name, + "category": t.Category, + "updated_at": t.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update risk threat: %w", err) + } + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + return nil +} + +func (t *RiskAssessmentThreat) Delete(ctx context.Context, conn pg.Tx, scope Scoper, id gid.GID) error { + q := ` +DELETE FROM risk_assessment_threats +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + _, err := conn.Exec(ctx, q, args) + return err +} diff --git a/pkg/coredata/risk_assessment_threat_order_field.go b/pkg/coredata/risk_assessment_threat_order_field.go new file mode 100644 index 000000000..0b41fbbe7 --- /dev/null +++ b/pkg/coredata/risk_assessment_threat_order_field.go @@ -0,0 +1,34 @@ +// 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 coredata + +type RiskAssessmentThreatOrderField string + +const ( + RiskAssessmentThreatOrderFieldCreatedAt RiskAssessmentThreatOrderField = "CREATED_AT" + RiskAssessmentThreatOrderFieldName RiskAssessmentThreatOrderField = "NAME" +) + +func (p RiskAssessmentThreatOrderField) Column() string { return string(p) } +func (p RiskAssessmentThreatOrderField) String() string { return string(p) } + +func (p RiskAssessmentThreatOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *RiskAssessmentThreatOrderField) UnmarshalText(text []byte) error { + *p = RiskAssessmentThreatOrderField(text) + return nil +} diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 890e4d176..ba3e2b4ab 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -396,6 +396,56 @@ const ( ActionCookieCategoryUpdate = "core:cookie-category:update" ActionCookieCategoryDelete = "core:cookie-category:delete" + // RiskAssessment actions + ActionRiskAssessmentGet = "core:risk-assessment:get" + ActionRiskAssessmentList = "core:risk-assessment:list" + ActionRiskAssessmentCreate = "core:risk-assessment:create" + ActionRiskAssessmentUpdate = "core:risk-assessment:update" + ActionRiskAssessmentDelete = "core:risk-assessment:delete" + + // RiskAssessmentScope actions + ActionRiskAssessmentScopeGet = "core:risk-assessment-scope:get" + ActionRiskAssessmentScopeList = "core:risk-assessment-scope:list" + ActionRiskAssessmentScopeCreate = "core:risk-assessment-scope:create" + ActionRiskAssessmentScopeUpdate = "core:risk-assessment-scope:update" + ActionRiskAssessmentScopeDelete = "core:risk-assessment-scope:delete" + + // RiskAssessmentNode actions + ActionRiskAssessmentNodeGet = "core:risk-assessment-node:get" + ActionRiskAssessmentNodeList = "core:risk-assessment-node:list" + ActionRiskAssessmentNodeCreate = "core:risk-assessment-node:create" + ActionRiskAssessmentNodeUpdate = "core:risk-assessment-node:update" + ActionRiskAssessmentNodeDelete = "core:risk-assessment-node:delete" + + // RiskAssessmentProcess actions + ActionRiskAssessmentProcessGet = "core:risk-assessment-process:get" + ActionRiskAssessmentProcessList = "core:risk-assessment-process:list" + ActionRiskAssessmentProcessCreate = "core:risk-assessment-process:create" + ActionRiskAssessmentProcessUpdate = "core:risk-assessment-process:update" + ActionRiskAssessmentProcessDelete = "core:risk-assessment-process:delete" + + // RiskAssessmentThreat actions + ActionRiskAssessmentThreatGet = "core:risk-assessment-threat:get" + ActionRiskAssessmentThreatList = "core:risk-assessment-threat:list" + ActionRiskAssessmentThreatCreate = "core:risk-assessment-threat:create" + ActionRiskAssessmentThreatUpdate = "core:risk-assessment-threat:update" + ActionRiskAssessmentThreatDelete = "core:risk-assessment-threat:delete" + + // RiskAssessmentScenario actions + ActionRiskAssessmentScenarioGet = "core:risk-assessment-scenario:get" + ActionRiskAssessmentScenarioList = "core:risk-assessment-scenario:list" + ActionRiskAssessmentScenarioCreate = "core:risk-assessment-scenario:create" + ActionRiskAssessmentScenarioUpdate = "core:risk-assessment-scenario:update" + ActionRiskAssessmentScenarioDelete = "core:risk-assessment-scenario:delete" + + // RiskAssessmentScenarioThreat actions + ActionRiskAssessmentScenarioThreatLink = "core:risk-assessment-scenario-threat:create" + ActionRiskAssessmentScenarioThreatUnlink = "core:risk-assessment-scenario-threat:delete" + + // RiskAssessmentScenarioRisk actions + ActionRiskAssessmentScenarioRiskLink = "core:risk-assessment-scenario-risk:create" + ActionRiskAssessmentScenarioRiskUnlink = "core:risk-assessment-scenario-risk:delete" + // Cookie actions ActionCookieGet = "core:cookie:get" ActionCookieList = "core:cookie:list" diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 85d29a516..58627878f 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -88,6 +88,12 @@ var ViewerPolicy = policy.NewPolicy( ActionCookieCategoryGet, ActionCookieCategoryList, ActionCookieGet, ActionCookieList, ActionCookieConsentRecordList, + ActionRiskAssessmentGet, ActionRiskAssessmentList, + ActionRiskAssessmentScopeGet, ActionRiskAssessmentScopeList, + ActionRiskAssessmentNodeGet, ActionRiskAssessmentNodeList, + ActionRiskAssessmentProcessGet, ActionRiskAssessmentProcessList, + ActionRiskAssessmentThreatGet, ActionRiskAssessmentThreatList, + ActionRiskAssessmentScenarioGet, ActionRiskAssessmentScenarioList, ).WithSID("entity-read-access").When(organizationCondition), policy.Allow( @@ -154,6 +160,12 @@ var AuditorPolicy = policy.NewPolicy( ActionFileGet, ActionFileDownloadUrl, ActionStatementOfApplicabilityGet, ActionStatementOfApplicabilityList, ActionApplicabilityStatementGet, ActionApplicabilityStatementList, + ActionRiskAssessmentGet, ActionRiskAssessmentList, + ActionRiskAssessmentScopeGet, ActionRiskAssessmentScopeList, + ActionRiskAssessmentNodeGet, ActionRiskAssessmentNodeList, + ActionRiskAssessmentProcessGet, ActionRiskAssessmentProcessList, + ActionRiskAssessmentThreatGet, ActionRiskAssessmentThreatList, + ActionRiskAssessmentScenarioGet, ActionRiskAssessmentScenarioList, ).WithSID("entity-read-access").When(organizationCondition), policy.Allow( diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 555ced30f..41cb533fb 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -65,6 +65,7 @@ import ( "go.probo.inc/probo/pkg/mailer" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server" "go.probo.inc/probo/pkg/server/trustedproxy" @@ -526,6 +527,7 @@ func (impl *Implm) Run( ) thirdPartyService := thirdparty.NewService(pgClient, fileService) + riskManagementService := riskmanagement.NewService(pgClient) serverHandler, err := server.NewServer( server.Config{ @@ -541,6 +543,7 @@ func (impl *Implm) Run( CookieBanner: cookieBannerService, Geoloc: geolocService, ThirdParty: thirdPartyService, + RiskManagement: riskManagementService, Slack: slackService, ConnectorRegistry: defaultConnectorRegistry, BaseURL: baseURL, diff --git a/pkg/riskmanagement/service.go b/pkg/riskmanagement/service.go new file mode 100644 index 000000000..2eec0e29f --- /dev/null +++ b/pkg/riskmanagement/service.go @@ -0,0 +1,1437 @@ +// 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 riskmanagement + +import ( + "context" + "fmt" + "time" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/validator" +) + +const ( + TitleMaxLength = 1000 + ContentMaxLength = 5000 +) + +type Service struct { + pg *pg.Client +} + +func NewService(pgClient *pg.Client) *Service { + return &Service{pg: pgClient} +} + +type ( + CreateRiskAssessmentRequest struct { + OrganizationID gid.GID + Name string + Description *string + } + + UpdateRiskAssessmentRequest struct { + ID gid.GID + Name *string + Description **string + } + + CreateRiskAssessmentScopeRequest struct { + RiskAssessmentID gid.GID + Name string + } + + UpdateRiskAssessmentScopeRequest struct { + ID gid.GID + Name *string + } + + CreateRiskAssessmentNodeRequest struct { + RiskAssessmentScopeID gid.GID + NodeType coredata.RiskAssessmentNodeType + Name string + } + + UpdateRiskAssessmentNodeRequest struct { + ID gid.GID + NodeType *coredata.RiskAssessmentNodeType + Name *string + } + + CreateRiskAssessmentProcessRequest struct { + RiskAssessmentScopeID gid.GID + SourceNodeID gid.GID + TargetNodeID gid.GID + Name string + } + + UpdateRiskAssessmentProcessRequest struct { + ID gid.GID + SourceNodeID *gid.GID + TargetNodeID *gid.GID + Name *string + } + + CreateRiskAssessmentThreatRequest struct { + RiskAssessmentScopeID gid.GID + ProcessID gid.GID + Name string + Category string + } + + UpdateRiskAssessmentThreatRequest struct { + ID gid.GID + ProcessID *gid.GID + Name *string + Category *string + } + + CreateRiskAssessmentScenarioRequest struct { + RiskAssessmentScopeID gid.GID + Name string + Description *string + } + + UpdateRiskAssessmentScenarioRequest struct { + ID gid.GID + Name *string + Description **string + } + + LinkRiskAssessmentScenarioThreatRequest struct { + RiskAssessmentScenarioID gid.GID + ThreatID gid.GID + } + + UnlinkRiskAssessmentScenarioThreatRequest struct { + RiskAssessmentScenarioID gid.GID + ThreatID gid.GID + } + + LinkRiskAssessmentScenarioRiskRequest struct { + RiskAssessmentScenarioID gid.GID + RiskID gid.GID + } + + UnlinkRiskAssessmentScenarioRiskRequest struct { + RiskAssessmentScenarioID gid.GID + RiskID gid.GID + } +) + +func (r *CreateRiskAssessmentRequest) Validate() error { + v := validator.New() + v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) + v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.Description, "description", validator.SafeText(ContentMaxLength)) + return v.Error() +} + +func (r *UpdateRiskAssessmentRequest) Validate() error { + v := validator.New() + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.RiskAssessmentEntityType)) + v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.Description, "description", validator.SafeText(ContentMaxLength)) + return v.Error() +} + +func (r *CreateRiskAssessmentScopeRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentID, "risk_assessment_id", validator.Required(), validator.GID(coredata.RiskAssessmentEntityType)) + v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength)) + return v.Error() +} + +func (r *UpdateRiskAssessmentScopeRequest) Validate() error { + v := validator.New() + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.RiskAssessmentScopeEntityType)) + v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) + return v.Error() +} + +func (r *CreateRiskAssessmentNodeRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScopeID, "risk_assessment_scope_id", validator.Required(), validator.GID(coredata.RiskAssessmentScopeEntityType)) + v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.NodeType, "node_type", validator.Required(), validator.OneOfSlice(coredata.RiskAssessmentNodeTypes())) + return v.Error() +} + +func (r *UpdateRiskAssessmentNodeRequest) Validate() error { + v := validator.New() + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.RiskAssessmentNodeEntityType)) + v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.NodeType, "node_type", validator.OneOfSlice(coredata.RiskAssessmentNodeTypes())) + return v.Error() +} + +func (r *CreateRiskAssessmentProcessRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScopeID, "risk_assessment_scope_id", validator.Required(), validator.GID(coredata.RiskAssessmentScopeEntityType)) + v.Check(r.SourceNodeID, "source_node_id", validator.Required(), validator.GID(coredata.RiskAssessmentNodeEntityType)) + v.Check(r.TargetNodeID, "target_node_id", validator.Required(), validator.GID(coredata.RiskAssessmentNodeEntityType)) + v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength)) + return v.Error() +} + +func (r *UpdateRiskAssessmentProcessRequest) Validate() error { + v := validator.New() + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.RiskAssessmentProcessEntityType)) + v.Check(r.SourceNodeID, "source_node_id", validator.GID(coredata.RiskAssessmentNodeEntityType)) + v.Check(r.TargetNodeID, "target_node_id", validator.GID(coredata.RiskAssessmentNodeEntityType)) + v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) + return v.Error() +} + +func (r *CreateRiskAssessmentThreatRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScopeID, "risk_assessment_scope_id", validator.Required(), validator.GID(coredata.RiskAssessmentScopeEntityType)) + v.Check(r.ProcessID, "process_id", validator.Required(), validator.GID(coredata.RiskAssessmentProcessEntityType)) + v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.Category, "category", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength)) + return v.Error() +} + +func (r *UpdateRiskAssessmentThreatRequest) Validate() error { + v := validator.New() + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.RiskAssessmentThreatEntityType)) + v.Check(r.ProcessID, "process_id", validator.GID(coredata.RiskAssessmentProcessEntityType)) + v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.Category, "category", validator.SafeTextNoNewLine(TitleMaxLength)) + return v.Error() +} + +func (r *CreateRiskAssessmentScenarioRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScopeID, "risk_assessment_scope_id", validator.Required(), validator.GID(coredata.RiskAssessmentScopeEntityType)) + v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.Description, "description", validator.SafeText(ContentMaxLength)) + return v.Error() +} + +func (r *LinkRiskAssessmentScenarioThreatRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScenarioID, "risk_scenario_id", validator.Required(), validator.GID(coredata.RiskAssessmentScenarioEntityType)) + v.Check(r.ThreatID, "threat_id", validator.Required(), validator.GID(coredata.RiskAssessmentThreatEntityType)) + return v.Error() +} + +func (r *UnlinkRiskAssessmentScenarioThreatRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScenarioID, "risk_scenario_id", validator.Required(), validator.GID(coredata.RiskAssessmentScenarioEntityType)) + v.Check(r.ThreatID, "threat_id", validator.Required(), validator.GID(coredata.RiskAssessmentThreatEntityType)) + return v.Error() +} + +func (r *LinkRiskAssessmentScenarioRiskRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScenarioID, "risk_scenario_id", validator.Required(), validator.GID(coredata.RiskAssessmentScenarioEntityType)) + v.Check(r.RiskID, "risk_id", validator.Required(), validator.GID(coredata.RiskEntityType)) + return v.Error() +} + +func (r *UnlinkRiskAssessmentScenarioRiskRequest) Validate() error { + v := validator.New() + v.Check(r.RiskAssessmentScenarioID, "risk_scenario_id", validator.Required(), validator.GID(coredata.RiskAssessmentScenarioEntityType)) + v.Check(r.RiskID, "risk_id", validator.Required(), validator.GID(coredata.RiskEntityType)) + return v.Error() +} + +func (r *UpdateRiskAssessmentScenarioRequest) Validate() error { + v := validator.New() + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.RiskAssessmentScenarioEntityType)) + v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) + v.Check(r.Description, "description", validator.SafeText(ContentMaxLength)) + return v.Error() +} + +func (s *Service) Create(ctx context.Context, scope coredata.Scoper, req CreateRiskAssessmentRequest) (*coredata.RiskAssessment, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + ra := &coredata.RiskAssessment{ + ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentEntityType), + OrganizationID: req.OrganizationID, + Name: req.Name, + Description: req.Description, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := ra.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert risk assessment: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return ra, nil +} + +func (s *Service) Get(ctx context.Context, scope coredata.Scoper, id gid.GID) (*coredata.RiskAssessment, error) { + ra := &coredata.RiskAssessment{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := ra.LoadByID(ctx, conn, scope, id); err != nil { + return fmt.Errorf("cannot load risk assessment: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return ra, nil +} + +func (s *Service) Update(ctx context.Context, scope coredata.Scoper, req UpdateRiskAssessmentRequest) (*coredata.RiskAssessment, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + ra := &coredata.RiskAssessment{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := ra.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load risk assessment: %w", err) + } + if req.Name != nil { + ra.Name = *req.Name + } + if req.Description != nil { + ra.Description = *req.Description + } + ra.UpdatedAt = time.Now() + if err := ra.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update risk assessment: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return ra, nil +} + +func (s *Service) Delete(ctx context.Context, scope coredata.Scoper, id gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + ra := &coredata.RiskAssessment{} + if err := ra.Delete(ctx, tx, scope, id); err != nil { + return fmt.Errorf("cannot delete risk assessment: %w", err) + } + return nil + }, + ) +} + +func (s *Service) ListForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentOrderField], +) (*page.Page[*coredata.RiskAssessment, coredata.RiskAssessmentOrderField], error) { + var results coredata.RiskAssessments + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor); err != nil { + return fmt.Errorf("cannot list risk assessments: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, +) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ras := &coredata.RiskAssessments{} + count, err = ras.CountByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot count risk assessments: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) CreateScope(ctx context.Context, scope coredata.Scoper, req CreateRiskAssessmentScopeRequest) (*coredata.RiskAssessmentScope, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + raScope := &coredata.RiskAssessmentScope{ + ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentScopeEntityType), + RiskAssessmentID: req.RiskAssessmentID, + Name: req.Name, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + ra := coredata.RiskAssessment{} + if err := ra.LoadByID(ctx, tx, scope, req.RiskAssessmentID); err != nil { + return fmt.Errorf("cannot load risk assessment: %w", err) + } + raScope.OrganizationID = ra.OrganizationID + if err := raScope.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert risk assessment scope: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return raScope, nil +} + +func (s *Service) GetScope(ctx context.Context, scope coredata.Scoper, id gid.GID) (*coredata.RiskAssessmentScope, error) { + raScope := &coredata.RiskAssessmentScope{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := raScope.LoadByID(ctx, conn, scope, id); err != nil { + return fmt.Errorf("cannot load risk assessment scope: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return raScope, nil +} + +func (s *Service) UpdateScope(ctx context.Context, scope coredata.Scoper, req UpdateRiskAssessmentScopeRequest) (*coredata.RiskAssessmentScope, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + raScope := &coredata.RiskAssessmentScope{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := raScope.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load risk assessment scope: %w", err) + } + if req.Name != nil { + raScope.Name = *req.Name + } + raScope.UpdatedAt = time.Now() + if err := raScope.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update risk assessment scope: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return raScope, nil +} + +func (s *Service) DeleteScope(ctx context.Context, scope coredata.Scoper, id gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + raScope := &coredata.RiskAssessmentScope{} + if err := raScope.Delete(ctx, tx, scope, id); err != nil { + return fmt.Errorf("cannot delete risk assessment scope: %w", err) + } + return nil + }, + ) +} + +func (s *Service) ListScopesForRiskAssessmentID( + ctx context.Context, + scope coredata.Scoper, + riskAssessmentID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentScopeOrderField], +) (*page.Page[*coredata.RiskAssessmentScope, coredata.RiskAssessmentScopeOrderField], error) { + var results coredata.RiskAssessmentScopes + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByRiskAssessmentID(ctx, conn, scope, riskAssessmentID, cursor); err != nil { + return fmt.Errorf("cannot list risk assessment scopes: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountScopesForRiskAssessmentID(ctx context.Context, scope coredata.Scoper, riskAssessmentID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ss := &coredata.RiskAssessmentScopes{} + count, err = ss.CountByRiskAssessmentID(ctx, conn, scope, riskAssessmentID) + if err != nil { + return fmt.Errorf("cannot count risk assessment scopes: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) CreateNode(ctx context.Context, scope coredata.Scoper, req CreateRiskAssessmentNodeRequest) (*coredata.RiskAssessmentNode, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + node := &coredata.RiskAssessmentNode{ + ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentNodeEntityType), + RiskAssessmentScopeID: req.RiskAssessmentScopeID, + NodeType: req.NodeType, + Name: req.Name, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + raScope := coredata.RiskAssessmentScope{} + if err := raScope.LoadByID(ctx, tx, scope, req.RiskAssessmentScopeID); err != nil { + return fmt.Errorf("cannot load risk assessment scope: %w", err) + } + node.OrganizationID = raScope.OrganizationID + if err := node.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert risk assessment node: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return node, nil +} + +func (s *Service) GetNode(ctx context.Context, scope coredata.Scoper, id gid.GID) (*coredata.RiskAssessmentNode, error) { + node := &coredata.RiskAssessmentNode{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := node.LoadByID(ctx, conn, scope, id); err != nil { + return fmt.Errorf("cannot load risk assessment node: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return node, nil +} + +func (s *Service) UpdateNode(ctx context.Context, scope coredata.Scoper, req UpdateRiskAssessmentNodeRequest) (*coredata.RiskAssessmentNode, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + node := &coredata.RiskAssessmentNode{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := node.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load risk assessment node: %w", err) + } + if req.Name != nil { + node.Name = *req.Name + } + if req.NodeType != nil { + node.NodeType = *req.NodeType + } + node.UpdatedAt = time.Now() + if err := node.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update risk assessment node: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return node, nil +} + +func (s *Service) DeleteNode(ctx context.Context, scope coredata.Scoper, id gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + node := &coredata.RiskAssessmentNode{} + if err := node.Delete(ctx, tx, scope, id); err != nil { + return fmt.Errorf("cannot delete risk assessment node: %w", err) + } + return nil + }, + ) +} + +func (s *Service) ListNodesForScopeID( + ctx context.Context, + scope coredata.Scoper, + scopeID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentNodeOrderField], +) (*page.Page[*coredata.RiskAssessmentNode, coredata.RiskAssessmentNodeOrderField], error) { + var results coredata.RiskAssessmentNodes + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByRiskAssessmentScopeID(ctx, conn, scope, scopeID, cursor); err != nil { + return fmt.Errorf("cannot list risk assessment nodes: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountNodesForScopeID(ctx context.Context, scope coredata.Scoper, scopeID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ns := &coredata.RiskAssessmentNodes{} + count, err = ns.CountByRiskAssessmentScopeID(ctx, conn, scope, scopeID) + if err != nil { + return fmt.Errorf("cannot count risk assessment nodes: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) CreateProcess(ctx context.Context, scope coredata.Scoper, req CreateRiskAssessmentProcessRequest) (*coredata.RiskAssessmentProcess, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + process := &coredata.RiskAssessmentProcess{ + ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentProcessEntityType), + RiskAssessmentScopeID: req.RiskAssessmentScopeID, + SourceNodeID: req.SourceNodeID, + TargetNodeID: req.TargetNodeID, + Name: req.Name, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + raScope := coredata.RiskAssessmentScope{} + if err := raScope.LoadByID(ctx, tx, scope, req.RiskAssessmentScopeID); err != nil { + return fmt.Errorf("cannot load risk assessment scope: %w", err) + } + process.OrganizationID = raScope.OrganizationID + if err := s.assertNodeInScope(ctx, tx, scope, req.SourceNodeID, req.RiskAssessmentScopeID, "source_node_id"); err != nil { + return err + } + if err := s.assertNodeInScope(ctx, tx, scope, req.TargetNodeID, req.RiskAssessmentScopeID, "target_node_id"); err != nil { + return err + } + if err := process.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert risk assessment process: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return process, nil +} + +func (s *Service) GetProcess(ctx context.Context, scope coredata.Scoper, id gid.GID) (*coredata.RiskAssessmentProcess, error) { + process := &coredata.RiskAssessmentProcess{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := process.LoadByID(ctx, conn, scope, id); err != nil { + return fmt.Errorf("cannot load risk assessment process: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return process, nil +} + +func (s *Service) UpdateProcess(ctx context.Context, scope coredata.Scoper, req UpdateRiskAssessmentProcessRequest) (*coredata.RiskAssessmentProcess, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + process := &coredata.RiskAssessmentProcess{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := process.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load risk assessment process: %w", err) + } + if req.SourceNodeID != nil { + if err := s.assertNodeInScope(ctx, tx, scope, *req.SourceNodeID, process.RiskAssessmentScopeID, "source_node_id"); err != nil { + return err + } + process.SourceNodeID = *req.SourceNodeID + } + if req.TargetNodeID != nil { + if err := s.assertNodeInScope(ctx, tx, scope, *req.TargetNodeID, process.RiskAssessmentScopeID, "target_node_id"); err != nil { + return err + } + process.TargetNodeID = *req.TargetNodeID + } + if req.Name != nil { + process.Name = *req.Name + } + process.UpdatedAt = time.Now() + if err := process.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update risk assessment process: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return process, nil +} + +func (s *Service) DeleteProcess(ctx context.Context, scope coredata.Scoper, id gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + process := &coredata.RiskAssessmentProcess{} + if err := process.Delete(ctx, tx, scope, id); err != nil { + return fmt.Errorf("cannot delete risk assessment process: %w", err) + } + return nil + }, + ) +} + +func (s *Service) ListProcessesForScopeID( + ctx context.Context, + scope coredata.Scoper, + scopeID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentProcessOrderField], +) (*page.Page[*coredata.RiskAssessmentProcess, coredata.RiskAssessmentProcessOrderField], error) { + var results coredata.RiskAssessmentProcesses + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByRiskAssessmentScopeID(ctx, conn, scope, scopeID, cursor); err != nil { + return fmt.Errorf("cannot list risk assessment processes: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountProcessesForScopeID(ctx context.Context, scope coredata.Scoper, scopeID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ps := &coredata.RiskAssessmentProcesses{} + count, err = ps.CountByRiskAssessmentScopeID(ctx, conn, scope, scopeID) + if err != nil { + return fmt.Errorf("cannot count risk assessment processes: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) CreateThreat(ctx context.Context, scope coredata.Scoper, req CreateRiskAssessmentThreatRequest) (*coredata.RiskAssessmentThreat, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + threat := &coredata.RiskAssessmentThreat{ + ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentThreatEntityType), + RiskAssessmentScopeID: req.RiskAssessmentScopeID, + ProcessID: req.ProcessID, + Name: req.Name, + Category: req.Category, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + raScope := coredata.RiskAssessmentScope{} + if err := raScope.LoadByID(ctx, tx, scope, req.RiskAssessmentScopeID); err != nil { + return fmt.Errorf("cannot load risk assessment scope: %w", err) + } + threat.OrganizationID = raScope.OrganizationID + if err := s.assertProcessInScope(ctx, tx, scope, req.ProcessID, req.RiskAssessmentScopeID, "process_id"); err != nil { + return err + } + if err := threat.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert risk threat: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return threat, nil +} + +func (s *Service) GetThreat(ctx context.Context, scope coredata.Scoper, id gid.GID) (*coredata.RiskAssessmentThreat, error) { + threat := &coredata.RiskAssessmentThreat{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := threat.LoadByID(ctx, conn, scope, id); err != nil { + return fmt.Errorf("cannot load risk threat: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return threat, nil +} + +func (s *Service) UpdateThreat(ctx context.Context, scope coredata.Scoper, req UpdateRiskAssessmentThreatRequest) (*coredata.RiskAssessmentThreat, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + threat := &coredata.RiskAssessmentThreat{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := threat.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load risk threat: %w", err) + } + if req.ProcessID != nil { + if err := s.assertProcessInScope(ctx, tx, scope, *req.ProcessID, threat.RiskAssessmentScopeID, "process_id"); err != nil { + return err + } + threat.ProcessID = *req.ProcessID + } + if req.Name != nil { + threat.Name = *req.Name + } + if req.Category != nil { + threat.Category = *req.Category + } + threat.UpdatedAt = time.Now() + if err := threat.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update risk threat: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return threat, nil +} + +func (s *Service) DeleteThreat(ctx context.Context, scope coredata.Scoper, id gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + threat := &coredata.RiskAssessmentThreat{} + if err := threat.Delete(ctx, tx, scope, id); err != nil { + return fmt.Errorf("cannot delete risk threat: %w", err) + } + return nil + }, + ) +} + +func (s *Service) ListThreatsForScopeID( + ctx context.Context, + scope coredata.Scoper, + scopeID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentThreatOrderField], +) (*page.Page[*coredata.RiskAssessmentThreat, coredata.RiskAssessmentThreatOrderField], error) { + var results coredata.RiskAssessmentThreats + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByRiskAssessmentScopeID(ctx, conn, scope, scopeID, cursor); err != nil { + return fmt.Errorf("cannot list risk threats: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountThreatsForScopeID(ctx context.Context, scope coredata.Scoper, scopeID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ts := &coredata.RiskAssessmentThreats{} + count, err = ts.CountByRiskAssessmentScopeID(ctx, conn, scope, scopeID) + if err != nil { + return fmt.Errorf("cannot count risk threats: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) CreateScenario(ctx context.Context, scope coredata.Scoper, req CreateRiskAssessmentScenarioRequest) (*coredata.RiskAssessmentScenario, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + scenario := &coredata.RiskAssessmentScenario{ + ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentScenarioEntityType), + RiskAssessmentScopeID: req.RiskAssessmentScopeID, + Name: req.Name, + Description: req.Description, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + raScope := coredata.RiskAssessmentScope{} + if err := raScope.LoadByID(ctx, tx, scope, req.RiskAssessmentScopeID); err != nil { + return fmt.Errorf("cannot load risk assessment scope: %w", err) + } + scenario.OrganizationID = raScope.OrganizationID + if err := scenario.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert risk scenario: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return scenario, nil +} + +func (s *Service) GetScenario(ctx context.Context, scope coredata.Scoper, id gid.GID) (*coredata.RiskAssessmentScenario, error) { + scenario := &coredata.RiskAssessmentScenario{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := scenario.LoadByID(ctx, conn, scope, id); err != nil { + return fmt.Errorf("cannot load risk scenario: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return scenario, nil +} + +func (s *Service) UpdateScenario(ctx context.Context, scope coredata.Scoper, req UpdateRiskAssessmentScenarioRequest) (*coredata.RiskAssessmentScenario, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + scenario := &coredata.RiskAssessmentScenario{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := scenario.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load risk scenario: %w", err) + } + if req.Name != nil { + scenario.Name = *req.Name + } + if req.Description != nil { + scenario.Description = *req.Description + } + scenario.UpdatedAt = time.Now() + if err := scenario.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update risk scenario: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return scenario, nil +} + +func (s *Service) DeleteScenario(ctx context.Context, scope coredata.Scoper, id gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + scenario := &coredata.RiskAssessmentScenario{} + if err := scenario.Delete(ctx, tx, scope, id); err != nil { + return fmt.Errorf("cannot delete risk scenario: %w", err) + } + return nil + }, + ) +} + +func (s *Service) ListScenariosForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentScenarioOrderField], +) (*page.Page[*coredata.RiskAssessmentScenario, coredata.RiskAssessmentScenarioOrderField], error) { + var results coredata.RiskAssessmentScenarios + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor); err != nil { + return fmt.Errorf("cannot list risk scenarios: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountScenariosForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, +) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ss := &coredata.RiskAssessmentScenarios{} + count, err = ss.CountByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot count risk scenarios: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) ListScenariosForRiskID( + ctx context.Context, + scope coredata.Scoper, + riskID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentScenarioOrderField], +) (*page.Page[*coredata.RiskAssessmentScenario, coredata.RiskAssessmentScenarioOrderField], error) { + var results coredata.RiskAssessmentScenarios + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByRiskID(ctx, conn, scope, riskID, cursor); err != nil { + return fmt.Errorf("cannot list risk scenarios: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountScenariosForRiskID(ctx context.Context, scope coredata.Scoper, riskID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ss := &coredata.RiskAssessmentScenarios{} + count, err = ss.CountByRiskID(ctx, conn, scope, riskID) + if err != nil { + return fmt.Errorf("cannot count risk scenarios: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) ListScenariosForScopeID( + ctx context.Context, + scope coredata.Scoper, + scopeID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentScenarioOrderField], +) (*page.Page[*coredata.RiskAssessmentScenario, coredata.RiskAssessmentScenarioOrderField], error) { + var results coredata.RiskAssessmentScenarios + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByRiskAssessmentScopeID(ctx, conn, scope, scopeID, cursor); err != nil { + return fmt.Errorf("cannot list risk scenarios: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountScenariosForScopeID(ctx context.Context, scope coredata.Scoper, scopeID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ss := &coredata.RiskAssessmentScenarios{} + count, err = ss.CountByRiskAssessmentScopeID(ctx, conn, scope, scopeID) + if err != nil { + return fmt.Errorf("cannot count risk scenarios: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) LinkScenarioThreat(ctx context.Context, scope coredata.Scoper, req LinkRiskAssessmentScenarioThreatRequest) error { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + scenario := coredata.RiskAssessmentScenario{} + if err := scenario.LoadByID(ctx, tx, scope, req.RiskAssessmentScenarioID); err != nil { + return fmt.Errorf("cannot load risk scenario: %w", err) + } + threat := coredata.RiskAssessmentThreat{} + if err := threat.LoadByID(ctx, tx, scope, req.ThreatID); err != nil { + return fmt.Errorf("cannot load threat: %w", err) + } + if scenario.OrganizationID != threat.OrganizationID { + return validator.ValidationErrors{{ + Field: "threat_id", + Code: validator.ErrorCodeCustom, + Message: "threat and scenario must belong to the same organization", + }} + } + link := &coredata.RiskAssessmentScenarioThreat{ + RiskAssessmentScenarioID: req.RiskAssessmentScenarioID, + RiskAssessmentThreatID: req.ThreatID, + CreatedAt: time.Now(), + } + if err := link.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot link scenario threat: %w", err) + } + return nil + }, + ) +} + +func (s *Service) UnlinkScenarioThreat(ctx context.Context, scope coredata.Scoper, req UnlinkRiskAssessmentScenarioThreatRequest) error { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + link := &coredata.RiskAssessmentScenarioThreat{ + RiskAssessmentScenarioID: req.RiskAssessmentScenarioID, + RiskAssessmentThreatID: req.ThreatID, + } + if err := link.Delete(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot unlink scenario threat: %w", err) + } + return nil + }, + ) +} + +func (s *Service) LinkScenarioRisk(ctx context.Context, scope coredata.Scoper, req LinkRiskAssessmentScenarioRiskRequest) error { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + scenario := coredata.RiskAssessmentScenario{} + if err := scenario.LoadByID(ctx, tx, scope, req.RiskAssessmentScenarioID); err != nil { + return fmt.Errorf("cannot load risk scenario: %w", err) + } + risk := coredata.Risk{} + if err := risk.LoadByID(ctx, tx, scope, req.RiskID); err != nil { + return fmt.Errorf("cannot load risk: %w", err) + } + if scenario.OrganizationID != risk.OrganizationID { + return validator.ValidationErrors{{ + Field: "risk_id", + Code: validator.ErrorCodeCustom, + Message: "risk and scenario must belong to the same organization", + }} + } + link := &coredata.RiskAssessmentScenarioRisk{ + RiskAssessmentScenarioID: req.RiskAssessmentScenarioID, + RiskID: req.RiskID, + CreatedAt: time.Now(), + } + if err := link.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot link scenario risk: %w", err) + } + return nil + }, + ) +} + +func (s *Service) UnlinkScenarioRisk(ctx context.Context, scope coredata.Scoper, req UnlinkRiskAssessmentScenarioRiskRequest) error { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + link := &coredata.RiskAssessmentScenarioRisk{ + RiskAssessmentScenarioID: req.RiskAssessmentScenarioID, + RiskID: req.RiskID, + } + if err := link.Delete(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot unlink scenario risk: %w", err) + } + return nil + }, + ) +} + +func (s *Service) ListThreatsForScenarioID( + ctx context.Context, + scope coredata.Scoper, + scenarioID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentThreatOrderField], +) (*page.Page[*coredata.RiskAssessmentThreat, coredata.RiskAssessmentThreatOrderField], error) { + var results coredata.RiskAssessmentThreats + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByScenarioID(ctx, conn, scope, scenarioID, cursor); err != nil { + return fmt.Errorf("cannot list scenario threats: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountThreatsForScenarioID(ctx context.Context, scope coredata.Scoper, scenarioID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + ts := &coredata.RiskAssessmentThreats{} + count, err = ts.CountByScenarioID(ctx, conn, scope, scenarioID) + if err != nil { + return fmt.Errorf("cannot count scenario threats: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) ListRisksForScenarioID( + ctx context.Context, + scope coredata.Scoper, + scenarioID gid.GID, + cursor *page.Cursor[coredata.RiskOrderField], +) (*page.Page[*coredata.Risk, coredata.RiskOrderField], error) { + var results coredata.Risks + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := results.LoadByScenarioID(ctx, conn, scope, scenarioID, cursor); err != nil { + return fmt.Errorf("cannot list scenario risks: %w", err) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountRisksForScenarioID(ctx context.Context, scope coredata.Scoper, scenarioID gid.GID) (int, error) { + var count int + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + rs := &coredata.Risks{} + count, err = rs.CountByScenarioID(ctx, conn, scope, scenarioID) + if err != nil { + return fmt.Errorf("cannot count scenario risks: %w", err) + } + return nil + }, + ) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *Service) assertNodeInScope( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + nodeID gid.GID, + scopeID gid.GID, + field string, +) error { + node := &coredata.RiskAssessmentNode{} + if err := node.LoadByID(ctx, tx, scope, nodeID); err != nil { + return validator.ValidationErrors{{ + Field: field, + Code: validator.ErrorCodeCustom, + Message: "node not found", + }} + } + if node.RiskAssessmentScopeID != scopeID { + return validator.ValidationErrors{{ + Field: field, + Code: validator.ErrorCodeCustom, + Message: "node does not belong to this scope", + }} + } + return nil +} + +func (s *Service) assertProcessInScope( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + processID gid.GID, + scopeID gid.GID, + field string, +) error { + process := &coredata.RiskAssessmentProcess{} + if err := process.LoadByID(ctx, tx, scope, processID); err != nil { + return validator.ValidationErrors{{ + Field: field, + Code: validator.ErrorCodeCustom, + Message: "process not found", + }} + } + if process.RiskAssessmentScopeID != scopeID { + return validator.ValidationErrors{{ + Field: field, + Code: validator.ErrorCodeCustom, + Message: "process does not belong to this scope", + }} + } + return nil +} diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index c2abacf91..a750b11ca 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -35,6 +35,7 @@ import ( "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/securecookie" connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1" console_v1 "go.probo.inc/probo/pkg/server/api/console/v1" @@ -63,6 +64,7 @@ type ( CookieBanner *cookiebanner.Service Geoloc *geoloc.Service ThirdParty *thirdparty.Service + RiskManagement *riskmanagement.Service Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry @@ -193,6 +195,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.BaseURL, cfg.CustomDomainCname, cfg.ThirdParty, + cfg.RiskManagement, ), cookieBannerHandler: cookiebanner_v1.NewMux( cfg.Logger.Named("cookiebanner.v1"), diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 37d0d971e..169c07a6c 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -111,6 +111,66 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error } return types.NewRisk(risk), nil } + case coredata.RiskAssessmentEntityType: + action = probo.ActionRiskAssessmentGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + ra, err := r.riskManagement.Get(ctx, scope, id) + if err != nil { + return nil, err + } + return types.NewRiskAssessment(ra), nil + } + case coredata.RiskAssessmentNodeEntityType: + action = probo.ActionRiskAssessmentNodeGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + n, err := r.riskManagement.GetNode(ctx, scope, id) + if err != nil { + return nil, err + } + return types.NewRiskAssessmentNode(n), nil + } + case coredata.RiskAssessmentProcessEntityType: + action = probo.ActionRiskAssessmentProcessGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + p, err := r.riskManagement.GetProcess(ctx, scope, id) + if err != nil { + return nil, err + } + return types.NewRiskAssessmentProcess(p), nil + } + case coredata.RiskAssessmentThreatEntityType: + action = probo.ActionRiskAssessmentThreatGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + t, err := r.riskManagement.GetThreat(ctx, scope, id) + if err != nil { + return nil, err + } + return types.NewRiskAssessmentThreat(t), nil + } + case coredata.RiskAssessmentScopeEntityType: + action = probo.ActionRiskAssessmentScopeGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + s, err := r.riskManagement.GetScope(ctx, scope, id) + if err != nil { + return nil, err + } + return types.NewRiskAssessmentScope(s), nil + } + case coredata.RiskAssessmentScenarioEntityType: + action = probo.ActionRiskAssessmentScenarioGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + s, err := r.riskManagement.GetScenario(ctx, scope, id) + if err != nil { + return nil, err + } + return types.NewRiskAssessmentScenario(s), nil + } case coredata.ThirdPartyComplianceReportEntityType: action = probo.ActionThirdPartyComplianceReportGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index d0e7e4be0..c32df773b 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -291,6 +291,22 @@ type Organization implements Node { risksDocument: Document @goField(forceResolver: true) + riskAssessments( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentOrder + ): RiskAssessmentConnection! @goField(forceResolver: true) + + riskAssessmentScenarios( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentScenarioOrder + ): RiskAssessmentScenarioConnection @goField(forceResolver: true) + tasks( first: Int after: CursorKey diff --git a/pkg/server/api/console/v1/graphql/risk.graphql b/pkg/server/api/console/v1/graphql/risk.graphql index 18778da8d..831e37664 100644 --- a/pkg/server/api/console/v1/graphql/risk.graphql +++ b/pkg/server/api/console/v1/graphql/risk.graphql @@ -107,6 +107,14 @@ type Risk implements Node { orderBy: ObligationOrder ): ObligationConnection! @goField(forceResolver: true) + scenarios( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentScenarioOrder + ): RiskAssessmentScenarioConnection! @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! diff --git a/pkg/server/api/console/v1/graphql/risk_assessment.graphql b/pkg/server/api/console/v1/graphql/risk_assessment.graphql new file mode 100644 index 000000000..a1371322e --- /dev/null +++ b/pkg/server/api/console/v1/graphql/risk_assessment.graphql @@ -0,0 +1,663 @@ +# 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. + +# Enums + +enum RiskAssessmentOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentOrderFieldCreatedAt" + ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentOrderFieldName" + ) +} + +enum RiskAssessmentScopeOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentScopeOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentScopeOrderFieldCreatedAt" + ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentScopeOrderFieldName" + ) +} + +enum RiskAssessmentNodeType + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeType" + ) { + ENTITY + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeTypeEntity" + ) + BOUNDARY + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeTypeBoundary" + ) + ASSET + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeTypeAsset" + ) + DATA + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeTypeData" + ) +} + +enum RiskAssessmentNodeOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeOrderFieldCreatedAt" + ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeOrderFieldName" + ) +} + +enum RiskAssessmentProcessOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentProcessOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentProcessOrderFieldCreatedAt" + ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentProcessOrderFieldName" + ) +} + +enum RiskAssessmentThreatOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentThreatOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentThreatOrderFieldCreatedAt" + ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentThreatOrderFieldName" + ) +} + +enum RiskAssessmentScenarioOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentScenarioOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentScenarioOrderFieldCreatedAt" + ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentScenarioOrderFieldName" + ) +} + +# Input types + +input RiskAssessmentOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentOrderBy" + ) { + direction: OrderDirection! + field: RiskAssessmentOrderField! +} + +input RiskAssessmentScopeOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentScopeOrderBy" + ) { + direction: OrderDirection! + field: RiskAssessmentScopeOrderField! +} + +input RiskAssessmentNodeOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentNodeOrderBy" + ) { + direction: OrderDirection! + field: RiskAssessmentNodeOrderField! +} + +input RiskAssessmentProcessOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentProcessOrderBy" + ) { + direction: OrderDirection! + field: RiskAssessmentProcessOrderField! +} + +input RiskAssessmentThreatOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentThreatOrderBy" + ) { + direction: OrderDirection! + field: RiskAssessmentThreatOrderField! +} + +input RiskAssessmentScenarioOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentScenarioOrderBy" + ) { + direction: OrderDirection! + field: RiskAssessmentScenarioOrderField! +} + +# Types + +type RiskAssessment implements Node { + id: ID! + name: String! + description: String + organization: Organization @goField(forceResolver: true) + + scopes( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentScopeOrder + ): RiskAssessmentScopeConnection @goField(forceResolver: true) + + createdAt: Datetime! + updatedAt: Datetime! + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +type RiskAssessmentScope implements Node { + id: ID! + riskAssessmentId: ID! + name: String! + + nodes( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentNodeOrder + ): RiskAssessmentNodeConnection @goField(forceResolver: true) + + processes( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentProcessOrder + ): RiskAssessmentProcessConnection @goField(forceResolver: true) + + threats( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentThreatOrder + ): RiskAssessmentThreatConnection @goField(forceResolver: true) + + scenarios( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentScenarioOrder + ): RiskAssessmentScenarioConnection @goField(forceResolver: true) + + createdAt: Datetime! + updatedAt: Datetime! +} + +type RiskAssessmentNode implements Node { + id: ID! + riskAssessmentScopeId: ID! + nodeType: RiskAssessmentNodeType! + name: String! + createdAt: Datetime! + updatedAt: Datetime! +} + +type RiskAssessmentProcess implements Node { + id: ID! + riskAssessmentScopeId: ID! + sourceNodeId: ID! + targetNodeId: ID! + name: String! + createdAt: Datetime! + updatedAt: Datetime! +} + +type RiskAssessmentThreat implements Node { + id: ID! + riskAssessmentScopeId: ID! + processId: ID! + name: String! + category: String! + createdAt: Datetime! + updatedAt: Datetime! +} + +type RiskAssessmentScenario implements Node { + id: ID! + riskAssessmentScopeId: ID! + scope: RiskAssessmentScope @goField(forceResolver: true) + name: String! + description: String + + threats( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentThreatOrder + ): RiskAssessmentThreatConnection @goField(forceResolver: true) + + risks( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskOrder + ): RiskConnection @goField(forceResolver: true) + + createdAt: Datetime! + updatedAt: Datetime! +} + +# Connections + +type RiskAssessmentConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentConnection" + ) { + totalCount: Int @goField(forceResolver: true) + edges: [RiskAssessmentConnectionEdge!]! + pageInfo: PageInfo! +} + +type RiskAssessmentConnectionEdge { + cursor: CursorKey! + node: RiskAssessment! +} + +type RiskAssessmentScopeConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentScopeConnection" + ) { + totalCount: Int @goField(forceResolver: true) + edges: [RiskAssessmentScopeConnectionEdge!]! + pageInfo: PageInfo! +} + +type RiskAssessmentScopeConnectionEdge { + cursor: CursorKey! + node: RiskAssessmentScope! +} + +type RiskAssessmentNodeConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentNodeConnection" + ) { + totalCount: Int @goField(forceResolver: true) + edges: [RiskAssessmentNodeConnectionEdge!]! + pageInfo: PageInfo! +} + +type RiskAssessmentNodeConnectionEdge { + cursor: CursorKey! + node: RiskAssessmentNode! +} + +type RiskAssessmentProcessConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentProcessConnection" + ) { + totalCount: Int @goField(forceResolver: true) + edges: [RiskAssessmentProcessConnectionEdge!]! + pageInfo: PageInfo! +} + +type RiskAssessmentProcessConnectionEdge { + cursor: CursorKey! + node: RiskAssessmentProcess! +} + +type RiskAssessmentThreatConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentThreatConnection" + ) { + totalCount: Int @goField(forceResolver: true) + edges: [RiskAssessmentThreatConnectionEdge!]! + pageInfo: PageInfo! +} + +type RiskAssessmentThreatConnectionEdge { + cursor: CursorKey! + node: RiskAssessmentThreat! +} + +type RiskAssessmentScenarioConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentScenarioConnection" + ) { + totalCount: Int @goField(forceResolver: true) + edges: [RiskAssessmentScenarioConnectionEdge!]! + pageInfo: PageInfo! +} + +type RiskAssessmentScenarioConnectionEdge { + cursor: CursorKey! + node: RiskAssessmentScenario! +} + +# Mutations + +extend type Mutation { + createRiskAssessment( + input: CreateRiskAssessmentInput! + ): CreateRiskAssessmentPayload! + updateRiskAssessment( + input: UpdateRiskAssessmentInput! + ): UpdateRiskAssessmentPayload! + deleteRiskAssessment( + input: DeleteRiskAssessmentInput! + ): DeleteRiskAssessmentPayload! + + createRiskAssessmentScope( + input: CreateRiskAssessmentScopeInput! + ): CreateRiskAssessmentScopePayload! + updateRiskAssessmentScope( + input: UpdateRiskAssessmentScopeInput! + ): UpdateRiskAssessmentScopePayload! + deleteRiskAssessmentScope( + input: DeleteRiskAssessmentScopeInput! + ): DeleteRiskAssessmentScopePayload! + + createRiskAssessmentNode( + input: CreateRiskAssessmentNodeInput! + ): CreateRiskAssessmentNodePayload! + updateRiskAssessmentNode( + input: UpdateRiskAssessmentNodeInput! + ): UpdateRiskAssessmentNodePayload! + deleteRiskAssessmentNode( + input: DeleteRiskAssessmentNodeInput! + ): DeleteRiskAssessmentNodePayload! + + createRiskAssessmentProcess( + input: CreateRiskAssessmentProcessInput! + ): CreateRiskAssessmentProcessPayload! + updateRiskAssessmentProcess( + input: UpdateRiskAssessmentProcessInput! + ): UpdateRiskAssessmentProcessPayload! + deleteRiskAssessmentProcess( + input: DeleteRiskAssessmentProcessInput! + ): DeleteRiskAssessmentProcessPayload! + + createRiskAssessmentThreat( + input: CreateRiskAssessmentThreatInput! + ): CreateRiskAssessmentThreatPayload! + updateRiskAssessmentThreat( + input: UpdateRiskAssessmentThreatInput! + ): UpdateRiskAssessmentThreatPayload! + deleteRiskAssessmentThreat( + input: DeleteRiskAssessmentThreatInput! + ): DeleteRiskAssessmentThreatPayload! + + createRiskAssessmentScenario( + input: CreateRiskAssessmentScenarioInput! + ): CreateRiskAssessmentScenarioPayload! + updateRiskAssessmentScenario( + input: UpdateRiskAssessmentScenarioInput! + ): UpdateRiskAssessmentScenarioPayload! + deleteRiskAssessmentScenario( + input: DeleteRiskAssessmentScenarioInput! + ): DeleteRiskAssessmentScenarioPayload! + + linkRiskAssessmentScenarioThreat( + input: LinkRiskAssessmentScenarioThreatInput! + ): LinkRiskAssessmentScenarioThreatPayload! + unlinkRiskAssessmentScenarioThreat( + input: UnlinkRiskAssessmentScenarioThreatInput! + ): UnlinkRiskAssessmentScenarioThreatPayload! + + linkRiskAssessmentScenarioRisk( + input: LinkRiskAssessmentScenarioRiskInput! + ): LinkRiskAssessmentScenarioRiskPayload! + unlinkRiskAssessmentScenarioRisk( + input: UnlinkRiskAssessmentScenarioRiskInput! + ): UnlinkRiskAssessmentScenarioRiskPayload! +} + +# Mutation inputs & payloads + +input CreateRiskAssessmentInput { + organizationId: ID! + name: String! + description: String +} + +input UpdateRiskAssessmentInput { + id: ID! + name: String + description: String @goField(omittable: true) +} + +input DeleteRiskAssessmentInput { + riskAssessmentId: ID! +} + +type CreateRiskAssessmentPayload { + riskAssessmentEdge: RiskAssessmentConnectionEdge! +} + +type UpdateRiskAssessmentPayload { + riskAssessment: RiskAssessment! +} + +type DeleteRiskAssessmentPayload { + deletedRiskAssessmentId: ID! +} + +input CreateRiskAssessmentScopeInput { + riskAssessmentId: ID! + name: String! +} + +input UpdateRiskAssessmentScopeInput { + id: ID! + name: String +} + +input DeleteRiskAssessmentScopeInput { + riskAssessmentScopeId: ID! +} + +type CreateRiskAssessmentScopePayload { + riskAssessmentScopeEdge: RiskAssessmentScopeConnectionEdge! +} + +type UpdateRiskAssessmentScopePayload { + riskAssessmentScope: RiskAssessmentScope! +} + +type DeleteRiskAssessmentScopePayload { + deletedRiskAssessmentScopeId: ID! +} + +input CreateRiskAssessmentNodeInput { + riskAssessmentScopeId: ID! + nodeType: RiskAssessmentNodeType! + name: String! +} + +input UpdateRiskAssessmentNodeInput { + id: ID! + nodeType: RiskAssessmentNodeType + name: String +} + +input DeleteRiskAssessmentNodeInput { + riskAssessmentNodeId: ID! +} + +type CreateRiskAssessmentNodePayload { + riskAssessmentNodeEdge: RiskAssessmentNodeConnectionEdge! +} + +type UpdateRiskAssessmentNodePayload { + riskAssessmentNode: RiskAssessmentNode! +} + +type DeleteRiskAssessmentNodePayload { + deletedRiskAssessmentNodeId: ID! +} + +input CreateRiskAssessmentProcessInput { + riskAssessmentScopeId: ID! + sourceNodeId: ID! + targetNodeId: ID! + name: String! +} + +input UpdateRiskAssessmentProcessInput { + id: ID! + sourceNodeId: ID + targetNodeId: ID + name: String +} + +input DeleteRiskAssessmentProcessInput { + riskAssessmentProcessId: ID! +} + +type CreateRiskAssessmentProcessPayload { + riskAssessmentProcessEdge: RiskAssessmentProcessConnectionEdge! +} + +type UpdateRiskAssessmentProcessPayload { + riskAssessmentProcess: RiskAssessmentProcess! +} + +type DeleteRiskAssessmentProcessPayload { + deletedRiskAssessmentProcessId: ID! +} + +input CreateRiskAssessmentThreatInput { + riskAssessmentScopeId: ID! + processId: ID! + name: String! + category: String! +} + +input UpdateRiskAssessmentThreatInput { + id: ID! + processId: ID + name: String + category: String +} + +input DeleteRiskAssessmentThreatInput { + riskAssessmentThreatId: ID! +} + +type CreateRiskAssessmentThreatPayload { + riskAssessmentThreatEdge: RiskAssessmentThreatConnectionEdge! +} + +type UpdateRiskAssessmentThreatPayload { + riskAssessmentThreat: RiskAssessmentThreat! +} + +type DeleteRiskAssessmentThreatPayload { + deletedRiskAssessmentThreatId: ID! +} + +input CreateRiskAssessmentScenarioInput { + riskAssessmentScopeId: ID! + name: String! + description: String +} + +input UpdateRiskAssessmentScenarioInput { + id: ID! + name: String + description: String @goField(omittable: true) +} + +input DeleteRiskAssessmentScenarioInput { + riskAssessmentScenarioId: ID! +} + +type CreateRiskAssessmentScenarioPayload { + riskAssessmentScenarioEdge: RiskAssessmentScenarioConnectionEdge! +} + +type UpdateRiskAssessmentScenarioPayload { + riskAssessmentScenario: RiskAssessmentScenario! +} + +type DeleteRiskAssessmentScenarioPayload { + deletedRiskAssessmentScenarioId: ID! +} + +input LinkRiskAssessmentScenarioThreatInput { + riskAssessmentScenarioId: ID! + threatId: ID! +} + +type LinkRiskAssessmentScenarioThreatPayload { + riskAssessmentScenario: RiskAssessmentScenario! +} + +input UnlinkRiskAssessmentScenarioThreatInput { + riskAssessmentScenarioId: ID! + threatId: ID! +} + +type UnlinkRiskAssessmentScenarioThreatPayload { + riskAssessmentScenario: RiskAssessmentScenario! +} + +input LinkRiskAssessmentScenarioRiskInput { + riskAssessmentScenarioId: ID! + riskId: ID! +} + +type LinkRiskAssessmentScenarioRiskPayload { + riskAssessmentScenario: RiskAssessmentScenario! + riskAssessmentScenarioEdge: RiskAssessmentScenarioConnectionEdge! +} + +input UnlinkRiskAssessmentScenarioRiskInput { + riskAssessmentScenarioId: ID! + riskId: ID! +} + +type UnlinkRiskAssessmentScenarioRiskPayload { + riskAssessmentScenario: RiskAssessmentScenario! + deletedRiskAssessmentScenarioId: ID! +} diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index ac117f0a6..b455f1909 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -25,6 +25,7 @@ import ( "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/server/api/authz" "go.probo.inc/probo/pkg/server/api/console/v1/schema" "go.probo.inc/probo/pkg/server/gqlutils" @@ -42,6 +43,7 @@ func NewGraphQLHandler( customDomainCname string, logger *log.Logger, thirdPartySvc *thirdparty.Service, + riskManagementSvc *riskmanagement.Service, ) http.Handler { config := schema.Config{ Resolvers: &Resolver{ @@ -53,6 +55,7 @@ func NewGraphQLHandler( mailman: mailmanSvc, cookieBanner: cookieBannerSvc, connectorRegistry: connectorRegistry, + riskManagement: riskManagementSvc, thirdParty: thirdPartySvc, customDomainCname: customDomainCname, logger: logger, diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index 6687e6c88..10eb03f1b 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -1070,6 +1070,66 @@ func (r *organizationResolver) RisksDocument(ctx context.Context, obj *types.Org return types.NewDocument(document), nil } +// RiskAssessments is the resolver for the riskAssessments field. +func (r *organizationResolver) RiskAssessments(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentOrderBy) (*types.RiskAssessmentConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + pageOrderBy := page.OrderBy[coredata.RiskAssessmentOrderField]{ + Field: coredata.RiskAssessmentOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.riskManagement.ListForOrganizationID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk assessments", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewRiskAssessmentConnection(p, r, obj.ID), nil +} + +// RiskAssessmentScenarios is the resolver for the riskAssessmentScenarios field. +func (r *organizationResolver) RiskAssessmentScenarios(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentScenarioOrderBy) (*types.RiskAssessmentScenarioConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentScenarioList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + pageOrderBy := page.OrderBy[coredata.RiskAssessmentScenarioOrderField]{ + Field: coredata.RiskAssessmentScenarioOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentScenarioOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.riskManagement.ListScenariosForOrganizationID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk scenarios", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewRiskAssessmentScenarioConnection(p, r, obj.ID), nil +} + // Tasks is the resolver for the tasks field. func (r *organizationResolver) Tasks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TaskOrderBy) (*types.TaskConnection, error) { if err := r.authorize(ctx, obj.ID, probo.ActionTaskList); err != nil { diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 3af3a4f38..a84b4227b 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -35,6 +35,7 @@ import ( "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api/authn" @@ -54,6 +55,7 @@ type ( mailman *mailman.Service cookieBanner *cookiebanner.Service connectorRegistry *connector.ConnectorRegistry + riskManagement *riskmanagement.Service thirdParty *thirdparty.Service logger *log.Logger customDomainCname string @@ -74,6 +76,7 @@ func NewMux( baseURL *baseurl.BaseURL, customDomainCname string, thirdPartySvc *thirdparty.Service, + riskManagementSvc *riskmanagement.Service, ) *chi.Mux { r := chi.NewMux() @@ -90,6 +93,7 @@ func NewMux( customDomainCname, logger, thirdPartySvc, + riskManagementSvc, ) r.Group(func(r chi.Router) { diff --git a/pkg/server/api/console/v1/risk_assessment_resolvers.go b/pkg/server/api/console/v1/risk_assessment_resolvers.go new file mode 100644 index 000000000..da53dbf36 --- /dev/null +++ b/pkg/server/api/console/v1/risk_assessment_resolvers.go @@ -0,0 +1,947 @@ +package console_v1 + +// This file will be automatically regenerated based on the schema, any resolver +// implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.90 + +import ( + "context" + "errors" + + "github.com/vikstrous/dataloadgen" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/riskmanagement" + "go.probo.inc/probo/pkg/server/api/console/v1/dataloader" + "go.probo.inc/probo/pkg/server/api/console/v1/schema" + "go.probo.inc/probo/pkg/server/api/console/v1/types" + "go.probo.inc/probo/pkg/server/gqlutils" + "go.probo.inc/probo/pkg/validator" +) + +// CreateRiskAssessment is the resolver for the createRiskAssessment field. +func (r *mutationResolver) CreateRiskAssessment(ctx context.Context, input types.CreateRiskAssessmentInput) (*types.CreateRiskAssessmentPayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionRiskAssessmentCreate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.OrganizationID) + ra, err := r.riskManagement.Create( + ctx, + scope, + riskmanagement.CreateRiskAssessmentRequest{ + OrganizationID: input.OrganizationID, + Name: input.Name, + Description: input.Description, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create risk assessment", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.CreateRiskAssessmentPayload{ + RiskAssessmentEdge: types.NewRiskAssessmentConnectionEdge(ra, coredata.RiskAssessmentOrderFieldCreatedAt), + }, nil +} + +// UpdateRiskAssessment is the resolver for the updateRiskAssessment field. +func (r *mutationResolver) UpdateRiskAssessment(ctx context.Context, input types.UpdateRiskAssessmentInput) (*types.UpdateRiskAssessmentPayload, error) { + if err := r.authorize(ctx, input.ID, probo.ActionRiskAssessmentUpdate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.ID) + ra, err := r.riskManagement.Update( + ctx, + scope, + riskmanagement.UpdateRiskAssessmentRequest{ + ID: input.ID, + Name: input.Name, + Description: gqlutils.UnwrapOmittable(input.Description), + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update risk assessment", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UpdateRiskAssessmentPayload{RiskAssessment: types.NewRiskAssessment(ra)}, nil +} + +// DeleteRiskAssessment is the resolver for the deleteRiskAssessment field. +func (r *mutationResolver) DeleteRiskAssessment(ctx context.Context, input types.DeleteRiskAssessmentInput) (*types.DeleteRiskAssessmentPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentID, probo.ActionRiskAssessmentDelete); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentID) + if err := r.riskManagement.Delete(ctx, scope, input.RiskAssessmentID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete risk assessment", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.DeleteRiskAssessmentPayload{DeletedRiskAssessmentID: input.RiskAssessmentID}, nil +} + +// CreateRiskAssessmentScope is the resolver for the createRiskAssessmentScope field. +func (r *mutationResolver) CreateRiskAssessmentScope(ctx context.Context, input types.CreateRiskAssessmentScopeInput) (*types.CreateRiskAssessmentScopePayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentID, probo.ActionRiskAssessmentScopeCreate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentID) + raScope, err := r.riskManagement.CreateScope( + ctx, + scope, + riskmanagement.CreateRiskAssessmentScopeRequest{ + RiskAssessmentID: input.RiskAssessmentID, + Name: input.Name, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create risk assessment scope", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.CreateRiskAssessmentScopePayload{ + RiskAssessmentScopeEdge: types.NewRiskAssessmentScopeConnectionEdge(raScope, coredata.RiskAssessmentScopeOrderFieldCreatedAt), + }, nil +} + +// UpdateRiskAssessmentScope is the resolver for the updateRiskAssessmentScope field. +func (r *mutationResolver) UpdateRiskAssessmentScope(ctx context.Context, input types.UpdateRiskAssessmentScopeInput) (*types.UpdateRiskAssessmentScopePayload, error) { + if err := r.authorize(ctx, input.ID, probo.ActionRiskAssessmentScopeUpdate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.ID) + raScope, err := r.riskManagement.UpdateScope( + ctx, + scope, + riskmanagement.UpdateRiskAssessmentScopeRequest{ + ID: input.ID, + Name: input.Name, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update risk assessment scope", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UpdateRiskAssessmentScopePayload{RiskAssessmentScope: types.NewRiskAssessmentScope(raScope)}, nil +} + +// DeleteRiskAssessmentScope is the resolver for the deleteRiskAssessmentScope field. +func (r *mutationResolver) DeleteRiskAssessmentScope(ctx context.Context, input types.DeleteRiskAssessmentScopeInput) (*types.DeleteRiskAssessmentScopePayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentScopeDelete); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScopeID) + if err := r.riskManagement.DeleteScope(ctx, scope, input.RiskAssessmentScopeID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete risk assessment scope", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.DeleteRiskAssessmentScopePayload{DeletedRiskAssessmentScopeID: input.RiskAssessmentScopeID}, nil +} + +// CreateRiskAssessmentNode is the resolver for the createRiskAssessmentNode field. +func (r *mutationResolver) CreateRiskAssessmentNode(ctx context.Context, input types.CreateRiskAssessmentNodeInput) (*types.CreateRiskAssessmentNodePayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentNodeCreate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScopeID) + node, err := r.riskManagement.CreateNode( + ctx, + scope, + riskmanagement.CreateRiskAssessmentNodeRequest{ + RiskAssessmentScopeID: input.RiskAssessmentScopeID, + NodeType: input.NodeType, + Name: input.Name, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create risk assessment node", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.CreateRiskAssessmentNodePayload{ + RiskAssessmentNodeEdge: &types.RiskAssessmentNodeConnectionEdge{ + Cursor: node.CursorKey(coredata.RiskAssessmentNodeOrderFieldCreatedAt), + Node: types.NewRiskAssessmentNode(node), + }, + }, nil +} + +// UpdateRiskAssessmentNode is the resolver for the updateRiskAssessmentNode field. +func (r *mutationResolver) UpdateRiskAssessmentNode(ctx context.Context, input types.UpdateRiskAssessmentNodeInput) (*types.UpdateRiskAssessmentNodePayload, error) { + if err := r.authorize(ctx, input.ID, probo.ActionRiskAssessmentNodeUpdate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.ID) + node, err := r.riskManagement.UpdateNode( + ctx, + scope, + riskmanagement.UpdateRiskAssessmentNodeRequest{ + ID: input.ID, + NodeType: input.NodeType, + Name: input.Name, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update risk assessment node", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UpdateRiskAssessmentNodePayload{RiskAssessmentNode: types.NewRiskAssessmentNode(node)}, nil +} + +// DeleteRiskAssessmentNode is the resolver for the deleteRiskAssessmentNode field. +func (r *mutationResolver) DeleteRiskAssessmentNode(ctx context.Context, input types.DeleteRiskAssessmentNodeInput) (*types.DeleteRiskAssessmentNodePayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentNodeID, probo.ActionRiskAssessmentNodeDelete); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentNodeID) + if err := r.riskManagement.DeleteNode(ctx, scope, input.RiskAssessmentNodeID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete risk assessment node", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.DeleteRiskAssessmentNodePayload{DeletedRiskAssessmentNodeID: input.RiskAssessmentNodeID}, nil +} + +// CreateRiskAssessmentProcess is the resolver for the createRiskAssessmentProcess field. +func (r *mutationResolver) CreateRiskAssessmentProcess(ctx context.Context, input types.CreateRiskAssessmentProcessInput) (*types.CreateRiskAssessmentProcessPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentProcessCreate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScopeID) + process, err := r.riskManagement.CreateProcess( + ctx, + scope, + riskmanagement.CreateRiskAssessmentProcessRequest{ + RiskAssessmentScopeID: input.RiskAssessmentScopeID, + SourceNodeID: input.SourceNodeID, + TargetNodeID: input.TargetNodeID, + Name: input.Name, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create risk assessment process", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.CreateRiskAssessmentProcessPayload{ + RiskAssessmentProcessEdge: &types.RiskAssessmentProcessConnectionEdge{ + Cursor: process.CursorKey(coredata.RiskAssessmentProcessOrderFieldCreatedAt), + Node: types.NewRiskAssessmentProcess(process), + }, + }, nil +} + +// UpdateRiskAssessmentProcess is the resolver for the updateRiskAssessmentProcess field. +func (r *mutationResolver) UpdateRiskAssessmentProcess(ctx context.Context, input types.UpdateRiskAssessmentProcessInput) (*types.UpdateRiskAssessmentProcessPayload, error) { + if err := r.authorize(ctx, input.ID, probo.ActionRiskAssessmentProcessUpdate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.ID) + process, err := r.riskManagement.UpdateProcess( + ctx, + scope, + riskmanagement.UpdateRiskAssessmentProcessRequest{ + ID: input.ID, + SourceNodeID: input.SourceNodeID, + TargetNodeID: input.TargetNodeID, + Name: input.Name, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update risk assessment process", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UpdateRiskAssessmentProcessPayload{RiskAssessmentProcess: types.NewRiskAssessmentProcess(process)}, nil +} + +// DeleteRiskAssessmentProcess is the resolver for the deleteRiskAssessmentProcess field. +func (r *mutationResolver) DeleteRiskAssessmentProcess(ctx context.Context, input types.DeleteRiskAssessmentProcessInput) (*types.DeleteRiskAssessmentProcessPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentProcessID, probo.ActionRiskAssessmentProcessDelete); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentProcessID) + if err := r.riskManagement.DeleteProcess(ctx, scope, input.RiskAssessmentProcessID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete risk assessment process", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.DeleteRiskAssessmentProcessPayload{DeletedRiskAssessmentProcessID: input.RiskAssessmentProcessID}, nil +} + +// CreateRiskAssessmentThreat is the resolver for the createRiskAssessmentThreat field. +func (r *mutationResolver) CreateRiskAssessmentThreat(ctx context.Context, input types.CreateRiskAssessmentThreatInput) (*types.CreateRiskAssessmentThreatPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentThreatCreate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScopeID) + threat, err := r.riskManagement.CreateThreat( + ctx, + scope, + riskmanagement.CreateRiskAssessmentThreatRequest{ + RiskAssessmentScopeID: input.RiskAssessmentScopeID, + ProcessID: input.ProcessID, + Name: input.Name, + Category: input.Category, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create risk threat", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.CreateRiskAssessmentThreatPayload{ + RiskAssessmentThreatEdge: &types.RiskAssessmentThreatConnectionEdge{ + Cursor: threat.CursorKey(coredata.RiskAssessmentThreatOrderFieldCreatedAt), + Node: types.NewRiskAssessmentThreat(threat), + }, + }, nil +} + +// UpdateRiskAssessmentThreat is the resolver for the updateRiskAssessmentThreat field. +func (r *mutationResolver) UpdateRiskAssessmentThreat(ctx context.Context, input types.UpdateRiskAssessmentThreatInput) (*types.UpdateRiskAssessmentThreatPayload, error) { + if err := r.authorize(ctx, input.ID, probo.ActionRiskAssessmentThreatUpdate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.ID) + threat, err := r.riskManagement.UpdateThreat( + ctx, + scope, + riskmanagement.UpdateRiskAssessmentThreatRequest{ + ID: input.ID, + ProcessID: input.ProcessID, + Name: input.Name, + Category: input.Category, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update risk threat", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UpdateRiskAssessmentThreatPayload{RiskAssessmentThreat: types.NewRiskAssessmentThreat(threat)}, nil +} + +// DeleteRiskAssessmentThreat is the resolver for the deleteRiskAssessmentThreat field. +func (r *mutationResolver) DeleteRiskAssessmentThreat(ctx context.Context, input types.DeleteRiskAssessmentThreatInput) (*types.DeleteRiskAssessmentThreatPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentThreatID, probo.ActionRiskAssessmentThreatDelete); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentThreatID) + if err := r.riskManagement.DeleteThreat(ctx, scope, input.RiskAssessmentThreatID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete risk threat", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.DeleteRiskAssessmentThreatPayload{DeletedRiskAssessmentThreatID: input.RiskAssessmentThreatID}, nil +} + +// CreateRiskAssessmentScenario is the resolver for the createRiskAssessmentScenario field. +func (r *mutationResolver) CreateRiskAssessmentScenario(ctx context.Context, input types.CreateRiskAssessmentScenarioInput) (*types.CreateRiskAssessmentScenarioPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentScenarioCreate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScopeID) + scenario, err := r.riskManagement.CreateScenario( + ctx, + scope, + riskmanagement.CreateRiskAssessmentScenarioRequest{ + RiskAssessmentScopeID: input.RiskAssessmentScopeID, + Name: input.Name, + Description: input.Description, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create risk scenario", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.CreateRiskAssessmentScenarioPayload{ + RiskAssessmentScenarioEdge: &types.RiskAssessmentScenarioConnectionEdge{ + Cursor: scenario.CursorKey(coredata.RiskAssessmentScenarioOrderFieldCreatedAt), + Node: types.NewRiskAssessmentScenario(scenario), + }, + }, nil +} + +// UpdateRiskAssessmentScenario is the resolver for the updateRiskAssessmentScenario field. +func (r *mutationResolver) UpdateRiskAssessmentScenario(ctx context.Context, input types.UpdateRiskAssessmentScenarioInput) (*types.UpdateRiskAssessmentScenarioPayload, error) { + if err := r.authorize(ctx, input.ID, probo.ActionRiskAssessmentScenarioUpdate); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.ID) + scenario, err := r.riskManagement.UpdateScenario( + ctx, + scope, + riskmanagement.UpdateRiskAssessmentScenarioRequest{ + ID: input.ID, + Name: input.Name, + Description: gqlutils.UnwrapOmittable(input.Description), + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update risk scenario", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UpdateRiskAssessmentScenarioPayload{RiskAssessmentScenario: types.NewRiskAssessmentScenario(scenario)}, nil +} + +// DeleteRiskAssessmentScenario is the resolver for the deleteRiskAssessmentScenario field. +func (r *mutationResolver) DeleteRiskAssessmentScenario(ctx context.Context, input types.DeleteRiskAssessmentScenarioInput) (*types.DeleteRiskAssessmentScenarioPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScenarioID, probo.ActionRiskAssessmentScenarioDelete); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScenarioID) + if err := r.riskManagement.DeleteScenario(ctx, scope, input.RiskAssessmentScenarioID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete risk scenario", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.DeleteRiskAssessmentScenarioPayload{DeletedRiskAssessmentScenarioID: input.RiskAssessmentScenarioID}, nil +} + +// LinkRiskAssessmentScenarioThreat is the resolver for the linkRiskAssessmentScenarioThreat field. +func (r *mutationResolver) LinkRiskAssessmentScenarioThreat(ctx context.Context, input types.LinkRiskAssessmentScenarioThreatInput) (*types.LinkRiskAssessmentScenarioThreatPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScenarioID, probo.ActionRiskAssessmentScenarioThreatLink); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScenarioID) + if err := r.riskManagement.LinkScenarioThreat( + ctx, + scope, + riskmanagement.LinkRiskAssessmentScenarioThreatRequest{ + RiskAssessmentScenarioID: input.RiskAssessmentScenarioID, + ThreatID: input.ThreatID, + }, + ); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot link risk scenario threat", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + scenario, err := r.riskManagement.GetScenario(ctx, scope, input.RiskAssessmentScenarioID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot load risk scenario", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.LinkRiskAssessmentScenarioThreatPayload{RiskAssessmentScenario: types.NewRiskAssessmentScenario(scenario)}, nil +} + +// UnlinkRiskAssessmentScenarioThreat is the resolver for the unlinkRiskAssessmentScenarioThreat field. +func (r *mutationResolver) UnlinkRiskAssessmentScenarioThreat(ctx context.Context, input types.UnlinkRiskAssessmentScenarioThreatInput) (*types.UnlinkRiskAssessmentScenarioThreatPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScenarioID, probo.ActionRiskAssessmentScenarioThreatUnlink); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScenarioID) + if err := r.riskManagement.UnlinkScenarioThreat( + ctx, + scope, + riskmanagement.UnlinkRiskAssessmentScenarioThreatRequest{ + RiskAssessmentScenarioID: input.RiskAssessmentScenarioID, + ThreatID: input.ThreatID, + }, + ); err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot unlink risk scenario threat", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + scenario, err := r.riskManagement.GetScenario(ctx, scope, input.RiskAssessmentScenarioID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot load risk scenario", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UnlinkRiskAssessmentScenarioThreatPayload{RiskAssessmentScenario: types.NewRiskAssessmentScenario(scenario)}, nil +} + +// LinkRiskAssessmentScenarioRisk is the resolver for the linkRiskAssessmentScenarioRisk field. +func (r *mutationResolver) LinkRiskAssessmentScenarioRisk(ctx context.Context, input types.LinkRiskAssessmentScenarioRiskInput) (*types.LinkRiskAssessmentScenarioRiskPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScenarioID, probo.ActionRiskAssessmentScenarioRiskLink); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScenarioID) + if err := r.riskManagement.LinkScenarioRisk( + ctx, + scope, + riskmanagement.LinkRiskAssessmentScenarioRiskRequest{ + RiskAssessmentScenarioID: input.RiskAssessmentScenarioID, + RiskID: input.RiskID, + }, + ); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot link risk scenario risk", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + scenario, err := r.riskManagement.GetScenario(ctx, scope, input.RiskAssessmentScenarioID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot load risk scenario", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + s := types.NewRiskAssessmentScenario(scenario) + return &types.LinkRiskAssessmentScenarioRiskPayload{ + RiskAssessmentScenario: s, + RiskAssessmentScenarioEdge: &types.RiskAssessmentScenarioConnectionEdge{ + Cursor: scenario.CursorKey(coredata.RiskAssessmentScenarioOrderFieldCreatedAt), + Node: s, + }, + }, nil +} + +// UnlinkRiskAssessmentScenarioRisk is the resolver for the unlinkRiskAssessmentScenarioRisk field. +func (r *mutationResolver) UnlinkRiskAssessmentScenarioRisk(ctx context.Context, input types.UnlinkRiskAssessmentScenarioRiskInput) (*types.UnlinkRiskAssessmentScenarioRiskPayload, error) { + if err := r.authorize(ctx, input.RiskAssessmentScenarioID, probo.ActionRiskAssessmentScenarioRiskUnlink); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(input.RiskAssessmentScenarioID) + if err := r.riskManagement.UnlinkScenarioRisk( + ctx, + scope, + riskmanagement.UnlinkRiskAssessmentScenarioRiskRequest{ + RiskAssessmentScenarioID: input.RiskAssessmentScenarioID, + RiskID: input.RiskID, + }, + ); err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot unlink risk scenario risk", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + scenario, err := r.riskManagement.GetScenario(ctx, scope, input.RiskAssessmentScenarioID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot load risk scenario", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &types.UnlinkRiskAssessmentScenarioRiskPayload{ + RiskAssessmentScenario: types.NewRiskAssessmentScenario(scenario), + DeletedRiskAssessmentScenarioID: input.RiskAssessmentScenarioID, + }, nil +} + +// Organization is the resolver for the organization field. +func (r *riskAssessmentResolver) Organization(ctx context.Context, obj *types.RiskAssessment) (*types.Organization, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { + return nil, err + } + loaders := dataloader.FromContext(ctx) + organization, err := loaders.Organization.Load(ctx, obj.Organization.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot get organization", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewOrganization(organization), nil +} + +// Scopes is the resolver for the scopes field. +func (r *riskAssessmentResolver) Scopes(ctx context.Context, obj *types.RiskAssessment, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentScopeOrderBy) (*types.RiskAssessmentScopeConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentScopeList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskAssessmentScopeOrderField]{ + Field: coredata.RiskAssessmentScopeOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentScopeOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListScopesForRiskAssessmentID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk assessment scopes", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentScopeConnection(p, r, obj.ID), nil +} + +// Permission is the resolver for the permission field. +func (r *riskAssessmentResolver) Permission(ctx context.Context, obj *types.RiskAssessment, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *riskAssessmentConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentConnection) (*int, error) { + if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ParentID) + count, err := r.riskManagement.CountForOrganizationID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk assessments", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil +} + +// TotalCount is the resolver for the totalCount field. +func (r *riskAssessmentNodeConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentNodeConnection) (*int, error) { + if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentNodeList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ParentID) + count, err := r.riskManagement.CountNodesForScopeID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk assessment nodes", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil +} + +// TotalCount is the resolver for the totalCount field. +func (r *riskAssessmentProcessConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentProcessConnection) (*int, error) { + if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentProcessList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ParentID) + count, err := r.riskManagement.CountProcessesForScopeID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk assessment processes", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil +} + +// Scope is the resolver for the scope field. +func (r *riskAssessmentScenarioResolver) Scope(ctx context.Context, obj *types.RiskAssessmentScenario) (*types.RiskAssessmentScope, error) { + if err := r.authorize(ctx, obj.RiskAssessmentScopeID, probo.ActionRiskAssessmentScopeGet); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.RiskAssessmentScopeID) + raScope, err := r.riskManagement.GetScope(ctx, scope, obj.RiskAssessmentScopeID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot load risk assessment scope", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentScope(raScope), nil +} + +// Threats is the resolver for the threats field. +func (r *riskAssessmentScenarioResolver) Threats(ctx context.Context, obj *types.RiskAssessmentScenario, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentThreatOrderBy) (*types.RiskAssessmentThreatConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentThreatList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskAssessmentThreatOrderField]{ + Field: coredata.RiskAssessmentThreatOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentThreatOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListThreatsForScenarioID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list scenario threats", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentThreatConnection(p, r, obj.ID), nil +} + +// Risks is the resolver for the risks field. +func (r *riskAssessmentScenarioResolver) Risks(ctx context.Context, obj *types.RiskAssessmentScenario, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskOrderBy) (*types.RiskConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskOrderField]{ + Field: coredata.RiskOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListRisksForScenarioID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list scenario risks", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskConnection(p, r, obj.ID, nil), nil +} + +// TotalCount is the resolver for the totalCount field. +func (r *riskAssessmentScenarioConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentScenarioConnection) (*int, error) { + if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentScenarioList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + switch obj.Resolver.(type) { + case *riskAssessmentScopeResolver: + count, err := r.riskManagement.CountScenariosForScopeID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk scenarios", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil + case *organizationResolver: + count, err := r.riskManagement.CountScenariosForOrganizationID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk scenarios", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil + default: + count, err := r.riskManagement.CountScenariosForRiskID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk scenarios", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil + } +} + +// Nodes is the resolver for the nodes field. +func (r *riskAssessmentScopeResolver) Nodes(ctx context.Context, obj *types.RiskAssessmentScope, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentNodeOrderBy) (*types.RiskAssessmentNodeConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentNodeList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskAssessmentNodeOrderField]{ + Field: coredata.RiskAssessmentNodeOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentNodeOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListNodesForScopeID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk assessment nodes", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentNodeConnection(p, r, obj.ID), nil +} + +// Processes is the resolver for the processes field. +func (r *riskAssessmentScopeResolver) Processes(ctx context.Context, obj *types.RiskAssessmentScope, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentProcessOrderBy) (*types.RiskAssessmentProcessConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentProcessList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskAssessmentProcessOrderField]{ + Field: coredata.RiskAssessmentProcessOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentProcessOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListProcessesForScopeID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk assessment processes", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentProcessConnection(p, r, obj.ID), nil +} + +// Threats is the resolver for the threats field. +func (r *riskAssessmentScopeResolver) Threats(ctx context.Context, obj *types.RiskAssessmentScope, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentThreatOrderBy) (*types.RiskAssessmentThreatConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentThreatList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskAssessmentThreatOrderField]{ + Field: coredata.RiskAssessmentThreatOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentThreatOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListThreatsForScopeID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk threats", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentThreatConnection(p, r, obj.ID), nil +} + +// Scenarios is the resolver for the scenarios field. +func (r *riskAssessmentScopeResolver) Scenarios(ctx context.Context, obj *types.RiskAssessmentScope, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentScenarioOrderBy) (*types.RiskAssessmentScenarioConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentScenarioList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskAssessmentScenarioOrderField]{ + Field: coredata.RiskAssessmentScenarioOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentScenarioOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListScenariosForScopeID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk scenarios", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentScenarioConnection(p, r, obj.ID), nil +} + +// TotalCount is the resolver for the totalCount field. +func (r *riskAssessmentScopeConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentScopeConnection) (*int, error) { + if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentScopeList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ParentID) + count, err := r.riskManagement.CountScopesForRiskAssessmentID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk assessment scopes", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil +} + +// TotalCount is the resolver for the totalCount field. +func (r *riskAssessmentThreatConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentThreatConnection) (*int, error) { + if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentThreatList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + switch obj.Resolver.(type) { + case *riskAssessmentScenarioResolver: + count, err := r.riskManagement.CountThreatsForScenarioID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count scenario threats", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil + default: + count, err := r.riskManagement.CountThreatsForScopeID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk threats", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil + } +} + +// RiskAssessment returns schema.RiskAssessmentResolver implementation. +func (r *Resolver) RiskAssessment() schema.RiskAssessmentResolver { return &riskAssessmentResolver{r} } + +// RiskAssessmentConnection returns schema.RiskAssessmentConnectionResolver implementation. +func (r *Resolver) RiskAssessmentConnection() schema.RiskAssessmentConnectionResolver { + return &riskAssessmentConnectionResolver{r} +} + +// RiskAssessmentNodeConnection returns schema.RiskAssessmentNodeConnectionResolver implementation. +func (r *Resolver) RiskAssessmentNodeConnection() schema.RiskAssessmentNodeConnectionResolver { + return &riskAssessmentNodeConnectionResolver{r} +} + +// RiskAssessmentProcessConnection returns schema.RiskAssessmentProcessConnectionResolver implementation. +func (r *Resolver) RiskAssessmentProcessConnection() schema.RiskAssessmentProcessConnectionResolver { + return &riskAssessmentProcessConnectionResolver{r} +} + +// RiskAssessmentScenario returns schema.RiskAssessmentScenarioResolver implementation. +func (r *Resolver) RiskAssessmentScenario() schema.RiskAssessmentScenarioResolver { + return &riskAssessmentScenarioResolver{r} +} + +// RiskAssessmentScenarioConnection returns schema.RiskAssessmentScenarioConnectionResolver implementation. +func (r *Resolver) RiskAssessmentScenarioConnection() schema.RiskAssessmentScenarioConnectionResolver { + return &riskAssessmentScenarioConnectionResolver{r} +} + +// RiskAssessmentScope returns schema.RiskAssessmentScopeResolver implementation. +func (r *Resolver) RiskAssessmentScope() schema.RiskAssessmentScopeResolver { + return &riskAssessmentScopeResolver{r} +} + +// RiskAssessmentScopeConnection returns schema.RiskAssessmentScopeConnectionResolver implementation. +func (r *Resolver) RiskAssessmentScopeConnection() schema.RiskAssessmentScopeConnectionResolver { + return &riskAssessmentScopeConnectionResolver{r} +} + +// RiskAssessmentThreatConnection returns schema.RiskAssessmentThreatConnectionResolver implementation. +func (r *Resolver) RiskAssessmentThreatConnection() schema.RiskAssessmentThreatConnectionResolver { + return &riskAssessmentThreatConnectionResolver{r} +} + +type riskAssessmentResolver struct{ *Resolver } +type riskAssessmentConnectionResolver struct{ *Resolver } +type riskAssessmentNodeConnectionResolver struct{ *Resolver } +type riskAssessmentProcessConnectionResolver struct{ *Resolver } +type riskAssessmentScenarioResolver struct{ *Resolver } +type riskAssessmentScenarioConnectionResolver struct{ *Resolver } +type riskAssessmentScopeResolver struct{ *Resolver } +type riskAssessmentScopeConnectionResolver struct{ *Resolver } +type riskAssessmentThreatConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/console/v1/risk_resolvers.go b/pkg/server/api/console/v1/risk_resolvers.go index 4ce4e0539..c41f3078a 100644 --- a/pkg/server/api/console/v1/risk_resolvers.go +++ b/pkg/server/api/console/v1/risk_resolvers.go @@ -448,6 +448,28 @@ func (r *riskResolver) Obligations(ctx context.Context, obj *types.Risk, first * return types.NewObligationConnection(page, r, obj.ID), nil } +// Scenarios is the resolver for the scenarios field. +func (r *riskResolver) Scenarios(ctx context.Context, obj *types.Risk, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentScenarioOrderBy) (*types.RiskAssessmentScenarioConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentScenarioList); err != nil { + return nil, err + } + scope := coredata.NewScopeFromObjectID(obj.ID) + pageOrderBy := page.OrderBy[coredata.RiskAssessmentScenarioOrderField]{ + Field: coredata.RiskAssessmentScenarioOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentScenarioOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + p, err := r.riskManagement.ListScenariosForRiskID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk scenarios", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return types.NewRiskAssessmentScenarioConnection(p, r, obj.ID), nil +} + // Permission is the resolver for the permission field. func (r *riskResolver) Permission(ctx context.Context, obj *types.Risk, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -476,6 +498,14 @@ func (r *riskConnectionResolver) TotalCount(ctx context.Context, obj *types.Risk return 0, gqlutils.Internal(ctx) } return count, nil + case *riskAssessmentScenarioResolver: + scope := coredata.NewScopeFromObjectID(obj.ParentID) + count, err := r.riskManagement.CountRisksForScenarioID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count scenario risks", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + return count, nil } r.logger.ErrorCtx(ctx, "unsupported resolver") diff --git a/pkg/server/api/console/v1/types/risk_assessment.go b/pkg/server/api/console/v1/types/risk_assessment.go new file mode 100644 index 000000000..597a54071 --- /dev/null +++ b/pkg/server/api/console/v1/types/risk_assessment.go @@ -0,0 +1,73 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentOrderBy OrderBy[coredata.RiskAssessmentOrderField] + + RiskAssessmentConnection struct { + TotalCount int + Edges []*RiskAssessmentConnectionEdge + PageInfo PageInfo + Resolver any + ParentID gid.GID + } +) + +func NewRiskAssessmentConnection( + p *page.Page[*coredata.RiskAssessment, coredata.RiskAssessmentOrderField], + parentType any, + parentID gid.GID, +) *RiskAssessmentConnection { + edges := make([]*RiskAssessmentConnectionEdge, len(p.Data)) + for i := range edges { + edges[i] = &RiskAssessmentConnectionEdge{ + Cursor: p.Data[i].CursorKey(p.Cursor.OrderBy.Field), + Node: NewRiskAssessment(p.Data[i]), + } + } + return &RiskAssessmentConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +} + +func NewRiskAssessmentConnectionEdge(ra *coredata.RiskAssessment, orderBy coredata.RiskAssessmentOrderField) *RiskAssessmentConnectionEdge { + return &RiskAssessmentConnectionEdge{ + Cursor: ra.CursorKey(orderBy), + Node: NewRiskAssessment(ra), + } +} + +func NewRiskAssessment(ra *coredata.RiskAssessment) *RiskAssessment { + return &RiskAssessment{ + ID: ra.ID, + Name: ra.Name, + Description: ra.Description, + Organization: &Organization{ + ID: ra.OrganizationID, + }, + CreatedAt: ra.CreatedAt, + UpdatedAt: ra.UpdatedAt, + } +} diff --git a/pkg/server/api/console/v1/types/risk_assessment_node.go b/pkg/server/api/console/v1/types/risk_assessment_node.go new file mode 100644 index 000000000..b7444c98e --- /dev/null +++ b/pkg/server/api/console/v1/types/risk_assessment_node.go @@ -0,0 +1,64 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentNodeOrderBy OrderBy[coredata.RiskAssessmentNodeOrderField] + + RiskAssessmentNodeConnection struct { + TotalCount int + Edges []*RiskAssessmentNodeConnectionEdge + PageInfo PageInfo + Resolver any + ParentID gid.GID + } +) + +func NewRiskAssessmentNodeConnection( + p *page.Page[*coredata.RiskAssessmentNode, coredata.RiskAssessmentNodeOrderField], + parentType any, + parentID gid.GID, +) *RiskAssessmentNodeConnection { + edges := make([]*RiskAssessmentNodeConnectionEdge, len(p.Data)) + for i := range edges { + edges[i] = &RiskAssessmentNodeConnectionEdge{ + Cursor: p.Data[i].CursorKey(p.Cursor.OrderBy.Field), + Node: NewRiskAssessmentNode(p.Data[i]), + } + } + return &RiskAssessmentNodeConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +} + +func NewRiskAssessmentNode(n *coredata.RiskAssessmentNode) *RiskAssessmentNode { + return &RiskAssessmentNode{ + ID: n.ID, + RiskAssessmentScopeID: n.RiskAssessmentScopeID, + NodeType: n.NodeType, + Name: n.Name, + CreatedAt: n.CreatedAt, + UpdatedAt: n.UpdatedAt, + } +} diff --git a/pkg/server/api/console/v1/types/risk_assessment_process.go b/pkg/server/api/console/v1/types/risk_assessment_process.go new file mode 100644 index 000000000..a129a5057 --- /dev/null +++ b/pkg/server/api/console/v1/types/risk_assessment_process.go @@ -0,0 +1,65 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentProcessOrderBy OrderBy[coredata.RiskAssessmentProcessOrderField] + + RiskAssessmentProcessConnection struct { + TotalCount int + Edges []*RiskAssessmentProcessConnectionEdge + PageInfo PageInfo + Resolver any + ParentID gid.GID + } +) + +func NewRiskAssessmentProcessConnection( + p *page.Page[*coredata.RiskAssessmentProcess, coredata.RiskAssessmentProcessOrderField], + parentType any, + parentID gid.GID, +) *RiskAssessmentProcessConnection { + edges := make([]*RiskAssessmentProcessConnectionEdge, len(p.Data)) + for i := range edges { + edges[i] = &RiskAssessmentProcessConnectionEdge{ + Cursor: p.Data[i].CursorKey(p.Cursor.OrderBy.Field), + Node: NewRiskAssessmentProcess(p.Data[i]), + } + } + return &RiskAssessmentProcessConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +} + +func NewRiskAssessmentProcess(pr *coredata.RiskAssessmentProcess) *RiskAssessmentProcess { + return &RiskAssessmentProcess{ + ID: pr.ID, + RiskAssessmentScopeID: pr.RiskAssessmentScopeID, + SourceNodeID: pr.SourceNodeID, + TargetNodeID: pr.TargetNodeID, + Name: pr.Name, + CreatedAt: pr.CreatedAt, + UpdatedAt: pr.UpdatedAt, + } +} diff --git a/pkg/server/api/console/v1/types/risk_assessment_scenario.go b/pkg/server/api/console/v1/types/risk_assessment_scenario.go new file mode 100644 index 000000000..eba9226c3 --- /dev/null +++ b/pkg/server/api/console/v1/types/risk_assessment_scenario.go @@ -0,0 +1,64 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentScenarioOrderBy OrderBy[coredata.RiskAssessmentScenarioOrderField] + + RiskAssessmentScenarioConnection struct { + TotalCount int + Edges []*RiskAssessmentScenarioConnectionEdge + PageInfo PageInfo + Resolver any + ParentID gid.GID + } +) + +func NewRiskAssessmentScenarioConnection( + p *page.Page[*coredata.RiskAssessmentScenario, coredata.RiskAssessmentScenarioOrderField], + parentType any, + parentID gid.GID, +) *RiskAssessmentScenarioConnection { + edges := make([]*RiskAssessmentScenarioConnectionEdge, len(p.Data)) + for i := range edges { + edges[i] = &RiskAssessmentScenarioConnectionEdge{ + Cursor: p.Data[i].CursorKey(p.Cursor.OrderBy.Field), + Node: NewRiskAssessmentScenario(p.Data[i]), + } + } + return &RiskAssessmentScenarioConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +} + +func NewRiskAssessmentScenario(s *coredata.RiskAssessmentScenario) *RiskAssessmentScenario { + return &RiskAssessmentScenario{ + ID: s.ID, + RiskAssessmentScopeID: s.RiskAssessmentScopeID, + Name: s.Name, + Description: s.Description, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} diff --git a/pkg/server/api/console/v1/types/risk_assessment_scope.go b/pkg/server/api/console/v1/types/risk_assessment_scope.go new file mode 100644 index 000000000..7afb35507 --- /dev/null +++ b/pkg/server/api/console/v1/types/risk_assessment_scope.go @@ -0,0 +1,70 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentScopeOrderBy OrderBy[coredata.RiskAssessmentScopeOrderField] + + RiskAssessmentScopeConnection struct { + TotalCount int + Edges []*RiskAssessmentScopeConnectionEdge + PageInfo PageInfo + Resolver any + ParentID gid.GID + } +) + +func NewRiskAssessmentScopeConnection( + p *page.Page[*coredata.RiskAssessmentScope, coredata.RiskAssessmentScopeOrderField], + parentType any, + parentID gid.GID, +) *RiskAssessmentScopeConnection { + edges := make([]*RiskAssessmentScopeConnectionEdge, len(p.Data)) + for i := range edges { + edges[i] = &RiskAssessmentScopeConnectionEdge{ + Cursor: p.Data[i].CursorKey(p.Cursor.OrderBy.Field), + Node: NewRiskAssessmentScope(p.Data[i]), + } + } + return &RiskAssessmentScopeConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +} + +func NewRiskAssessmentScopeConnectionEdge(s *coredata.RiskAssessmentScope, orderBy coredata.RiskAssessmentScopeOrderField) *RiskAssessmentScopeConnectionEdge { + return &RiskAssessmentScopeConnectionEdge{ + Cursor: s.CursorKey(orderBy), + Node: NewRiskAssessmentScope(s), + } +} + +func NewRiskAssessmentScope(s *coredata.RiskAssessmentScope) *RiskAssessmentScope { + return &RiskAssessmentScope{ + ID: s.ID, + RiskAssessmentID: s.RiskAssessmentID, + Name: s.Name, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} diff --git a/pkg/server/api/console/v1/types/risk_assessment_threat.go b/pkg/server/api/console/v1/types/risk_assessment_threat.go new file mode 100644 index 000000000..ee42d1371 --- /dev/null +++ b/pkg/server/api/console/v1/types/risk_assessment_threat.go @@ -0,0 +1,65 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentThreatOrderBy OrderBy[coredata.RiskAssessmentThreatOrderField] + + RiskAssessmentThreatConnection struct { + TotalCount int + Edges []*RiskAssessmentThreatConnectionEdge + PageInfo PageInfo + Resolver any + ParentID gid.GID + } +) + +func NewRiskAssessmentThreatConnection( + p *page.Page[*coredata.RiskAssessmentThreat, coredata.RiskAssessmentThreatOrderField], + parentType any, + parentID gid.GID, +) *RiskAssessmentThreatConnection { + edges := make([]*RiskAssessmentThreatConnectionEdge, len(p.Data)) + for i := range edges { + edges[i] = &RiskAssessmentThreatConnectionEdge{ + Cursor: p.Data[i].CursorKey(p.Cursor.OrderBy.Field), + Node: NewRiskAssessmentThreat(p.Data[i]), + } + } + return &RiskAssessmentThreatConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +} + +func NewRiskAssessmentThreat(t *coredata.RiskAssessmentThreat) *RiskAssessmentThreat { + return &RiskAssessmentThreat{ + ID: t.ID, + RiskAssessmentScopeID: t.RiskAssessmentScopeID, + ProcessID: t.ProcessID, + Name: t.Name, + Category: t.Category, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 078dc18f3..30469da10 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -35,6 +35,7 @@ import ( "go.probo.inc/probo/pkg/iam/oauth2server" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api" "go.probo.inc/probo/pkg/server/api/compliancepage" @@ -62,6 +63,7 @@ type Config struct { CookieBanner *cookiebanner.Service Geoloc *geoloc.Service ThirdParty *thirdparty.Service + RiskManagement *riskmanagement.Service Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry @@ -98,6 +100,7 @@ func NewServer(cfg Config) (*Server, error) { CookieBanner: cfg.CookieBanner, Geoloc: cfg.Geoloc, ThirdParty: cfg.ThirdParty, + RiskManagement: cfg.RiskManagement, Cookie: cfg.Cookie, TokenSecret: cfg.TokenSecret, ConnectorRegistry: cfg.ConnectorRegistry,