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

@@ -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:

View File

@@ -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,