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

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