Add risk publish to document system

Replace the old snapshot-based system for risks with the publish
document system, mirroring the prior vendor / processing activity / DPIA
/ TIA migration. Includes the GraphQL mutation, MCP tool, CLI command,
n8n operation, frontend publish dialog, e2e tests, and a prosemirror
register template covering name, description, category, treatment,
owner, inherent and residual scoring, and notes.

The risk register lives as a generated DocumentTypeRegister document on
the organization, reused across publishes (the major version bumps on
every republish). Approvers can be passed in to create a draft pending
approval; otherwise the version is published immediately. The frontend
Risks page exposes a Publish button and a Document link button when the
document exists, and pre-fills the previous default approvers.

Risks was the last remaining snapshot type, so this commit also removes
the entire snapshot system: drop snapshotId from the Risk GraphQL type
and RiskFilter; remove RiskSnapshotter, Risks.Snapshot,
InsertRiskSnapshots, and the SnapshotID/SourceID fields on Risk; delete
Snapshot, ControlSnapshot, SnapshotsType, SnapshotOrderField,
Snapshottable, the SnapshotService, the Snapshot console resolvers and
GraphQL schema, the Snapshot MCP types and operations
(list/get/take/listControlSnapshots), the snapshot CLI (prb snapshot),
the snapshot frontend pages, routes, banner, LinkedSnapshotsCard,
SnapshotGraph, snapshot helpers, and the snapshot n8n resource and
control link/unlink snapshot operations. The snapshot_id columns remain
in the database but are now filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on Risk backed by a new
risks_document_id column on generated_documents, matching the
ProcessingActivity/Finding/Vendor pattern. The migration command
migrate-risk-snapshots-to-documents uses raw SQL queries instead of the
Go snapshot types, since those are gone.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-29 18:11:19 +02:00
parent 01bc3ac696
commit 553901e4ad
93 changed files with 2384 additions and 5741 deletions

View File

@@ -0,0 +1,148 @@
// 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 publish
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const publishMutation = `
mutation($input: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge {
node {
id
status
createdAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
}
}
}
}
`
type publishResponse struct {
PublishRiskList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Title string `json:"title"`
Major int `json:"major"`
Minor int `json:"minor"`
Status string `json:"status"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishRiskList"`
}
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagApprover []string
)
cmd := &cobra.Command{
Use: "publish",
Short: "Publish the risk register as a document version",
Example: ` # Publish the risk register
prb risk publish --org ORG_ID
# Publish with approvers
prb risk publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
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
}
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required: pass --org or run `prb auth login`")
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
input := map[string]any{
"organizationId": flagOrg,
}
if len(flagApprover) > 0 {
input["approverIds"] = flagApprover
}
data, err := client.Do(
publishMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp publishResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
v := resp.PublishRiskList.DocumentVersionEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Published risk register %s (v%d.%d)\n",
v.Title,
v.Major,
v.Minor,
)
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
return cmd
}

View File

@@ -20,6 +20,7 @@ import (
"go.probo.inc/probo/pkg/cmd/risk/create"
"go.probo.inc/probo/pkg/cmd/risk/delete"
"go.probo.inc/probo/pkg/cmd/risk/list"
"go.probo.inc/probo/pkg/cmd/risk/publish"
"go.probo.inc/probo/pkg/cmd/risk/update"
"go.probo.inc/probo/pkg/cmd/risk/view"
)
@@ -35,6 +36,7 @@ func NewCmdRisk(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}

View File

@@ -44,7 +44,6 @@ import (
processingactivity "go.probo.inc/probo/pkg/cmd/processing-activity"
rightsrequest "go.probo.inc/probo/pkg/cmd/rights-request"
"go.probo.inc/probo/pkg/cmd/risk"
"go.probo.inc/probo/pkg/cmd/snapshot"
"go.probo.inc/probo/pkg/cmd/soa"
"go.probo.inc/probo/pkg/cmd/task"
"go.probo.inc/probo/pkg/cmd/tia"
@@ -112,7 +111,6 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(processingactivity.NewCmdProcessingActivity(f))
cmd.AddCommand(rightsrequest.NewCmdRightsRequest(f))
cmd.AddCommand(risk.NewCmdRisk(f))
cmd.AddCommand(snapshot.NewCmdSnapshot(f))
cmd.AddCommand(soa.NewCmdSoa(f))
cmd.AddCommand(task.NewCmdTask(f))
cmd.AddCommand(tia.NewCmdTIA(f))

View File

@@ -1,175 +0,0 @@
// 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: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
name
type
}
}
}
}
`
type createResponse struct {
CreateSnapshot struct {
SnapshotEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"node"`
} `json:"snapshotEdge"`
} `json:"createSnapshot"`
}
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagName string
flagType string
flagDescription string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a new snapshot",
Example: ` # Create a snapshot interactively
prb snapshot create
# Create a snapshot non-interactively
prb snapshot create --name "Q1 2026 Risks" --type RISKS`,
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 flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
if f.IOStreams.IsInteractive() {
if flagName == "" {
err := huh.NewInput().
Title("Snapshot name").
Value(&flagName).
Run()
if err != nil {
return err
}
}
if flagType == "" {
err := huh.NewSelect[string]().
Title("Snapshot type").
Options(
huh.NewOption("Risks", "RISKS"),
huh.NewOption("Vendors", "VENDORS"),
huh.NewOption("Assets", "ASSETS"),
huh.NewOption("Findings", "FINDINGS"),
huh.NewOption("Obligations", "OBLIGATIONS"),
huh.NewOption("Processing Activities", "PROCESSING_ACTIVITIES"),
huh.NewOption("Statements of Applicability", "STATEMENTS_OF_APPLICABILITY"),
).
Value(&flagType).
Run()
if err != nil {
return err
}
}
}
if flagName == "" {
return fmt.Errorf("name is required; pass --name or run interactively")
}
if flagType == "" {
return fmt.Errorf("type is required; pass --type or run interactively")
}
input := map[string]any{
"organizationId": flagOrg,
"name": flagName,
"type": flagType,
}
if flagDescription != "" {
input["description"] = flagDescription
}
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)
}
s := resp.CreateSnapshot.SnapshotEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Created snapshot %s (%s)\n",
s.ID,
s.Name,
)
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().StringVar(&flagName, "name", "", "Snapshot name (required)")
cmd.Flags().StringVar(&flagType, "type", "", "Snapshot type: RISKS, VENDORS, ASSETS, FINDINGS, OBLIGATIONS, PROCESSING_ACTIVITIES, STATEMENTS_OF_APPLICABILITY (required)")
cmd.Flags().StringVar(&flagDescription, "description", "", "Snapshot description")
return cmd
}

View File

@@ -1,103 +0,0 @@
// 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: DeleteSnapshotInput!) {
deleteSnapshot(input: $input) {
deletedSnapshotId
}
}
`
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
var flagYes bool
cmd := &cobra.Command{
Use: "delete <id>",
Short: "Delete a snapshot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !flagYes {
if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot delete snapshot: confirmation required, use --yes to confirm")
}
var confirmed bool
err := huh.NewConfirm().
Title(fmt.Sprintf("Delete snapshot %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{
"snapshotId": args[0],
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Deleted snapshot %s\n",
args[0],
)
return nil
},
}
cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt")
return cmd
}

View File

@@ -1,193 +0,0 @@
// 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: SnapshotOrder) {
node(id: $id) {
__typename
... on Organization {
snapshots(first: $first, after: $after, orderBy: $orderBy) {
totalCount
edges {
node {
id
name
type
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`
type snapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
}
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagLimit int
flagOrderBy string
flagOrderDir string
flagOutput *string
)
cmd := &cobra.Command{
Use: "list",
Short: "List snapshots in an organization",
Aliases: []string{"ls"},
Example: ` # List snapshots in the default organization
prb snapshot list
# List snapshots sorted by name
prb snapshot ls --order-by NAME --json`,
Args: cobra.NoArgs,
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),
)
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
variables := map[string]any{
"id": flagOrg,
}
if flagOrderBy != "" {
if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT", "NAME", "TYPE"}); err != nil {
return err
}
variables["orderBy"] = map[string]any{
"field": flagOrderBy,
"direction": flagOrderDir,
}
}
snapshots, totalCount, err := api.Paginate(
client,
listQuery,
variables,
flagLimit,
func(data json.RawMessage) (*api.Connection[snapshot], error) {
var resp struct {
Node *struct {
Typename string `json:"__typename"`
Snapshots api.Connection[snapshot] `json:"snapshots"`
} `json:"node"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
if resp.Node == nil {
return nil, fmt.Errorf("organization %s not found", flagOrg)
}
if resp.Node.Typename != "Organization" {
return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename)
}
return &resp.Node.Snapshots, nil
},
)
if err != nil {
return err
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, snapshots)
}
if len(snapshots) == 0 {
_, _ = fmt.Fprintln(f.IOStreams.Out, "No snapshots found.")
return nil
}
rows := make([][]string, 0, len(snapshots))
for _, s := range snapshots {
rows = append(rows, []string{
s.ID,
s.Name,
s.Type,
cmdutil.FormatTime(s.CreatedAt),
})
}
t := cmdutil.NewTable("ID", "NAME", "TYPE", "CREATED AT").Rows(rows...)
_, _ = fmt.Fprintln(f.IOStreams.Out, t)
if totalCount > len(snapshots) {
_, _ = fmt.Fprintf(
f.IOStreams.ErrOut,
"\nShowing %d of %d snapshots\n",
len(snapshots),
totalCount,
)
}
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of snapshots to list")
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, NAME, TYPE)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}

View File

@@ -1,38 +0,0 @@
// 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 snapshot
import (
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/snapshot/create"
"go.probo.inc/probo/pkg/cmd/snapshot/delete"
"go.probo.inc/probo/pkg/cmd/snapshot/list"
"go.probo.inc/probo/pkg/cmd/snapshot/view"
)
func NewCmdSnapshot(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "snapshot <command>",
Short: "Manage snapshots",
}
cmd.AddCommand(list.NewCmdList(f))
cmd.AddCommand(create.NewCmdCreate(f))
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(delete.NewCmdDelete(f))
return cmd
}

View File

