Add async third-party vetting

Queue vetting on third_parties with PENDING, PROCESSING,
COMPLETED, and FAILED states. Expose enqueue and status through
GraphQL, MCP, CLI, and n8n, validate vet requests, tune the
worker via config, and poll the detail page while vetting runs.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-27 13:34:49 +02:00
parent 1a71d15bc5
commit 6e7c96732f
59 changed files with 3251 additions and 520 deletions

View File

@@ -1,3 +1,23 @@
enum ThirdPartyVettingStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatus") {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusPending"
)
PROCESSING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusProcessing"
)
COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusCompleted"
)
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusFailed"
)
}
enum ThirdPartyCategory
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyCategory") {
ANALYTICS
@@ -293,6 +313,8 @@ type ThirdParty implements Node {
orderBy: ThirdPartyOrder
): ThirdPartyConnection! @goField(forceResolver: true)
vettingStatus: ThirdPartyVettingStatus @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -480,7 +502,7 @@ extend type Mutation {
createThirdPartyRiskAssessment(
input: CreateThirdPartyRiskAssessmentInput!
): CreateThirdPartyRiskAssessmentPayload!
assessThirdParty(input: AssessThirdPartyInput!): AssessThirdPartyPayload!
vetThirdParty(input: VetThirdPartyInput!): VetThirdPartyPayload!
publishThirdPartyList(
input: PublishThirdPartyListInput!
): PublishThirdPartyListPayload!
@@ -652,18 +674,12 @@ input CreateThirdPartyRiskAssessmentInput {
notes: String
}
input AssessThirdPartyInput {
input VetThirdPartyInput {
id: ID!
websiteUrl: String!
procedure: String
}
type ThirdPartySubprocessor {
name: String!
country: String!
purpose: String!
}
type CreateThirdPartyPayload {
thirdPartyEdge: ThirdPartyEdge!
}
@@ -736,10 +752,8 @@ type CreateThirdPartyRiskAssessmentPayload {
thirdPartyRiskAssessmentEdge: ThirdPartyRiskAssessmentEdge!
}
type AssessThirdPartyPayload {
type VetThirdPartyPayload {
thirdParty: ThirdParty!
report: String!
subprocessors: [ThirdPartySubprocessor!]!
}
input CreateThirdPartyThirdPartyMappingInput {

View File

@@ -22,6 +22,7 @@ import (
"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"
"go.probo.inc/probo/pkg/thirdparty"
"go.probo.inc/probo/pkg/validator"
)
@@ -536,35 +537,45 @@ func (r *mutationResolver) CreateThirdPartyRiskAssessment(ctx context.Context, i
}, nil
}
// AssessThirdParty is the resolver for the assessThirdParty field.
func (r *mutationResolver) AssessThirdParty(ctx context.Context, input types.AssessThirdPartyInput) (*types.AssessThirdPartyPayload, error) {
scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyAssess)
// VetThirdParty is the resolver for the vetThirdParty field.
func (r *mutationResolver) VetThirdParty(ctx context.Context, input types.VetThirdPartyInput) (*types.VetThirdPartyPayload, error) {
scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyVet)
if err != nil {
return nil, err
}
result, err := r.probo.ThirdParties.Assess(
thirdParty, err := r.thirdParty.Vet(
ctx, scope,
probo.AssessThirdPartyRequest{
thirdparty.VetRequest{
ID: input.ID,
WebsiteURL: input.WebsiteURL,
Procedure: input.Procedure,
},
)
if err != nil {
if errors.Is(err, probo.ErrThirdPartyAssessmentDisabled) {
return nil, gqlutils.Unavailable(ctx, probo.ErrThirdPartyAssessmentDisabled)
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot assess thirdParty", log.Error(err))
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
if errors.Is(err, thirdparty.ErrVettingDisabled) {
return nil, gqlutils.Unavailable(ctx, thirdparty.ErrVettingDisabled)
}
if errors.Is(err, thirdparty.ErrVettingInProgress) {
return nil, gqlutils.Conflict(ctx, thirdparty.ErrVettingInProgress)
}
r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AssessThirdPartyPayload{
ThirdParty: types.NewThirdParty(result.ThirdParty),
Report: result.Report,
Subprocessors: types.NewThirdPartySubprocessors(result.Subprocessors),
return &types.VetThirdPartyPayload{
ThirdParty: types.NewThirdParty(thirdParty),
}, nil
}
@@ -931,6 +942,22 @@ func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.T
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
}
// VettingStatus is the resolver for the vettingStatus field.
func (r *thirdPartyResolver) VettingStatus(ctx context.Context, obj *types.ThirdParty) (*coredata.ThirdPartyVettingStatus, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet)
if err != nil {
return nil, err
}
status, err := r.thirdParty.VettingStatus(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get vetting status", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return status, nil
}
// Permission is the resolver for the permission field.
func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)

View File

@@ -18,7 +18,6 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
)
type (
@@ -107,16 +106,3 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
return object
}
func NewThirdPartySubprocessors(sps []probo.Subprocessor) []*ThirdPartySubprocessor {
result := make([]*ThirdPartySubprocessor, len(sps))
for i, sp := range sps {
result[i] = &ThirdPartySubprocessor{
Name: sp.Name,
Country: sp.Country,
Purpose: sp.Purpose,
}
}
return result
}