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" },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
40
pkg/cmd/risk-assessment/boundary/boundary.go
Normal file
40
pkg/cmd/risk-assessment/boundary/boundary.go
Normal 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
|
||||
}
|
||||
153
pkg/cmd/risk-assessment/boundary/create/create.go
Normal file
153
pkg/cmd/risk-assessment/boundary/create/create.go
Normal 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
|
||||
}
|
||||
105
pkg/cmd/risk-assessment/boundary/delete/delete.go
Normal file
105
pkg/cmd/risk-assessment/boundary/delete/delete.go
Normal 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
|
||||
}
|
||||
208
pkg/cmd/risk-assessment/boundary/list/list.go
Normal file
208
pkg/cmd/risk-assessment/boundary/list/list.go
Normal 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
|
||||
}
|
||||
136
pkg/cmd/risk-assessment/boundary/update/update.go
Normal file
136
pkg/cmd/risk-assessment/boundary/update/update.go
Normal 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
|
||||
}
|
||||
139
pkg/cmd/risk-assessment/boundary/view/view.go
Normal file
139
pkg/cmd/risk-assessment/boundary/view/view.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
70
pkg/coredata/migrations/20260608T122627Z.sql
Normal file
70
pkg/coredata/migrations/20260608T122627Z.sql
Normal 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;
|
||||
344
pkg/coredata/risk_assessment_boundary.go
Normal file
344
pkg/coredata/risk_assessment_boundary.go
Normal 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
|
||||
}
|
||||
75
pkg/coredata/risk_assessment_boundary_order_field.go
Normal file
75
pkg/coredata/risk_assessment_boundary_order_field.go
Normal 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) }
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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 }
|
||||
|
||||
65
pkg/server/api/console/v1/types/risk_assessment_boundary.go
Normal file
65
pkg/server/api/console/v1/types/risk_assessment_boundary.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user