@@ -1,133 +0,0 @@
// 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 Snapshot {
id
name
description
type
createdAt
}
}
}
`
type viewResponse struct {
Node *struct {
Typename string `json:"__typename"`
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
} `json:"node"`
}
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
var flagOutput *string
cmd := &cobra.Command{
Use: "view <id>",
Short: "View a snapshot",
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("snapshot %s not found", args[0])
}
if resp.Node.Typename != "Snapshot" {
return fmt.Errorf("expected Snapshot node, got %s", resp.Node.Typename)
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node)
}
s := 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(s.Name))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), s.ID)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Type:"), s.Type)
if s.Description != nil && *s.Description != "" {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Description:"), *s.Description)
}
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(s.CreatedAt))
return nil
},
}
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}

View File

@@ -73,8 +73,6 @@ func ResourceTypeName(entityType uint16) string {
return "Obligation"
case VendorServiceEntityType:
return "VendorService"
case SnapshotEntityType:
return "Snapshot"
case ProcessingActivityEntityType:
return "ProcessingActivity"
case TrustCenterReferenceEntityType:

View File

@@ -970,77 +970,6 @@ WHERE %s
return nil
}
func (c *Controls) LoadBySnapshotID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
snapshotID gid.GID,
cursor *page.Cursor[ControlOrderField],
filter *ControlFilter,
) error {
q := `
WITH ctrl AS (
SELECT
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
c.best_practice,
c.not_implemented_justification,
c.maturity_level,
c.created_at,
c.updated_at,
c.search_vector
FROM
controls c
INNER JOIN
controls_snapshots cs ON c.id = cs.control_id
WHERE
cs.snapshot_id = @snapshot_id
)
SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
best_practice,
not_implemented_justification,
maturity_level,
created_at,
updated_at
FROM
ctrl
WHERE %s
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"snapshot_id": snapshotID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query controls: %w", err)
}
controls, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Control])
if err != nil {
return fmt.Errorf("cannot collect controls: %w", err)
}
*c = controls
return nil
}
func (c *Controls) CountByStatementOfApplicabilityID(
ctx context.Context,
conn pg.Querier,

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2025-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"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
ControlSnapshot struct {
ControlID gid.GID `db:"control_id"`
SnapshotID gid.GID `db:"snapshot_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
ControlSnapshots []*ControlSnapshot
)
func (cs ControlSnapshot) Upsert(
ctx context.Context,
conn pg.Querier,
scope Scoper,
) error {
q := `
INSERT INTO
controls_snapshots (
control_id,
snapshot_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@control_id,
@snapshot_id,
@organization_id,
@tenant_id,
@created_at
)
ON CONFLICT (control_id, snapshot_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"control_id": cs.ControlID,
"snapshot_id": cs.SnapshotID,
"organization_id": cs.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": cs.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}
func (cs ControlSnapshot) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
controlID gid.GID,
snapshotID gid.GID,
) error {
q := `
DELETE
FROM
controls_snapshots
WHERE
%s
AND control_id = @control_id
AND snapshot_id = @snapshot_id;
`
args := pgx.StrictNamedArgs{
"control_id": controlID,
"snapshot_id": snapshotID,
}
maps.Copy(args, scope.SQLArguments())
q = fmt.Sprintf(q, scope.SQLFragment())
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -54,7 +54,7 @@ const (
_ uint16 = 28 // NonconformityEntityType - removed
ObligationEntityType uint16 = 29
VendorServiceEntityType uint16 = 30
SnapshotEntityType uint16 = 31
_ uint16 = 31 // SnapshotEntityType - removed
_ uint16 = 32 // ContinualImprovementEntityType - removed
ProcessingActivityEntityType uint16 = 33
ExportJobEntityType uint16 = 34
@@ -176,8 +176,6 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &Obligation{ID: id}, true
case VendorServiceEntityType:
return &VendorService{ID: id}, true
case SnapshotEntityType:
return &Snapshot{ID: id}, true
case ProcessingActivityEntityType:
return &ProcessingActivity{ID: id}, true
case ExportJobEntityType:

View File

@@ -0,0 +1,90 @@
-- 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.
ALTER TABLE generated_documents
ADD COLUMN risks_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;
-- Backfill controls_documents from the legacy controls_snapshots links.
-- For every snapshot type whose register is now an org-level generated
-- document, link each control that was attached to a snapshot to the
-- corresponding generated document. Best effort: skips rows whose target
-- document hasn't been created yet (the matching `cmd/migrate-*` data
-- migration must have already run). ON CONFLICT keeps the migration
-- idempotent and tolerant of pre-existing mappings.
INSERT INTO controls_documents (control_id, document_id, organization_id, tenant_id, created_at)
SELECT DISTINCT
cs.control_id,
CASE s.type
WHEN 'RISKS' THEN gd.risks_document_id
WHEN 'VENDORS' THEN gd.vendors_document_id
WHEN 'ASSETS' THEN gd.asset_list_document_id
WHEN 'DATA' THEN gd.data_document_id
WHEN 'FINDINGS' THEN gd.findings_document_id
WHEN 'OBLIGATIONS' THEN gd.obligations_document_id
WHEN 'PROCESSING_ACTIVITIES' THEN gd.processing_activities_document_id
END AS document_id,
s.organization_id,
s.tenant_id,
NOW()
FROM controls_snapshots cs
INNER JOIN snapshots s ON s.id = cs.snapshot_id
LEFT JOIN generated_documents gd ON gd.organization_id = s.organization_id
WHERE s.type IN (
'RISKS',
'VENDORS',
'ASSETS',
'DATA',
'FINDINGS',
'OBLIGATIONS',
'PROCESSING_ACTIVITIES'
)
AND CASE s.type
WHEN 'RISKS' THEN gd.risks_document_id
WHEN 'VENDORS' THEN gd.vendors_document_id
WHEN 'ASSETS' THEN gd.asset_list_document_id
WHEN 'DATA' THEN gd.data_document_id
WHEN 'FINDINGS' THEN gd.findings_document_id
WHEN 'OBLIGATIONS' THEN gd.obligations_document_id
WHEN 'PROCESSING_ACTIVITIES' THEN gd.processing_activities_document_id
END IS NOT NULL
ON CONFLICT DO NOTHING;
-- For STATEMENTS_OF_APPLICABILITY snapshots, the published document lives on
-- the source SOA (the live row, snapshot_id IS NULL). Link controls that
-- were attached to a SOA snapshot to that source SOA's document.
INSERT INTO controls_documents (control_id, document_id, organization_id, tenant_id, created_at)
SELECT DISTINCT
cs.control_id,
live_soa.document_id,
s.organization_id,
s.tenant_id,
NOW()
FROM controls_snapshots cs
INNER JOIN snapshots s ON s.id = cs.snapshot_id
INNER JOIN statements_of_applicability snap_soa ON snap_soa.snapshot_id = s.id
INNER JOIN statements_of_applicability live_soa
ON live_soa.id = snap_soa.source_id
AND live_soa.snapshot_id IS NULL
WHERE s.type = 'STATEMENTS_OF_APPLICABILITY'
AND live_soa.document_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- Drop the trailing " List" suffix from previously published register
-- documents so the version title matches the new naming convention used by
-- the publish flow. Restricted to REGISTER document types so unrelated
-- documents that happen to share a title aren't touched.
UPDATE document_versions SET title = 'Assets' WHERE title = 'Asset List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Data' WHERE title = 'Data List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Findings' WHERE title = 'Finding List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Obligations' WHERE title = 'Obligation List' AND document_type = 'REGISTER';

View File

@@ -27,6 +27,113 @@ import (
"go.probo.inc/probo/pkg/page"
)
func (r Risk) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
risks_document_id
FROM
generated_documents
WHERE
organization_id = @organization_id
`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&documentID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get risk list document ID: %w", err)
}
return documentID, nil
}
func (r Risk) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
organizationID gid.GID,
tenantID gid.TenantID,
documentID gid.GID,
) error {
now := time.Now()
_, err := conn.Exec(
ctx,
`
INSERT INTO generated_documents (
organization_id,
tenant_id,
risks_document_id,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@risks_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
risks_document_id = @risks_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"risks_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert risk list document ID: %w", err)
}
return nil
}
func (r Risk) ClearGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
generated_documents
SET
risks_document_id = NULL,
updated_at = @now
WHERE
risks_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear risk list document references: %w", err)
}
return nil
}
type (
Risk struct {
ID gid.GID `db:"id"`
@@ -43,8 +150,6 @@ type (
ResidualLikelihood int `db:"residual_likelihood"`
ResidualImpact int `db:"residual_impact"`
ResidualRiskScore int `db:"residual_risk_score"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
@@ -53,10 +158,6 @@ type (
}
Risks []*Risk
RiskSnapshotter interface {
InsertRiskSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
)
func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey {
@@ -106,14 +207,14 @@ WITH rsks AS (
SELECT
r.id,
r.tenant_id,
r.search_vector,
r.snapshot_id
r.search_vector
FROM
risks r
INNER JOIN
risks_measures rm ON r.id = rm.risk_id
WHERE
rm.measure_id = @measure_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -165,8 +266,6 @@ WITH rsks AS (
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score,
r.snapshot_id,
r.source_id,
r.search_vector,
r.created_at,
r.updated_at
@@ -178,6 +277,7 @@ WITH rsks AS (
iam_membership_profiles p ON r.owner_profile_id = p.id
WHERE
rm.measure_id = @measure_id
AND r.snapshot_id IS NULL
)
SELECT
id,
@@ -195,8 +295,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -240,6 +338,7 @@ SELECT
FROM risks
WHERE %s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -285,8 +384,6 @@ WITH rsks AS (
r.residual_impact,
r.residual_risk_score,
r.category,
r.snapshot_id,
r.source_id,
r.search_vector,
r.created_at,
r.updated_at
@@ -296,6 +393,7 @@ WITH rsks AS (
iam_membership_profiles p ON r.owner_profile_id = p.id
WHERE
r.organization_id = @organization_id
AND r.snapshot_id IS NULL
)
SELECT
id,
@@ -313,8 +411,6 @@ SELECT
residual_impact,
residual_risk_score,
category,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -345,6 +441,58 @@ WHERE %s
return nil
}
func (r *Risks) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
r.id,
r.organization_id,
r.name,
r.description,
r.category,
r.owner_profile_id,
NULL as owner_full_name,
r.treatment,
r.note,
r.inherent_likelihood,
r.inherent_impact,
r.inherent_risk_score,
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score,
r.created_at,
r.updated_at
FROM
risks r
WHERE %s
AND r.organization_id = @organization_id
AND r.snapshot_id IS NULL
ORDER BY r.name ASC, r.id ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query risks: %w", err)
}
risks, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Risk])
if err != nil {
return fmt.Errorf("cannot collect risks: %w", err)
}
*r = risks
return nil
}
func (r *Risk) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -368,8 +516,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM risks
@@ -424,8 +570,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM risks
@@ -567,14 +711,14 @@ WITH rsks AS (
SELECT
r.id,
r.tenant_id,
r.search_vector,
r.snapshot_id
r.search_vector
FROM
risks r
INNER JOIN
risks_documents rd ON r.id = rd.risk_id
WHERE
rd.document_id = @document_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -598,78 +742,3 @@ WHERE %s
return count, nil
}
func (r Risks) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
if err := r.InsertRiskSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot create risk snapshots: %w", err)
}
return nil
}
func (r Risks) InsertRiskSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
INSERT INTO risks (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
name,
description,
category,
treatment,
note,
owner_profile_id,
inherent_likelihood,
inherent_impact,
residual_likelihood,
residual_impact,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @risk_entity_type),
@snapshot_id,
r.id,
r.organization_id,
r.name,
r.description,
r.category,
r.treatment,
r.note,
r.owner_profile_id,
r.inherent_likelihood,
r.inherent_impact,
r.residual_likelihood,
r.residual_impact,
r.created_at,
r.updated_at
FROM risks r
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"risk_entity_type": RiskEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert risk snapshots: %w", err)
}
return nil
}

View File

@@ -16,40 +16,24 @@ package coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
RiskFilter struct {
query *string
snapshotID **gid.GID
query *string
}
)
func NewRiskFilter(query *string, snapshotID **gid.GID) *RiskFilter {
func NewRiskFilter(query *string) *RiskFilter {
return &RiskFilter{
query: query,
snapshotID: snapshotID,
query: query,
}
}
func (f *RiskFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{
return pgx.StrictNamedArgs{
"query": f.query,
}
if f.snapshotID == nil {
args["has_snapshot_filter"] = false
args["filter_snapshot_id"] = nil
} else if *f.snapshotID == nil {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = nil
} else {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
func (f *RiskFilter) SQLFragment() string {
@@ -63,14 +47,5 @@ func (f *RiskFilter) SQLFragment() string {
)
ELSE TRUE
END
AND
CASE
WHEN @has_snapshot_filter::boolean = false THEN TRUE
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
snapshot_id = @filter_snapshot_id::text
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
snapshot_id IS NULL
ELSE TRUE
END
)`
}

View File

@@ -1,316 +0,0 @@
// Copyright (c) 2025-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"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Snapshot struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Name string `db:"name"`
Description *string `db:"description"`
Type SnapshotsType `db:"type"`
CreatedAt time.Time `db:"created_at"`
}
Snapshots []*Snapshot
)
func (s *Snapshot) CursorKey(field SnapshotOrderField) page.CursorKey {
switch field {
case SnapshotOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
case SnapshotOrderFieldName:
return page.NewCursorKey(s.ID, s.Name)
case SnapshotOrderFieldType:
return page.NewCursorKey(s.ID, s.Type)
}
panic(fmt.Sprintf("unsupported order by: %s", field))
}
func (s *Snapshot) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `SELECT organization_id FROM snapshots WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query snapshot authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
func (s *Snapshot) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
snapshotID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots
WHERE
%s
AND id = @snapshot_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"snapshot_id": snapshotID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query snapshots: %w", err)
}
snapshot, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshot: %w", err)
}
*s = snapshot
return nil
}
func (s *Snapshots) CountByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *SnapshotFilter,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
snapshots
WHERE
%s
AND organization_id = @organization_id
AND type = 'RISKS'
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (s *Snapshots) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[SnapshotOrderField],
) error {
q := `
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots
WHERE
%s
AND organization_id = @organization_id
AND type = 'RISKS'
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
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 snapshots: %w", err)
}
snapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshots: %w", err)
}
*s = snapshots
return nil
}
func (s *Snapshot) Insert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO snapshots (
id,
tenant_id,
organization_id,
name,
description,
type,
created_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@name,
@description,
@type,
@created_at
)
`
args := pgx.StrictNamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"name": s.Name,
"description": s.Description,
"type": s.Type,
"created_at": s.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert snapshot: %w", err)
}
return nil
}
func (s *Snapshot) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
DELETE FROM snapshots
WHERE
%s
AND organization_id = @organization_id
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": s.ID, "organization_id": s.OrganizationID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete snapshot: %w", err)
}
return nil
}
func (s *Snapshots) LoadByControlID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
controlID gid.GID,
cursor *page.Cursor[SnapshotOrderField],
) error {
q := `
WITH snapshots_by_control AS (
SELECT
s.id,
s.tenant_id,
s.organization_id,
s.name,
s.description,
s.type,
s.created_at
FROM
snapshots s
INNER JOIN
controls_snapshots cs ON s.id = cs.snapshot_id
WHERE
cs.control_id = @control_id
)
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots_by_control
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"control_id": controlID}
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 snapshots: %w", err)
}
snapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshots: %w", err)
}
*s = snapshots
return nil
}

