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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})`}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user