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

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

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@@ -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")

View File

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

View File

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

View File

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

View 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;

View 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
}

View 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) }

View File

@@ -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())

View File

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

View File

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

View File

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

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,

View File

@@ -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) {

View File

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

View File

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

View 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,
}
}

View File

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

View File

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

View File

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

View File

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