View File

@@ -1,65 +0,0 @@
// Copyright (c) 2025-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 (
"time"
"github.com/jackc/pgx/v5"
)
type (
SnapshotFilter struct {
snapshotType *SnapshotsType
beforeDate *time.Time
}
)
func NewSnapshotFilter(snapshotType *SnapshotsType) *SnapshotFilter {
return &SnapshotFilter{
snapshotType: snapshotType,
}
}
func (f *SnapshotFilter) WithBeforeDate(beforeDate *time.Time) *SnapshotFilter {
f.beforeDate = beforeDate
return f
}
func (f *SnapshotFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{
"filter_snapshot_type": f.snapshotType,
"filter_before_date": f.beforeDate,
}
return args
}
func (f *SnapshotFilter) SQLFragment() string {
return `
(
CASE
WHEN @filter_snapshot_type::snapshots_type IS NOT NULL THEN
type = @filter_snapshot_type::snapshots_type
ELSE TRUE
END
AND
CASE
WHEN @filter_before_date::timestamptz IS NOT NULL THEN
created_at <= @filter_before_date::timestamptz
ELSE TRUE
END
)`
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2025-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 (
"fmt"
)
type SnapshotOrderField string
const (
SnapshotOrderFieldCreatedAt SnapshotOrderField = "CREATED_AT"
SnapshotOrderFieldName SnapshotOrderField = "NAME"
SnapshotOrderFieldType SnapshotOrderField = "TYPE"
)
func (p SnapshotOrderField) Column() string {
return string(p)
}
func (p SnapshotOrderField) String() string {
return string(p)
}
func (p SnapshotOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *SnapshotOrderField) UnmarshalText(text []byte) error {
val := string(text)
switch val {
case string(SnapshotOrderFieldCreatedAt),
string(SnapshotOrderFieldName),
string(SnapshotOrderFieldType):
*p = SnapshotOrderField(val)
return nil
}
return fmt.Errorf("invalid SnapshotOrderField value: %q", val)
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2025-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 (
"database/sql/driver"
"fmt"
)
type (
SnapshotsType string
)
const (
SnapshotsTypeRisks SnapshotsType = "RISKS"
SnapshotsTypeAssets SnapshotsType = "ASSETS"
SnapshotsTypeData SnapshotsType = "DATA"
SnapshotsTypeFindings SnapshotsType = "FINDINGS"
SnapshotsTypeObligations SnapshotsType = "OBLIGATIONS"
SnapshotsTypeProcessingActivities SnapshotsType = "PROCESSING_ACTIVITIES"
SnapshotsTypeStatementsOfApplicability SnapshotsType = "STATEMENTS_OF_APPLICABILITY"
)
func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
}
}
func (st SnapshotsType) String() string {
return string(st)
}
func (st *SnapshotsType) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for SnapshotsType: %T", value)
}
switch s {
case SnapshotsTypeRisks.String():
*st = SnapshotsTypeRisks
case SnapshotsTypeAssets.String():
*st = SnapshotsTypeAssets
case SnapshotsTypeData.String():
*st = SnapshotsTypeData
case SnapshotsTypeFindings.String(), "NONCONFORMITIES", "CONTINUAL_IMPROVEMENTS":
*st = SnapshotsTypeFindings
case SnapshotsTypeObligations.String():
*st = SnapshotsTypeObligations
case SnapshotsTypeProcessingActivities.String():
*st = SnapshotsTypeProcessingActivities
case SnapshotsTypeStatementsOfApplicability.String():
*st = SnapshotsTypeStatementsOfApplicability
default:
return fmt.Errorf("invalid SnapshotsType value: %q", s)
}
return nil
}
func (st SnapshotsType) Value() (driver.Value, error) {
return st.String(), nil
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2025-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"
"fmt"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type Snapshottable interface {
Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
default:
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
}
}

View File

