Render mermaid diagram per risk assessment scope
Each scope card now shows a flowchart of its nodes, processes, and threats, with a distinct shape per type: stadium for entities, hexagon for boundaries, rectangle for assets, cylinder for data, and a red hexagon for threats attached via dashed edges to their process target. The Mermaid source is built on the backend and exposed as a new `mermaid` field on RiskAssessmentScope; the frontend just renders it via @probo/ui's MermaidDiagram and shows a copy button + legend. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -27,12 +27,15 @@ import {
|
||||
Select,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
|
||||
|
||||
import type { CreateScenarioInScopeDialogLinkRiskMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkRiskMutation.graphql";
|
||||
import type { CreateScenarioInScopeDialogLinkThreatMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkThreatMutation.graphql";
|
||||
import type { CreateScenarioInScopeDialogMutation } from "#/__generated__/core/CreateScenarioInScopeDialogMutation.graphql";
|
||||
import type { CreateScenarioInScopeDialogRisksQuery } from "#/__generated__/core/CreateScenarioInScopeDialogRisksQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const createScenarioMutation = graphql`
|
||||
mutation CreateScenarioInScopeDialogMutation(
|
||||
@@ -56,11 +59,75 @@ const linkThreatMutation = graphql`
|
||||
$input: LinkRiskAssessmentScenarioThreatInput!
|
||||
) {
|
||||
linkRiskAssessmentScenarioThreat(input: $input) {
|
||||
riskAssessmentScenario { id }
|
||||
riskAssessmentScenario {
|
||||
id
|
||||
threats(first: 10) { edges { node { id name } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const linkRiskMutation = graphql`
|
||||
mutation CreateScenarioInScopeDialogLinkRiskMutation(
|
||||
$input: LinkRiskAssessmentScenarioRiskInput!
|
||||
) {
|
||||
linkRiskAssessmentScenarioRisk(input: $input) {
|
||||
riskAssessmentScenario {
|
||||
id
|
||||
risks(first: 10) { edges { node { id name } } }
|
||||
}
|
||||
riskAssessmentScenarioEdge { node { id } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const risksQuery = graphql`
|
||||
query CreateScenarioInScopeDialogRisksQuery($organizationId: ID!) {
|
||||
node(id: $organizationId) {
|
||||
... on Organization {
|
||||
risks(first: 100) {
|
||||
edges { node { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function RiskSelector(props: {
|
||||
selectedRisks: Map<string, string>;
|
||||
onSelect: (id: string, name: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const data = useLazyLoadQuery<CreateScenarioInScopeDialogRisksQuery>(
|
||||
risksQuery,
|
||||
{ organizationId },
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
const allRisks = data.node?.risks?.edges?.map(e => e.node) ?? [];
|
||||
const available = allRisks.filter(r => !props.selectedRisks.has(r.id));
|
||||
|
||||
if (available.length === 0) {
|
||||
return <p className="text-xs text-txt-tertiary">{__("No more risks available.")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={props.selectedRisks.size}
|
||||
placeholder={__("Select a risk to link...")}
|
||||
onValueChange={(riskId) => {
|
||||
if (typeof riskId !== "string") return;
|
||||
const risk = allRisks.find(r => r.id === riskId);
|
||||
if (risk) props.onSelect(risk.id, risk.name);
|
||||
}}
|
||||
>
|
||||
{available.map(r => (
|
||||
<Option key={r.id} value={r.id}>{r.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateScenarioInScopeDialog(props: {
|
||||
scopeId: string;
|
||||
threats: { id: string; name: string }[];
|
||||
@@ -69,8 +136,10 @@ export function CreateScenarioInScopeDialog(props: {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const [selectedThreats, setSelectedThreats] = useState<Map<string, string>>(new Map());
|
||||
const [selectedRisks, setSelectedRisks] = useState<Map<string, string>>(new Map());
|
||||
const [createScenario, isCreating] = useMutation<CreateScenarioInScopeDialogMutation>(createScenarioMutation);
|
||||
const [linkThreat] = useMutation<CreateScenarioInScopeDialogLinkThreatMutation>(linkThreatMutation);
|
||||
const [linkRisk] = useMutation<CreateScenarioInScopeDialogLinkRiskMutation>(linkRiskMutation);
|
||||
const { register, handleSubmit, reset, formState } = useForm({
|
||||
defaultValues: { name: "", description: "" },
|
||||
});
|
||||
@@ -96,8 +165,16 @@ export function CreateScenarioInScopeDialog(props: {
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const riskId of selectedRisks.keys()) {
|
||||
linkRisk({
|
||||
variables: {
|
||||
input: { riskAssessmentScenarioId: scenarioId, riskId },
|
||||
},
|
||||
});
|
||||
}
|
||||
reset();
|
||||
setSelectedThreats(new Map());
|
||||
setSelectedRisks(new Map());
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
@@ -152,6 +229,7 @@ export function CreateScenarioInScopeDialog(props: {
|
||||
)}
|
||||
{availableThreats.length > 0 && (
|
||||
<Select
|
||||
key={selectedThreats.size}
|
||||
placeholder={__("Select a threat to link...")}
|
||||
onValueChange={(threatId) => {
|
||||
if (typeof threatId !== "string") return;
|
||||
@@ -172,6 +250,44 @@ export function CreateScenarioInScopeDialog(props: {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">{__("Risks")}</div>
|
||||
{selectedRisks.size > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{[...selectedRisks.entries()].map(([id, name]) => (
|
||||
<Badge key={id}>
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 hover:text-txt-danger"
|
||||
onClick={() => {
|
||||
setSelectedRisks((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconCrossLargeX size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<p className="text-xs text-txt-tertiary">{__("Loading risks...")}</p>}>
|
||||
<RiskSelector
|
||||
selectedRisks={selectedRisks}
|
||||
onSelect={(id, name) => {
|
||||
setSelectedRisks((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, name);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter><Button type="submit" disabled={isCreating}>{__("Add")}</Button></DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { NodeActions } from "./NodeActions";
|
||||
import { ProcessActions } from "./ProcessActions";
|
||||
import { ScenarioInScopeActions } from "./ScenarioInScopeActions";
|
||||
import { ScopeActions } from "./ScopeActions";
|
||||
import { ScopeDiagram } from "./ScopeDiagram";
|
||||
import { ThreatActions } from "./ThreatActions";
|
||||
|
||||
export const scopeCardFragment = graphql`
|
||||
@@ -82,6 +83,7 @@ export const scopeCardFragment = graphql`
|
||||
}
|
||||
}
|
||||
}
|
||||
...ScopeDiagram_scope
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -164,6 +166,16 @@ export function ScopeCard(props: {
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border-low px-4 py-4 space-y-6">
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">{__("Diagram")}</h3>
|
||||
<p className="text-xs text-txt-tertiary mt-1">
|
||||
{__("Visualization of nodes, processes, and threats in this scope.")}
|
||||
</p>
|
||||
</div>
|
||||
<ScopeDiagram scopeKey={scope} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<SectionHeader
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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 { CheckIcon, CopyIcon } from "@phosphor-icons/react";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, MermaidDiagram, useToast } from "@probo/ui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchQuery, graphql, useFragment, useRelayEnvironment } from "react-relay";
|
||||
|
||||
import type { ScopeDiagram_scope$key } from "#/__generated__/core/ScopeDiagram_scope.graphql";
|
||||
import type { ScopeDiagramMermaidQuery } from "#/__generated__/core/ScopeDiagramMermaidQuery.graphql";
|
||||
|
||||
const scopeDiagramFragment = graphql`
|
||||
fragment ScopeDiagram_scope on RiskAssessmentScope {
|
||||
id
|
||||
mermaidChart
|
||||
nodes(first: 100)
|
||||
@connection(key: "RiskAssessmentScope_nodes", filters: []) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
nodeType
|
||||
}
|
||||
}
|
||||
}
|
||||
processes(first: 100)
|
||||
@connection(key: "RiskAssessmentScope_processes", filters: []) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
sourceNodeId
|
||||
targetNodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
threats(first: 100)
|
||||
@connection(key: "RiskAssessmentScope_threats", filters: []) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
processId
|
||||
category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const scopeDiagramMermaidQuery = graphql`
|
||||
query ScopeDiagramMermaidQuery($scopeId: ID!) {
|
||||
node(id: $scopeId) {
|
||||
... on RiskAssessmentScope {
|
||||
id
|
||||
mermaidChart
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ScopeDiagramProps {
|
||||
scopeKey: ScopeDiagram_scope$key;
|
||||
}
|
||||
|
||||
export function ScopeDiagram({ scopeKey }: ScopeDiagramProps) {
|
||||
const { __ } = useTranslate();
|
||||
const environment = useRelayEnvironment();
|
||||
const scope = useFragment(scopeDiagramFragment, scopeKey);
|
||||
const mermaidChart = scope.mermaidChart;
|
||||
|
||||
const nodeSignature = scope.nodes?.edges
|
||||
.map(e => `${e.node.id}|${e.node.name}|${e.node.nodeType}`)
|
||||
.join(";") ?? "";
|
||||
const processSignature = scope.processes?.edges
|
||||
.map(e => `${e.node.id}|${e.node.name}|${e.node.sourceNodeId}|${e.node.targetNodeId}`)
|
||||
.join(";") ?? "";
|
||||
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 previousSignature = useRef(signature);
|
||||
useEffect(() => {
|
||||
if (previousSignature.current === signature) {
|
||||
return;
|
||||
}
|
||||
previousSignature.current = signature;
|
||||
const subscription = fetchQuery<ScopeDiagramMermaidQuery>(
|
||||
environment,
|
||||
scopeDiagramMermaidQuery,
|
||||
{ scopeId: scope.id },
|
||||
{ fetchPolicy: "network-only" },
|
||||
).subscribe({});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [signature, environment, scope.id]);
|
||||
|
||||
if (!mermaidChart) {
|
||||
return (
|
||||
<div className="text-center text-txt-secondary text-sm py-6">
|
||||
{__("Add nodes and processes to see the diagram.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 top-0 z-10">
|
||||
<CopyChartButton chart={mermaidChart} />
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<MermaidDiagram chart={mermaidChart} />
|
||||
<Legend />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyChartButtonProps {
|
||||
chart: string;
|
||||
}
|
||||
|
||||
function CopyChartButton({ chart }: CopyChartButtonProps) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onClick = () => {
|
||||
const onFailure = () => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: __("Failed to copy to clipboard"),
|
||||
variant: "error",
|
||||
});
|
||||
};
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
onFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.clipboard.writeText(chart).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
},
|
||||
onFailure,
|
||||
);
|
||||
} catch {
|
||||
onFailure();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied ? CheckIcon : CopyIcon}
|
||||
onClick={onClick}
|
||||
aria-label={__("Copy mermaid source")}
|
||||
title={__("Copy mermaid source")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type LegendShape = "stadium" | "hexagon" | "rectangle" | "cylinder";
|
||||
|
||||
type LegendItem = {
|
||||
label: string;
|
||||
shape: LegendShape;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
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: __("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" },
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||
{items.map(item => (
|
||||
<LegendSwatch key={item.label} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LegendSwatchProps {
|
||||
item: LegendItem;
|
||||
}
|
||||
|
||||
function LegendSwatch({ item }: LegendSwatchProps) {
|
||||
const w = 88;
|
||||
const h = 28;
|
||||
return (
|
||||
<svg
|
||||
width={w}
|
||||
height={h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<LegendShapeEl shape={item.shape} w={w} h={h} fill={item.fill} stroke={item.stroke} />
|
||||
<text
|
||||
x={w / 2}
|
||||
y={h / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={11}
|
||||
fontFamily="inherit"
|
||||
fill={item.text}
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface LegendShapeElProps {
|
||||
shape: LegendShape;
|
||||
w: number;
|
||||
h: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
function LegendShapeEl({ shape, w, h, fill, stroke }: LegendShapeElProps) {
|
||||
const sw = 1.5;
|
||||
const common = { fill, stroke, strokeWidth: sw };
|
||||
|
||||
switch (shape) {
|
||||
case "stadium":
|
||||
return <rect x={sw / 2} y={sw / 2} width={w - sw} height={h - sw} rx={(h - sw) / 2} {...common} />;
|
||||
case "rectangle":
|
||||
return <rect x={sw / 2} y={sw / 2} width={w - sw} height={h - sw} {...common} />;
|
||||
case "hexagon": {
|
||||
const inset = 8;
|
||||
const pts = [
|
||||
`${inset},${h / 2}`,
|
||||
`${inset + 4},${sw}`,
|
||||
`${w - inset - 4},${sw}`,
|
||||
`${w - inset},${h / 2}`,
|
||||
`${w - inset - 4},${h - sw}`,
|
||||
`${inset + 4},${h - sw}`,
|
||||
].join(" ");
|
||||
return <polygon points={pts} {...common} />;
|
||||
}
|
||||
case "cylinder": {
|
||||
const ry = 4;
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`M ${sw / 2} ${ry + sw / 2} A ${(w - sw) / 2} ${ry} 0 0 1 ${w - sw / 2} ${ry + sw / 2} L ${w - sw / 2} ${h - ry - sw / 2} A ${(w - sw) / 2} ${ry} 0 0 1 ${sw / 2} ${h - ry - sw / 2} Z`}
|
||||
{...common}
|
||||
/>
|
||||
<path
|
||||
d={`M ${sw / 2} ${ry + sw / 2} A ${(w - sw) / 2} ${ry} 0 0 0 ${w - sw / 2} ${ry + sw / 2}`}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,19 @@ export function MermaidDiagram({ chart }: Props) {
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const source = (chart ?? "").trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
|
||||
|
||||
mermaid
|
||||
.render(`mermaid-${id}`, chart.trim())
|
||||
.render(`mermaid-${id}`, source)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setSvg(result.svg);
|
||||
@@ -46,7 +52,7 @@ export function MermaidDiagram({ chart }: Props) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, id]);
|
||||
}, [source, id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -47,6 +47,7 @@ export { PropertyRow } from "./Atoms/PropertyRow/PropertyRow";
|
||||
export { Table, Tbody, Td, Th, Thead, Tr, TrButton } from "./Atoms/Table/Table";
|
||||
export { TabBadge, TabItem, TabLink, Tabs } from "./Atoms/Tabs/Tabs";
|
||||
export { Markdown } from "./Atoms/Markdown/Markdown";
|
||||
export { MermaidDiagram } from "./Atoms/Markdown/MermaidDiagram";
|
||||
export { Dropzone } from "./Atoms/Dropzone/Dropzone";
|
||||
export { ControlItem } from "./Atoms/ControlItem/ControlItem";
|
||||
export { InfiniteScrollTrigger } from "./Atoms/InfiniteScrollTrigger/InfiniteScrollTrigger";
|
||||
|
||||
@@ -106,6 +106,45 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *RiskAssessmentNodes) LoadAllByRiskAssessmentScopeID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
riskAssessmentScopeID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
risk_assessment_scope_id,
|
||||
node_type,
|
||||
name,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
risk_assessment_nodes
|
||||
WHERE
|
||||
%s
|
||||
AND 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 nodes: %w", err)
|
||||
}
|
||||
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentNode])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect risk assessment nodes: %w", err)
|
||||
}
|
||||
*ns = results
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *RiskAssessmentNodes) CountByRiskAssessmentScopeID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
|
||||
@@ -108,6 +108,46 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *RiskAssessmentProcesses) LoadAllByRiskAssessmentScopeID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
riskAssessmentScopeID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
risk_assessment_scope_id,
|
||||
source_node_id,
|
||||
target_node_id,
|
||||
name,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
risk_assessment_processes
|
||||
WHERE
|
||||
%s
|
||||
AND 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 processes: %w", err)
|
||||
}
|
||||
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentProcess])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect risk assessment processes: %w", err)
|
||||
}
|
||||
*ps = results
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *RiskAssessmentProcesses) CountByRiskAssessmentScopeID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
|
||||
@@ -108,6 +108,46 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *RiskAssessmentThreats) LoadAllByRiskAssessmentScopeID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
riskAssessmentScopeID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
risk_assessment_scope_id,
|
||||
process_id,
|
||||
name,
|
||||
category,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
risk_assessment_threats
|
||||
WHERE
|
||||
%s
|
||||
AND 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 threats: %w", err)
|
||||
}
|
||||
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentThreat])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect risk threats: %w", err)
|
||||
}
|
||||
*ts = results
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *RiskAssessmentThreats) CountByRiskAssessmentScopeID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
|
||||
157
pkg/riskmanagement/mermaid.go
Normal file
157
pkg/riskmanagement/mermaid.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// 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 riskmanagement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
err := s.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
if err := nodes.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil {
|
||||
return fmt.Errorf("cannot load nodes: %w", err)
|
||||
}
|
||||
if err := processes.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil {
|
||||
return fmt.Errorf("cannot load processes: %w", err)
|
||||
}
|
||||
if err := threats.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil {
|
||||
return fmt.Errorf("cannot load threats: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buildScopeMermaidChart(nodes, processes, threats), nil
|
||||
}
|
||||
|
||||
func buildScopeMermaidChart(
|
||||
nodes coredata.RiskAssessmentNodes,
|
||||
processes coredata.RiskAssessmentProcesses,
|
||||
threats coredata.RiskAssessmentThreats,
|
||||
) string {
|
||||
if len(nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
nodeAlias := make(map[gid.GID]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
nodeAlias[n.ID] = fmt.Sprintf("n%d", i)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("flowchart LR\n")
|
||||
|
||||
for _, n := range nodes {
|
||||
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))
|
||||
}
|
||||
|
||||
for _, p := range processes {
|
||||
src, srcOK := nodeAlias[p.SourceNodeID]
|
||||
dst, dstOK := nodeAlias[p.TargetNodeID]
|
||||
if !srcOK || !dstOK {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " %s -- \"%s\" --> %s\n", src, escapeMermaidLabel(p.Name), dst)
|
||||
}
|
||||
|
||||
processTarget := make(map[gid.GID]gid.GID, len(processes))
|
||||
for _, p := range processes {
|
||||
processTarget[p.ID] = p.TargetNodeID
|
||||
}
|
||||
|
||||
for i, t := range threats {
|
||||
target, ok := processTarget[t.ProcessID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
targetAlias, ok := nodeAlias[target]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tid := fmt.Sprintf("t%d", i)
|
||||
label := escapeMermaidLabel(fmt.Sprintf("%s (%s)", t.Name, t.Category))
|
||||
fmt.Fprintf(&b, " %s{{\"%s\"}}\n", tid, label)
|
||||
fmt.Fprintf(&b, " class %s nodeThreat\n", tid)
|
||||
fmt.Fprintf(&b, " %s -.-> %s\n", tid, targetAlias)
|
||||
}
|
||||
|
||||
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 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")
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func mermaidNodeShape(t coredata.RiskAssessmentNodeType, id, name string) string {
|
||||
label := `"` + escapeMermaidLabel(name) + `"`
|
||||
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:
|
||||
fallthrough
|
||||
default:
|
||||
return fmt.Sprintf("%s[%s]", id, label)
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
fallthrough
|
||||
default:
|
||||
return "nodeAsset"
|
||||
}
|
||||
}
|
||||
|
||||
var mermaidLabelReplacer = strings.NewReplacer(
|
||||
"&", "&",
|
||||
`"`, "#quot;",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\r\n", " ",
|
||||
"\n", " ",
|
||||
)
|
||||
|
||||
func escapeMermaidLabel(s string) string {
|
||||
return mermaidLabelReplacer.Replace(s)
|
||||
}
|
||||
@@ -228,6 +228,8 @@ type RiskAssessmentScope implements Node {
|
||||
orderBy: RiskAssessmentScenarioOrder
|
||||
): RiskAssessmentScenarioConnection @goField(forceResolver: true)
|
||||
|
||||
mermaidChart: String! @goField(forceResolver: true)
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
@@ -854,6 +854,20 @@ func (r *riskAssessmentScopeResolver) Scenarios(ctx context.Context, obj *types.
|
||||
return types.NewRiskAssessmentScenarioConnection(p, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// MermaidChart is the resolver for the mermaidChart field.
|
||||
func (r *riskAssessmentScopeResolver) MermaidChart(ctx context.Context, obj *types.RiskAssessmentScope) (string, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentScopeGet); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scope := coredata.NewScopeFromObjectID(obj.ID)
|
||||
chart, err := r.riskManagement.BuildScopeMermaidChart(ctx, scope, obj.ID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot build risk assessment scope mermaid chart", log.Error(err))
|
||||
return "", gqlutils.Internal(ctx)
|
||||
}
|
||||
return chart, nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *riskAssessmentScopeConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentScopeConnection) (*int, error) {
|
||||
if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentScopeList); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user