Add risk assessment boundary model

Introduce RiskAssessmentBoundary as a first-class, self-nesting entity that
groups nodes within a risk assessment scope, and thread it through every
surface.

- coredata: new risk_assessment_boundaries table + migration, boundary_id on
  nodes, self-referential parent_boundary_id, entity type registration
- riskmanagement: boundary CRUD service methods, boundary_id wiring on node
  create/update, scope-membership and self-parent validation, nested-subgraph
  Mermaid rendering
- IAM: core:risk-assessment-boundary:{get,list,create,update,delete} actions
  and viewer/auditor read policies
- console GraphQL: RiskAssessmentBoundary type, connection, order enum, CRUD
  mutations, boundaries field on scope, boundaryId on nodes
- CLI: risk-assessment boundary command group and --boundary-id on nodes
- MCP: boundary tools and boundary_id on node tools
- n8n: boundary operations and boundary fields on node operations
- console UI: boundary list/create/edit, boundary selector on nodes, diagram
  refetch on boundary changes

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-06-08 15:37:56 +02:00
parent b643c8eb6d
commit dbf915047d
44 changed files with 3620 additions and 79 deletions

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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<BoundaryActionsUpdateMutation>(updateBoundaryMutation);
const [deleteBoundary] = useMutation<BoundaryActionsDeleteMutation>(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 (
<>
<ActionDropdown>
<DropdownItem icon={IconPencil} onSelect={() => dialogRef.current?.open()}>
{__("Edit")}
</DropdownItem>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={() => 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")}
</DropdownItem>
</ActionDropdown>
<Dialog className="max-w-lg" ref={dialogRef} title={<Breadcrumb items={[__("Boundaries"), __("Edit")]} />}>
<form onSubmit={e => 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)}
>
<DialogContent padded className="space-y-4">
<Field label={__("Name")} {...register("name", { required: __("This field is required") })} type="text" />
<ControlledField label={__("Parent boundary")} name="parentBoundaryId" control={control} type="select">
<Option value="none">{__("None (top level)")}</Option>
{parentOptions.map(b => (
<Option key={b.id} value={b.id}>{b.name}</Option>
))}
</ControlledField>
</DialogContent>
<DialogFooter><Button type="submit">{__("Save")}</Button></DialogFooter>
</form>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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<CreateBoundaryDialogMutation>(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 (
<Dialog
className="max-w-lg"
ref={dialogRef}
trigger={<Button icon={IconPlusLarge} variant="secondary">{__("Add")}</Button>}
title={<Breadcrumb items={[__("Boundaries"), __("Add Boundary")]} />}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-4">
<Field label={__("Name")} {...register("name", { required: __("This field is required") })} type="text" error={formState.errors.name?.message} />
<ControlledField label={__("Parent boundary")} name="parentBoundaryId" control={control} type="select">
<Option value="none">{__("None (top level)")}</Option>
{props.boundaries.map(b => (
<Option key={b.id} value={b.id}>{b.name}</Option>
))}
</ControlledField>
</DialogContent>
<DialogFooter><Button type="submit" disabled={isCreating}>{__("Add")}</Button></DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -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<CreateNodeDialogMutation>(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
<DialogContent padded className="space-y-4">
<ControlledField label={__("Type")} name="nodeType" control={control} type="select">
<Option value="ENTITY">{__("Entity")}</Option>
<Option value="BOUNDARY">{__("Boundary")}</Option>
<Option value="ASSET">{__("Asset")}</Option>
<Option value="DATA">{__("Data")}</Option>
</ControlledField>
<Field label={__("Name")} {...register("name", { required: __("This field is required") })} type="text" error={formState.errors.name?.message} />
{props.boundaries.length > 0 && (
<ControlledField label={__("Boundary")} name="boundaryId" control={control} type="select">
<Option value="none">{__("None")}</Option>
{props.boundaries.map(b => (
<Option key={b.id} value={b.id}>{b.name}</Option>
))}
</ControlledField>
)}
</DialogContent>
<DialogFooter><Button type="submit" disabled={isCreating}>{__("Add")}</Button></DialogFooter>
</form>

View File

@@ -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<NodeActionsUpdateMutation>(updateNodeMutation);
const [deleteNode] = useMutation<NodeActionsDeleteMutation>(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: {
<Dialog className="max-w-lg" ref={dialogRef} title={<Breadcrumb items={[__("Nodes"), __("Edit")]} />}>
<form onSubmit={e => 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: {
<DialogContent padded className="space-y-4">
<ControlledField label={__("Type")} name="nodeType" control={control} type="select">
<Option value="ENTITY">{__("Entity")}</Option>
<Option value="BOUNDARY">{__("Boundary")}</Option>
<Option value="ASSET">{__("Asset")}</Option>
<Option value="DATA">{__("Data")}</Option>
</ControlledField>
<Field label={__("Name")} {...register("name", { required: __("This field is required") })} type="text" />
<ControlledField label={__("Boundary")} name="boundaryId" control={control} type="select">
<Option value="none">{__("None")}</Option>
{props.boundaries.map(b => (
<Option key={b.id} value={b.id}>{b.name}</Option>
))}
</ControlledField>
</DialogContent>
<DialogFooter><Button type="submit">{__("Save")}</Button></DialogFooter>
</form>

View File

@@ -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.")}
>
<CreateNodeDialog scopeId={scope.id} connectionId={nodesConnId} />
<CreateNodeDialog scopeId={scope.id} connectionId={nodesConnId} boundaries={boundaryOptions} />
</SectionHeader>
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("Boundary")}</Th>
<Th className="w-12" />
</Tr>
</Thead>
@@ -197,9 +211,16 @@ export function ScopeCard(props: {
<Tr key={node.id}>
<Td className="font-medium">{node.name}</Td>
<Td><Badge>{node.nodeType}</Badge></Td>
<Td className="text-txt-secondary">{node.boundaryId ? boundaryMap.get(node.boundaryId)?.name ?? "—" : "—"}</Td>
<Td>
<NodeActions
node={{ id: node.id, name: node.name, nodeType: node.nodeType }}
node={{
id: node.id,
name: node.name,
nodeType: node.nodeType,
boundaryId: node.boundaryId ?? null,
}}
boundaries={boundaryOptions}
connectionId={nodesConnId}
/>
</Td>
@@ -207,7 +228,7 @@ export function ScopeCard(props: {
))}
{nodes.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">{__("No nodes")}</Td>
<Td colSpan={4} className="text-center text-txt-secondary">{__("No nodes")}</Td>
</Tr>
)}
</Tbody>
@@ -264,6 +285,52 @@ export function ScopeCard(props: {
</div>
</div>
<div>
<SectionHeader
title={`${__("Boundaries")} (${boundaries.length})`}
hint={__("Groupings that contain nodes and can be nested inside other boundaries.")}
>
<CreateBoundaryDialog
scopeId={scope.id}
connectionId={boundariesConnId}
boundaries={boundaryOptions}
/>
</SectionHeader>
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Parent")}</Th>
<Th className="w-12" />
</Tr>
</Thead>
<Tbody>
{boundaries.map(boundary => (
<Tr key={boundary.id}>
<Td className="font-medium">{boundary.name}</Td>
<Td className="text-txt-secondary">{boundary.parentBoundaryId ? boundaryMap.get(boundary.parentBoundaryId)?.name ?? "—" : "—"}</Td>
<Td>
<BoundaryActions
boundary={{
id: boundary.id,
name: boundary.name,
parentBoundaryId: boundary.parentBoundaryId ?? null,
}}
boundaries={boundaryOptions}
connectionId={boundariesConnId}
/>
</Td>
</Tr>
))}
{boundaries.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">{__("No boundaries")}</Td>
</Tr>
)}
</Tbody>
</Table>
</div>
<div>
<SectionHeader
title={`${__("Threats")} (${threats.length})`}

View File

@@ -32,6 +32,17 @@ const scopeDiagramFragment = graphql`
id
name
nodeType
boundaryId
}
}
}
boundaries(first: 100)
@connection(key: "RiskAssessmentScope_boundaries", filters: []) {
edges {
node {
id
name
parentBoundaryId
}
}
}
@@ -82,7 +93,10 @@ export function ScopeDiagram({ scopeKey }: ScopeDiagramProps) {
const mermaidChart = scope.mermaidChart;
const nodeSignature = scope.nodes?.edges
.map(e => `${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" },

View File

@@ -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)
})
}

View File

@@ -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()

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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<INodeExecutionData> {
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<string, unknown> = { riskAssessmentScopeId, name };
if (parentBoundaryId) input.parentBoundaryId = parentBoundaryId;
const responseData = await proboApiRequest.call(this, query, { input });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -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<string, unknown> = { riskAssessmentScopeId, nodeType, name };
if (boundaryId) input.boundaryId = boundaryId;
const responseData = await proboApiRequest.call(this, query, { input });
return {
json: responseData,

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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<INodeExecutionData> {
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 },
};
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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<INodeExecutionData> {
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 },
};
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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<INodeExecutionData> {
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 },
};
}

View File

@@ -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,

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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<INodeExecutionData> {
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<string, unknown> = { 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 },
};
}

View File

@@ -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<string, unknown> = { 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');

View File

@@ -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
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <command>",
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
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <id>
# Create a boundary non-interactively
prb risk-assessment boundary create --scope-id <id> --name "Production environment"
# Create a boundary nested inside another boundary
prb risk-assessment boundary create --scope-id <id> --name "Database tier" --parent-id <boundary-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
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <id>",
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
}

View File

@@ -0,0 +1,208 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <id>
# List boundaries as JSON
prb risk-assessment boundary ls --scope <id> --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
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <id>",
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
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <id>",
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
}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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
}

View File

@@ -0,0 +1,70 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
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;

View File

@@ -0,0 +1,344 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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) }

View File

@@ -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())

View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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:

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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!

View File

@@ -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 }

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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,
}
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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 {