@@ -259,6 +259,35 @@ type (
Vendors string
}
RiskListData struct {
Title string
OrganizationName string
CreatedAt time.Time
TotalRisks int
Rows []RiskListRow
}
RiskListRow struct {
Name string
Description string
Category string
Treatment string
Owner string
InherentLikelihood int
InherentLikelihoodLabel string
InherentImpact int
InherentImpactLabel string
InherentRiskScore int
InherentSeverity string
ResidualLikelihood int
ResidualLikelihoodLabel string
ResidualImpact int
ResidualImpactLabel string
ResidualRiskScore int
ResidualSeverity string
Note string
}
FindingListData struct {
Title string
OrganizationName string

View File

@@ -149,8 +149,6 @@ const (
ActionControlDocumentMappingDelete = "core:control:delete-document-mapping"
ActionControlAuditMappingCreate = "core:control:create-audit-mapping"
ActionControlAuditMappingDelete = "core:control:delete-audit-mapping"
ActionControlSnapshotMappingCreate = "core:control:create-snapshot-mapping"
ActionControlSnapshotMappingDelete = "core:control:delete-snapshot-mapping"
ActionControlObligationMappingCreate = "core:control:create-obligation-mapping"
ActionControlObligationMappingDelete = "core:control:delete-obligation-mapping"
@@ -226,6 +224,7 @@ const (
ActionRiskDocumentMappingDelete = "core:risk:delete-document-mapping"
ActionRiskObligationMappingCreate = "core:risk:create-obligation-mapping"
ActionRiskObligationMappingDelete = "core:risk:delete-obligation-mapping"
ActionRiskPublish = "core:risk:publish"
// Asset actions
ActionAssetGet = "core:asset:get"
@@ -283,12 +282,6 @@ const (
ActionProcessingActivityDelete = "core:processing-activity:delete"
ActionProcessingActivityPublish = "core:processing-activity:publish"
// Snapshot actions
ActionSnapshotGet = "core:snapshot:get"
ActionSnapshotList = "core:snapshot:list"
ActionSnapshotCreate = "core:snapshot:create"
ActionSnapshotDelete = "core:snapshot:delete"
// CustomDomain actions
ActionCustomDomainGet = "core:custom-domain:get"
ActionCustomDomainCreate = "core:custom-domain:create"

View File

@@ -707,112 +707,6 @@ func (s ControlService) ListForAuditID(
return page.NewPage([]*coredata.Control(controls), cursor), nil
}
func (s ControlService) CreateSnapshotMapping(
ctx context.Context,
controlID gid.GID,
snapshotID gid.GID,
) (*coredata.Control, *coredata.Snapshot, error) {
control := &coredata.Control{}
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := control.LoadByID(ctx, conn, s.svc.scope, controlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
controlSnapshot := &coredata.ControlSnapshot{
ControlID: controlID,
SnapshotID: snapshotID,
OrganizationID: control.OrganizationID,
CreatedAt: time.Now(),
}
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
if err := controlSnapshot.Upsert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot create control snapshot mapping: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return control, snapshot, nil
}
func (s ControlService) DeleteSnapshotMapping(
ctx context.Context,
controlID gid.GID,
snapshotID gid.GID,
) (*coredata.Control, *coredata.Snapshot, error) {
control := &coredata.Control{}
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := control.LoadByID(ctx, tx, s.svc.scope, controlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
if err := snapshot.LoadByID(ctx, tx, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
controlSnapshot := &coredata.ControlSnapshot{}
if err := controlSnapshot.Delete(ctx, tx, s.svc.scope, control.ID, snapshot.ID); err != nil {
return fmt.Errorf("cannot delete control snapshot mapping: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, fmt.Errorf("cannot delete control snapshot mapping: %w", err)
}
return control, snapshot, nil
}
func (s ControlService) ListForSnapshotID(
ctx context.Context,
snapshotID gid.GID,
cursor *page.Cursor[coredata.ControlOrderField],
filter *coredata.ControlFilter,
) (*page.Page[*coredata.Control, coredata.ControlOrderField], error) {
var controls coredata.Controls
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
if err := controls.LoadBySnapshotID(ctx, conn, s.svc.scope, snapshotID, cursor, filter); err != nil {
return fmt.Errorf("cannot load controls: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage([]*coredata.Control(controls), cursor), nil
}
func (s ControlService) CountForStatementOfApplicabilityID(
ctx context.Context,
statementOfApplicabilityID gid.GID,

View File

@@ -83,8 +83,6 @@ func (s *GeneratedDocumentService) PublishStatementOfApplicability(
}
}
hasApprovers := len(approverIDs) > 0
if existingDoc == nil {
documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
@@ -111,16 +109,11 @@ func (s *GeneratedDocumentService) PublishStatementOfApplicability(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
if hasApprovers {
if len(approverIDs) > 0 {
versionStatus = coredata.DocumentVersionStatusDraft
} else {
publishedAt = &now
@@ -144,41 +137,7 @@ func (s *GeneratedDocumentService) PublishStatementOfApplicability(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, soa.OrganizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, soa.OrganizationID, approverIDs, newMajor, now)
},
)
@@ -415,12 +374,7 @@ func (s *GeneratedDocumentService) PublishDataList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -435,7 +389,7 @@ func (s *GeneratedDocumentService) PublishDataList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Data List",
Title: "Data",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -448,41 +402,7 @@ func (s *GeneratedDocumentService) PublishDataList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -524,7 +444,7 @@ func (s *GeneratedDocumentService) buildDataListDocumentData(
if len(data) == 0 {
return docgen.DataListData{
Title: "Data List",
Title: "Data",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalData: 0,
@@ -581,7 +501,7 @@ func (s *GeneratedDocumentService) buildDataListDocumentData(
}
return docgen.DataListData{
Title: "Data List",
Title: "Data",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalData: len(data),
@@ -705,12 +625,7 @@ func (s *GeneratedDocumentService) PublishAssetList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -725,7 +640,7 @@ func (s *GeneratedDocumentService) PublishAssetList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Asset List",
Title: "Assets",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -738,41 +653,7 @@ func (s *GeneratedDocumentService) PublishAssetList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -814,7 +695,7 @@ func (s *GeneratedDocumentService) buildAssetListDocumentData(
if len(assets) == 0 {
return docgen.AssetListData{
Title: "Asset List",
Title: "Assets",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalAssets: 0,
@@ -873,7 +754,7 @@ func (s *GeneratedDocumentService) buildAssetListDocumentData(
}
return docgen.AssetListData{
Title: "Asset List",
Title: "Assets",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalAssets: len(assets),
@@ -1016,12 +897,7 @@ func (s *GeneratedDocumentService) PublishFindingList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -1036,7 +912,7 @@ func (s *GeneratedDocumentService) PublishFindingList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Finding List",
Title: "Findings",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -1049,41 +925,7 @@ func (s *GeneratedDocumentService) PublishFindingList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -1125,7 +967,7 @@ func (s *GeneratedDocumentService) buildFindingListDocumentData(
if len(findings) == 0 {
return docgen.FindingListData{
Title: "Finding List",
Title: "Findings",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalFindings: 0,
@@ -1216,7 +1058,7 @@ func (s *GeneratedDocumentService) buildFindingListDocumentData(
}
return docgen.FindingListData{
Title: "Finding List",
Title: "Findings",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalFindings: len(findings),
@@ -1372,12 +1214,7 @@ func (s *GeneratedDocumentService) PublishObligationList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -1392,7 +1229,7 @@ func (s *GeneratedDocumentService) PublishObligationList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Obligation List",
Title: "Obligations",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -1405,41 +1242,7 @@ func (s *GeneratedDocumentService) PublishObligationList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -1481,7 +1284,7 @@ func (s *GeneratedDocumentService) buildObligationListDocumentData(
if len(obligations) == 0 {
return docgen.ObligationListData{
Title: "Obligation List",
Title: "Obligations",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalObligations: 0,
@@ -1563,7 +1366,7 @@ func (s *GeneratedDocumentService) buildObligationListDocumentData(
}
return docgen.ObligationListData{
Title: "Obligation List",
Title: "Obligations",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalObligations: len(obligations),
@@ -1696,12 +1499,7 @@ func (s *GeneratedDocumentService) PublishProcessingActivityList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -1729,41 +1527,7 @@ func (s *GeneratedDocumentService) PublishProcessingActivityList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -2115,12 +1879,7 @@ func (s *GeneratedDocumentService) PublishDataProtectionImpactAssessmentList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -2148,41 +1907,7 @@ func (s *GeneratedDocumentService) PublishDataProtectionImpactAssessmentList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -2379,12 +2104,7 @@ func (s *GeneratedDocumentService) PublishTransferImpactAssessmentList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -2412,41 +2132,7 @@ func (s *GeneratedDocumentService) PublishTransferImpactAssessmentList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -2656,12 +2342,7 @@ func (s *GeneratedDocumentService) PublishVendorList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -2689,42 +2370,7 @@ func (s *GeneratedDocumentService) PublishVendorList(
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
zero := 0
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = &zero
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -3096,3 +2742,350 @@ func BuildVendorListDocument(data docgen.VendorListData) (string, error) {
}
return buf.String(), nil
}
var riskListTemplate = template.Must(
template.New("risk_list.json.tmpl").
Funcs(template.FuncMap{
"json": func(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
"printf": fmt.Sprintf,
"add": func(a, b int) int { return a + b },
}).
ParseFS(Templates, "templates/risk_list.json.tmpl"),
)
func BuildRiskListDocument(data docgen.RiskListData) (string, error) {
var buf bytes.Buffer
if err := riskListTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute risk list template: %w", err)
}
return buf.String(), nil
}
func (s *GeneratedDocumentService) PublishRiskList(
ctx context.Context,
organizationID gid.GID,
approverIDs []gid.GID,
) (*coredata.Document, *coredata.DocumentVersion, error) {
var (
document *coredata.Document
documentVersion *coredata.DocumentVersion
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
documentData, err := s.buildRiskListDocumentData(ctx, tx, organization)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
prosemirrorJSON, err := BuildRiskListDocument(documentData)
if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err)
}
now := time.Now()
risk := coredata.Risk{}
riskDocumentID, err := risk.GetGeneratedDocumentID(ctx, tx, organizationID)
if err != nil {
return fmt.Errorf("cannot query generated documents: %w", err)
}
var existingDoc *coredata.Document
if riskDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, s.svc.scope, *riskDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load risk list document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
if err := risk.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*riskDocumentID}); err != nil {
return fmt.Errorf("cannot clear document reference: %w", err)
}
}
}
hasApprovers := len(approverIDs) > 0
if existingDoc == nil {
documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
document = &coredata.Document{
ID: documentID,
OrganizationID: organizationID,
WriteMode: coredata.DocumentWriteModeGenerated,
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
Status: coredata.DocumentStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
if err := document.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
if err := risk.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
return fmt.Errorf("cannot upsert generated documents: %w", err)
}
} else {
document = existingDoc
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
if hasApprovers {
versionStatus = coredata.DocumentVersionStatusDraft
} else {
publishedAt = &now
}
documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
documentVersion = &coredata.DocumentVersion{
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Risks",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
Status: versionStatus,
Classification: coredata.DocumentClassificationConfidential,
DocumentType: coredata.DocumentTypeRegister,
Orientation: coredata.DocumentVersionOrientationPortrait,
PublishedAt: publishedAt,
CreatedAt: now,
UpdatedAt: now,
}
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
if err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}
func (s *GeneratedDocumentService) GetRisksDocumentID(
ctx context.Context,
organizationID gid.GID,
) (*gid.GID, error) {
var riskDocumentID *gid.GID
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
risk := coredata.Risk{}
var err error
riskDocumentID, err = risk.GetGeneratedDocumentID(ctx, conn, organizationID)
return err
})
if err != nil {
return nil, fmt.Errorf("cannot get risk list document ID: %w", err)
}
return riskDocumentID, nil
}
func (s *GeneratedDocumentService) buildRiskListDocumentData(
ctx context.Context,
conn pg.Querier,
organization *coredata.Organization,
) (docgen.RiskListData, error) {
var risks coredata.Risks
if err := risks.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
return docgen.RiskListData{}, fmt.Errorf("cannot load risks: %w", err)
}
if len(risks) == 0 {
return docgen.RiskListData{
Title: "Risks",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalRisks: 0,
}, nil
}
ownerIDs := make([]gid.GID, 0, len(risks))
ownerIDSet := make(map[gid.GID]struct{})
for _, r := range risks {
if r.OwnerID != nil {
if _, ok := ownerIDSet[*r.OwnerID]; !ok {
ownerIDs = append(ownerIDs, *r.OwnerID)
ownerIDSet[*r.OwnerID] = struct{}{}
}
}
}
profileMap := make(map[gid.GID]*coredata.MembershipProfile)
if len(ownerIDs) > 0 {
var profiles coredata.MembershipProfiles
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
return docgen.RiskListData{}, fmt.Errorf("cannot load profiles: %w", err)
}
for _, p := range profiles {
profileMap[p.ID] = p
}
}
rows := make([]docgen.RiskListRow, 0, len(risks))
for _, r := range risks {
rows = append(rows, docgen.RiskListRow{
Name: r.Name,
Description: derefStringOrNotSpecified(r.Description),
Category: stringOrNotSpecified(r.Category),
Treatment: formatRiskTreatment(r.Treatment),
Owner: lookupProfileName(profileMap, r.OwnerID),
InherentLikelihood: r.InherentLikelihood,
InherentLikelihoodLabel: riskLikelihoodLabel(r.InherentLikelihood),
InherentImpact: r.InherentImpact,
InherentImpactLabel: riskImpactLabel(r.InherentImpact),
InherentRiskScore: r.InherentRiskScore,
InherentSeverity: riskSeverityLabel(r.InherentRiskScore),
ResidualLikelihood: r.ResidualLikelihood,
ResidualLikelihoodLabel: riskLikelihoodLabel(r.ResidualLikelihood),
ResidualImpact: r.ResidualImpact,
ResidualImpactLabel: riskImpactLabel(r.ResidualImpact),
ResidualRiskScore: r.ResidualRiskScore,
ResidualSeverity: riskSeverityLabel(r.ResidualRiskScore),
Note: stringOrNotSpecified(r.Note),
})
}
return docgen.RiskListData{
Title: "Risks",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalRisks: len(risks),
Rows: rows,
}, nil
}
func riskLikelihoodLabel(v int) string {
switch v {
case 1:
return "Improbable"
case 2:
return "Remote"
case 3:
return "Occasional"
case 4:
return "Probable"
case 5:
return "Frequent"
default:
return "Unknown"
}
}
func riskImpactLabel(v int) string {
switch v {
case 1:
return "Negligible"
case 2:
return "Low"
case 3:
return "Moderate"
case 4:
return "Significant"
case 5:
return "Catastrophic"
default:
return "Unknown"
}
}
func riskSeverityLabel(score int) string {
switch {
case score >= 15:
return "Critical"
case score >= 5:
return "High"
default:
return "Low"
}
}
func formatRiskTreatment(t coredata.RiskTreatment) string {
switch t {
case coredata.RiskTreatmentMitigated:
return "Mitigated"
case coredata.RiskTreatmentAccepted:
return "Accepted"
case coredata.RiskTreatmentAvoided:
return "Avoided"
case coredata.RiskTreatmentTransferred:
return "Transferred"
default:
return stringOrNotSpecified(string(t))
}
}
// nextDocumentMajor returns the major version to use for a new published
// version of a generated document.
func nextDocumentMajor(doc *coredata.Document) int {
if doc.CurrentPublishedMajor != nil {
return *doc.CurrentPublishedMajor + 1
}
return 1
}
// publishOrRequestApproval inserts a freshly built generated document version
// and either requests approval (if approverIDs is non-empty) or marks the
// document as currently published at newMajor.0. The pending-approval insert
// conflict is mapped to a friendlier error.
func (s *GeneratedDocumentService) publishOrRequestApproval(
ctx context.Context,
tx pg.Tx,
document *coredata.Document,
version *coredata.DocumentVersion,
organizationID gid.GID,
approverIDs []gid.GID,
newMajor int,
now time.Time,
) error {
if err := version.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if len(approverIDs) > 0 {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
if _, err := s.svc.DocumentApprovals.RequestApprovalInTx(ctx, tx, document, version, approverIDs, nil); err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
return nil
}
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = new(0)
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
return nil
}

View File

@@ -74,7 +74,6 @@ var ViewerPolicy = policy.NewPolicy(
ActionProcessingActivityGet, ActionProcessingActivityList,
ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentList,
ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList,
ActionSnapshotGet, ActionSnapshotList,
ActionFileGet, ActionFileDownloadUrl,
ActionSlackConnectionList, ActionConnectorList,
ActionRightsRequestGet, ActionRightsRequestList,
@@ -152,7 +151,6 @@ var AuditorPolicy = policy.NewPolicy(
ActionProcessingActivityGet, ActionProcessingActivityList,
ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentList,
ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList,
ActionSnapshotGet, ActionSnapshotList,
ActionFileGet, ActionFileDownloadUrl,
ActionStatementOfApplicabilityGet, ActionStatementOfApplicabilityList,
ActionApplicabilityStatementGet, ActionApplicabilityStatementList,

View File

@@ -115,7 +115,6 @@ type (
ComplianceExternalURLs *ComplianceExternalURLService
Findings *FindingService
Obligations *ObligationService
Snapshots *SnapshotService
RightsRequests *RightsRequestService
ProcessingActivities *ProcessingActivityService
DataProtectionImpactAssessments *DataProtectionImpactAssessmentService
@@ -278,7 +277,6 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
}
tenantService.Findings = &FindingService{svc: tenantService}
tenantService.Obligations = &ObligationService{svc: tenantService}
tenantService.Snapshots = &SnapshotService{svc: tenantService}
tenantService.RightsRequests = &RightsRequestService{svc: tenantService}
tenantService.ProcessingActivities = &ProcessingActivityService{
svc: tenantService,

View File

@@ -1,221 +0,0 @@
// Copyright (c) 2025-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 probo
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type SnapshotService struct {
svc *TenantService
}
type CreateSnapshotRequest struct {
OrganizationID gid.GID
Name string
Description *string
Type coredata.SnapshotsType
}
func (csr *CreateSnapshotRequest) Validate() error {
v := validator.New()
v.Check(csr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(csr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(csr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(csr.Type, "type", validator.Required(), validator.OneOfSlice(coredata.SnapshotsTypes()))
return v.Error()
}
func (s *SnapshotService) Get(
ctx context.Context,
snapshotID gid.GID,
) (*coredata.Snapshot, error) {
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return snapshot.LoadByID(ctx, conn, s.svc.scope, snapshotID)
},
)
if err != nil {
return nil, err
}
return snapshot, nil
}
func (s *SnapshotService) Create(
ctx context.Context,
req *CreateSnapshotRequest,
) (*coredata.Snapshot, error) {
now := time.Now()
snapshot := &coredata.Snapshot{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.SnapshotEntityType),
OrganizationID: req.OrganizationID,
Name: req.Name,
Description: req.Description,
Type: req.Type,
CreatedAt: now,
}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
if err := snapshot.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert snapshot: %w", err)
}
snapshottable, err := coredata.GetSnapshottable(req.Type)
if err != nil {
return err
}
if err := snapshottable.Snapshot(ctx, conn, s.svc.scope, req.OrganizationID, snapshot.ID); err != nil {
return fmt.Errorf("cannot create snapshot: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return snapshot, nil
}
func (s *SnapshotService) Delete(
ctx context.Context,
snapshotID gid.GID,
) error {
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
snapshot := &coredata.Snapshot{}
if err := snapshot.LoadByID(ctx, tx, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
if err := snapshot.Delete(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete snapshot: %w", err)
}
return nil
},
)
return err
}
func (s *SnapshotService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.SnapshotOrderField],
) (*page.Page[*coredata.Snapshot, coredata.SnapshotOrderField], error) {
snapshots := coredata.Snapshots{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := snapshots.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor); err != nil {
return fmt.Errorf("cannot load snapshots: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(snapshots, cursor), nil
}
func (s *SnapshotService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
snapshots := coredata.Snapshots{}
filter := coredata.NewSnapshotFilter(nil)
count, err = snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot count snapshots: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *SnapshotService) ListForControlID(
ctx context.Context,
controlID gid.GID,
cursor *page.Cursor[coredata.SnapshotOrderField],
) (*page.Page[*coredata.Snapshot, coredata.SnapshotOrderField], error) {
var snapshots coredata.Snapshots
control := &coredata.Control{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := control.LoadByID(ctx, conn, s.svc.scope, controlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
err := snapshots.LoadByControlID(ctx, conn, s.svc.scope, control.ID, cursor)
if err != nil {
return fmt.Errorf("cannot load snapshots: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(snapshots, cursor), nil
}

View File

@@ -0,0 +1,178 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "1. Purpose" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "This document provides a comprehensive register of all risks identified within the organization. It captures each risk's classification, treatment, ownership, inherent and residual scoring (likelihood × impact), the related controls, measures, documents and obligations, plus any relevant notes." }]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "2. Risks" }]
}{{range $i, $r := .Rows}},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": {{json (printf "2.%d %s" (add $i 1) $r.Name)}} }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.1 General Information" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Description: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Description}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Category: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Category}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Owner: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Owner}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Treatment: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Treatment}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.2 Inherent Risk" (add $i 1))}} }]
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Likelihood", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Impact", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Score", "marks": [{ "type": "bold" }] }] }] }
]
},
{
"type": "tableRow",
"content": [
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.InherentLikelihood $r.InherentLikelihoodLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.InherentImpact $r.InherentImpactLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.InherentRiskScore $r.InherentSeverity)}} }] }] }
]
}
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.3 Residual Risk" (add $i 1))}} }]
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Likelihood", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Impact", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Score", "marks": [{ "type": "bold" }] }] }] }
]
},
{
"type": "tableRow",
"content": [
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.ResidualLikelihood $r.ResidualLikelihoodLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.ResidualImpact $r.ResidualImpactLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.ResidualRiskScore $r.ResidualSeverity)}} }] }] }
]
}
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.4 Notes" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": {{json $r.Note}} }]
},
{ "type": "horizontalRule" }{{end}},
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "3. Definitions" }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Treatment" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Mitigated: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Controls are in place to reduce the likelihood and/or impact of the risk." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Accepted: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The risk is acknowledged and accepted without further action." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Avoided: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Activities giving rise to the risk are avoided altogether." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Transferred: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The risk is shifted to a third party (e.g., insurance, vendor contract)." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Likelihood" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "1 — Improbable: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Highly unlikely to occur under normal circumstances." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "2 — Remote: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Unlikely but possible." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "3 — Occasional: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Could occur from time to time." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "4 — Probable: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Likely to occur in most circumstances." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "5 — Frequent: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Expected to occur regularly." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Impact" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "1 — Negligible: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Minimal disruption; absorbed by routine operations." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "2 — Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Limited disruption; localized impact." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "3 — Moderate: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Notable disruption to operations or finances." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "4 — Significant: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Major disruption; prolonged recovery effort needed." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "5 — Catastrophic: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Severe organization-wide impact threatening continuity." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Risk Score" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Risk Score is the product of Likelihood × Impact (range 1–25). Inherent values represent the risk before controls; residual values represent the risk after controls have been applied. Severity bands: Low (≤4), High (5–14), Critical (≥15)." }]
}
]
}

