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" },