Add risk assessment boundary model

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

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

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

View File

@@ -136,7 +136,7 @@ func TestRiskAssessmentScope_CRUD(t *testing.T) {
func TestRiskAssessmentNode_Create(t *testing.T) {
t.Parallel()
for _, nodeType := range []string{"ENTITY", "BOUNDARY", "ASSET", "DATA"} {
for _, nodeType := range []string{"ENTITY", "ASSET", "DATA"} {
t.Run("nodeType="+nodeType, func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
@@ -479,13 +479,13 @@ func TestRiskAssessmentNode_Update(t *testing.T) {
"input": map[string]any{
"id": nodeID,
"name": "Updated node",
"nodeType": "BOUNDARY",
"nodeType": "ASSET",
},
}, &result)
require.NoError(t, err)
assert.Equal(t, "Updated node", result.UpdateRiskAssessmentNode.RiskAssessmentNode.Name)
assert.Equal(t, "BOUNDARY", result.UpdateRiskAssessmentNode.RiskAssessmentNode.NodeType)
assert.Equal(t, "ASSET", result.UpdateRiskAssessmentNode.RiskAssessmentNode.NodeType)
}
func TestRiskAssessmentProcess_Update(t *testing.T) {
@@ -779,3 +779,316 @@ func TestRiskAssessment_TenantIsolation(t *testing.T) {
`, map[string]any{"id": raID}, &result)
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "RiskAssessment")
}
func TestRiskAssessmentBoundary_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
parentID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "External Zone"})
var result struct {
CreateRiskAssessmentBoundary struct {
RiskAssessmentBoundaryEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
ParentBoundaryID *string `json:"parentBoundaryId"`
} `json:"node"`
} `json:"riskAssessmentBoundaryEdge"`
} `json:"createRiskAssessmentBoundary"`
}
err := owner.Execute(`
mutation($input: CreateRiskAssessmentBoundaryInput!) {
createRiskAssessmentBoundary(input: $input) {
riskAssessmentBoundaryEdge { node { id name parentBoundaryId } }
}
}
`, map[string]any{
"input": map[string]any{
"riskAssessmentScopeId": scopeID,
"parentBoundaryId": parentID,
"name": "Internal Network",
},
}, &result)
require.NoError(t, err)
node := result.CreateRiskAssessmentBoundary.RiskAssessmentBoundaryEdge.Node
assert.NotEmpty(t, node.ID)
assert.Equal(t, "Internal Network", node.Name)
require.NotNil(t, node.ParentBoundaryID)
assert.Equal(t, parentID, *node.ParentBoundaryID)
}
func TestRiskAssessmentBoundary_ListViaScope(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Zone A"})
factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Zone B"})
var result struct {
Node struct {
Boundaries struct {
TotalCount int `json:"totalCount"`
Edges []struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"node"`
} `json:"edges"`
} `json:"boundaries"`
} `json:"node"`
}
err := owner.Execute(`
query($id: ID!) {
node(id: $id) {
... on RiskAssessmentScope {
boundaries(first: 10) {
totalCount
edges { node { id name } }
}
}
}
}
`, map[string]any{"id": scopeID}, &result)
require.NoError(t, err)
assert.Equal(t, 2, result.Node.Boundaries.TotalCount)
assert.Len(t, result.Node.Boundaries.Edges, 2)
}
func TestRiskAssessmentBoundary_Update(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
parentID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Parent"})
boundaryID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Original"})
const mutation = `
mutation($input: UpdateRiskAssessmentBoundaryInput!) {
updateRiskAssessmentBoundary(input: $input) {
riskAssessmentBoundary { id name parentBoundaryId }
}
}
`
var result struct {
UpdateRiskAssessmentBoundary struct {
RiskAssessmentBoundary struct {
ID string `json:"id"`
Name string `json:"name"`
ParentBoundaryID *string `json:"parentBoundaryId"`
} `json:"riskAssessmentBoundary"`
} `json:"updateRiskAssessmentBoundary"`
}
// Rename and assign a parent.
err := owner.Execute(mutation, map[string]any{
"input": map[string]any{
"id": boundaryID,
"name": "Renamed",
"parentBoundaryId": parentID,
},
}, &result)
require.NoError(t, err)
assert.Equal(t, "Renamed", result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.Name)
require.NotNil(t, result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.ParentBoundaryID)
assert.Equal(t, parentID, *result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.ParentBoundaryID)
// Clear the parent (move back to the top level).
err = owner.Execute(mutation, map[string]any{
"input": map[string]any{
"id": boundaryID,
"parentBoundaryId": nil,
},
}, &result)
require.NoError(t, err)
assert.Nil(t, result.UpdateRiskAssessmentBoundary.RiskAssessmentBoundary.ParentBoundaryID)
}
func TestRiskAssessmentBoundary_PreventCycle(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
a := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "A"})
b := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "B", "parentBoundaryId": a})
// B is nested under A, so nesting A under B would create a cycle.
_, err := owner.Do(`
mutation($input: UpdateRiskAssessmentBoundaryInput!) {
updateRiskAssessmentBoundary(input: $input) {
riskAssessmentBoundary { id }
}
}
`, map[string]any{
"input": map[string]any{
"id": a,
"parentBoundaryId": b,
},
})
require.Error(t, err, "nesting a boundary under its own descendant should be rejected")
}
func TestRiskAssessmentBoundary_Delete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
parentID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Parent"})
childID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Child", "parentBoundaryId": parentID})
nodeID := factory.CreateRiskAssessmentNode(owner, scopeID, factory.Attrs{"name": "Member", "boundaryId": parentID})
_, err := owner.Do(`
mutation($input: DeleteRiskAssessmentBoundaryInput!) {
deleteRiskAssessmentBoundary(input: $input) { deletedRiskAssessmentBoundaryId }
}
`, map[string]any{"input": map[string]any{"riskAssessmentBoundaryId": parentID}})
require.NoError(t, err)
// Deleting a parent moves its nested boundary and member node to the top
// level instead of cascading the delete (ON DELETE SET NULL).
var result struct {
Child *struct {
ParentBoundaryID *string `json:"parentBoundaryId"`
} `json:"child"`
Member *struct {
BoundaryID *string `json:"boundaryId"`
} `json:"member"`
}
err = owner.Execute(`
query($child: ID!, $member: ID!) {
child: node(id: $child) { ... on RiskAssessmentBoundary { parentBoundaryId } }
member: node(id: $member) { ... on RiskAssessmentNode { boundaryId } }
}
`, map[string]any{"child": childID, "member": nodeID}, &result)
require.NoError(t, err)
require.NotNil(t, result.Child)
assert.Nil(t, result.Child.ParentBoundaryID)
require.NotNil(t, result.Member)
assert.Nil(t, result.Member.BoundaryID)
}
func TestRiskAssessmentNode_WithBoundary(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
boundaryID := factory.CreateRiskAssessmentBoundary(owner, scopeID)
var createResult struct {
CreateRiskAssessmentNode struct {
RiskAssessmentNodeEdge struct {
Node struct {
ID string `json:"id"`
BoundaryID *string `json:"boundaryId"`
} `json:"node"`
} `json:"riskAssessmentNodeEdge"`
} `json:"createRiskAssessmentNode"`
}
err := owner.Execute(`
mutation($input: CreateRiskAssessmentNodeInput!) {
createRiskAssessmentNode(input: $input) {
riskAssessmentNodeEdge { node { id boundaryId } }
}
}
`, map[string]any{
"input": map[string]any{
"riskAssessmentScopeId": scopeID,
"nodeType": "ASSET",
"name": "Member",
"boundaryId": boundaryID,
},
}, &createResult)
require.NoError(t, err)
created := createResult.CreateRiskAssessmentNode.RiskAssessmentNodeEdge.Node
require.NotNil(t, created.BoundaryID)
assert.Equal(t, boundaryID, *created.BoundaryID)
// Clearing boundaryId moves the node back to the top level.
var updateResult struct {
UpdateRiskAssessmentNode struct {
RiskAssessmentNode struct {
BoundaryID *string `json:"boundaryId"`
} `json:"riskAssessmentNode"`
} `json:"updateRiskAssessmentNode"`
}
err = owner.Execute(`
mutation($input: UpdateRiskAssessmentNodeInput!) {
updateRiskAssessmentNode(input: $input) {
riskAssessmentNode { boundaryId }
}
}
`, map[string]any{
"input": map[string]any{
"id": created.ID,
"boundaryId": nil,
},
}, &updateResult)
require.NoError(t, err)
assert.Nil(t, updateResult.UpdateRiskAssessmentNode.RiskAssessmentNode.BoundaryID)
}
func TestRiskAssessmentBoundary_RBAC(t *testing.T) {
t.Parallel()
t.Run("viewer cannot create", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
_, err := viewer.Do(`
mutation($input: CreateRiskAssessmentBoundaryInput!) {
createRiskAssessmentBoundary(input: $input) {
riskAssessmentBoundaryEdge { node { id } }
}
}
`, map[string]any{
"input": map[string]any{
"riskAssessmentScopeId": scopeID,
"name": "Nope",
},
})
testutil.RequireForbiddenError(t, err, "viewer cannot create risk assessment boundary")
})
t.Run("viewer can read", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
raID := factory.CreateRiskAssessment(owner)
scopeID := factory.CreateRiskAssessmentScope(owner, raID)
boundaryID := factory.CreateRiskAssessmentBoundary(owner, scopeID, factory.Attrs{"name": "Visible"})
var result struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"node"`
}
err := viewer.Execute(`
query($id: ID!) { node(id: $id) { ... on RiskAssessmentBoundary { id name } } }
`, map[string]any{"id": boundaryID}, &result)
require.NoError(t, err)
assert.Equal(t, "Visible", result.Node.Name)
})
}

View File

@@ -1598,6 +1598,10 @@ func CreateRiskAssessmentNode(c *testutil.Client, scopeID string, attrs ...Attrs
"name": a.getString("name", SafeName("Node")),
}
if boundaryID := a.getString("boundaryId", ""); boundaryID != "" {
input["boundaryId"] = boundaryID
}
var result struct {
CreateRiskAssessmentNode struct {
RiskAssessmentNodeEdge struct {
@@ -1614,6 +1618,47 @@ func CreateRiskAssessmentNode(c *testutil.Client, scopeID string, attrs ...Attrs
return result.CreateRiskAssessmentNode.RiskAssessmentNodeEdge.Node.ID
}
func CreateRiskAssessmentBoundary(c *testutil.Client, scopeID string, attrs ...Attrs) string {
c.T.Helper()
var a Attrs
if len(attrs) > 0 {
a = attrs[0]
}
const query = `
mutation($input: CreateRiskAssessmentBoundaryInput!) {
createRiskAssessmentBoundary(input: $input) {
riskAssessmentBoundaryEdge { node { id } }
}
}
`
input := map[string]any{
"riskAssessmentScopeId": scopeID,
"name": a.getString("name", SafeName("Boundary")),
}
if parentID := a.getString("parentBoundaryId", ""); parentID != "" {
input["parentBoundaryId"] = parentID
}
var result struct {
CreateRiskAssessmentBoundary struct {
RiskAssessmentBoundaryEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"riskAssessmentBoundaryEdge"`
} `json:"createRiskAssessmentBoundary"`
}
err := c.Execute(query, map[string]any{"input": input}, &result)
require.NoError(c.T, err, "createRiskAssessmentBoundary mutation failed")
return result.CreateRiskAssessmentBoundary.RiskAssessmentBoundaryEdge.Node.ID
}
func CreateRiskAssessmentProcess(c *testutil.Client, scopeID, sourceNodeID, targetNodeID string, attrs ...Attrs) string {
c.T.Helper()