View File

@@ -239,15 +239,6 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
return types.NewTransferImpactAssessment(tia), nil
}
case coredata.SnapshotEntityType:
action = probo.ActionSnapshotList
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
snapshot, err := prb.Snapshots.Get(ctx, id)
if err != nil {
return nil, err
}
return types.NewSnapshot(snapshot), nil
}
case coredata.TrustCenterEntityType:
action = probo.ActionTrustCenterGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {

View File

@@ -300,37 +300,6 @@ func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, f
return types.NewObligationConnection(page, r, obj.ID), nil
}
// Snapshots is the resolver for the snapshots field.
func (r *controlResolver) Snapshots(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SnapshotOrderBy) (*types.SnapshotConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionSnapshotList); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := prb.Snapshots.ListForControlID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list control snapshots", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewSnapshotConnection(page, r, obj.ID), nil
}
// Permission is the resolver for the permission field.
func (r *controlResolver) Permission(ctx context.Context, obj *types.Control, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -708,46 +677,6 @@ func (r *mutationResolver) DeleteControlObligationMapping(ctx context.Context, i
}, nil
}
// CreateControlSnapshotMapping is the resolver for the createControlSnapshotMapping field.
func (r *mutationResolver) CreateControlSnapshotMapping(ctx context.Context, input types.CreateControlSnapshotMappingInput) (*types.CreateControlSnapshotMappingPayload, error) {
if err := r.authorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingCreate); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.SnapshotID.TenantID())
control, snapshot, err := prb.Controls.CreateSnapshotMapping(ctx, input.ControlID, input.SnapshotID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create control snapshot mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateControlSnapshotMappingPayload{
ControlEdge: types.NewControlEdge(control, coredata.ControlOrderFieldCreatedAt),
SnapshotEdge: types.NewSnapshotEdge(snapshot, coredata.SnapshotOrderFieldCreatedAt),
}, nil
}
// DeleteControlSnapshotMapping is the resolver for the deleteControlSnapshotMapping field.
func (r *mutationResolver) DeleteControlSnapshotMapping(ctx context.Context, input types.DeleteControlSnapshotMappingInput) (*types.DeleteControlSnapshotMappingPayload, error) {
if err := r.authorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingDelete); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.SnapshotID.TenantID())
control, snapshot, err := prb.Controls.DeleteSnapshotMapping(ctx, input.ControlID, input.SnapshotID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot delete control snapshot mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteControlSnapshotMappingPayload{
DeletedControlID: control.ID,
DeletedSnapshotID: snapshot.ID,
}, nil
}
// CreateStatementOfApplicability is the resolver for the createStatementOfApplicability field.
func (r *mutationResolver) CreateStatementOfApplicability(ctx context.Context, input types.CreateStatementOfApplicabilityInput) (*types.CreateStatementOfApplicabilityPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionStatementOfApplicabilityCreate); err != nil {

View File

@@ -141,14 +141,6 @@ type Control implements Node {
orderBy: ObligationOrder
): ObligationConnection! @goField(forceResolver: true)
snapshots(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: SnapshotOrder
): SnapshotConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -265,12 +257,6 @@ extend type Mutation {
deleteControlObligationMapping(
input: DeleteControlObligationMappingInput!
): DeleteControlObligationMappingPayload!
createControlSnapshotMapping(
input: CreateControlSnapshotMappingInput!
): CreateControlSnapshotMappingPayload!
deleteControlSnapshotMapping(
input: DeleteControlSnapshotMappingInput!
): DeleteControlSnapshotMappingPayload!
createStatementOfApplicability(
input: CreateStatementOfApplicabilityInput!
): CreateStatementOfApplicabilityPayload!
@@ -366,16 +352,6 @@ input DeleteControlObligationMappingInput {
obligationId: ID!
}
input CreateControlSnapshotMappingInput {
controlId: ID!
snapshotId: ID!
}
input DeleteControlSnapshotMappingInput {
controlId: ID!
snapshotId: ID!
}
input CreateStatementOfApplicabilityInput {
organizationId: ID!
name: String!
@@ -465,16 +441,6 @@ type DeleteControlObligationMappingPayload {
deletedObligationId: ID!
}
type CreateControlSnapshotMappingPayload {
controlEdge: ControlEdge!
snapshotEdge: SnapshotEdge!
}
type DeleteControlSnapshotMappingPayload {
deletedControlId: ID!
deletedSnapshotId: ID!
}
type CreateStatementOfApplicabilityPayload {
statementOfApplicabilityEdge: StatementOfApplicabilityEdge!
}

View File

@@ -286,16 +286,10 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: RiskOrder
filter: RiskFilter = { snapshotId: null }
filter: RiskFilter
): RiskConnection! @goField(forceResolver: true)
snapshots(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: SnapshotOrder
): SnapshotConnection! @goField(forceResolver: true)
risksDocument: Document @goField(forceResolver: true)
tasks(
first: Int

View File

@@ -53,12 +53,10 @@ input RiskOrder
input RiskFilter {
query: String
snapshotId: ID
}
type Risk implements Node {
id: ID!
snapshotId: ID
name: String!
description: String
category: String!
@@ -151,6 +149,19 @@ extend type Mutation {
deleteRiskObligationMapping(
input: DeleteRiskObligationMappingInput!
): DeleteRiskObligationMappingPayload!
publishRiskList(
input: PublishRiskListInput!
): PublishRiskListPayload!
}
input PublishRiskListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishRiskListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input CreateRiskInput {

View File

@@ -1,83 +0,0 @@
enum SnapshotsType
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotsType") {
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
}
enum SnapshotOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotOrderField") {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotOrderFieldCreatedAt"
)
NAME
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotOrderFieldName")
TYPE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotOrderFieldType")
}
input SnapshotOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SnapshotOrderBy"
) {
direction: OrderDirection!
field: SnapshotOrderField!
}
type Snapshot implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
name: String!
description: String
type: SnapshotsType!
controls(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ControlOrder
filter: ControlFilter
): ControlConnection! @goField(forceResolver: true)
createdAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type SnapshotConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SnapshotConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [SnapshotEdge!]!
pageInfo: PageInfo!
}
type SnapshotEdge {
cursor: CursorKey!
node: Snapshot!
}
extend type Mutation {
createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload!
deleteSnapshot(input: DeleteSnapshotInput!): DeleteSnapshotPayload!
}
input CreateSnapshotInput {
organizationId: ID!
name: String!
description: String
type: SnapshotsType!
}
input DeleteSnapshotInput {
snapshotId: ID!
}
type CreateSnapshotPayload {
snapshotEdge: SnapshotEdge!
}
type DeleteSnapshotPayload {
deletedSnapshotId: ID!
}

