Add finding and obligation publish to document system

Replace the old snapshot-based approach with the new publish document
system for findings and obligations. Includes GraphQL mutations, MCP
tools, CLI commands, e2e tests, frontend publish dialogs, and
snapshot-to-document migration tools.

Remove snapshot mode entirely from findings and obligations: drop
snapshotId from GraphQL schemas, filters, resolvers, MCP spec, frontend
routes, pages, and helpers. The snapshot_id column remains in the
database but is now filtered out with snapshot_id IS NULL.

Remove auditor's ability to publish SoA.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-23 10:53:57 +02:00
parent e473884b31
commit bdb16d4abe
57 changed files with 4241 additions and 609 deletions

View File

@@ -181,10 +181,7 @@ func (r *auditResolver) Findings(ctx context.Context, obj *types.Audit, first *i
ownerID = filter.OwnerID
}
findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
if filter != nil {
findingFilter = coredata.NewFindingFilter(&filter.SnapshotID, kind, status, priority, ownerID)
}
findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
p, err := prb.Findings.ListForAuditID(ctx, obj.ID, cursor, findingFilter)
if err != nil {
@@ -363,10 +360,7 @@ func (r *findingConnectionResolver) TotalCount(ctx context.Context, obj *types.F
ownerID = obj.Filter.OwnerID
}
findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
if obj.Filter != nil {
findingFilter = coredata.NewFindingFilter(&obj.Filter.SnapshotID, kind, status, priority, ownerID)
}
findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
switch obj.Resolver.(type) {
case *organizationResolver:
@@ -677,6 +671,29 @@ func (r *mutationResolver) DeleteFindingAuditMapping(ctx context.Context, input
}, nil
}
// PublishFindingList is the resolver for the publishFindingList field.
func (r *mutationResolver) PublishFindingList(ctx context.Context, input types.PublishFindingListInput) (*types.PublishFindingListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionFindingPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishFindingList(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 finding list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishFindingListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// DownloadURL is the resolver for the downloadUrl field.
func (r *reportResolver) DownloadURL(ctx context.Context, obj *types.Report) (*string, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionReportDownloadUrlGet); err != nil {

View File

@@ -13,7 +13,6 @@ import (
"github.com/vikstrous/dataloadgen"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
@@ -272,7 +271,7 @@ func (r *controlResolver) Audits(ctx context.Context, obj *types.Control, first
}
// Obligations is the resolver for the obligations field.
func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy, filter *types.ObligationFilter) (*types.ObligationConnection, error) {
func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy) (*types.ObligationConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
return nil, err
}
@@ -292,18 +291,13 @@ func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, f
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var snapshotID **gid.GID
if filter != nil {
snapshotID = &filter.SnapshotID
}
obligationFilter := coredata.NewObligationFilter(snapshotID)
page, err := prb.Obligations.ListForControlID(ctx, obj.ID, cursor, obligationFilter)
page, err := prb.Obligations.ListForControlID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list control obligations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewObligationConnection(page, r, obj.ID, filter), nil
return types.NewObligationConnection(page, r, obj.ID), nil
}
// Snapshots is the resolver for the snapshots field.

View File

@@ -138,7 +138,6 @@ input FindingOrder {
}
input FindingFilter {
snapshotId: ID
kind: FindingKind
status: FindingStatus
priority: FindingPriority
@@ -171,7 +170,7 @@ type Audit implements Node {
last: Int
before: CursorKey
orderBy: FindingOrder
filter: FindingFilter = { snapshotId: null }
filter: FindingFilter
): FindingConnection @goField(forceResolver: true)
trustCenterVisibility: TrustCenterVisibility!
@@ -183,7 +182,6 @@ type Audit implements Node {
type Finding implements Node {
id: ID!
snapshotId: ID
organization: Organization @goField(forceResolver: true)
kind: FindingKind!
referenceId: String!
@@ -270,6 +268,19 @@ extend type Mutation {
deleteFindingAuditMapping(
input: DeleteFindingAuditMappingInput!
): DeleteFindingAuditMappingPayload
publishFindingList(
input: PublishFindingListInput!
): PublishFindingListPayload!
}
input PublishFindingListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishFindingListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input CreateAuditInput {

View File

@@ -139,7 +139,6 @@ type Control implements Node {
last: Int
before: CursorKey
orderBy: ObligationOrder
filter: ObligationFilter
): ObligationConnection! @goField(forceResolver: true)
snapshots(

View File

@@ -51,14 +51,8 @@ input ObligationOrder
field: ObligationOrderField!
}
input ObligationFilter {
snapshotId: ID
}
type Obligation implements Node {
id: ID!
snapshotId: ID
sourceId: ID
organization: Organization! @goField(forceResolver: true)
area: String
source: String
@@ -94,6 +88,19 @@ extend type Mutation {
createObligation(input: CreateObligationInput!): CreateObligationPayload!
updateObligation(input: UpdateObligationInput!): UpdateObligationPayload!
deleteObligation(input: DeleteObligationInput!): DeleteObligationPayload!
publishObligationList(
input: PublishObligationListInput!
): PublishObligationListPayload!
}
input PublishObligationListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishObligationListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input CreateObligationInput {

View File

@@ -150,13 +150,15 @@ type Organization implements Node {
orderBy: AuditOrder
): AuditConnection! @goField(forceResolver: true)
findingsDocument: Document @goField(forceResolver: true)
findings(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: FindingOrder
filter: FindingFilter = { snapshotId: null }
filter: FindingFilter
): FindingConnection @goField(forceResolver: true)
auditLogEntries(
@@ -247,13 +249,14 @@ type Organization implements Node {
filter: MeasureFilter
): MeasureConnection! @goField(forceResolver: true)
obligationsDocument: Document @goField(forceResolver: true)
obligations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ObligationOrder
filter: ObligationFilter = { snapshotId: null }
): ObligationConnection! @goField(forceResolver: true)
processingActivities(

View File

@@ -107,7 +107,6 @@ type Risk implements Node {
last: Int
before: CursorKey
orderBy: ObligationOrder
filter: ObligationFilter
): ObligationConnection! @goField(forceResolver: true)
createdAt: Datetime!

View File

@@ -3,14 +3,6 @@ enum SnapshotsType
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
VENDORS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
FINDINGS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"
)
OBLIGATIONS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeObligations"
)
PROCESSING_ACTIVITIES
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeProcessingActivities"

View File

@@ -112,6 +112,29 @@ func (r *mutationResolver) DeleteObligation(ctx context.Context, input types.Del
}, nil
}
// PublishObligationList is the resolver for the publishObligationList field.
func (r *mutationResolver) PublishObligationList(ctx context.Context, input types.PublishObligationListInput) (*types.PublishObligationListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionObligationPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishObligationList(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 obligation list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishObligationListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Organization is the resolver for the organization field.
func (r *obligationResolver) Organization(ctx context.Context, obj *types.Obligation) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
@@ -169,24 +192,14 @@ func (r *obligationConnectionResolver) TotalCount(ctx context.Context, obj *type
switch obj.Resolver.(type) {
case *organizationResolver:
obligationFilter := coredata.NewObligationFilter(nil)
if obj.Filter != nil {
obligationFilter = coredata.NewObligationFilter(&obj.Filter.SnapshotID)
}
count, err := prb.Obligations.CountForOrganizationID(ctx, obj.ParentID, obligationFilter)
count, err := prb.Obligations.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count obligations", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *riskResolver:
obligationFilter := coredata.NewObligationFilter(nil)
if obj.Filter != nil {
obligationFilter = coredata.NewObligationFilter(&obj.Filter.SnapshotID)
}
count, err := prb.Obligations.CountForRiskID(ctx, obj.ParentID, obligationFilter)
count, err := prb.Obligations.CountForRiskID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count risk obligations", log.Error(err))
return 0, gqlutils.Internal(ctx)

View File

@@ -362,6 +362,30 @@ func (r *organizationResolver) Audits(ctx context.Context, obj *types.Organizati
return types.NewAuditConnection(page, r, obj.ID), nil
}
// FindingsDocument is the resolver for the findingsDocument field.
func (r *organizationResolver) FindingsDocument(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())
findingDocumentID, err := prb.GeneratedDocuments.GetFindingsDocumentID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot get finding list document ID: %w", err)
}
if findingDocumentID == nil {
return nil, nil
}
doc, err := prb.Documents.Get(ctx, *findingDocumentID)
if err != nil {
return nil, fmt.Errorf("cannot get finding list document: %w", err)
}
return types.NewDocument(doc), nil
}
// Findings is the resolver for the findings field.
func (r *organizationResolver) Findings(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.FindingOrder, filter *types.FindingFilter) (*types.FindingConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionFindingList); err != nil {
@@ -397,10 +421,7 @@ func (r *organizationResolver) Findings(ctx context.Context, obj *types.Organiza
ownerID = filter.OwnerID
}
findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
if filter != nil {
findingFilter = coredata.NewFindingFilter(&filter.SnapshotID, kind, status, priority, ownerID)
}
findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
page, err := prb.Findings.ListForOrganizationID(ctx, obj.ID, cursor, findingFilter)
if err != nil {
@@ -791,8 +812,32 @@ func (r *organizationResolver) Measures(ctx context.Context, obj *types.Organiza
return types.NewMeasureConnection(page, r, obj.ID, measureFilter), nil
}
// ObligationsDocument is the resolver for the obligationsDocument field.
func (r *organizationResolver) ObligationsDocument(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())
obligationDocumentID, err := prb.GeneratedDocuments.GetObligationsDocumentID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot get obligation list document ID: %w", err)
}
if obligationDocumentID == nil {
return nil, nil
}
doc, err := prb.Documents.Get(ctx, *obligationDocumentID)
if err != nil {
return nil, fmt.Errorf("cannot get obligation list document: %w", err)
}
return types.NewDocument(doc), nil
}
// Obligations is the resolver for the obligations field.
func (r *organizationResolver) Obligations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy, filter *types.ObligationFilter) (*types.ObligationConnection, error) {
func (r *organizationResolver) Obligations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy) (*types.ObligationConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
return nil, err
}
@@ -813,18 +858,13 @@ func (r *organizationResolver) Obligations(ctx context.Context, obj *types.Organ
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
obligationFilter := coredata.NewObligationFilter(nil)
if filter != nil {
obligationFilter = coredata.NewObligationFilter(&filter.SnapshotID)
}
page, err := prb.Obligations.ListForOrganizationID(ctx, obj.ID, cursor, obligationFilter)
page, err := prb.Obligations.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization obligations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewObligationConnection(page, r, obj.ID, filter), nil
return types.NewObligationConnection(page, r, obj.ID), nil
}
// ProcessingActivities is the resolver for the processingActivities field.

View File

@@ -393,7 +393,7 @@ func (r *riskResolver) Controls(ctx context.Context, obj *types.Risk, first *int
}
// Obligations is the resolver for the obligations field.
func (r *riskResolver) Obligations(ctx context.Context, obj *types.Risk, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy, filter *types.ObligationFilter) (*types.ObligationConnection, error) {
func (r *riskResolver) Obligations(ctx context.Context, obj *types.Risk, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy) (*types.ObligationConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
return nil, err
}
@@ -413,18 +413,13 @@ func (r *riskResolver) Obligations(ctx context.Context, obj *types.Risk, first *
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var obligationFilter = coredata.NewObligationFilter(nil)
if filter != nil {
obligationFilter = coredata.NewObligationFilter(&filter.SnapshotID)
}
page, err := prb.Obligations.ListForRiskID(ctx, obj.ID, cursor, obligationFilter)
page, err := prb.Obligations.ListForRiskID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list risk obligations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewObligationConnection(page, r, obj.ID, filter), nil
return types.NewObligationConnection(page, r, obj.ID), nil
}
// Permission is the resolver for the permission field.

View File

@@ -67,8 +67,7 @@ func NewFindingEdge(f *coredata.Finding, orderField coredata.FindingOrderField)
func NewFinding(f *coredata.Finding) *Finding {
finding := &Finding{
ID: f.ID,
SnapshotID: f.SnapshotID,
ID: f.ID,
Organization: &Organization{
ID: f.OrganizationID,
},

View File

@@ -30,7 +30,6 @@ type (
Resolver any
ParentID gid.GID
Filter *ObligationFilter
}
)
@@ -38,7 +37,6 @@ func NewObligationConnection(
p *page.Page[*coredata.Obligation, coredata.ObligationOrderField],
parentType any,
parentID gid.GID,
filter *ObligationFilter,
) *ObligationConnection {
edges := make([]*ObligationEdge, len(p.Data))
for i, obligation := range p.Data {
@@ -51,15 +49,12 @@ func NewObligationConnection(
Resolver: parentType,
ParentID: parentID,
Filter: filter,
}
}
func NewObligation(cr *coredata.Obligation) *Obligation {
return &Obligation{
ID: cr.ID,
SnapshotID: cr.SnapshotID,
SourceID: cr.SourceID,
ID: cr.ID,
Organization: &Organization{
ID: cr.OrganizationID,
},