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:
@@ -212,6 +212,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
mcpHandler: mcp_v1.NewMux(
|
||||
cfg.Logger.Named("mcp.v1"),
|
||||
cfg.Probo,
|
||||
cfg.ThirdParty,
|
||||
cfg.IAM,
|
||||
cfg.AccessReview,
|
||||
cfg.CookieBanner,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,10 +32,12 @@ import (
|
||||
"go.probo.inc/probo/pkg/prosemirror"
|
||||
"go.probo.inc/probo/pkg/riskmanagement"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/thirdparty"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
proboSvc *probo.Service
|
||||
thirdPartySvc *thirdparty.Service
|
||||
iamSvc *iam.Service
|
||||
accessReview *accessreview.Service
|
||||
cookieBanner *cookiebanner.Service
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview"
|
||||
"go.probo.inc/probo/pkg/cookiebanner"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
@@ -23,6 +24,8 @@ import (
|
||||
"go.probo.inc/probo/pkg/riskmanagement"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/types"
|
||||
"go.probo.inc/probo/pkg/thirdparty"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
// ListOrganizationsTool handles the listOrganizations tool
|
||||
@@ -5296,27 +5299,47 @@ func (r *Resolver) DeleteCustomDomainTool(ctx context.Context, req *mcp.CallTool
|
||||
return nil, types.DeleteCustomDomainOutput{DeletedCustomDomain: deletedDomain}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) AssessThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AssessThirdPartyInput) (*mcp.CallToolResult, types.AssessThirdPartyOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyAssess)
|
||||
func (r *Resolver) VetThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.VetThirdPartyInput) (*mcp.CallToolResult, types.VetThirdPartyOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyVet)
|
||||
if err != nil {
|
||||
return nil, types.AssessThirdPartyOutput{}, err
|
||||
return nil, types.VetThirdPartyOutput{}, err
|
||||
}
|
||||
|
||||
svc := r.proboSvc
|
||||
svc := r.thirdPartySvc
|
||||
|
||||
result, err := svc.ThirdParties.Assess(
|
||||
thirdParty, err := svc.Vet(
|
||||
ctx, scope,
|
||||
probo.AssessThirdPartyRequest{
|
||||
thirdparty.VetRequest{
|
||||
ID: input.ID,
|
||||
WebsiteURL: input.WebsiteURL,
|
||||
Procedure: input.Procedure,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, types.AssessThirdPartyOutput{}, fmt.Errorf("cannot assess thirdParty: %w", err)
|
||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return nil, types.VetThirdPartyOutput{}, validationErrors
|
||||
}
|
||||
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("resource not found")
|
||||
}
|
||||
|
||||
if errors.Is(err, thirdparty.ErrVettingDisabled) {
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is not configured")
|
||||
}
|
||||
|
||||
if errors.Is(err, thirdparty.ErrVettingInProgress) {
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is already in progress")
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err))
|
||||
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("internal server error")
|
||||
}
|
||||
|
||||
return nil, types.NewAssessThirdPartyOutput(result), nil
|
||||
return nil, types.VetThirdPartyOutput{
|
||||
ThirdParty: types.NewThirdParty(thirdParty),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) PublishFindingListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishFindingListInput) (*mcp.CallToolResult, types.PublishFindingListOutput, error) {
|
||||
|
||||
@@ -1480,7 +1480,7 @@ components:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Deleted thirdParty service ID
|
||||
|
||||
AssessThirdPartyInput:
|
||||
VetThirdPartyInput:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
@@ -1488,48 +1488,21 @@ components:
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: ThirdParty ID to assess
|
||||
description: ThirdParty ID to vet
|
||||
website_url:
|
||||
type: string
|
||||
description: ThirdParty website URL to crawl and assess
|
||||
description: ThirdParty website URL to crawl and vet
|
||||
procedure:
|
||||
type: string
|
||||
description: Optional custom assessment procedure (overrides the default)
|
||||
description: Optional custom vetting procedure (overrides the default)
|
||||
|
||||
ThirdPartySubprocessor:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- country
|
||||
- purpose
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Sub-processor name
|
||||
country:
|
||||
type: string
|
||||
description: Country where the sub-processor operates
|
||||
purpose:
|
||||
type: string
|
||||
description: Purpose of the sub-processor
|
||||
|
||||
AssessThirdPartyOutput:
|
||||
VetThirdPartyOutput:
|
||||
type: object
|
||||
required:
|
||||
- thirdParty
|
||||
- report
|
||||
- subprocessors
|
||||
properties:
|
||||
thirdParty:
|
||||
$ref: "#/components/schemas/ThirdParty"
|
||||
report:
|
||||
type: string
|
||||
description: Markdown-formatted thirdParty assessment report
|
||||
subprocessors:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ThirdPartySubprocessor"
|
||||
description: Sub-processors discovered during the assessment
|
||||
|
||||
GetUserInput:
|
||||
type: object
|
||||
@@ -12096,14 +12069,14 @@ tools:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyServiceInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyServiceOutput"
|
||||
- name: assessThirdParty
|
||||
description: Run an AI-powered assessment on a thirdParty by crawling its website. Returns a markdown report, the discovered sub-processors, and an enriched thirdParty record. Long-running (up to 20 minutes).
|
||||
- name: vetThirdParty
|
||||
description: Start AI-powered vetting of a third party by crawling its website. Returns immediately; vetting runs in the background.
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/AssessThirdPartyInput"
|
||||
$ref: "#/components/schemas/VetThirdPartyInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/AssessThirdPartyOutput"
|
||||
$ref: "#/components/schemas/VetThirdPartyOutput"
|
||||
- name: listRisks
|
||||
description: List all risks for the organization
|
||||
hints:
|
||||
|
||||
@@ -17,7 +17,6 @@ package types
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
)
|
||||
|
||||
func NewThirdPartyRiskAssessment(v *coredata.ThirdPartyRiskAssessment) *ThirdPartyRiskAssessment {
|
||||
@@ -229,24 +228,3 @@ func NewListThirdPartyServicesOutput(p *page.Page[*coredata.ThirdPartyService, c
|
||||
ThirdPartyServices: services,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func NewAssessThirdPartyOutput(result *probo.AssessThirdPartyResult) AssessThirdPartyOutput {
|
||||
return AssessThirdPartyOutput{
|
||||
ThirdParty: NewThirdParty(result.ThirdParty),
|
||||
Report: result.Report,
|
||||
Subprocessors: NewThirdPartySubprocessors(result.Subprocessors),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,26 @@ import (
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/mcputils"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/server"
|
||||
"go.probo.inc/probo/pkg/thirdparty"
|
||||
)
|
||||
|
||||
func NewMux(logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service, accessReviewSvc *accessreview.Service, cookieBannerSvc *cookiebanner.Service, riskManagementSvc *riskmanagement.Service, tokenSecret string) *chi.Mux {
|
||||
func NewMux(
|
||||
logger *log.Logger,
|
||||
proboSvc *probo.Service,
|
||||
thirdPartySvc *thirdparty.Service,
|
||||
iamSvc *iam.Service,
|
||||
accessReviewSvc *accessreview.Service,
|
||||
cookieBannerSvc *cookiebanner.Service,
|
||||
riskManagementSvc *riskmanagement.Service,
|
||||
tokenSecret string,
|
||||
) *chi.Mux {
|
||||
logger = logger.Named("mcp.v1")
|
||||
|
||||
logger.Info("initializing MCP server")
|
||||
|
||||
resolver := &Resolver{
|
||||
proboSvc: proboSvc,
|
||||
thirdPartySvc: thirdPartySvc,
|
||||
iamSvc: iamSvc,
|
||||
accessReview: accessReviewSvc,
|
||||
cookieBanner: cookieBannerSvc,
|
||||
|
||||
Reference in New Issue
Block a user