View File

@@ -101,9 +101,9 @@ func (r *measureResolver) Risks(ctx context.Context, obj *types.Measure, first *
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var riskFilter = coredata.NewRiskFilter(nil, nil)
var riskFilter = coredata.NewRiskFilter(nil)
if filter != nil {
riskFilter = coredata.NewRiskFilter(filter.Query, &filter.SnapshotID)
riskFilter = coredata.NewRiskFilter(filter.Query)
}
page, err := prb.Risks.ListForMeasureID(ctx, obj.ID, cursor, riskFilter)

View File

@@ -1027,9 +1027,9 @@ func (r *organizationResolver) Risks(ctx context.Context, obj *types.Organizatio
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var riskFilter = coredata.NewRiskFilter(nil, nil)
var riskFilter = coredata.NewRiskFilter(nil)
if filter != nil {
riskFilter = coredata.NewRiskFilter(filter.Query, &filter.SnapshotID)
riskFilter = coredata.NewRiskFilter(filter.Query)
}
page, err := prb.Risks.ListForOrganizationID(ctx, obj.ID, cursor, riskFilter)
@@ -1041,34 +1041,33 @@ func (r *organizationResolver) Risks(ctx context.Context, obj *types.Organizatio
return types.NewRiskConnection(page, r, obj.ID, riskFilter), nil
}
// Snapshots is the resolver for the snapshots field.
func (r *organizationResolver) Snapshots(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SnapshotOrderBy) (*types.SnapshotConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionSnapshotList); err != nil {
// RisksDocument is the resolver for the risksDocument field.
func (r *organizationResolver) RisksDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := prb.Snapshots.ListForOrganizationID(ctx, obj.ID, cursor)
documentID, err := prb.GeneratedDocuments.GetRisksDocumentID(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization snapshots", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot get risks document ID", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if documentID == nil {
return nil, nil
}
document, err := prb.Documents.Get(ctx, *documentID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load risks document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewSnapshotConnection(page, r, obj.ID), nil
return types.NewDocument(document), nil
}
// Tasks is the resolver for the tasks field.

View File

@@ -239,6 +239,29 @@ func (r *mutationResolver) DeleteRiskObligationMapping(ctx context.Context, inpu
}, nil
}
// PublishRiskList is the resolver for the publishRiskList field.
func (r *mutationResolver) PublishRiskList(ctx context.Context, input types.PublishRiskListInput) (*types.PublishRiskListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionRiskPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishRiskList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot publish risk list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishRiskListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Owner is the resolver for the owner field.
func (r *riskResolver) Owner(ctx context.Context, obj *types.Risk) (*types.Profile, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil {

View File

@@ -1,165 +0,0 @@
package console_v1
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.87
import (
"context"
"errors"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
// CreateSnapshot is the resolver for the createSnapshot field.
func (r *mutationResolver) CreateSnapshot(ctx context.Context, input types.CreateSnapshotInput) (*types.CreateSnapshotPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionSnapshotCreate); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
snapshot, err := prb.Snapshots.Create(
ctx,
&probo.CreateSnapshotRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
Type: input.Type,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create snapshot", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateSnapshotPayload{
SnapshotEdge: types.NewSnapshotEdge(snapshot, coredata.SnapshotOrderFieldCreatedAt),
}, nil
}
// DeleteSnapshot is the resolver for the deleteSnapshot field.
func (r *mutationResolver) DeleteSnapshot(ctx context.Context, input types.DeleteSnapshotInput) (*types.DeleteSnapshotPayload, error) {
if err := r.authorize(ctx, input.SnapshotID, probo.ActionSnapshotDelete); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.SnapshotID.TenantID())
err := prb.Snapshots.Delete(ctx, input.SnapshotID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot delete snapshot", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteSnapshotPayload{
DeletedSnapshotID: input.SnapshotID,
}, nil
}
// Organization is the resolver for the organization field.
func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
snapshot, err := prb.Snapshots.Get(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get snapshot", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
organization, err := prb.Organizations.Get(ctx, snapshot.OrganizationID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get organization", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewOrganization(organization), nil
}
// Controls is the resolver for the controls field.
func (r *snapshotResolver) Controls(ctx context.Context, obj *types.Snapshot, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ControlOrderBy, filter *types.ControlFilter) (*types.ControlConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionControlList); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.ControlOrderField]{
Field: coredata.ControlOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.ControlOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var controlFilter = coredata.NewControlFilter(nil)
if filter != nil {
controlFilter = coredata.NewControlFilter(filter.Query)
}
page, err := prb.Controls.ListForSnapshotID(ctx, obj.ID, cursor, controlFilter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list snapshot controls", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewControlConnection(page, r, obj.ID, controlFilter), nil
}
// Permission is the resolver for the permission field.
func (r *snapshotResolver) Permission(ctx context.Context, obj *types.Snapshot, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// TotalCount is the resolver for the totalCount field.
func (r *snapshotConnectionResolver) TotalCount(ctx context.Context, obj *types.SnapshotConnection) (int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionSnapshotList); err != nil {
return 0, err
}
prb := r.ProboService(ctx, obj.ParentID.TenantID())
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := prb.Snapshots.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count snapshots", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
r.logger.ErrorCtx(ctx, "unsupported resolver")
return 0, gqlutils.Internal(ctx)
}
// Snapshot returns schema.SnapshotResolver implementation.
func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} }
// SnapshotConnection returns schema.SnapshotConnectionResolver implementation.
func (r *Resolver) SnapshotConnection() schema.SnapshotConnectionResolver {
return &snapshotConnectionResolver{r}
}
type snapshotResolver struct{ *Resolver }
type snapshotConnectionResolver struct{ *Resolver }

View File

@@ -67,7 +67,6 @@ func NewRisk(r *coredata.Risk) *Risk {
risk := &Risk{
ID: r.ID,
Name: r.Name,
SnapshotID: r.SnapshotID,
Description: r.Description,
Treatment: r.Treatment,
InherentLikelihood: r.InherentLikelihood,

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2025-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 (
SnapshotOrderBy OrderBy[coredata.SnapshotOrderField]
SnapshotConnection struct {
TotalCount int
Edges []*SnapshotEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewSnapshotConnection(
p *page.Page[*coredata.Snapshot, coredata.SnapshotOrderField],
parentType any,
parentID gid.GID,
) *SnapshotConnection {
edges := make([]*SnapshotEdge, len(p.Data))
for i, snapshot := range p.Data {
edges[i] = NewSnapshotEdge(snapshot, p.Cursor.OrderBy.Field)
}
return &SnapshotConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewSnapshotEdge(s *coredata.Snapshot, orderField coredata.SnapshotOrderField) *SnapshotEdge {
return &SnapshotEdge{
Node: NewSnapshot(s),
Cursor: s.CursorKey(orderField),
}
}
func NewSnapshot(s *coredata.Snapshot) *Snapshot {
return &Snapshot{
ID: s.ID,
Organization: &Organization{
ID: s.OrganizationID,
},
Name: s.Name,
Type: s.Type,
Description: s.Description,
CreatedAt: s.CreatedAt,
}
}

View File

@@ -274,10 +274,9 @@ func (r *Resolver) ListRisksTool(ctx context.Context, req *mcp.CallToolRequest,
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
riskFilter := coredata.NewRiskFilter(nil, &noSnapshot)
riskFilter := coredata.NewRiskFilter(nil)
if input.Filter != nil {
riskFilter = coredata.NewRiskFilter(input.Filter.Query, &input.Filter.SnapshotID)
riskFilter = coredata.NewRiskFilter(input.Filter.Query)
}
page, err := prb.Risks.ListForOrganizationID(ctx, input.OrganizationID, cursor, riskFilter)
@@ -1511,11 +1510,6 @@ func (r *Resolver) LinkControlTool(ctx context.Context, req *mcp.CallToolRequest
if _, _, err := svc.Controls.CreateAuditMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.LinkControlOutput{}, fmt.Errorf("failed to link control to audit: %w", err)
}
case coredata.SnapshotEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingCreate)
if _, _, err := svc.Controls.CreateSnapshotMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.LinkControlOutput{}, fmt.Errorf("failed to link control to snapshot: %w", err)
}
case coredata.ObligationEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlObligationMappingCreate)
if _, _, err := svc.Controls.CreateObligationMapping(ctx, input.ControlID, input.ResourceID); err != nil {
@@ -1547,11 +1541,6 @@ func (r *Resolver) UnlinkControlTool(ctx context.Context, req *mcp.CallToolReque
if _, _, err := svc.Controls.DeleteAuditMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.UnlinkControlOutput{}, fmt.Errorf("failed to unlink control from audit: %w", err)
}
case coredata.SnapshotEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingDelete)
if _, _, err := svc.Controls.DeleteSnapshotMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.UnlinkControlOutput{}, fmt.Errorf("failed to unlink control from snapshot: %w", err)
}
case coredata.ObligationEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlObligationMappingDelete)
if _, _, err := svc.Controls.DeleteObligationMapping(ctx, input.ControlID, input.ResourceID); err != nil {
@@ -1668,32 +1657,6 @@ func (r *Resolver) ListControlAuditsTool(ctx context.Context, req *mcp.CallToolR
return nil, types.NewListControlAuditsOutput(auditPage), nil
}
func (r *Resolver) ListControlSnapshotsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlSnapshotsInput) (*mcp.CallToolResult, types.ListControlSnapshotsOutput, error) {
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlGet)
prb := r.ProboService(ctx, input.ControlID)
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
}
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
snapshotPage, err := prb.Snapshots.ListForControlID(ctx, input.ControlID, cursor)
if err != nil {
return nil, types.ListControlSnapshotsOutput{}, fmt.Errorf("failed to list control snapshots: %w", err)
}
return nil, types.NewListControlSnapshotsOutput(snapshotPage), nil
}
func (r *Resolver) ListRiskObligationsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRiskObligationsInput) (*mcp.CallToolResult, types.ListRiskObligationsOutput, error) {
r.MustAuthorize(ctx, input.RiskID, probo.ActionRiskGet)
@@ -1915,68 +1878,6 @@ func (r *Resolver) DeleteTaskTool(ctx context.Context, req *mcp.CallToolRequest,
}, nil
}
func (r *Resolver) ListSnapshotsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListSnapshotsInput) (*mcp.CallToolResult, types.ListSnapshotsOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionSnapshotList)
prb := r.ProboService(ctx, input.OrganizationID)
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
}
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
page, err := prb.Snapshots.ListForOrganizationID(ctx, input.OrganizationID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization snapshots: %w", err))
}
return nil, types.NewListSnapshotsOutput(page), nil
}
func (r *Resolver) GetSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetSnapshotInput) (*mcp.CallToolResult, types.GetSnapshotOutput, error) {
r.MustAuthorize(ctx, input.ID, probo.ActionSnapshotGet)
prb := r.ProboService(ctx, input.ID)
snapshot, err := prb.Snapshots.Get(ctx, input.ID)
if err != nil {
return nil, types.GetSnapshotOutput{}, fmt.Errorf("failed to get snapshot: %w", err)
}
return nil, types.GetSnapshotOutput{
Snapshot: types.NewSnapshot(snapshot),
}, nil
}
func (r *Resolver) TakeSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.TakeSnapshotInput) (*mcp.CallToolResult, types.TakeSnapshotOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionSnapshotCreate)
prb := r.ProboService(ctx, input.OrganizationID)
snapshot, err := prb.Snapshots.Create(
ctx,
&probo.CreateSnapshotRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
Type: input.Type,
},
)
if err != nil {
return nil, types.TakeSnapshotOutput{}, fmt.Errorf("failed to take snapshot: %w", err)
}
return nil, types.TakeSnapshotOutput{
Snapshot: types.NewSnapshot(snapshot),
}, nil
}
func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDocumentsInput) (*mcp.CallToolResult, types.ListDocumentsOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionDocumentList)
@@ -2314,7 +2215,7 @@ func (r *Resolver) ListMeasureRisksTool(ctx context.Context, req *mcp.CallToolRe
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
riskPage, err := prb.Risks.ListForMeasureID(ctx, input.MeasureID, cursor, coredata.NewRiskFilter(nil, nil))
riskPage, err := prb.Risks.ListForMeasureID(ctx, input.MeasureID, cursor, coredata.NewRiskFilter(nil))
if err != nil {
return nil, types.ListMeasureRisksOutput{}, fmt.Errorf("failed to list measure risks: %w", err)
}
@@ -5190,3 +5091,19 @@ func (r *Resolver) GetCookieConsentRecordTool(ctx context.Context, req *mcp.Call
}
return nil, types.GetCookieConsentRecordOutput{CookieConsentRecord: types.NewCookieConsentRecord(record)}, nil
}
func (r *Resolver) PublishRiskListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishRiskListInput) (*mcp.CallToolResult, types.PublishRiskListOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionRiskPublish)
svc := r.ProboService(ctx, input.OrganizationID)
document, documentVersion, err := svc.GeneratedDocuments.PublishRiskList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
return nil, types.PublishRiskListOutput{}, fmt.Errorf("cannot publish risk list: %w", err)
}
return nil, types.PublishRiskListOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -1499,13 +1499,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
snapshot_id:
anyOf:
- type: string
$ref: "#/components/schemas/GID"
- type: "null"
description: No snapshot
description: Snapshot ID
name:
type: string
description: Risk name
@@ -1579,12 +1572,6 @@ components:
query:
type: string
description: Search query
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Filter by snapshot ID. Defaults to null, which returns only risks with no snapshot (current live data). Pass a specific snapshot ID to retrieve risks as they were at that snapshot.
default: null
ListRisksOutput:
type: object
@@ -4772,7 +4759,7 @@ components:
description: Control ID
resource_id:
$ref: "#/components/schemas/GID"
description: ID of the resource to link (measure, document, audit, snapshot, or obligation)
description: ID of the resource to link (measure, document, audit, or obligation)
LinkControlOutput:
type: object
@@ -4788,7 +4775,7 @@ components:
description: Control ID
resource_id:
$ref: "#/components/schemas/GID"
description: ID of the resource to unlink (measure, document, audit, snapshot, or obligation)
description: ID of the resource to unlink (measure, document, audit, or obligation)
UnlinkControlOutput:
type: object
@@ -4917,37 +4904,6 @@ components:
items:
$ref: "#/components/schemas/Audit"
ListControlSnapshotsInput:
type: object
required:
- control_id
properties:
control_id:
$ref: "#/components/schemas/GID"
description: Control ID
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
size:
type: integer
description: Page size
order_by:
$ref: "#/components/schemas/SnapshotOrderBy"
description: Snapshot order by
ListControlSnapshotsOutput:
type: object
required:
- snapshots
properties:
next_cursor:
$ref: "#/components/schemas/CursorKey"
description: Next cursor
snapshots:
type: array
items:
$ref: "#/components/schemas/Snapshot"
ListRiskObligationsInput:
type: object
required:
@@ -5354,150 +5310,6 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted task ID
SnapshotsType:
type: string
enum:
- RISKS
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
- PROCESSING_ACTIVITIES
- STATEMENTS_OF_APPLICABILITY
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.SnapshotsType
SnapshotOrderField:
type: string
enum:
- CREATED_AT
- NAME
- TYPE
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.SnapshotOrderField
SnapshotOrderBy:
type: object
required:
- field
- direction
properties:
field:
$ref: "#/components/schemas/SnapshotOrderField"
description: Snapshot order field
direction:
$ref: "#/components/schemas/OrderDirection"
description: Snapshot order direction
Snapshot:
type: object
required:
- id
- organization_id
- name
- type
- created_at
properties:
id:
$ref: "#/components/schemas/GID"
description: Snapshot ID
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
name:
type: string
description: Snapshot name
description:
anyOf:
- type: string
description: Snapshot description
- type: "null"
description: No description
description: Snapshot description
type:
$ref: "#/components/schemas/SnapshotsType"
description: Snapshot type
created_at:
type: string
format: date-time
description: Creation timestamp
ListSnapshotsInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
order_by:
$ref: "#/components/schemas/SnapshotOrderBy"
description: Snapshot order by
size:
type: integer
description: Page size
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
ListSnapshotsOutput:
type: object
required:
- snapshots
properties:
snapshots:
type: array
items:
$ref: "#/components/schemas/Snapshot"
description: List of snapshots
next_cursor:
anyOf:
- $ref: "#/components/schemas/CursorKey"
- type: "null"
description: Next page cursor
GetSnapshotInput:
type: object
required:
- id
properties:
id:
$ref: "#/components/schemas/GID"
description: Snapshot ID
GetSnapshotOutput:
type: object
required:
- snapshot
properties:
snapshot:
$ref: "#/components/schemas/Snapshot"
TakeSnapshotInput:
type: object
required:
- organization_id
- name
- type
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
name:
type: string
description: Snapshot name
description:
type: string
description: Snapshot description
type:
$ref: "#/components/schemas/SnapshotsType"
description: Snapshot type (determines which collection to snapshot)
TakeSnapshotOutput:
type: object
required:
- snapshot
properties:
snapshot:
$ref: "#/components/schemas/Snapshot"
DocumentType:
type: string
enum:
@@ -6815,16 +6627,6 @@ components:
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
filter:
type: object
properties:
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Filter by snapshot ID. Defaults to null, which returns only statements of applicability with no snapshot (current live data). Pass a specific snapshot ID to retrieve statements of applicability as they were at that snapshot.
default: null
ListStatementsOfApplicabilityOutput:
type: object
required:
@@ -7131,6 +6933,33 @@ components:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishRiskListInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately.
PublishRiskListOutput:
type: object
required:
- document_id
- document_version_id
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Created or updated document ID
document_version_id:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishStatementOfApplicabilityInput:
type: object
required:
@@ -7201,11 +7030,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Snapshot ID
applicability:
type: boolean
description: Whether the control is applicable
@@ -10842,7 +10666,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/UpdateControlOutput"
- name: linkControl
description: Link a resource to a control (measure, document, audit, snapshot, or obligation). The resource type is determined from the resource_id GID.
description: Link a resource to a control (measure, document, audit, or obligation). The resource type is determined from the resource_id GID.
hints:
readonly: false
inputSchema:
@@ -10850,7 +10674,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/LinkControlOutput"
- name: unlinkControl
description: Unlink a resource from a control (measure, document, audit, snapshot, or obligation). The resource type is determined from the resource_id GID.
description: Unlink a resource from a control (measure, document, audit, or obligation). The resource type is determined from the resource_id GID.
hints:
readonly: false
inputSchema:
@@ -10893,15 +10717,6 @@ tools:
$ref: "#/components/schemas/ListControlAuditsInput"
outputSchema:
$ref: "#/components/schemas/ListControlAuditsOutput"
- name: listControlSnapshots
description: List snapshots linked to a control
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/ListControlSnapshotsInput"
outputSchema:
$ref: "#/components/schemas/ListControlSnapshotsOutput"
- name: listRiskObligations
description: List obligations linked to a risk
hints:
@@ -10986,32 +10801,6 @@ tools:
$ref: "#/components/schemas/DeleteTaskInput"
outputSchema:
$ref: "#/components/schemas/DeleteTaskOutput"
- name: listSnapshots
description: List all snapshots for the organization
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/ListSnapshotsInput"
outputSchema:
$ref: "#/components/schemas/ListSnapshotsOutput"
- name: getSnapshot
description: Get a snapshot by ID
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/GetSnapshotInput"
outputSchema:
$ref: "#/components/schemas/GetSnapshotOutput"
- name: takeSnapshot
description: Take a snapshot of a collection of objects (risks, vendors, findings, obligations, or processing activities)
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/TakeSnapshotInput"
outputSchema:
$ref: "#/components/schemas/TakeSnapshotOutput"
- name: listDocuments
description: List documents for the organization. By default only ACTIVE documents are returned; pass status filter to include ARCHIVED.
hints:
@@ -11281,6 +11070,14 @@ tools:
$ref: "#/components/schemas/PublishVendorListInput"
outputSchema:
$ref: "#/components/schemas/PublishVendorListOutput"
- name: publishRiskList
description: Publish the risk register for an organization as a document. If a document already exists, a new version is created.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishRiskListInput"
outputSchema:
$ref: "#/components/schemas/PublishRiskListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:

