diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/BoundaryActions.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/BoundaryActions.tsx new file mode 100644 index 000000000..e096b9385 --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/BoundaryActions.tsx @@ -0,0 +1,120 @@ +// 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 { BoundaryActionsDeleteMutation } from "#/__generated__/core/BoundaryActionsDeleteMutation.graphql"; +import type { BoundaryActionsUpdateMutation } from "#/__generated__/core/BoundaryActionsUpdateMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; + +const updateBoundaryMutation = graphql` + mutation BoundaryActionsUpdateMutation($input: UpdateRiskAssessmentBoundaryInput!) { + updateRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundary { id name parentBoundaryId } + } + } +`; + +const deleteBoundaryMutation = graphql` + mutation BoundaryActionsDeleteMutation( + $input: DeleteRiskAssessmentBoundaryInput! + $connections: [ID!]! + ) { + deleteRiskAssessmentBoundary(input: $input) { + deletedRiskAssessmentBoundaryId @deleteEdge(connections: $connections) + } + } +`; + +export function BoundaryActions(props: { + boundary: { id: string; name: string; parentBoundaryId: string | null }; + boundaries: { id: string; name: string }[]; + connectionId: string; +}) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const dialogRef = useDialogRef(); + const [updateBoundary] = useMutation(updateBoundaryMutation); + const [deleteBoundary] = useMutation(deleteBoundaryMutation); + const { register, control, handleSubmit } = useForm({ + values: { + name: props.boundary.name, + parentBoundaryId: props.boundary.parentBoundaryId ?? "none", + }, + }); + const parentOptions = props.boundaries.filter(b => b.id !== props.boundary.id); + return ( + <> + + dialogRef.current?.open()}> + {__("Edit")} + + confirm( + () => { + deleteBoundary({ + variables: { + input: { riskAssessmentBoundaryId: props.boundary.id }, + connections: [props.connectionId], + }, + }); + }, + { message: __("Delete this boundary? Nodes and nested boundaries inside it will be moved to the top level.") }, + )} + > + {__("Delete")} + + + }> +
void handleSubmit((d) => { + updateBoundary({ + variables: { input: { id: props.boundary.id, name: d.name, parentBoundaryId: d.parentBoundaryId === "none" ? null : d.parentBoundaryId } }, + onCompleted: () => { dialogRef.current?.close(); }, + }); + })(e)} + > + + + + + {parentOptions.map(b => ( + + ))} + + + +
+
+ + ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateBoundaryDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateBoundaryDialog.tsx new file mode 100644 index 000000000..5e8b9147d --- /dev/null +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateBoundaryDialog.tsx @@ -0,0 +1,94 @@ +// 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 { CreateBoundaryDialogMutation } from "#/__generated__/core/CreateBoundaryDialogMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; + +const createBoundaryMutation = graphql` + mutation CreateBoundaryDialogMutation( + $input: CreateRiskAssessmentBoundaryInput! + $connections: [ID!]! + ) { + createRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundaryEdge @appendEdge(connections: $connections) { + node { id name parentBoundaryId } + } + } + } +`; + +export function CreateBoundaryDialog(props: { + scopeId: string; + connectionId: string; + boundaries: { id: string; name: string }[]; +}) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [createBoundary, isCreating] = useMutation(createBoundaryMutation); + const { register, control, handleSubmit, reset, formState } = useForm({ + defaultValues: { name: "", parentBoundaryId: "none" }, + }); + const onSubmit = (data: { name: string; parentBoundaryId: string }) => { + createBoundary({ + variables: { + input: { + riskAssessmentScopeId: props.scopeId, + name: data.name, + parentBoundaryId: data.parentBoundaryId === "none" ? null : data.parentBoundaryId, + }, + connections: [props.connectionId], + }, + onCompleted: () => { + reset(); + dialogRef.current?.close(); + }, + }); + }; + return ( + {__("Add")}} + title={} + > +
void handleSubmit(onSubmit)(e)}> + + + + + {props.boundaries.map(b => ( + + ))} + + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateNodeDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateNodeDialog.tsx index bffde534e..a01b450c1 100644 --- a/apps/console/src/pages/organizations/risk-assessments/_components/CreateNodeDialog.tsx +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateNodeDialog.tsx @@ -37,26 +37,31 @@ const createNodeMutation = graphql` ) { createRiskAssessmentNode(input: $input) { riskAssessmentNodeEdge @appendEdge(connections: $connections) { - node { id nodeType name } + node { id nodeType name boundaryId } } } } `; -export function CreateNodeDialog(props: { scopeId: string; connectionId: string }) { +export function CreateNodeDialog(props: { + scopeId: string; + connectionId: string; + boundaries: { id: string; name: string }[]; +}) { const { __ } = useTranslate(); const dialogRef = useDialogRef(); const [createNode, isCreating] = useMutation(createNodeMutation); const { register, control, handleSubmit, reset, formState } = useForm({ - defaultValues: { name: "", nodeType: "ASSET" }, + defaultValues: { name: "", nodeType: "ASSET", boundaryId: "none" }, }); - const onSubmit = (data: { name: string; nodeType: string }) => { + const onSubmit = (data: { name: string; nodeType: string; boundaryId: string }) => { createNode({ variables: { input: { riskAssessmentScopeId: props.scopeId, - nodeType: data.nodeType as "ENTITY" | "BOUNDARY" | "ASSET" | "DATA", + nodeType: data.nodeType as "ENTITY" | "ASSET" | "DATA", name: data.name, + boundaryId: data.boundaryId === "none" ? null : data.boundaryId, }, connections: [props.connectionId], }, @@ -77,11 +82,18 @@ export function CreateNodeDialog(props: { scopeId: string; connectionId: string - + {props.boundaries.length > 0 && ( + + + {props.boundaries.map(b => ( + + ))} + + )} diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/NodeActions.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/NodeActions.tsx index 193971703..996eec982 100644 --- a/apps/console/src/pages/organizations/risk-assessments/_components/NodeActions.tsx +++ b/apps/console/src/pages/organizations/risk-assessments/_components/NodeActions.tsx @@ -38,7 +38,7 @@ import { ControlledField } from "#/components/form/ControlledField"; const updateNodeMutation = graphql` mutation NodeActionsUpdateMutation($input: UpdateRiskAssessmentNodeInput!) { updateRiskAssessmentNode(input: $input) { - riskAssessmentNode { id nodeType name } + riskAssessmentNode { id nodeType name boundaryId } } } `; @@ -55,7 +55,8 @@ const deleteNodeMutation = graphql` `; export function NodeActions(props: { - node: { id: string; name: string; nodeType: string }; + node: { id: string; name: string; nodeType: string; boundaryId: string | null }; + boundaries: { id: string; name: string }[]; connectionId: string; }) { const { __ } = useTranslate(); @@ -64,7 +65,11 @@ export function NodeActions(props: { const [updateNode] = useMutation(updateNodeMutation); const [deleteNode] = useMutation(deleteNodeMutation); const { register, control, handleSubmit } = useForm({ - values: { name: props.node.name, nodeType: props.node.nodeType }, + values: { + name: props.node.name, + nodeType: props.node.nodeType, + boundaryId: props.node.boundaryId ?? "none", + }, }); return ( <> @@ -93,7 +98,7 @@ export function NodeActions(props: { }>
void handleSubmit((d) => { updateNode({ - variables: { input: { id: props.node.id, name: d.name, nodeType: d.nodeType as "ENTITY" | "BOUNDARY" | "ASSET" | "DATA" } }, + variables: { input: { id: props.node.id, name: d.name, nodeType: d.nodeType as "ENTITY" | "ASSET" | "DATA", boundaryId: d.boundaryId === "none" ? null : d.boundaryId } }, onCompleted: () => { dialogRef.current?.close(); }, }); })(e)} @@ -101,11 +106,16 @@ export function NodeActions(props: { - + + + {props.boundaries.map(b => ( + + ))} +
diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/ScopeCard.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/ScopeCard.tsx index 321c98393..1fc4827a0 100644 --- a/apps/console/src/pages/organizations/risk-assessments/_components/ScopeCard.tsx +++ b/apps/console/src/pages/organizations/risk-assessments/_components/ScopeCard.tsx @@ -32,6 +32,8 @@ import { Link } from "react-router"; import type { ScopeCardFragment$key } from "#/__generated__/core/ScopeCardFragment.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; +import { BoundaryActions } from "./BoundaryActions"; +import { CreateBoundaryDialog } from "./CreateBoundaryDialog"; import { CreateNodeDialog } from "./CreateNodeDialog"; import { CreateProcessDialog } from "./CreateProcessDialog"; import { CreateScenarioInScopeDialog } from "./CreateScenarioInScopeDialog"; @@ -51,7 +53,14 @@ export const scopeCardFragment = graphql` @connection(key: "RiskAssessmentScope_nodes", filters: []) { __id edges { - node { id nodeType name } + node { id nodeType name boundaryId } + } + } + boundaries(first: 100) + @connection(key: "RiskAssessmentScope_boundaries", filters: []) { + __id + edges { + node { id name parentBoundaryId } } } processes(first: 100) @@ -112,11 +121,15 @@ export function ScopeCard(props: { const { scopesConnectionId } = props; const nodes = scope.nodes?.edges.map(e => e.node) ?? []; + const boundaries = scope.boundaries?.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 boundaryMap = new Map(boundaries.map(b => [b.id, b])); + const boundaryOptions = boundaries.map(b => ({ id: b.id, name: b.name })); const nodesConnId = scope.nodes?.__id ?? ""; + const boundariesConnId = scope.boundaries?.__id ?? ""; const processesConnId = scope.processes?.__id ?? ""; const threatsConnId = scope.threats?.__id ?? ""; const scenariosConnId = scope.scenarios?.__id ?? ""; @@ -182,13 +195,14 @@ export function ScopeCard(props: { title={`${__("Nodes")} (${nodes.length})`} hint={__("Entities, boundaries, assets, and data involved in this scope.")} > - + + @@ -197,9 +211,16 @@ export function ScopeCard(props: { + @@ -207,7 +228,7 @@ export function ScopeCard(props: { ))} {nodes.length === 0 && ( - + )} @@ -264,6 +285,52 @@ export function ScopeCard(props: { +
+ + + +
{__("Name")} {__("Type")}{__("Boundary")}
{node.name} {node.nodeType}{node.boundaryId ? boundaryMap.get(node.boundaryId)?.name ?? "—" : "—"}
{__("No nodes")}{__("No nodes")}
+ + + + + + + + {boundaries.map(boundary => ( + + + + + + ))} + {boundaries.length === 0 && ( + + + + )} + +
{__("Name")}{__("Parent")} +
{boundary.name}{boundary.parentBoundaryId ? boundaryMap.get(boundary.parentBoundaryId)?.name ?? "—" : "—"} + +
{__("No boundaries")}
+ +
`${e.node.id}|${e.node.name}|${e.node.nodeType}`) + .map(e => `${e.node.id}|${e.node.name}|${e.node.nodeType}|${e.node.boundaryId ?? ""}`) + .join(";") ?? ""; + const boundarySignature = scope.boundaries?.edges + .map(e => `${e.node.id}|${e.node.name}|${e.node.parentBoundaryId ?? ""}`) .join(";") ?? ""; const processSignature = scope.processes?.edges .map(e => `${e.node.id}|${e.node.name}|${e.node.sourceNodeId}|${e.node.targetNodeId}`) @@ -90,7 +104,7 @@ export function ScopeDiagram({ scopeKey }: ScopeDiagramProps) { const threatSignature = scope.threats?.edges .map(e => `${e.node.id}|${e.node.name}|${e.node.processId}|${e.node.category}`) .join(";") ?? ""; - const signature = `${nodeSignature}::${processSignature}::${threatSignature}`; + const signature = `${nodeSignature}::${boundarySignature}::${processSignature}::${threatSignature}`; const previousSignature = useRef(signature); useEffect(() => { if (previousSignature.current === signature) { @@ -188,7 +202,7 @@ function Legend() { const { __ } = useTranslate(); const items: LegendItem[] = [ { label: __("Entity"), shape: "stadium", fill: "#dbeafe", stroke: "#1d4ed8", text: "#1e3a8a" }, - { label: __("Boundary"), shape: "hexagon", fill: "#fef3c7", stroke: "#b45309", text: "#78350f" }, + { label: __("Boundary"), shape: "rectangle", fill: "#ffffff", stroke: "#b45309", text: "#78350f" }, { label: __("Asset"), shape: "rectangle", fill: "#e5e7eb", stroke: "#374151", text: "#111827" }, { label: __("Data"), shape: "cylinder", fill: "#dcfce7", stroke: "#15803d", text: "#14532d" }, { label: __("Threat"), shape: "hexagon", fill: "#fee2e2", stroke: "#b91c1c", text: "#7f1d1d" }, diff --git a/e2e/console/risk_assessment_test.go b/e2e/console/risk_assessment_test.go index 689068107..b9dc23c00 100644 --- a/e2e/console/risk_assessment_test.go +++ b/e2e/console/risk_assessment_test.go @@ -136,7 +136,7 @@ func TestRiskAssessmentScope_CRUD(t *testing.T) { func TestRiskAssessmentNode_Create(t *testing.T) { t.Parallel() - for _, nodeType := range []string{"ENTITY", "BOUNDARY", "ASSET", "DATA"} { + for _, nodeType := range []string{"ENTITY", "ASSET", "DATA"} { t.Run("nodeType="+nodeType, func(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) @@ -479,13 +479,13 @@ func TestRiskAssessmentNode_Update(t *testing.T) { "input": map[string]any{ "id": nodeID, "name": "Updated node", - "nodeType": "BOUNDARY", + "nodeType": "ASSET", }, }, &result) require.NoError(t, err) assert.Equal(t, "Updated node", result.UpdateRiskAssessmentNode.RiskAssessmentNode.Name) - assert.Equal(t, "BOUNDARY", result.UpdateRiskAssessmentNode.RiskAssessmentNode.NodeType) + assert.Equal(t, "ASSET", result.UpdateRiskAssessmentNode.RiskAssessmentNode.NodeType) } func TestRiskAssessmentProcess_Update(t *testing.T) { @@ -779,3 +779,316 @@ func TestRiskAssessment_TenantIsolation(t *testing.T) { `, map[string]any{"id": raID}, &result) testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "RiskAssessment") } + +func TestRiskAssessmentBoundary_Create(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + parentID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "External Zone"}) + + var result struct { + CreateRiskAssessmentBoundary struct { + RiskAssessmentBoundaryEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + ParentBoundaryID *string `json:"parentBoundaryId"` + } `json:"node"` + } `json:"riskAssessmentBoundaryEdge"` + } `json:"createRiskAssessmentBoundary"` + } + + err := owner.Execute(` + mutation($input: CreateRiskAssessmentBoundaryInput!) { + createRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundaryEdge { node { id name parentBoundaryId } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScopeId": scopeID, + "parentBoundaryId": parentID, + "name": "Internal Network", + }, + }, &result) + + require.NoError(t, err) + + node := result.CreateRiskAssessmentBoundary.RiskAssessmentBoundaryEdge.Node + assert.NotEmpty(t, node.ID) + assert.Equal(t, "Internal Network", node.Name) + require.NotNil(t, node.ParentBoundaryID) + assert.Equal(t, parentID, *node.ParentBoundaryID) +} + +func TestRiskAssessmentBoundary_ListViaScope(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Zone A"}) + factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Zone B"}) + + var result struct { + Node struct { + Boundaries struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"boundaries"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on RiskAssessmentScope { + boundaries(first: 10) { + totalCount + edges { node { id name } } + } + } + } + } + `, map[string]any{"id": scopeID}, &result) + + require.NoError(t, err) + assert.Equal(t, 2, result.Node.Boundaries.TotalCount) + assert.Len(t, result.Node.Boundaries.Edges, 2) +} + +func TestRiskAssessmentBoundary_Update(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + parentID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Parent"}) + boundaryID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Original"}) + + const mutation = ` + mutation($input: UpdateRiskAssessmentBoundaryInput!) { + updateRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundary { id name parentBoundaryId } + } + } + ` + + var result struct { + UpdateRiskAssessmentBoundary struct { + RiskAssessmentBoundary struct { + ID string `json:"id"` + Name string `json:"name"` + ParentBoundaryID *string `json:"parentBoundaryId"` + } `json:"riskAssessmentBoundary"` + } `json:"updateRiskAssessmentBoundary"` + } + + // Rename and assign a parent. + err := owner.Execute(mutation, map[string]any{ + "input": map[string]any{ + "id": boundaryID, + "name": "Renamed", + "parentBoundaryId": parentID, + }, + }, &result) + require.NoError(t, err) + assert.Equal(t, "Renamed", result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.Name) + require.NotNil(t, result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.ParentBoundaryID) + assert.Equal(t, parentID, *result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.ParentBoundaryID) + + // Clear the parent (move back to the top level). + err = owner.Execute(mutation, map[string]any{ + "input": map[string]any{ + "id": boundaryID, + "parentBoundaryId": nil, + }, + }, &result) + require.NoError(t, err) + assert.Nil(t, result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.ParentBoundaryID) +} + +func TestRiskAssessmentBoundary_PreventCycle(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + a := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "A"}) + b := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "B", "parentBoundaryId": a}) + + // B is nested under A, so nesting A under B would create a cycle. + _, err := owner.Do(` + mutation($input: UpdateRiskAssessmentBoundaryInput!) { + updateRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundary { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": a, + "parentBoundaryId": b, + }, + }) + require.Error(t, err, "nesting a boundary under its own descendant should be rejected") +} + +func TestRiskAssessmentBoundary_Delete(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + parentID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Parent"}) + childID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Child", "parentBoundaryId": parentID}) + nodeID := factory.CreateRiskAssessmentNode(owner, scopeID, factory.Attrs{"name": "Member", "boundaryId": parentID}) + + _, err := owner.Do(` + mutation($input: DeleteRiskAssessmentBoundaryInput!) { + deleteRiskAssessmentBoundary(input: $input) { deletedRiskAssessmentBoundaryId } + } + `, map[string]any{"input": map[string]any{"riskAssessmentBoundaryId": parentID}}) + require.NoError(t, err) + + // Deleting a parent moves its nested boundary and member node to the top + // level instead of cascading the delete (ON DELETE SET NULL). + var result struct { + Child *struct { + ParentBoundaryID *string `json:"parentBoundaryId"` + } `json:"child"` + Member *struct { + BoundaryID *string `json:"boundaryId"` + } `json:"member"` + } + + err = owner.Execute(` + query($child: ID!, $member: ID!) { + child: node(id: $child) { ... on RiskAssessmentBoundary { parentBoundaryId } } + member: node(id: $member) { ... on RiskAssessmentNode { boundaryId } } + } + `, map[string]any{"child": childID, "member": nodeID}, &result) + require.NoError(t, err) + require.NotNil(t, result.Child) + assert.Nil(t, result.Child.ParentBoundaryID) + require.NotNil(t, result.Member) + assert.Nil(t, result.Member.BoundaryID) +} + +func TestRiskAssessmentNode_WithBoundary(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + boundaryID := factory.CreateRiskAssessmentBoundary(owner, scopeID) + + var createResult struct { + CreateRiskAssessmentNode struct { + RiskAssessmentNodeEdge struct { + Node struct { + ID string `json:"id"` + BoundaryID *string `json:"boundaryId"` + } `json:"node"` + } `json:"riskAssessmentNodeEdge"` + } `json:"createRiskAssessmentNode"` + } + + err := owner.Execute(` + mutation($input: CreateRiskAssessmentNodeInput!) { + createRiskAssessmentNode(input: $input) { + riskAssessmentNodeEdge { node { id boundaryId } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScopeId": scopeID, + "nodeType": "ASSET", + "name": "Member", + "boundaryId": boundaryID, + }, + }, &createResult) + require.NoError(t, err) + + created := createResult.CreateRiskAssessmentNode.RiskAssessmentNodeEdge.Node + require.NotNil(t, created.BoundaryID) + assert.Equal(t, boundaryID, *created.BoundaryID) + + // Clearing boundaryId moves the node back to the top level. + var updateResult struct { + UpdateRiskAssessmentNode struct { + RiskAssessmentNode struct { + BoundaryID *string `json:"boundaryId"` + } `json:"riskAssessmentNode"` + } `json:"updateRiskAssessmentNode"` + } + + err = owner.Execute(` + mutation($input: UpdateRiskAssessmentNodeInput!) { + updateRiskAssessmentNode(input: $input) { + riskAssessmentNode { boundaryId } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": created.ID, + "boundaryId": nil, + }, + }, &updateResult) + require.NoError(t, err) + assert.Nil(t, updateResult.UpdateRiskAssessmentNode.RiskAssessmentNode.BoundaryID) +} + +func TestRiskAssessmentBoundary_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) + raID := factory.CreateRiskAssessment(owner) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + + _, err := viewer.Do(` + mutation($input: CreateRiskAssessmentBoundaryInput!) { + createRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundaryEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskAssessmentScopeId": scopeID, + "name": "Nope", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer cannot create risk assessment boundary") + }) + + 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) + scopeID := factory.CreateRiskAssessmentScope(owner, raID) + boundaryID := factory.CreateRiskAssessmentBoundary(owner, scopeID, 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 RiskAssessmentBoundary { id name } } } + `, map[string]any{"id": boundaryID}, &result) + require.NoError(t, err) + assert.Equal(t, "Visible", result.Node.Name) + }) +} diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index 5c303c6e6..352f1da73 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -1598,6 +1598,10 @@ func CreateRiskAssessmentNode(c *testutil.Client, scopeID string, attrs ...Attrs "name": a.getString("name", SafeName("Node")), } + if boundaryID := a.getString("boundaryId", ""); boundaryID != "" { + input["boundaryId"] = boundaryID + } + var result struct { CreateRiskAssessmentNode struct { RiskAssessmentNodeEdge struct { @@ -1614,6 +1618,47 @@ func CreateRiskAssessmentNode(c *testutil.Client, scopeID string, attrs ...Attrs return result.CreateRiskAssessmentNode.RiskAssessmentNodeEdge.Node.ID } +func CreateRiskAssessmentBoundary(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: CreateRiskAssessmentBoundaryInput!) { + createRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundaryEdge { node { id } } + } + } + ` + + input := map[string]any{ + "riskAssessmentScopeId": scopeID, + "name": a.getString("name", SafeName("Boundary")), + } + + if parentID := a.getString("parentBoundaryId", ""); parentID != "" { + input["parentBoundaryId"] = parentID + } + + var result struct { + CreateRiskAssessmentBoundary struct { + RiskAssessmentBoundaryEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"riskAssessmentBoundaryEdge"` + } `json:"createRiskAssessmentBoundary"` + } + + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createRiskAssessmentBoundary mutation failed") + + return result.CreateRiskAssessmentBoundary.RiskAssessmentBoundaryEdge.Node.ID +} + func CreateRiskAssessmentProcess(c *testutil.Client, scopeID, sourceNodeID, targetNodeID string, attrs ...Attrs) string { c.T.Helper() diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/createBoundary.operation.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/createBoundary.operation.ts new file mode 100644 index 000000000..ef1588900 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/createBoundary.operation.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Scope ID', + name: 'riskAssessmentScopeId', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['createBoundary'], + }, + }, + default: '', + description: 'The ID of the scope', + required: true, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['createBoundary'], + }, + }, + default: '', + description: 'The name of the boundary', + required: true, + }, + { + displayName: 'Parent Boundary ID', + name: 'parentBoundaryId', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['createBoundary'], + }, + }, + default: '', + description: 'The ID of the parent boundary, for nested boundaries (optional)', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const riskAssessmentScopeId = this.getNodeParameter('riskAssessmentScopeId', itemIndex) as string; + const name = this.getNodeParameter('name', itemIndex) as string; + const parentBoundaryId = this.getNodeParameter('parentBoundaryId', itemIndex, '') as string; + + const query = ` + mutation CreateRiskAssessmentBoundary($input: CreateRiskAssessmentBoundaryInput!) { + createRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundaryEdge { + node { + id + riskAssessmentScopeId + parentBoundaryId + name + createdAt + updatedAt + } + } + } + } + `; + + const input: Record = { riskAssessmentScopeId, name }; + if (parentBoundaryId) input.parentBoundaryId = parentBoundaryId; + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/createNode.operation.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/createNode.operation.ts index 7b336e0f5..6a971836e 100644 --- a/packages/n8n-node/nodes/Probo/actions/riskAssessment/createNode.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/createNode.operation.ts @@ -45,10 +45,6 @@ export const description: INodeProperties[] = [ name: 'Entity', value: 'ENTITY', }, - { - name: 'Boundary', - value: 'BOUNDARY', - }, { name: 'Asset', value: 'ASSET', @@ -76,6 +72,19 @@ export const description: INodeProperties[] = [ description: 'The name of the node', required: true, }, + { + displayName: 'Boundary ID', + name: 'boundaryId', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['createNode'], + }, + }, + default: '', + description: 'The ID of the boundary that contains this node (optional)', + }, ]; export async function execute( @@ -85,6 +94,7 @@ export async function execute( const riskAssessmentScopeId = this.getNodeParameter('riskAssessmentScopeId', itemIndex) as string; const nodeType = this.getNodeParameter('nodeType', itemIndex) as string; const name = this.getNodeParameter('name', itemIndex) as string; + const boundaryId = this.getNodeParameter('boundaryId', itemIndex, '') as string; const query = ` mutation CreateRiskAssessmentNode($input: CreateRiskAssessmentNodeInput!) { @@ -93,6 +103,7 @@ export async function execute( node { id riskAssessmentScopeId + boundaryId nodeType name createdAt @@ -103,9 +114,10 @@ export async function execute( } `; - const responseData = await proboApiRequest.call(this, query, { - input: { riskAssessmentScopeId, nodeType, name }, - }); + const input: Record = { riskAssessmentScopeId, nodeType, name }; + if (boundaryId) input.boundaryId = boundaryId; + + const responseData = await proboApiRequest.call(this, query, { input }); return { json: responseData, diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/deleteBoundary.operation.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/deleteBoundary.operation.ts new file mode 100644 index 000000000..ce046740b --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/deleteBoundary.operation.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Boundary ID', + name: 'boundaryId', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['deleteBoundary'], + }, + }, + default: '', + description: 'The ID of the boundary to delete', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const boundaryId = this.getNodeParameter('boundaryId', itemIndex) as string; + + const query = ` + mutation DeleteRiskAssessmentBoundary($input: DeleteRiskAssessmentBoundaryInput!) { + deleteRiskAssessmentBoundary(input: $input) { + deletedRiskAssessmentBoundaryId + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { + input: { riskAssessmentBoundaryId: boundaryId }, + }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/getAllBoundaries.operation.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/getAllBoundaries.operation.ts new file mode 100644 index 000000000..702959ce4 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/getAllBoundaries.operation.ts @@ -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 type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { proboApiRequestAllItems } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Scope ID', + name: 'scopeId', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['getAllBoundaries'], + }, + }, + default: '', + description: 'The ID of the scope', + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['getAllBoundaries'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['getAllBoundaries'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Max number of results to return', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const scopeId = this.getNodeParameter('scopeId', itemIndex) as string; + const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean; + const limit = this.getNodeParameter('limit', itemIndex, 50) as number; + + const query = ` + query GetBoundaries($scopeId: ID!, $first: Int, $after: CursorKey) { + node(id: $scopeId) { + ... on RiskAssessmentScope { + boundaries(first: $first, after: $after) { + edges { + node { + id + riskAssessmentScopeId + parentBoundaryId + name + createdAt + updatedAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + const boundaries = await proboApiRequestAllItems.call( + this, + query, + { scopeId }, + (response) => { + const data = response?.data as IDataObject | undefined; + const node = data?.node as IDataObject | undefined; + return node?.boundaries as IDataObject | undefined; + }, + returnAll, + limit, + ); + + return { + json: { boundaries }, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/getBoundary.operation.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/getBoundary.operation.ts new file mode 100644 index 000000000..47a710c37 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/getBoundary.operation.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Boundary ID', + name: 'boundaryId', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['getBoundary'], + }, + }, + default: '', + description: 'The ID of the boundary', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const boundaryId = this.getNodeParameter('boundaryId', itemIndex) as string; + + const query = ` + query GetRiskAssessmentBoundary($id: ID!) { + node(id: $id) { + ... on RiskAssessmentBoundary { + id + riskAssessmentScopeId + parentBoundaryId + name + createdAt + updatedAt + } + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { id: boundaryId }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/index.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/index.ts index df0156448..2cba2ccf7 100644 --- a/packages/n8n-node/nodes/Probo/actions/riskAssessment/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/index.ts @@ -29,6 +29,11 @@ import * as getNodeOp from './getNode.operation'; import * as getAllNodesOp from './getAllNodes.operation'; import * as updateNodeOp from './updateNode.operation'; import * as deleteNodeOp from './deleteNode.operation'; +import * as createBoundaryOp from './createBoundary.operation'; +import * as getBoundaryOp from './getBoundary.operation'; +import * as getAllBoundariesOp from './getAllBoundaries.operation'; +import * as updateBoundaryOp from './updateBoundary.operation'; +import * as deleteBoundaryOp from './deleteBoundary.operation'; import * as createProcessOp from './createProcess.operation'; import * as getProcessOp from './getProcess.operation'; import * as getAllProcessesOp from './getAllProcesses.operation'; @@ -67,6 +72,12 @@ export const description: INodeProperties[] = [ description: 'Create a risk assessment', action: 'Create a risk assessment', }, + { + name: 'Create Boundary', + value: 'createBoundary', + description: 'Create a boundary in a scope', + action: 'Create a boundary', + }, { name: 'Create Node', value: 'createNode', @@ -103,6 +114,12 @@ export const description: INodeProperties[] = [ description: 'Delete a risk assessment', action: 'Delete a risk assessment', }, + { + name: 'Delete Boundary', + value: 'deleteBoundary', + description: 'Delete a boundary', + action: 'Delete a boundary', + }, { name: 'Delete Node', value: 'deleteNode', @@ -139,12 +156,23 @@ export const description: INodeProperties[] = [ description: 'Get a risk assessment', action: 'Get a risk assessment', }, + { + name: 'Get Boundary', + value: 'getBoundary', + description: 'Get a boundary', + action: 'Get a boundary', + }, { name: 'Get Many', value: 'getAll', description: 'Get many risk assessments', action: 'Get many risk assessments', }, + { + name: 'Get Many Boundaries', + value: 'getAllBoundaries', + action: 'Get many boundaries', + }, { name: 'Get Many Nodes', value: 'getAllNodes', @@ -236,6 +264,12 @@ export const description: INodeProperties[] = [ description: 'Update a risk assessment', action: 'Update a risk assessment', }, + { + name: 'Update Boundary', + value: 'updateBoundary', + description: 'Update a boundary', + action: 'Update a boundary', + }, { name: 'Update Node', value: 'updateNode', @@ -285,6 +319,11 @@ export const description: INodeProperties[] = [ ...getAllNodesOp.description, ...updateNodeOp.description, ...deleteNodeOp.description, + ...createBoundaryOp.description, + ...getBoundaryOp.description, + ...getAllBoundariesOp.description, + ...updateBoundaryOp.description, + ...deleteBoundaryOp.description, ...createProcessOp.description, ...getProcessOp.description, ...getAllProcessesOp.description, @@ -323,6 +362,11 @@ export { getAllNodesOp as getAllNodes, updateNodeOp as updateNode, deleteNodeOp as deleteNode, + createBoundaryOp as createBoundary, + getBoundaryOp as getBoundary, + getAllBoundariesOp as getAllBoundaries, + updateBoundaryOp as updateBoundary, + deleteBoundaryOp as deleteBoundary, createProcessOp as createProcess, getProcessOp as getProcess, getAllProcessesOp as getAllProcesses, diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/updateBoundary.operation.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/updateBoundary.operation.ts new file mode 100644 index 000000000..66aa6f7e4 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/updateBoundary.operation.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Boundary ID', + name: 'boundaryId', + type: 'string', + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['updateBoundary'], + }, + }, + default: '', + description: 'The ID of the boundary to update', + required: true, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['riskAssessment'], + operation: ['updateBoundary'], + }, + }, + options: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'The name of the boundary', + }, + { + displayName: 'Parent Boundary ID', + name: 'parentBoundaryId', + type: 'string', + default: '', + description: 'The ID of the parent boundary. Leave empty to make the boundary top-level.', + }, + ], + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const boundaryId = this.getNodeParameter('boundaryId', itemIndex) as string; + const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as { + name?: string; + parentBoundaryId?: string; + }; + + const query = ` + mutation UpdateRiskAssessmentBoundary($input: UpdateRiskAssessmentBoundaryInput!) { + updateRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundary { + id + riskAssessmentScopeId + parentBoundaryId + name + createdAt + updatedAt + } + } + } + `; + + const input: Record = { id: boundaryId }; + if (additionalFields.name) input.name = additionalFields.name; + if (additionalFields.parentBoundaryId !== undefined) { + input.parentBoundaryId = additionalFields.parentBoundaryId || null; + } + + if (Object.keys(input).length === 1) { + throw new Error('At least one field must be provided to update'); + } + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/riskAssessment/updateNode.operation.ts b/packages/n8n-node/nodes/Probo/actions/riskAssessment/updateNode.operation.ts index b042f73dc..d13b14461 100644 --- a/packages/n8n-node/nodes/Probo/actions/riskAssessment/updateNode.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/riskAssessment/updateNode.operation.ts @@ -59,10 +59,6 @@ export const description: INodeProperties[] = [ name: 'Entity', value: 'ENTITY', }, - { - name: 'Boundary', - value: 'BOUNDARY', - }, { name: 'Asset', value: 'ASSET', @@ -75,6 +71,13 @@ export const description: INodeProperties[] = [ default: 'ENTITY', description: 'The type of the node', }, + { + displayName: 'Boundary ID', + name: 'boundaryId', + type: 'string', + default: '', + description: 'The ID of the boundary that contains this node. Leave empty to move it to the top level.', + }, ], }, ]; @@ -87,6 +90,7 @@ export async function execute( const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as { name?: string; nodeType?: string; + boundaryId?: string; }; const query = ` @@ -95,6 +99,7 @@ export async function execute( riskAssessmentNode { id riskAssessmentScopeId + boundaryId nodeType name createdAt @@ -107,6 +112,9 @@ export async function execute( const input: Record = { id: nodeId }; if (additionalFields.name) input.name = additionalFields.name; if (additionalFields.nodeType) input.nodeType = additionalFields.nodeType; + if (additionalFields.boundaryId !== undefined) { + input.boundaryId = additionalFields.boundaryId || null; + } if (Object.keys(input).length === 1) { throw new Error('At least one field must be provided to update'); diff --git a/pkg/cmd/cmdutil/flags.go b/pkg/cmd/cmdutil/flags.go index d9e0a2dfb..0a88902f6 100644 --- a/pkg/cmd/cmdutil/flags.go +++ b/pkg/cmd/cmdutil/flags.go @@ -74,3 +74,13 @@ func ValidateEnum(flag string, value string, allowed []string) error { strings.Join(allowed, ", "), ) } + +// ValidateLimit checks that a --limit value is positive. A non-positive limit +// would otherwise cause pagination to return no results without an error. +func ValidateLimit(value int) error { + if value <= 0 { + return fmt.Errorf("invalid --limit value %d: must be greater than 0", value) + } + + return nil +} diff --git a/pkg/cmd/risk-assessment/boundary/boundary.go b/pkg/cmd/risk-assessment/boundary/boundary.go new file mode 100644 index 000000000..93bc6ecee --- /dev/null +++ b/pkg/cmd/risk-assessment/boundary/boundary.go @@ -0,0 +1,40 @@ +// 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 boundary + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/risk-assessment/boundary/create" + "go.probo.inc/probo/pkg/cmd/risk-assessment/boundary/delete" + "go.probo.inc/probo/pkg/cmd/risk-assessment/boundary/list" + "go.probo.inc/probo/pkg/cmd/risk-assessment/boundary/update" + "go.probo.inc/probo/pkg/cmd/risk-assessment/boundary/view" +) + +func NewCmdBoundary(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "boundary ", + Short: "Manage risk assessment boundaries", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + + return cmd +} diff --git a/pkg/cmd/risk-assessment/boundary/create/create.go b/pkg/cmd/risk-assessment/boundary/create/create.go new file mode 100644 index 000000000..c9d2bb018 --- /dev/null +++ b/pkg/cmd/risk-assessment/boundary/create/create.go @@ -0,0 +1,153 @@ +// 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 create + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateRiskAssessmentBoundaryInput!) { + createRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundaryEdge { + node { + id + riskAssessmentScopeId + parentBoundaryId + name + createdAt + updatedAt + } + } + } +} +` + +type createResponse struct { + CreateRiskAssessmentBoundary struct { + RiskAssessmentBoundaryEdge struct { + Node struct { + ID string `json:"id"` + RiskAssessmentScopeId string `json:"riskAssessmentScopeId"` + ParentBoundaryId *string `json:"parentBoundaryId"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` + } `json:"riskAssessmentBoundaryEdge"` + } `json:"createRiskAssessmentBoundary"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagScopeId string + flagParentId string + flagName string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new risk assessment boundary", + Example: ` # Create a boundary interactively + prb risk-assessment boundary create --scope-id + + # Create a boundary non-interactively + prb risk-assessment boundary create --scope-id --name "Production environment" + + # Create a boundary nested inside another boundary + prb risk-assessment boundary create --scope-id --name "Database tier" --parent-id `, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if f.IOStreams.IsInteractive() { + if flagName == "" { + err := huh.NewInput(). + Title("Boundary name"). + Value(&flagName). + Run() + if err != nil { + return err + } + } + } + + if flagName == "" { + return fmt.Errorf("name is required; pass --name or run interactively") + } + + input := map[string]any{ + "riskAssessmentScopeId": flagScopeId, + "name": flagName, + } + + if flagParentId != "" { + input["parentBoundaryId"] = flagParentId + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + r := resp.CreateRiskAssessmentBoundary.RiskAssessmentBoundaryEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Created risk assessment boundary %s (%s)\n", + r.ID, + r.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagScopeId, "scope-id", "", "Risk assessment scope ID (required)") + cmd.Flags().StringVar(&flagParentId, "parent-id", "", "Parent boundary ID (optional, for nested boundaries)") + cmd.Flags().StringVar(&flagName, "name", "", "Boundary name (required)") + + _ = cmd.MarkFlagRequired("scope-id") + + return cmd +} diff --git a/pkg/cmd/risk-assessment/boundary/delete/delete.go b/pkg/cmd/risk-assessment/boundary/delete/delete.go new file mode 100644 index 000000000..03c7e47be --- /dev/null +++ b/pkg/cmd/risk-assessment/boundary/delete/delete.go @@ -0,0 +1,105 @@ +// 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 delete + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const deleteMutation = ` +mutation($input: DeleteRiskAssessmentBoundaryInput!) { + deleteRiskAssessmentBoundary(input: $input) { + deletedRiskAssessmentBoundaryId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a risk assessment boundary", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete risk assessment boundary: confirmation required, use --yes to confirm") + } + + var confirmed bool + + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete risk assessment boundary %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "riskAssessmentBoundaryId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted risk assessment boundary %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/risk-assessment/boundary/list/list.go b/pkg/cmd/risk-assessment/boundary/list/list.go new file mode 100644 index 000000000..26802d331 --- /dev/null +++ b/pkg/cmd/risk-assessment/boundary/list/list.go @@ -0,0 +1,208 @@ +// 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 list + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: RiskAssessmentBoundaryOrder) { + node(id: $id) { + __typename + ... on RiskAssessmentScope { + boundaries(first: $first, after: $after, orderBy: $orderBy) { + totalCount + edges { + node { + id + riskAssessmentScopeId + parentBoundaryId + name + createdAt + updatedAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type riskAssessmentBoundary struct { + ID string `json:"id"` + RiskAssessmentScopeId string `json:"riskAssessmentScopeId"` + ParentBoundaryId *string `json:"parentBoundaryId"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagScope string + flagLimit int + flagOrderBy string + flagOrderDir string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List boundaries in a risk assessment scope", + Aliases: []string{"ls"}, + Example: ` # List boundaries in a scope + prb risk-assessment boundary list --scope + + # List boundaries as JSON + prb risk-assessment boundary ls --scope --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + if err := cmdutil.ValidateLimit(flagLimit); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagScope == "" { + return fmt.Errorf("scope is required; pass --scope") + } + + variables := map[string]any{ + "id": flagScope, + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT", "NAME"}); err != nil { + return err + } + + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + boundaries, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[riskAssessmentBoundary], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + Boundaries api.Connection[riskAssessmentBoundary] `json:"boundaries"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + + if resp.Node == nil { + return nil, fmt.Errorf("scope %s not found", flagScope) + } + + if resp.Node.Typename != "RiskAssessmentScope" { + return nil, fmt.Errorf("expected RiskAssessmentScope node, got %s", resp.Node.Typename) + } + + return &resp.Node.Boundaries, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, boundaries) + } + + if len(boundaries) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No boundaries found.") + return nil + } + + rows := make([][]string, 0, len(boundaries)) + for _, b := range boundaries { + parent := "" + if b.ParentBoundaryId != nil { + parent = *b.ParentBoundaryId + } + + rows = append(rows, []string{ + b.ID, + b.Name, + parent, + cmdutil.FormatTime(b.CreatedAt), + }) + } + + t := cmdutil.NewTable("ID", "NAME", "PARENT", "CREATED AT").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(boundaries) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d boundaries\n", + len(boundaries), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagScope, "scope", "", "Risk assessment scope ID (required)") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of boundaries to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, NAME)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + _ = cmd.MarkFlagRequired("scope") + + return cmd +} diff --git a/pkg/cmd/risk-assessment/boundary/update/update.go b/pkg/cmd/risk-assessment/boundary/update/update.go new file mode 100644 index 000000000..6f4a1f7f0 --- /dev/null +++ b/pkg/cmd/risk-assessment/boundary/update/update.go @@ -0,0 +1,136 @@ +// 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 update + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const updateMutation = ` +mutation($input: UpdateRiskAssessmentBoundaryInput!) { + updateRiskAssessmentBoundary(input: $input) { + riskAssessmentBoundary { + id + parentBoundaryId + name + createdAt + updatedAt + } + } +} +` + +type updateResponse struct { + UpdateRiskAssessmentBoundary struct { + RiskAssessmentBoundary struct { + ID string `json:"id"` + ParentBoundaryId *string `json:"parentBoundaryId"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"riskAssessmentBoundary"` + } `json:"updateRiskAssessmentBoundary"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagName string + flagParentId string + flagClearParent bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a risk assessment boundary", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagClearParent && cmd.Flags().Changed("parent-id") { + return fmt.Errorf("cannot use --parent-id and --clear-parent together") + } + + input := map[string]any{ + "id": args[0], + } + + if cmd.Flags().Changed("name") { + input["name"] = flagName + } + + if cmd.Flags().Changed("parent-id") { + input["parentBoundaryId"] = flagParentId + } + + if flagClearParent { + input["parentBoundaryId"] = nil + } + + if len(input) == 1 { + return fmt.Errorf("at least one field must be specified for update") + } + + data, err := client.Do( + updateMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + r := resp.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated risk assessment boundary %s (%s)\n", + r.ID, + r.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Boundary name") + cmd.Flags().StringVar(&flagParentId, "parent-id", "", "Parent boundary ID") + cmd.Flags().BoolVar(&flagClearParent, "clear-parent", false, "Remove the parent boundary (make it top-level)") + + return cmd +} diff --git a/pkg/cmd/risk-assessment/boundary/view/view.go b/pkg/cmd/risk-assessment/boundary/view/view.go new file mode 100644 index 000000000..31c0223e0 --- /dev/null +++ b/pkg/cmd/risk-assessment/boundary/view/view.go @@ -0,0 +1,139 @@ +// 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 view + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const viewQuery = ` +query($id: ID!) { + node(id: $id) { + __typename + ... on RiskAssessmentBoundary { + id + riskAssessmentScopeId + parentBoundaryId + name + createdAt + updatedAt + } + } +} +` + +type viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + RiskAssessmentScopeId string `json:"riskAssessmentScopeId"` + ParentBoundaryId *string `json:"parentBoundaryId"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` +} + +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + var flagOutput *string + + cmd := &cobra.Command{ + Use: "view ", + Short: "View a risk assessment boundary", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + data, err := client.Do( + viewQuery, + map[string]any{"id": args[0]}, + ) + if err != nil { + return err + } + + var resp viewResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + if resp.Node == nil { + return fmt.Errorf("risk assessment boundary %s not found", args[0]) + } + + if resp.Node.Typename != "RiskAssessmentBoundary" { + return fmt.Errorf("expected RiskAssessmentBoundary node, got %s", resp.Node.Typename) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + r := resp.Node + out := f.IOStreams.Out + + bold := lipgloss.NewStyle().Bold(true) + label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22) + + _, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(r.Name)) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), r.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Scope:"), r.RiskAssessmentScopeId) + + parent := "(none)" + if r.ParentBoundaryId != nil { + parent = *r.ParentBoundaryId + } + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Parent Boundary:"), parent) + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(r.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(r.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/risk-assessment/node/create/create.go b/pkg/cmd/risk-assessment/node/create/create.go index 9a28df383..b9713cc5f 100644 --- a/pkg/cmd/risk-assessment/node/create/create.go +++ b/pkg/cmd/risk-assessment/node/create/create.go @@ -58,9 +58,10 @@ type createResponse struct { func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { var ( - flagScopeId string - flagNodeType string - flagName string + flagScopeId string + flagBoundaryId string + flagNodeType string + flagName string ) cmd := &cobra.Command{ @@ -106,7 +107,6 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { Title("Node type"). Options( huh.NewOption("Entity", "ENTITY"), - huh.NewOption("Boundary", "BOUNDARY"), huh.NewOption("Asset", "ASSET"), huh.NewOption("Data", "DATA"), ). @@ -126,7 +126,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("node type is required; pass --node-type or run interactively") } - if err := cmdutil.ValidateEnum("node-type", flagNodeType, []string{"ENTITY", "BOUNDARY", "ASSET", "DATA"}); err != nil { + if err := cmdutil.ValidateEnum("node-type", flagNodeType, []string{"ENTITY", "ASSET", "DATA"}); err != nil { return err } @@ -136,6 +136,10 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { "name": flagName, } + if flagBoundaryId != "" { + input["boundaryId"] = flagBoundaryId + } + data, err := client.Do( createMutation, map[string]any{"input": input}, @@ -162,7 +166,8 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&flagScopeId, "scope-id", "", "Risk assessment scope ID (required)") - cmd.Flags().StringVar(&flagNodeType, "node-type", "", "Node type: ENTITY, BOUNDARY, ASSET, DATA (required)") + cmd.Flags().StringVar(&flagBoundaryId, "boundary-id", "", "Boundary ID that contains this node (optional)") + cmd.Flags().StringVar(&flagNodeType, "node-type", "", "Node type: ENTITY, ASSET, DATA (required)") cmd.Flags().StringVar(&flagName, "name", "", "Node name (required)") _ = cmd.MarkFlagRequired("scope-id") diff --git a/pkg/cmd/risk-assessment/node/update/update.go b/pkg/cmd/risk-assessment/node/update/update.go index e5b91e19a..7ee090265 100644 --- a/pkg/cmd/risk-assessment/node/update/update.go +++ b/pkg/cmd/risk-assessment/node/update/update.go @@ -51,8 +51,10 @@ type updateResponse struct { func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { var ( - flagName string - flagNodeType string + flagName string + flagNodeType string + flagBoundaryId string + flagClearBoundary bool ) cmd := &cobra.Command{ @@ -78,6 +80,10 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { cmdutil.TokenRefreshOption(cfg, host, hc), ) + if flagClearBoundary && cmd.Flags().Changed("boundary-id") { + return fmt.Errorf("cannot use --boundary-id and --clear-boundary together") + } + input := map[string]any{ "id": args[0], } @@ -87,13 +93,21 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { } if cmd.Flags().Changed("node-type") { - if err := cmdutil.ValidateEnum("node-type", flagNodeType, []string{"ENTITY", "BOUNDARY", "ASSET", "DATA"}); err != nil { + if err := cmdutil.ValidateEnum("node-type", flagNodeType, []string{"ENTITY", "ASSET", "DATA"}); err != nil { return err } input["nodeType"] = flagNodeType } + if cmd.Flags().Changed("boundary-id") { + input["boundaryId"] = flagBoundaryId + } + + if flagClearBoundary { + input["boundaryId"] = nil + } + if len(input) == 1 { return fmt.Errorf("at least one field must be specified for update") } @@ -124,7 +138,9 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&flagName, "name", "", "Node name") - cmd.Flags().StringVar(&flagNodeType, "node-type", "", "Node type: ENTITY, BOUNDARY, ASSET, DATA") + cmd.Flags().StringVar(&flagNodeType, "node-type", "", "Node type: ENTITY, ASSET, DATA") + cmd.Flags().StringVar(&flagBoundaryId, "boundary-id", "", "Boundary ID that contains this node") + cmd.Flags().BoolVar(&flagClearBoundary, "clear-boundary", false, "Remove the node from its boundary (move to top level)") return cmd } diff --git a/pkg/cmd/risk-assessment/risk_assessment.go b/pkg/cmd/risk-assessment/risk_assessment.go index a35dedd57..eb5221d9d 100644 --- a/pkg/cmd/risk-assessment/risk_assessment.go +++ b/pkg/cmd/risk-assessment/risk_assessment.go @@ -17,6 +17,7 @@ package riskassessment import ( "github.com/spf13/cobra" "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/risk-assessment/boundary" "go.probo.inc/probo/pkg/cmd/risk-assessment/create" "go.probo.inc/probo/pkg/cmd/risk-assessment/delete" "go.probo.inc/probo/pkg/cmd/risk-assessment/list" @@ -42,6 +43,7 @@ func NewCmdRiskAssessment(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(delete.NewCmdDelete(f)) cmd.AddCommand(scope.NewCmdScope(f)) cmd.AddCommand(node.NewCmdNode(f)) + cmd.AddCommand(boundary.NewCmdBoundary(f)) cmd.AddCommand(process.NewCmdProcess(f)) cmd.AddCommand(threat.NewCmdThreat(f)) cmd.AddCommand(scenario.NewCmdScenario(f)) diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index 25922f639..b40f3eeb1 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -124,6 +124,7 @@ const ( RiskAssessmentThreatEntityType uint16 = 98 RiskAssessmentScopeEntityType uint16 = 99 RiskAssessmentScenarioEntityType uint16 = 100 + RiskAssessmentBoundaryEntityType uint16 = 101 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -312,6 +313,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &RiskAssessmentScope{ID: id}, true case RiskAssessmentScenarioEntityType: return &RiskAssessmentScenario{ID: id}, true + case RiskAssessmentBoundaryEntityType: + return &RiskAssessmentBoundary{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260608T122627Z.sql b/pkg/coredata/migrations/20260608T122627Z.sql new file mode 100644 index 000000000..5d3b1bf24 --- /dev/null +++ b/pkg/coredata/migrations/20260608T122627Z.sql @@ -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. + +CREATE TABLE risk_assessment_boundaries ( + 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, + parent_boundary_id TEXT REFERENCES risk_assessment_boundaries(id) ON DELETE SET NULL, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT risk_assessment_boundaries_unique_name UNIQUE (risk_assessment_scope_id, name) +); + +ALTER TABLE risk_assessment_nodes + ADD COLUMN boundary_id TEXT REFERENCES risk_assessment_boundaries(id) ON DELETE SET NULL; + +-- Migrate legacy BOUNDARY-typed nodes into the first-class boundary model. +-- Each boundary gets a freshly generated GID (entity type 101). Node names are +-- already unique per scope, so the boundary (scope_id, name) constraint cannot +-- be violated. +INSERT INTO risk_assessment_boundaries ( + id, + tenant_id, + organization_id, + risk_assessment_scope_id, + parent_boundary_id, + name, + created_at, + updated_at +) +SELECT + generate_gid(decode_base64_unpadded(tenant_id), 101), + tenant_id, + organization_id, + risk_assessment_scope_id, + NULL, + name, + created_at, + updated_at +FROM risk_assessment_nodes +WHERE node_type = 'BOUNDARY'; + +-- Remove the legacy node representation now that the boundaries exist. +DELETE FROM risk_assessment_nodes +WHERE node_type = 'BOUNDARY'; + +-- Drop the now-unused BOUNDARY value from the node_type enum. PostgreSQL cannot +-- remove a value from an enum in place, so the type is rebuilt without it. +ALTER TYPE risk_assessment_node_type RENAME TO risk_assessment_node_type_old; + +CREATE TYPE risk_assessment_node_type AS ENUM ('ENTITY', 'ASSET', 'DATA'); + +ALTER TABLE risk_assessment_nodes + ALTER COLUMN node_type TYPE risk_assessment_node_type + USING node_type::text::risk_assessment_node_type; + +DROP TYPE risk_assessment_node_type_old; diff --git a/pkg/coredata/risk_assessment_boundary.go b/pkg/coredata/risk_assessment_boundary.go new file mode 100644 index 000000000..23530d9da --- /dev/null +++ b/pkg/coredata/risk_assessment_boundary.go @@ -0,0 +1,344 @@ +// 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/iam/policy" + "go.probo.inc/probo/pkg/page" +) + +type ( + RiskAssessmentBoundary struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + RiskAssessmentScopeID gid.GID `db:"risk_assessment_scope_id"` + ParentBoundaryID *gid.GID `db:"parent_boundary_id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + RiskAssessmentBoundaries []*RiskAssessmentBoundary +) + +func (b *RiskAssessmentBoundary) CursorKey(orderBy RiskAssessmentBoundaryOrderField) page.CursorKey { + switch orderBy { + case RiskAssessmentBoundaryOrderFieldCreatedAt: + return page.CursorKey{ID: b.ID, Value: b.CreatedAt} + case RiskAssessmentBoundaryOrderFieldName: + return page.CursorKey{ID: b.ID, Value: b.Name} + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (b *RiskAssessmentBoundary) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risk_assessment_boundaries WHERE id = ANY(@resource_ids::text[])` + + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + + for rows.Next() { + var id, organizationID gid.GID + + if err := rows.Scan(&id, &organizationID); err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil +} + +func (bs *RiskAssessmentBoundaries) LoadByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, + cursor *page.Cursor[RiskAssessmentBoundaryOrderField], +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + parent_boundary_id, + name, + created_at, + updated_at +FROM + risk_assessment_boundaries +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 boundaries: %w", err) + } + + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentBoundary]) + if err != nil { + return fmt.Errorf("cannot collect risk assessment boundaries: %w", err) + } + + *bs = results + + return nil +} + +func (bs *RiskAssessmentBoundaries) LoadAllByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + parent_boundary_id, + name, + created_at, + updated_at +FROM + risk_assessment_boundaries +WHERE + %s + AND risk_assessment_scope_id = @risk_assessment_scope_id +ORDER BY + created_at ASC, id ASC +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query risk assessment boundaries: %w", err) + } + + results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentBoundary]) + if err != nil { + return fmt.Errorf("cannot collect risk assessment boundaries: %w", err) + } + + *bs = results + + return nil +} + +func (bs *RiskAssessmentBoundaries) CountByRiskAssessmentScopeID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + riskAssessmentScopeID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + risk_assessment_boundaries +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 boundaries: %w", err) + } + + return count, nil +} + +func (b *RiskAssessmentBoundary) LoadByID(ctx context.Context, conn pg.Querier, scope Scoper, id gid.GID) error { + q := ` +SELECT + id, + organization_id, + risk_assessment_scope_id, + parent_boundary_id, + name, + created_at, + updated_at +FROM + risk_assessment_boundaries +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 boundary: %w", err) + } + + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[RiskAssessmentBoundary]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect risk assessment boundary: %w", err) + } + + *b = result + + return nil +} + +func (b *RiskAssessmentBoundary) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO risk_assessment_boundaries ( + id, + tenant_id, + organization_id, + risk_assessment_scope_id, + parent_boundary_id, + name, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @risk_assessment_scope_id, + @parent_boundary_id, + @name, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "id": b.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": b.OrganizationID, + "risk_assessment_scope_id": b.RiskAssessmentScopeID, + "parent_boundary_id": b.ParentBoundaryID, + "name": b.Name, + "created_at": b.CreatedAt, + "updated_at": b.UpdatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessment_boundaries_unique_name" { + return ErrResourceAlreadyExists + } + + return fmt.Errorf("cannot insert risk assessment boundary: %w", err) + } + + return nil +} + +func (b *RiskAssessmentBoundary) Update(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +UPDATE risk_assessment_boundaries +SET + parent_boundary_id = @parent_boundary_id, + name = @name, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "id": b.ID, + "parent_boundary_id": b.ParentBoundaryID, + "name": b.Name, + "updated_at": b.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessment_boundaries_unique_name" { + return ErrResourceAlreadyExists + } + + return fmt.Errorf("cannot update risk assessment boundary: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +func (b *RiskAssessmentBoundary) Delete(ctx context.Context, conn pg.Tx, scope Scoper, id gid.GID) error { + q := ` +DELETE FROM risk_assessment_boundaries +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_boundary_order_field.go b/pkg/coredata/risk_assessment_boundary_order_field.go new file mode 100644 index 000000000..880b6574c --- /dev/null +++ b/pkg/coredata/risk_assessment_boundary_order_field.go @@ -0,0 +1,75 @@ +// 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 ( + "encoding" + "fmt" + + "go.probo.inc/probo/pkg/page" +) + +type RiskAssessmentBoundaryOrderField string + +const ( + RiskAssessmentBoundaryOrderFieldCreatedAt RiskAssessmentBoundaryOrderField = "CREATED_AT" + RiskAssessmentBoundaryOrderFieldName RiskAssessmentBoundaryOrderField = "NAME" +) + +var ( + _ page.OrderField = RiskAssessmentBoundaryOrderField("") + _ fmt.Stringer = RiskAssessmentBoundaryOrderField("") + _ encoding.TextMarshaler = RiskAssessmentBoundaryOrderField("") + _ encoding.TextUnmarshaler = (*RiskAssessmentBoundaryOrderField)(nil) +) + +func RiskAssessmentBoundaryOrderFields() []RiskAssessmentBoundaryOrderField { + return []RiskAssessmentBoundaryOrderField{ + RiskAssessmentBoundaryOrderFieldCreatedAt, + RiskAssessmentBoundaryOrderFieldName, + } +} + +func (v RiskAssessmentBoundaryOrderField) IsValid() bool { + switch v { + case + RiskAssessmentBoundaryOrderFieldCreatedAt, + RiskAssessmentBoundaryOrderFieldName: + return true + } + + return false +} + +func (v RiskAssessmentBoundaryOrderField) String() string { + return string(v) +} + +func (v RiskAssessmentBoundaryOrderField) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *RiskAssessmentBoundaryOrderField) UnmarshalText(text []byte) error { + val := RiskAssessmentBoundaryOrderField(text) + if !val.IsValid() { + return fmt.Errorf("invalid RiskAssessmentBoundaryOrderField value: %q", string(text)) + } + + *v = val + + return nil +} + +func (p RiskAssessmentBoundaryOrderField) Column() string { return string(p) } diff --git a/pkg/coredata/risk_assessment_node.go b/pkg/coredata/risk_assessment_node.go index 433db6ca4..84b7f5f0f 100644 --- a/pkg/coredata/risk_assessment_node.go +++ b/pkg/coredata/risk_assessment_node.go @@ -34,6 +34,7 @@ type ( ID gid.GID `db:"id"` OrganizationID gid.GID `db:"organization_id"` RiskAssessmentScopeID gid.GID `db:"risk_assessment_scope_id"` + BoundaryID *gid.GID `db:"boundary_id"` NodeType RiskAssessmentNodeType `db:"node_type"` Name string `db:"name"` CreatedAt time.Time `db:"created_at"` @@ -105,6 +106,7 @@ SELECT id, organization_id, risk_assessment_scope_id, + boundary_id, node_type, name, created_at, @@ -147,6 +149,7 @@ SELECT id, organization_id, risk_assessment_scope_id, + boundary_id, node_type, name, created_at, @@ -212,6 +215,7 @@ SELECT id, organization_id, risk_assessment_scope_id, + boundary_id, node_type, name, created_at, @@ -253,6 +257,7 @@ INSERT INTO risk_assessment_nodes ( tenant_id, organization_id, risk_assessment_scope_id, + boundary_id, node_type, name, created_at, @@ -262,6 +267,7 @@ INSERT INTO risk_assessment_nodes ( @tenant_id, @organization_id, @risk_assessment_scope_id, + @boundary_id, @node_type, @name, @created_at, @@ -273,6 +279,7 @@ INSERT INTO risk_assessment_nodes ( "tenant_id": scope.GetTenantID(), "organization_id": n.OrganizationID, "risk_assessment_scope_id": n.RiskAssessmentScopeID, + "boundary_id": n.BoundaryID, "node_type": n.NodeType, "name": n.Name, "created_at": n.CreatedAt, @@ -295,6 +302,7 @@ func (n *RiskAssessmentNode) Update(ctx context.Context, conn pg.Tx, scope Scope q := ` UPDATE risk_assessment_nodes SET + boundary_id = @boundary_id, node_type = @node_type, name = @name, updated_at = @updated_at @@ -304,10 +312,11 @@ WHERE ` q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{ - "id": n.ID, - "node_type": n.NodeType, - "name": n.Name, - "updated_at": n.UpdatedAt, + "id": n.ID, + "boundary_id": n.BoundaryID, + "node_type": n.NodeType, + "name": n.Name, + "updated_at": n.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) diff --git a/pkg/coredata/risk_assessment_node_type.go b/pkg/coredata/risk_assessment_node_type.go index 9bad46806..f4c51879c 100644 --- a/pkg/coredata/risk_assessment_node_type.go +++ b/pkg/coredata/risk_assessment_node_type.go @@ -22,10 +22,9 @@ import ( type RiskAssessmentNodeType string const ( - RiskAssessmentNodeTypeEntity RiskAssessmentNodeType = "ENTITY" - RiskAssessmentNodeTypeBoundary RiskAssessmentNodeType = "BOUNDARY" - RiskAssessmentNodeTypeAsset RiskAssessmentNodeType = "ASSET" - RiskAssessmentNodeTypeData RiskAssessmentNodeType = "DATA" + RiskAssessmentNodeTypeEntity RiskAssessmentNodeType = "ENTITY" + RiskAssessmentNodeTypeAsset RiskAssessmentNodeType = "ASSET" + RiskAssessmentNodeTypeData RiskAssessmentNodeType = "DATA" ) var ( @@ -37,7 +36,6 @@ var ( func RiskAssessmentNodeTypes() []RiskAssessmentNodeType { return []RiskAssessmentNodeType{ RiskAssessmentNodeTypeEntity, - RiskAssessmentNodeTypeBoundary, RiskAssessmentNodeTypeAsset, RiskAssessmentNodeTypeData, } @@ -47,7 +45,6 @@ func (v RiskAssessmentNodeType) IsValid() bool { switch v { case RiskAssessmentNodeTypeEntity, - RiskAssessmentNodeTypeBoundary, RiskAssessmentNodeTypeAsset, RiskAssessmentNodeTypeData: return true diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index e5530151b..6b0f07f92 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -424,6 +424,13 @@ const ( ActionRiskAssessmentNodeUpdate = "core:risk-assessment-node:update" ActionRiskAssessmentNodeDelete = "core:risk-assessment-node:delete" + // RiskAssessmentBoundary actions + ActionRiskAssessmentBoundaryGet = "core:risk-assessment-boundary:get" + ActionRiskAssessmentBoundaryList = "core:risk-assessment-boundary:list" + ActionRiskAssessmentBoundaryCreate = "core:risk-assessment-boundary:create" + ActionRiskAssessmentBoundaryUpdate = "core:risk-assessment-boundary:update" + ActionRiskAssessmentBoundaryDelete = "core:risk-assessment-boundary:delete" + // RiskAssessmentProcess actions ActionRiskAssessmentProcessGet = "core:risk-assessment-process:get" ActionRiskAssessmentProcessList = "core:risk-assessment-process:list" diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index e81cdc390..56ddf3817 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -93,6 +93,7 @@ var ViewerPolicy = policy.NewPolicy( ActionRiskAssessmentGet, ActionRiskAssessmentList, ActionRiskAssessmentScopeGet, ActionRiskAssessmentScopeList, ActionRiskAssessmentNodeGet, ActionRiskAssessmentNodeList, + ActionRiskAssessmentBoundaryGet, ActionRiskAssessmentBoundaryList, ActionRiskAssessmentProcessGet, ActionRiskAssessmentProcessList, ActionRiskAssessmentThreatGet, ActionRiskAssessmentThreatList, ActionRiskAssessmentScenarioGet, ActionRiskAssessmentScenarioList, @@ -169,6 +170,7 @@ var AuditorPolicy = policy.NewPolicy( ActionRiskAssessmentGet, ActionRiskAssessmentList, ActionRiskAssessmentScopeGet, ActionRiskAssessmentScopeList, ActionRiskAssessmentNodeGet, ActionRiskAssessmentNodeList, + ActionRiskAssessmentBoundaryGet, ActionRiskAssessmentBoundaryList, ActionRiskAssessmentProcessGet, ActionRiskAssessmentProcessList, ActionRiskAssessmentThreatGet, ActionRiskAssessmentThreatList, ActionRiskAssessmentScenarioGet, ActionRiskAssessmentScenarioList, diff --git a/pkg/riskmanagement/mermaid.go b/pkg/riskmanagement/mermaid.go index c2bba404c..e72bb6a23 100644 --- a/pkg/riskmanagement/mermaid.go +++ b/pkg/riskmanagement/mermaid.go @@ -26,9 +26,10 @@ import ( func (s *Service) BuildScopeMermaidChart(ctx context.Context, scope coredata.Scoper, scopeID gid.GID) (string, error) { var ( - nodes coredata.RiskAssessmentNodes - processes coredata.RiskAssessmentProcesses - threats coredata.RiskAssessmentThreats + nodes coredata.RiskAssessmentNodes + boundaries coredata.RiskAssessmentBoundaries + processes coredata.RiskAssessmentProcesses + threats coredata.RiskAssessmentThreats ) err := s.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { @@ -36,6 +37,10 @@ func (s *Service) BuildScopeMermaidChart(ctx context.Context, scope coredata.Sco return fmt.Errorf("cannot load nodes: %w", err) } + if err := boundaries.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil { + return fmt.Errorf("cannot load boundaries: %w", err) + } + if err := processes.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil { return fmt.Errorf("cannot load processes: %w", err) } @@ -50,15 +55,16 @@ func (s *Service) BuildScopeMermaidChart(ctx context.Context, scope coredata.Sco return "", err } - return buildScopeMermaidChart(nodes, processes, threats), nil + return buildScopeMermaidChart(nodes, boundaries, processes, threats), nil } func buildScopeMermaidChart( nodes coredata.RiskAssessmentNodes, + boundaries coredata.RiskAssessmentBoundaries, processes coredata.RiskAssessmentProcesses, threats coredata.RiskAssessmentThreats, ) string { - if len(nodes) == 0 { + if len(nodes) == 0 && len(boundaries) == 0 { return "" } @@ -67,13 +73,87 @@ func buildScopeMermaidChart( nodeAlias[n.ID] = fmt.Sprintf("n%d", i) } + boundaryAlias := make(map[gid.GID]string, len(boundaries)) + for i, bnd := range boundaries { + boundaryAlias[bnd.ID] = fmt.Sprintf("b%d", i) + } + + // Group boundaries by their parent so nested boundaries become nested subgraphs. + childBoundaries := make(map[gid.GID]coredata.RiskAssessmentBoundaries) + + var rootBoundaries coredata.RiskAssessmentBoundaries + + for _, bnd := range boundaries { + if bnd.ParentBoundaryID != nil { + if _, ok := boundaryAlias[*bnd.ParentBoundaryID]; ok { + childBoundaries[*bnd.ParentBoundaryID] = append(childBoundaries[*bnd.ParentBoundaryID], bnd) + continue + } + } + + rootBoundaries = append(rootBoundaries, bnd) + } + + // Group nodes by the boundary that contains them; nodes without a + // boundary (or referencing an unknown one) are rendered at the top level. + nodesByBoundary := make(map[gid.GID]coredata.RiskAssessmentNodes) + + var rootNodes coredata.RiskAssessmentNodes + + for _, n := range nodes { + if n.BoundaryID != nil { + if _, ok := boundaryAlias[*n.BoundaryID]; ok { + nodesByBoundary[*n.BoundaryID] = append(nodesByBoundary[*n.BoundaryID], n) + continue + } + } + + rootNodes = append(rootNodes, n) + } + var b strings.Builder b.WriteString("flowchart LR\n") - for _, n := range nodes { + // class statements must live at the flowchart level, not inside a + // subgraph block, so collect them and emit once all shapes are written. + var classLines []string + + emitNode := func(n *coredata.RiskAssessmentNode, indent string) { id := nodeAlias[n.ID] - fmt.Fprintf(&b, " %s\n", mermaidNodeShape(n.NodeType, id, n.Name)) - fmt.Fprintf(&b, " class %s %s\n", id, mermaidNodeClass(n.NodeType)) + fmt.Fprintf(&b, "%s%s\n", indent, mermaidNodeShape(n.NodeType, id, n.Name)) + classLines = append(classLines, fmt.Sprintf(" class %s %s", id, mermaidNodeClass(n.NodeType))) + } + + var emitBoundary func(bnd *coredata.RiskAssessmentBoundary, indent string) + + emitBoundary = func(bnd *coredata.RiskAssessmentBoundary, indent string) { + alias := boundaryAlias[bnd.ID] + fmt.Fprintf(&b, "%ssubgraph %s[\"%s\"]\n", indent, alias, escapeMermaidLabel(bnd.Name)) + + inner := indent + " " + for _, child := range childBoundaries[bnd.ID] { + emitBoundary(child, inner) + } + + for _, n := range nodesByBoundary[bnd.ID] { + emitNode(n, inner) + } + + fmt.Fprintf(&b, "%send\n", indent) + + classLines = append(classLines, fmt.Sprintf(" class %s nodeBoundary", alias)) + } + + for _, bnd := range rootBoundaries { + emitBoundary(bnd, " ") + } + + for _, n := range rootNodes { + emitNode(n, " ") + } + + for _, line := range classLines { + b.WriteString(line + "\n") } for _, p := range processes { @@ -111,7 +191,7 @@ func buildScopeMermaidChart( } b.WriteString(" classDef nodeEntity fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a\n") - b.WriteString(" classDef nodeBoundary fill:#fef3c7,stroke:#b45309,color:#78350f\n") + b.WriteString(" classDef nodeBoundary fill:#ffffff,stroke:#b45309,color:#78350f\n") b.WriteString(" classDef nodeAsset fill:#e5e7eb,stroke:#374151,color:#111827\n") b.WriteString(" classDef nodeData fill:#dcfce7,stroke:#15803d,color:#14532d\n") b.WriteString(" classDef nodeThreat fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d\n") @@ -125,8 +205,6 @@ func mermaidNodeShape(t coredata.RiskAssessmentNodeType, id, name string) string switch t { case coredata.RiskAssessmentNodeTypeEntity: return fmt.Sprintf("%s([%s])", id, label) - case coredata.RiskAssessmentNodeTypeBoundary: - return fmt.Sprintf("%s{{%s}}", id, label) case coredata.RiskAssessmentNodeTypeData: return fmt.Sprintf("%s[(%s)]", id, label) case coredata.RiskAssessmentNodeTypeAsset: @@ -140,8 +218,6 @@ func mermaidNodeClass(t coredata.RiskAssessmentNodeType) string { switch t { case coredata.RiskAssessmentNodeTypeEntity: return "nodeEntity" - case coredata.RiskAssessmentNodeTypeBoundary: - return "nodeBoundary" case coredata.RiskAssessmentNodeTypeData: return "nodeData" case coredata.RiskAssessmentNodeTypeAsset: diff --git a/pkg/riskmanagement/service.go b/pkg/riskmanagement/service.go index 4be7a749f..caaa5db6f 100644 --- a/pkg/riskmanagement/service.go +++ b/pkg/riskmanagement/service.go @@ -62,16 +62,30 @@ type ( Name *string } + CreateRiskAssessmentBoundaryRequest struct { + RiskAssessmentScopeID gid.GID + ParentBoundaryID *gid.GID + Name string + } + + UpdateRiskAssessmentBoundaryRequest struct { + ID gid.GID + ParentBoundaryID **gid.GID + Name *string + } + CreateRiskAssessmentNodeRequest struct { RiskAssessmentScopeID gid.GID + BoundaryID *gid.GID NodeType coredata.RiskAssessmentNodeType Name string } UpdateRiskAssessmentNodeRequest struct { - ID gid.GID - NodeType *coredata.RiskAssessmentNodeType - Name *string + ID gid.GID + BoundaryID **gid.GID + NodeType *coredata.RiskAssessmentNodeType + Name *string } CreateRiskAssessmentProcessRequest struct { @@ -169,12 +183,40 @@ func (r *UpdateRiskAssessmentScopeRequest) Validate() error { return v.Error() } +func (r *CreateRiskAssessmentBoundaryRequest) 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)) + + if r.ParentBoundaryID != nil { + v.Check(*r.ParentBoundaryID, "parent_boundary_id", validator.Required(), validator.GID(coredata.RiskAssessmentBoundaryEntityType)) + } + + return v.Error() +} + +func (r *UpdateRiskAssessmentBoundaryRequest) Validate() error { + v := validator.New() + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.RiskAssessmentBoundaryEntityType)) + v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) + + if r.ParentBoundaryID != nil && *r.ParentBoundaryID != nil { + v.Check(**r.ParentBoundaryID, "parent_boundary_id", validator.Required(), validator.GID(coredata.RiskAssessmentBoundaryEntityType)) + } + + 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())) + if r.BoundaryID != nil { + v.Check(*r.BoundaryID, "boundary_id", validator.Required(), validator.GID(coredata.RiskAssessmentBoundaryEntityType)) + } + return v.Error() } @@ -184,6 +226,10 @@ func (r *UpdateRiskAssessmentNodeRequest) Validate() error { v.Check(r.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(r.NodeType, "node_type", validator.OneOfSlice(coredata.RiskAssessmentNodeTypes())) + if r.BoundaryID != nil && *r.BoundaryID != nil { + v.Check(**r.BoundaryID, "boundary_id", validator.Required(), validator.GID(coredata.RiskAssessmentBoundaryEntityType)) + } + return v.Error() } @@ -593,6 +639,7 @@ func (s *Service) CreateNode(ctx context.Context, scope coredata.Scoper, req Cre node := &coredata.RiskAssessmentNode{ ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentNodeEntityType), RiskAssessmentScopeID: req.RiskAssessmentScopeID, + BoundaryID: req.BoundaryID, NodeType: req.NodeType, Name: req.Name, CreatedAt: now, @@ -607,6 +654,12 @@ func (s *Service) CreateNode(ctx context.Context, scope coredata.Scoper, req Cre return fmt.Errorf("cannot load risk assessment scope: %w", err) } + if req.BoundaryID != nil { + if err := s.assertBoundaryInScope(ctx, tx, scope, *req.BoundaryID, req.RiskAssessmentScopeID, "boundary_id"); err != nil { + return err + } + } + node.OrganizationID = raScope.OrganizationID if err := node.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert risk assessment node: %w", err) @@ -664,6 +717,16 @@ func (s *Service) UpdateNode(ctx context.Context, scope coredata.Scoper, req Upd node.NodeType = *req.NodeType } + if req.BoundaryID != nil { + if *req.BoundaryID != nil { + if err := s.assertBoundaryInScope(ctx, tx, scope, **req.BoundaryID, node.RiskAssessmentScopeID, "boundary_id"); err != nil { + return err + } + } + + node.BoundaryID = *req.BoundaryID + } + node.UpdatedAt = time.Now() if err := node.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update risk assessment node: %w", err) @@ -741,6 +804,179 @@ func (s *Service) CountNodesForScopeID(ctx context.Context, scope coredata.Scope return count, nil } +func (s *Service) CreateBoundary(ctx context.Context, scope coredata.Scoper, req CreateRiskAssessmentBoundaryRequest) (*coredata.RiskAssessmentBoundary, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + boundary := &coredata.RiskAssessmentBoundary{ + ID: gid.New(scope.GetTenantID(), coredata.RiskAssessmentBoundaryEntityType), + RiskAssessmentScopeID: req.RiskAssessmentScopeID, + ParentBoundaryID: req.ParentBoundaryID, + 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) + } + + if req.ParentBoundaryID != nil { + if err := s.assertBoundaryInScope(ctx, tx, scope, *req.ParentBoundaryID, req.RiskAssessmentScopeID, "parent_boundary_id"); err != nil { + return err + } + } + + boundary.OrganizationID = raScope.OrganizationID + if err := boundary.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert risk assessment boundary: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return boundary, nil +} + +func (s *Service) GetBoundary(ctx context.Context, scope coredata.Scoper, id gid.GID) (*coredata.RiskAssessmentBoundary, error) { + boundary := &coredata.RiskAssessmentBoundary{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := boundary.LoadByID(ctx, conn, scope, id); err != nil { + return fmt.Errorf("cannot load risk assessment boundary: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return boundary, nil +} + +func (s *Service) UpdateBoundary(ctx context.Context, scope coredata.Scoper, req UpdateRiskAssessmentBoundaryRequest) (*coredata.RiskAssessmentBoundary, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + boundary := &coredata.RiskAssessmentBoundary{} + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := boundary.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load risk assessment boundary: %w", err) + } + + if req.Name != nil { + boundary.Name = *req.Name + } + + if req.ParentBoundaryID != nil { + if *req.ParentBoundaryID != nil { + if err := s.assertBoundaryInScope(ctx, tx, scope, **req.ParentBoundaryID, boundary.RiskAssessmentScopeID, "parent_boundary_id"); err != nil { + return err + } + + if err := s.assertNoBoundaryCycle(ctx, tx, scope, boundary.ID, **req.ParentBoundaryID, "parent_boundary_id"); err != nil { + return err + } + } + + boundary.ParentBoundaryID = *req.ParentBoundaryID + } + + boundary.UpdatedAt = time.Now() + if err := boundary.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update risk assessment boundary: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return boundary, nil +} + +func (s *Service) DeleteBoundary(ctx context.Context, scope coredata.Scoper, id gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + boundary := &coredata.RiskAssessmentBoundary{} + if err := boundary.Delete(ctx, tx, scope, id); err != nil { + return fmt.Errorf("cannot delete risk assessment boundary: %w", err) + } + + return nil + }, + ) +} + +func (s *Service) ListBoundariesForScopeID( + ctx context.Context, + scope coredata.Scoper, + scopeID gid.GID, + cursor *page.Cursor[coredata.RiskAssessmentBoundaryOrderField], +) (*page.Page[*coredata.RiskAssessmentBoundary, coredata.RiskAssessmentBoundaryOrderField], error) { + var results coredata.RiskAssessmentBoundaries + + 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 boundaries: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(results, cursor), nil +} + +func (s *Service) CountBoundariesForScopeID(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) { + bs := &coredata.RiskAssessmentBoundaries{} + + count, err = bs.CountByRiskAssessmentScopeID(ctx, conn, scope, scopeID) + if err != nil { + return fmt.Errorf("cannot count risk assessment boundaries: %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) @@ -1594,6 +1830,79 @@ func (s *Service) assertNodeInScope( return nil } +func (s *Service) assertBoundaryInScope( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + boundaryID gid.GID, + scopeID gid.GID, + field string, +) error { + boundary := &coredata.RiskAssessmentBoundary{} + if err := boundary.LoadByID(ctx, tx, scope, boundaryID); err != nil { + return validator.ValidationErrors{{ + Field: field, + Code: validator.ErrorCodeCustom, + Message: "boundary not found", + }} + } + + // A boundary in a different scope is reported identically to a missing + // one so the error does not reveal that the resource exists elsewhere. + if boundary.RiskAssessmentScopeID != scopeID { + return validator.ValidationErrors{{ + Field: field, + Code: validator.ErrorCodeCustom, + Message: "boundary not found", + }} + } + + return nil +} + +// assertNoBoundaryCycle walks the ancestor chain starting from the proposed +// parent. If it reaches the boundary being updated, the new parent would make +// the boundary an ancestor of itself (a cycle), which is rejected. A visited +// set guards against any pre-existing cycle in stored data. +func (s *Service) assertNoBoundaryCycle( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + boundaryID gid.GID, + proposedParentID gid.GID, + field string, +) error { + visited := make(map[gid.GID]bool) + currentID := proposedParentID + + for { + if currentID == boundaryID { + return validator.ValidationErrors{{ + Field: field, + Code: validator.ErrorCodeCustom, + Message: "boundary cannot be nested under itself or one of its descendants", + }} + } + + if visited[currentID] { + return nil + } + + visited[currentID] = true + + current := &coredata.RiskAssessmentBoundary{} + if err := current.LoadByID(ctx, tx, scope, currentID); err != nil { + return fmt.Errorf("cannot load parent boundary: %w", err) + } + + if current.ParentBoundaryID == nil { + return nil + } + + currentID = *current.ParentBoundaryID + } +} + func (s *Service) assertProcessInScope( ctx context.Context, tx pg.Tx, diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 0ebeed6c4..82121c57a 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -170,6 +170,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewRiskAssessmentScope(s), nil } + case coredata.RiskAssessmentBoundaryEntityType: + action = probo.ActionRiskAssessmentBoundaryGet + loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { + b, err := r.riskManagement.GetBoundary(ctx, scope, id) + if err != nil { + return nil, err + } + + return types.NewRiskAssessmentBoundary(b), nil + } case coredata.RiskAssessmentScenarioEntityType: action = probo.ActionRiskAssessmentScenarioGet loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { diff --git a/pkg/server/api/console/v1/graphql/risk_assessment.graphql b/pkg/server/api/console/v1/graphql/risk_assessment.graphql index 673de068d..530cc618a 100644 --- a/pkg/server/api/console/v1/graphql/risk_assessment.graphql +++ b/pkg/server/api/console/v1/graphql/risk_assessment.graphql @@ -50,10 +50,6 @@ enum RiskAssessmentNodeType @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" @@ -78,6 +74,20 @@ enum RiskAssessmentNodeOrderField ) } +enum RiskAssessmentBoundaryOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentBoundaryOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentBoundaryOrderFieldCreatedAt" + ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.RiskAssessmentBoundaryOrderFieldName" + ) +} + enum RiskAssessmentProcessOrderField @goModel( model: "go.probo.inc/probo/pkg/coredata.RiskAssessmentProcessOrderField" @@ -146,6 +156,14 @@ input RiskAssessmentNodeOrder field: RiskAssessmentNodeOrderField! } +input RiskAssessmentBoundaryOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentBoundaryOrderBy" + ) { + direction: OrderDirection! + field: RiskAssessmentBoundaryOrderField! +} + input RiskAssessmentProcessOrder @goModel( model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentProcessOrderBy" @@ -204,6 +222,14 @@ type RiskAssessmentScope implements Node { orderBy: RiskAssessmentNodeOrder ): RiskAssessmentNodeConnection @goField(forceResolver: true) + boundaries( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: RiskAssessmentBoundaryOrder + ): RiskAssessmentBoundaryConnection @goField(forceResolver: true) + processes( first: Int after: CursorKey @@ -237,12 +263,22 @@ type RiskAssessmentScope implements Node { type RiskAssessmentNode implements Node { id: ID! riskAssessmentScopeId: ID! + boundaryId: ID nodeType: RiskAssessmentNodeType! name: String! createdAt: Datetime! updatedAt: Datetime! } +type RiskAssessmentBoundary implements Node { + id: ID! + riskAssessmentScopeId: ID! + parentBoundaryId: ID + name: String! + createdAt: Datetime! + updatedAt: Datetime! +} + type RiskAssessmentProcess implements Node { id: ID! riskAssessmentScopeId: ID! @@ -334,6 +370,20 @@ type RiskAssessmentNodeConnectionEdge { node: RiskAssessmentNode! } +type RiskAssessmentBoundaryConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentBoundaryConnection" + ) { + totalCount: Int @goField(forceResolver: true) + edges: [RiskAssessmentBoundaryConnectionEdge!]! + pageInfo: PageInfo! +} + +type RiskAssessmentBoundaryConnectionEdge { + cursor: CursorKey! + node: RiskAssessmentBoundary! +} + type RiskAssessmentProcessConnection @goModel( model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskAssessmentProcessConnection" @@ -409,6 +459,16 @@ extend type Mutation { input: DeleteRiskAssessmentNodeInput! ): DeleteRiskAssessmentNodePayload! + createRiskAssessmentBoundary( + input: CreateRiskAssessmentBoundaryInput! + ): CreateRiskAssessmentBoundaryPayload! + updateRiskAssessmentBoundary( + input: UpdateRiskAssessmentBoundaryInput! + ): UpdateRiskAssessmentBoundaryPayload! + deleteRiskAssessmentBoundary( + input: DeleteRiskAssessmentBoundaryInput! + ): DeleteRiskAssessmentBoundaryPayload! + createRiskAssessmentProcess( input: CreateRiskAssessmentProcessInput! ): CreateRiskAssessmentProcessPayload! @@ -512,12 +572,14 @@ type DeleteRiskAssessmentScopePayload { input CreateRiskAssessmentNodeInput { riskAssessmentScopeId: ID! + boundaryId: ID nodeType: RiskAssessmentNodeType! name: String! } input UpdateRiskAssessmentNodeInput { id: ID! + boundaryId: ID @goField(omittable: true) nodeType: RiskAssessmentNodeType name: String } @@ -538,6 +600,34 @@ type DeleteRiskAssessmentNodePayload { deletedRiskAssessmentNodeId: ID! } +input CreateRiskAssessmentBoundaryInput { + riskAssessmentScopeId: ID! + parentBoundaryId: ID + name: String! +} + +input UpdateRiskAssessmentBoundaryInput { + id: ID! + parentBoundaryId: ID @goField(omittable: true) + name: String +} + +input DeleteRiskAssessmentBoundaryInput { + riskAssessmentBoundaryId: ID! +} + +type CreateRiskAssessmentBoundaryPayload { + riskAssessmentBoundaryEdge: RiskAssessmentBoundaryConnectionEdge! +} + +type UpdateRiskAssessmentBoundaryPayload { + riskAssessmentBoundary: RiskAssessmentBoundary! +} + +type DeleteRiskAssessmentBoundaryPayload { + deletedRiskAssessmentBoundaryId: ID! +} + input CreateRiskAssessmentProcessInput { riskAssessmentScopeId: ID! sourceNodeId: ID! diff --git a/pkg/server/api/console/v1/risk_assessment_resolvers.go b/pkg/server/api/console/v1/risk_assessment_resolvers.go index 280f7673d..3cbb01b7c 100644 --- a/pkg/server/api/console/v1/risk_assessment_resolvers.go +++ b/pkg/server/api/console/v1/risk_assessment_resolvers.go @@ -200,6 +200,7 @@ func (r *mutationResolver) CreateRiskAssessmentNode(ctx context.Context, input t scope, riskmanagement.CreateRiskAssessmentNodeRequest{ RiskAssessmentScopeID: input.RiskAssessmentScopeID, + BoundaryID: input.BoundaryID, NodeType: input.NodeType, Name: input.Name, }, @@ -237,9 +238,10 @@ func (r *mutationResolver) UpdateRiskAssessmentNode(ctx context.Context, input t ctx, scope, riskmanagement.UpdateRiskAssessmentNodeRequest{ - ID: input.ID, - NodeType: input.NodeType, - Name: input.Name, + ID: input.ID, + BoundaryID: gqlutils.UnwrapOmittable(input.BoundaryID), + NodeType: input.NodeType, + Name: input.Name, }, ) if err != nil { @@ -275,6 +277,97 @@ func (r *mutationResolver) DeleteRiskAssessmentNode(ctx context.Context, input t return &types.DeleteRiskAssessmentNodePayload{DeletedRiskAssessmentNodeID: input.RiskAssessmentNodeID}, nil } +// CreateRiskAssessmentBoundary is the resolver for the createRiskAssessmentBoundary field. +func (r *mutationResolver) CreateRiskAssessmentBoundary(ctx context.Context, input types.CreateRiskAssessmentBoundaryInput) (*types.CreateRiskAssessmentBoundaryPayload, error) { + scope, err := r.authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentBoundaryCreate) + if err != nil { + return nil, err + } + + boundary, err := r.riskManagement.CreateBoundary( + ctx, + scope, + riskmanagement.CreateRiskAssessmentBoundaryRequest{ + RiskAssessmentScopeID: input.RiskAssessmentScopeID, + ParentBoundaryID: input.ParentBoundaryID, + 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 boundary", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateRiskAssessmentBoundaryPayload{ + RiskAssessmentBoundaryEdge: &types.RiskAssessmentBoundaryConnectionEdge{ + Cursor: boundary.CursorKey(coredata.RiskAssessmentBoundaryOrderFieldCreatedAt), + Node: types.NewRiskAssessmentBoundary(boundary), + }, + }, nil +} + +// UpdateRiskAssessmentBoundary is the resolver for the updateRiskAssessmentBoundary field. +func (r *mutationResolver) UpdateRiskAssessmentBoundary(ctx context.Context, input types.UpdateRiskAssessmentBoundaryInput) (*types.UpdateRiskAssessmentBoundaryPayload, error) { + scope, err := r.authorize(ctx, input.ID, probo.ActionRiskAssessmentBoundaryUpdate) + if err != nil { + return nil, err + } + + boundary, err := r.riskManagement.UpdateBoundary( + ctx, + scope, + riskmanagement.UpdateRiskAssessmentBoundaryRequest{ + ID: input.ID, + ParentBoundaryID: gqlutils.UnwrapOmittable(input.ParentBoundaryID), + 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 update risk assessment boundary", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateRiskAssessmentBoundaryPayload{RiskAssessmentBoundary: types.NewRiskAssessmentBoundary(boundary)}, nil +} + +// DeleteRiskAssessmentBoundary is the resolver for the deleteRiskAssessmentBoundary field. +func (r *mutationResolver) DeleteRiskAssessmentBoundary(ctx context.Context, input types.DeleteRiskAssessmentBoundaryInput) (*types.DeleteRiskAssessmentBoundaryPayload, error) { + scope, err := r.authorize(ctx, input.RiskAssessmentBoundaryID, probo.ActionRiskAssessmentBoundaryDelete) + if err != nil { + return nil, err + } + + if err := r.riskManagement.DeleteBoundary(ctx, scope, input.RiskAssessmentBoundaryID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot delete risk assessment boundary", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteRiskAssessmentBoundaryPayload{DeletedRiskAssessmentBoundaryID: input.RiskAssessmentBoundaryID}, nil +} + // CreateRiskAssessmentProcess is the resolver for the createRiskAssessmentProcess field. func (r *mutationResolver) CreateRiskAssessmentProcess(ctx context.Context, input types.CreateRiskAssessmentProcessInput) (*types.CreateRiskAssessmentProcessPayload, error) { scope, err := r.authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentProcessCreate) @@ -744,6 +837,22 @@ func (r *riskAssessmentResolver) Permission(ctx context.Context, obj *types.Risk return r.Resolver.Permission(ctx, obj, action) } +// TotalCount is the resolver for the totalCount field. +func (r *riskAssessmentBoundaryConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentBoundaryConnection) (*int, error) { + scope, err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentBoundaryList) + if err != nil { + return nil, err + } + + count, err := r.riskManagement.CountBoundariesForScopeID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count risk assessment boundaries", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &count, nil +} + // TotalCount is the resolver for the totalCount field. func (r *riskAssessmentConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentConnection) (*int, error) { scope, err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentList) @@ -921,6 +1030,32 @@ func (r *riskAssessmentScopeResolver) Nodes(ctx context.Context, obj *types.Risk return types.NewRiskAssessmentNodeConnection(p, r, obj.ID), nil } +// Boundaries is the resolver for the boundaries field. +func (r *riskAssessmentScopeResolver) Boundaries(ctx context.Context, obj *types.RiskAssessmentScope, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskAssessmentBoundaryOrderBy) (*types.RiskAssessmentBoundaryConnection, error) { + scope, err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentBoundaryList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.RiskAssessmentBoundaryOrderField]{ + Field: coredata.RiskAssessmentBoundaryOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentBoundaryOrderField]{Field: orderBy.Field, Direction: orderBy.Direction} + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.riskManagement.ListBoundariesForScopeID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list risk assessment boundaries", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewRiskAssessmentBoundaryConnection(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) { scope, err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentProcessList) @@ -1061,6 +1196,11 @@ func (r *riskAssessmentThreatConnectionResolver) TotalCount(ctx context.Context, // RiskAssessment returns schema.RiskAssessmentResolver implementation. func (r *Resolver) RiskAssessment() schema.RiskAssessmentResolver { return &riskAssessmentResolver{r} } +// RiskAssessmentBoundaryConnection returns schema.RiskAssessmentBoundaryConnectionResolver implementation. +func (r *Resolver) RiskAssessmentBoundaryConnection() schema.RiskAssessmentBoundaryConnectionResolver { + return &riskAssessmentBoundaryConnectionResolver{r} +} + // RiskAssessmentConnection returns schema.RiskAssessmentConnectionResolver implementation. func (r *Resolver) RiskAssessmentConnection() schema.RiskAssessmentConnectionResolver { return &riskAssessmentConnectionResolver{r} @@ -1102,6 +1242,7 @@ func (r *Resolver) RiskAssessmentThreatConnection() schema.RiskAssessmentThreatC } type riskAssessmentResolver struct{ *Resolver } +type riskAssessmentBoundaryConnectionResolver struct{ *Resolver } type riskAssessmentConnectionResolver struct{ *Resolver } type riskAssessmentNodeConnectionResolver struct{ *Resolver } type riskAssessmentProcessConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/console/v1/types/risk_assessment_boundary.go b/pkg/server/api/console/v1/types/risk_assessment_boundary.go new file mode 100644 index 000000000..0a0766fa1 --- /dev/null +++ b/pkg/server/api/console/v1/types/risk_assessment_boundary.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 ( + RiskAssessmentBoundaryOrderBy OrderBy[coredata.RiskAssessmentBoundaryOrderField] + + RiskAssessmentBoundaryConnection struct { + TotalCount int + Edges []*RiskAssessmentBoundaryConnectionEdge + PageInfo PageInfo + Resolver any + ParentID gid.GID + } +) + +func NewRiskAssessmentBoundaryConnection( + p *page.Page[*coredata.RiskAssessmentBoundary, coredata.RiskAssessmentBoundaryOrderField], + parentType any, + parentID gid.GID, +) *RiskAssessmentBoundaryConnection { + edges := make([]*RiskAssessmentBoundaryConnectionEdge, len(p.Data)) + for i := range edges { + edges[i] = &RiskAssessmentBoundaryConnectionEdge{ + Cursor: p.Data[i].CursorKey(p.Cursor.OrderBy.Field), + Node: NewRiskAssessmentBoundary(p.Data[i]), + } + } + + return &RiskAssessmentBoundaryConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +} + +func NewRiskAssessmentBoundary(b *coredata.RiskAssessmentBoundary) *RiskAssessmentBoundary { + return &RiskAssessmentBoundary{ + ID: b.ID, + RiskAssessmentScopeID: b.RiskAssessmentScopeID, + ParentBoundaryID: b.ParentBoundaryID, + Name: b.Name, + CreatedAt: b.CreatedAt, + UpdatedAt: b.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 index 9284d856e..aeb3a3b0d 100644 --- a/pkg/server/api/console/v1/types/risk_assessment_node.go +++ b/pkg/server/api/console/v1/types/risk_assessment_node.go @@ -57,6 +57,7 @@ func NewRiskAssessmentNode(n *coredata.RiskAssessmentNode) *RiskAssessmentNode { return &RiskAssessmentNode{ ID: n.ID, RiskAssessmentScopeID: n.RiskAssessmentScopeID, + BoundaryID: n.BoundaryID, NodeType: n.NodeType, Name: n.Name, CreatedAt: n.CreatedAt, diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 20f70fe47..52624fae9 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -6497,6 +6497,7 @@ func (r *Resolver) AddRiskAssessmentNodeTool(ctx context.Context, req *mcp.CallT n, err := r.riskManagement.CreateNode(ctx, scope, riskmanagement.CreateRiskAssessmentNodeRequest{ RiskAssessmentScopeID: input.RiskAssessmentScopeID, + BoundaryID: input.BoundaryID, NodeType: input.NodeType, Name: input.Name, }) @@ -6515,10 +6516,16 @@ func (r *Resolver) UpdateRiskAssessmentNodeTool(ctx context.Context, req *mcp.Ca return nil, types.UpdateRiskAssessmentNodeOutput{}, err } + var boundaryID **gid.GID + if input.BoundaryID != nil { + boundaryID = &input.BoundaryID + } + n, err := r.riskManagement.UpdateNode(ctx, scope, riskmanagement.UpdateRiskAssessmentNodeRequest{ - ID: input.ID, - NodeType: input.NodeType, - Name: input.Name, + ID: input.ID, + BoundaryID: boundaryID, + NodeType: input.NodeType, + Name: input.Name, }) if err != nil { return nil, types.UpdateRiskAssessmentNodeOutput{}, fmt.Errorf("failed to update risk assessment node: %w", err) @@ -6930,3 +6937,106 @@ func (r *Resolver) GetRiskAssessmentScopeMermaidChartTool(ctx context.Context, r MermaidChart: chart, }, nil } + +func (r *Resolver) ListRiskAssessmentBoundariesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRiskAssessmentBoundariesInput) (*mcp.CallToolResult, types.ListRiskAssessmentBoundariesOutput, error) { + scope, err := r.Authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentBoundaryList) + if err != nil { + return nil, types.ListRiskAssessmentBoundariesOutput{}, err + } + + pageOrderBy := page.OrderBy[coredata.RiskAssessmentBoundaryOrderField]{ + Field: coredata.RiskAssessmentBoundaryOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if input.OrderBy != nil { + pageOrderBy = page.OrderBy[coredata.RiskAssessmentBoundaryOrderField]{ + Field: input.OrderBy.Field, + Direction: input.OrderBy.Direction, + } + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + p, err := r.riskManagement.ListBoundariesForScopeID(ctx, scope, input.RiskAssessmentScopeID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list risk assessment boundaries: %w", err)) + } + + return nil, types.NewListRiskAssessmentBoundariesOutput(p), nil +} + +func (r *Resolver) GetRiskAssessmentBoundaryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetRiskAssessmentBoundaryInput) (*mcp.CallToolResult, types.GetRiskAssessmentBoundaryOutput, error) { + scope, err := r.Authorize(ctx, input.ID, probo.ActionRiskAssessmentBoundaryGet) + if err != nil { + return nil, types.GetRiskAssessmentBoundaryOutput{}, err + } + + b, err := r.riskManagement.GetBoundary(ctx, scope, input.ID) + if err != nil { + return nil, types.GetRiskAssessmentBoundaryOutput{}, fmt.Errorf("failed to get risk assessment boundary: %w", err) + } + + return nil, types.GetRiskAssessmentBoundaryOutput{ + RiskAssessmentBoundary: types.NewRiskAssessmentBoundary(b), + }, nil +} + +func (r *Resolver) AddRiskAssessmentBoundaryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddRiskAssessmentBoundaryInput) (*mcp.CallToolResult, types.AddRiskAssessmentBoundaryOutput, error) { + scope, err := r.Authorize(ctx, input.RiskAssessmentScopeID, probo.ActionRiskAssessmentBoundaryCreate) + if err != nil { + return nil, types.AddRiskAssessmentBoundaryOutput{}, err + } + + b, err := r.riskManagement.CreateBoundary(ctx, scope, riskmanagement.CreateRiskAssessmentBoundaryRequest{ + RiskAssessmentScopeID: input.RiskAssessmentScopeID, + ParentBoundaryID: input.ParentBoundaryID, + Name: input.Name, + }) + if err != nil { + return nil, types.AddRiskAssessmentBoundaryOutput{}, fmt.Errorf("failed to create risk assessment boundary: %w", err) + } + + return nil, types.AddRiskAssessmentBoundaryOutput{ + RiskAssessmentBoundary: types.NewRiskAssessmentBoundary(b), + }, nil +} + +func (r *Resolver) UpdateRiskAssessmentBoundaryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateRiskAssessmentBoundaryInput) (*mcp.CallToolResult, types.UpdateRiskAssessmentBoundaryOutput, error) { + scope, err := r.Authorize(ctx, input.ID, probo.ActionRiskAssessmentBoundaryUpdate) + if err != nil { + return nil, types.UpdateRiskAssessmentBoundaryOutput{}, err + } + + var parentBoundaryID **gid.GID + if input.ParentBoundaryID != nil { + parentBoundaryID = &input.ParentBoundaryID + } + + b, err := r.riskManagement.UpdateBoundary(ctx, scope, riskmanagement.UpdateRiskAssessmentBoundaryRequest{ + ID: input.ID, + ParentBoundaryID: parentBoundaryID, + Name: input.Name, + }) + if err != nil { + return nil, types.UpdateRiskAssessmentBoundaryOutput{}, fmt.Errorf("failed to update risk assessment boundary: %w", err) + } + + return nil, types.UpdateRiskAssessmentBoundaryOutput{ + RiskAssessmentBoundary: types.NewRiskAssessmentBoundary(b), + }, nil +} + +func (r *Resolver) DeleteRiskAssessmentBoundaryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteRiskAssessmentBoundaryInput) (*mcp.CallToolResult, types.DeleteRiskAssessmentBoundaryOutput, error) { + scope, err := r.Authorize(ctx, input.ID, probo.ActionRiskAssessmentBoundaryDelete) + if err != nil { + return nil, types.DeleteRiskAssessmentBoundaryOutput{}, err + } + + if err := r.riskManagement.DeleteBoundary(ctx, scope, input.ID); err != nil { + return nil, types.DeleteRiskAssessmentBoundaryOutput{}, fmt.Errorf("failed to delete risk assessment boundary: %w", err) + } + + return nil, types.DeleteRiskAssessmentBoundaryOutput{ + DeletedRiskAssessmentBoundaryID: input.ID, + }, nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 1db8fb95d..c5867c0ce 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -10792,7 +10792,6 @@ components: type: string enum: - ENTITY - - BOUNDARY - ASSET - DATA go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RiskAssessmentNodeType @@ -10851,6 +10850,24 @@ components: direction: $ref: "#/components/schemas/OrderDirection" + RiskAssessmentBoundaryOrderField: + type: string + enum: + - CREATED_AT + - NAME + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RiskAssessmentBoundaryOrderField + + RiskAssessmentBoundaryOrderBy: + type: object + required: + - field + - direction + properties: + field: + $ref: "#/components/schemas/RiskAssessmentBoundaryOrderField" + direction: + $ref: "#/components/schemas/OrderDirection" + RiskAssessmentProcessOrderField: type: string enum: @@ -10973,6 +10990,9 @@ components: $ref: "#/components/schemas/GID" risk_assessment_scope_id: $ref: "#/components/schemas/GID" + boundary_id: + $ref: "#/components/schemas/GID" + description: ID of the boundary that contains this node, if any node_type: $ref: "#/components/schemas/RiskAssessmentNodeType" name: @@ -10984,6 +11004,34 @@ components: type: string format: date-time + RiskAssessmentBoundary: + type: object + required: + - id + - organization_id + - risk_assessment_scope_id + - name + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + organization_id: + $ref: "#/components/schemas/GID" + risk_assessment_scope_id: + $ref: "#/components/schemas/GID" + parent_boundary_id: + $ref: "#/components/schemas/GID" + description: ID of the parent boundary, if this boundary is nested + name: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RiskAssessmentProcess: type: object required: @@ -11372,6 +11420,9 @@ components: risk_assessment_scope_id: $ref: "#/components/schemas/GID" description: Risk assessment scope ID + boundary_id: + $ref: "#/components/schemas/GID" + description: ID of the boundary that contains this node (optional) node_type: $ref: "#/components/schemas/RiskAssessmentNodeType" description: Node type @@ -11395,6 +11446,9 @@ components: id: $ref: "#/components/schemas/GID" description: Risk assessment node ID + boundary_id: + $ref: "#/components/schemas/GID" + description: ID of the boundary that contains this node (optional) node_type: $ref: "#/components/schemas/RiskAssessmentNodeType" description: Node type @@ -11428,6 +11482,119 @@ components: $ref: "#/components/schemas/GID" description: Deleted risk assessment node ID + ListRiskAssessmentBoundariesInput: + type: object + required: + - risk_assessment_scope_id + properties: + risk_assessment_scope_id: + $ref: "#/components/schemas/GID" + description: Risk assessment scope ID + order_by: + $ref: "#/components/schemas/RiskAssessmentBoundaryOrderBy" + description: Order by + size: + type: integer + description: Page size + cursor: + $ref: "#/components/schemas/CursorKey" + description: Page cursor + + ListRiskAssessmentBoundariesOutput: + type: object + required: + - risk_assessment_boundaries + properties: + next_cursor: + $ref: "#/components/schemas/CursorKey" + description: Next cursor + risk_assessment_boundaries: + type: array + items: + $ref: "#/components/schemas/RiskAssessmentBoundary" + + GetRiskAssessmentBoundaryInput: + type: object + required: + - id + properties: + id: + $ref: "#/components/schemas/GID" + description: Risk assessment boundary ID + + GetRiskAssessmentBoundaryOutput: + type: object + required: + - risk_assessment_boundary + properties: + risk_assessment_boundary: + $ref: "#/components/schemas/RiskAssessmentBoundary" + + AddRiskAssessmentBoundaryInput: + type: object + required: + - risk_assessment_scope_id + - name + properties: + risk_assessment_scope_id: + $ref: "#/components/schemas/GID" + description: Risk assessment scope ID + parent_boundary_id: + $ref: "#/components/schemas/GID" + description: ID of the parent boundary (optional, for nested boundaries) + name: + type: string + description: Risk assessment boundary name + + AddRiskAssessmentBoundaryOutput: + type: object + required: + - risk_assessment_boundary + properties: + risk_assessment_boundary: + $ref: "#/components/schemas/RiskAssessmentBoundary" + + UpdateRiskAssessmentBoundaryInput: + type: object + required: + - id + properties: + id: + $ref: "#/components/schemas/GID" + description: Risk assessment boundary ID + parent_boundary_id: + $ref: "#/components/schemas/GID" + description: ID of the parent boundary (optional, for nested boundaries) + name: + type: string + description: Risk assessment boundary name + + UpdateRiskAssessmentBoundaryOutput: + type: object + required: + - risk_assessment_boundary + properties: + risk_assessment_boundary: + $ref: "#/components/schemas/RiskAssessmentBoundary" + + DeleteRiskAssessmentBoundaryInput: + type: object + required: + - id + properties: + id: + $ref: "#/components/schemas/GID" + description: Risk assessment boundary ID + + DeleteRiskAssessmentBoundaryOutput: + type: object + required: + - deleted_risk_assessment_boundary_id + properties: + deleted_risk_assessment_boundary_id: + $ref: "#/components/schemas/GID" + description: Deleted risk assessment boundary ID + ListRiskAssessmentProcessesInput: type: object required: @@ -14005,6 +14172,49 @@ tools: $ref: "#/components/schemas/DeleteRiskAssessmentNodeInput" outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentNodeOutput" + - name: listRiskAssessmentBoundaries + description: List all boundaries for a risk assessment scope + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/ListRiskAssessmentBoundariesInput" + outputSchema: + $ref: "#/components/schemas/ListRiskAssessmentBoundariesOutput" + - name: getRiskAssessmentBoundary + description: Get a risk assessment boundary by ID + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/GetRiskAssessmentBoundaryInput" + outputSchema: + $ref: "#/components/schemas/GetRiskAssessmentBoundaryOutput" + - name: addRiskAssessmentBoundary + description: Create a new risk assessment boundary + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/AddRiskAssessmentBoundaryInput" + outputSchema: + $ref: "#/components/schemas/AddRiskAssessmentBoundaryOutput" + - name: updateRiskAssessmentBoundary + description: Update an existing risk assessment boundary + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/UpdateRiskAssessmentBoundaryInput" + outputSchema: + $ref: "#/components/schemas/UpdateRiskAssessmentBoundaryOutput" + - name: deleteRiskAssessmentBoundary + description: Delete a risk assessment boundary + hints: + readonly: false + destructive: true + inputSchema: + $ref: "#/components/schemas/DeleteRiskAssessmentBoundaryInput" + outputSchema: + $ref: "#/components/schemas/DeleteRiskAssessmentBoundaryOutput" - name: listRiskAssessmentProcesses description: List all processes for a risk assessment scope hints: diff --git a/pkg/server/api/mcp/v1/types/risk_assessment.go b/pkg/server/api/mcp/v1/types/risk_assessment.go index add4b1b1d..59fce1707 100644 --- a/pkg/server/api/mcp/v1/types/risk_assessment.go +++ b/pkg/server/api/mcp/v1/types/risk_assessment.go @@ -88,6 +88,7 @@ func NewRiskAssessmentNode(n *coredata.RiskAssessmentNode) *RiskAssessmentNode { ID: n.ID, OrganizationID: n.OrganizationID, RiskAssessmentScopeID: n.RiskAssessmentScopeID, + BoundaryID: n.BoundaryID, NodeType: n.NodeType, Name: n.Name, CreatedAt: n.CreatedAt, @@ -95,6 +96,39 @@ func NewRiskAssessmentNode(n *coredata.RiskAssessmentNode) *RiskAssessmentNode { } } +func NewRiskAssessmentBoundary(b *coredata.RiskAssessmentBoundary) *RiskAssessmentBoundary { + return &RiskAssessmentBoundary{ + ID: b.ID, + OrganizationID: b.OrganizationID, + RiskAssessmentScopeID: b.RiskAssessmentScopeID, + ParentBoundaryID: b.ParentBoundaryID, + Name: b.Name, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + } +} + +func NewListRiskAssessmentBoundariesOutput( + p *page.Page[*coredata.RiskAssessmentBoundary, coredata.RiskAssessmentBoundaryOrderField], +) ListRiskAssessmentBoundariesOutput { + items := make([]*RiskAssessmentBoundary, 0, len(p.Data)) + for _, v := range p.Data { + items = append(items, NewRiskAssessmentBoundary(v)) + } + + var nextCursor *page.CursorKey + + if len(p.Data) > 0 { + cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListRiskAssessmentBoundariesOutput{ + NextCursor: nextCursor, + RiskAssessmentBoundaries: items, + } +} + func NewListRiskAssessmentNodesOutput( p *page.Page[*coredata.RiskAssessmentNode, coredata.RiskAssessmentNodeOrderField], ) ListRiskAssessmentNodesOutput {