View File

@@ -35,7 +35,6 @@ func NewRisk(r *coredata.Risk) *Risk {
ResidualLikelihood: r.ResidualLikelihood,
ResidualImpact: r.ResidualImpact,
ResidualRiskScore: r.ResidualRiskScore,
SnapshotID: r.SnapshotID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}

View File

@@ -1,66 +0,0 @@
// Copyright (c) 2025-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
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
)
func NewSnapshot(s *coredata.Snapshot) *Snapshot {
return &Snapshot{
ID: s.ID,
OrganizationID: s.OrganizationID,
Name: s.Name,
Type: s.Type,
Description: s.Description,
CreatedAt: s.CreatedAt,
}
}
func NewListControlSnapshotsOutput(snapshotPage *page.Page[*coredata.Snapshot, coredata.SnapshotOrderField]) ListControlSnapshotsOutput {
snapshots := make([]*Snapshot, 0, len(snapshotPage.Data))
for _, s := range snapshotPage.Data {
snapshots = append(snapshots, NewSnapshot(s))
}
var nextCursor *page.CursorKey
if len(snapshotPage.Data) > 0 {
cursorKey := snapshotPage.Data[len(snapshotPage.Data)-1].CursorKey(snapshotPage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListControlSnapshotsOutput{
NextCursor: nextCursor,
Snapshots: snapshots,
}
}
func NewListSnapshotsOutput(snapshotPage *page.Page[*coredata.Snapshot, coredata.SnapshotOrderField]) ListSnapshotsOutput {
snapshots := make([]*Snapshot, 0, len(snapshotPage.Data))
for _, s := range snapshotPage.Data {
snapshots = append(snapshots, NewSnapshot(s))
}
var nextCursor *page.CursorKey
if len(snapshotPage.Data) > 0 {
cursorKey := snapshotPage.Data[len(snapshotPage.Data)-1].CursorKey(snapshotPage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListSnapshotsOutput{
NextCursor: nextCursor,
Snapshots: snapshots,
}
}

View File

@@ -51,7 +51,6 @@ func NewApplicabilityStatement(a *coredata.ApplicabilityStatement) *Applicabilit
StatementOfApplicabilityID: a.StatementOfApplicabilityID,
ControlID: a.ControlID,
OrganizationID: a.OrganizationID,
SnapshotID: a.SnapshotID,
Applicability: a.Applicability,
Justification: a.Justification,
CreatedAt: a.CreatedAt,