Add third-party self-referential relations

Introduce a self-referential many-to-many relation table so a
third party can have child third parties. Each relation is
directional (parent to child); both directions can coexist as
independent rows.

Add a first_level boolean on third_parties (default true) with
a filter on the list page that defaults to showing only
first-level third parties.

Frontend adds a "Third Parties" tab on the detail page where
users can link existing third parties or create new ones from
the common third party catalog (created as non-first-level).
The list page gets a First Level/All toggle filter.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-19 19:13:16 +02:00
parent 8ff68798fb
commit b6b1e801b1
37 changed files with 2399 additions and 45 deletions

View File

@@ -331,6 +331,7 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: ThirdPartyOrder
filter: ThirdPartyFilter
): ThirdPartyConnection! @goField(forceResolver: true)
thirdPartiesDocument: Document @goField(forceResolver: true)

View File

@@ -177,6 +177,10 @@ input ThirdPartyOrder
field: ThirdPartyOrderField!
}
input ThirdPartyFilter {
firstLevel: Boolean
}
input ThirdPartyComplianceReportOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyComplianceReportOrderBy"
@@ -269,6 +273,16 @@ type ThirdParty implements Node {
legalName: String
websiteUrl: String
showOnTrustCenter: Boolean!
firstLevel: Boolean!
childThirdParties(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ThirdPartyOrder
): ThirdPartyConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -460,6 +474,12 @@ extend type Mutation {
publishThirdPartyList(
input: PublishThirdPartyListInput!
): PublishThirdPartyListPayload!
createThirdPartyThirdPartyMapping(
input: CreateThirdPartyThirdPartyMappingInput!
): CreateThirdPartyThirdPartyMappingPayload!
deleteThirdPartyThirdPartyMapping(
input: DeleteThirdPartyThirdPartyMappingInput!
): DeleteThirdPartyThirdPartyMappingPayload!
}
input PublishThirdPartyListInput {
@@ -494,6 +514,7 @@ input CreateThirdPartyInput {
termsOfServiceUrl: String
businessOwnerId: ID
securityOwnerId: ID
firstLevel: Boolean
}
input UpdateThirdPartyInput {
@@ -518,6 +539,7 @@ input UpdateThirdPartyInput {
businessOwnerId: ID @goField(omittable: true)
securityOwnerId: ID @goField(omittable: true)
showOnTrustCenter: Boolean
firstLevel: Boolean
}
input DeleteThirdPartyInput {
@@ -709,3 +731,21 @@ type AssessThirdPartyPayload {
report: String!
subprocessors: [ThirdPartySubprocessor!]!
}
input CreateThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type CreateThirdPartyThirdPartyMappingPayload {
thirdPartyEdge: ThirdPartyEdge!
}
input DeleteThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type DeleteThirdPartyThirdPartyMappingPayload {
removedThirdPartyId: ID!
}

View File

@@ -1271,7 +1271,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org
}
// ThirdParties is the resolver for the thirdParties field.
func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy, filter *types.ThirdPartyFilter) (*types.ThirdPartyConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList)
if err != nil {
return nil, err
@@ -1291,7 +1291,12 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil)
var firstLevel *bool
if filter != nil {
firstLevel = filter.FirstLevel
}
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel)
page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
if err != nil {

View File

@@ -55,6 +55,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
BusinessOwnerID: input.BusinessOwnerID,
SecurityOwnerID: input.SecurityOwnerID,
Countries: input.Countries,
FirstLevel: input.FirstLevel,
},
)
if err != nil {
@@ -106,6 +107,7 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID),
SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID),
ShowOnTrustCenter: input.ShowOnTrustCenter,
FirstLevel: input.FirstLevel,
Countries: input.Countries,
},
)
@@ -594,6 +596,46 @@ func (r *mutationResolver) PublishThirdPartyList(ctx context.Context, input type
}, nil
}
// CreateThirdPartyThirdPartyMapping is the resolver for the linkThirdPartyThirdParty field.
func (r *mutationResolver) CreateThirdPartyThirdPartyMapping(ctx context.Context, input types.CreateThirdPartyThirdPartyMappingInput) (*types.CreateThirdPartyThirdPartyMappingPayload, error) {
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
if err != nil {
return nil, err
}
childThirdParty, err := r.probo.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot create third party mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateThirdPartyThirdPartyMappingPayload{
ThirdPartyEdge: types.NewThirdPartyEdge(childThirdParty, coredata.ThirdPartyOrderFieldName),
}, nil
}
// DeleteThirdPartyThirdPartyMapping is the resolver for the deleteThirdPartyThirdPartyMapping field.
func (r *mutationResolver) DeleteThirdPartyThirdPartyMapping(ctx context.Context, input types.DeleteThirdPartyThirdPartyMappingInput) (*types.DeleteThirdPartyThirdPartyMappingPayload, error) {
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
if err != nil {
return nil, err
}
if err := r.probo.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
r.logger.ErrorCtx(ctx, "cannot delete third party mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteThirdPartyThirdPartyMappingPayload{
RemovedThirdPartyID: input.ChildThirdPartyID,
}, nil
}
// Organization is the resolver for the organization field.
func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) {
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
@@ -830,6 +872,35 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third
return types.NewProfile(securityOwner), nil
}
// ChildThirdParties is the resolver for the childThirdParties field.
func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.ThirdParty, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyRelationList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
Field: coredata.ThirdPartyOrderFieldName,
Direction: page.OrderDirectionAsc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.probo.ThirdParties.ListForParentThirdPartyID(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list child third parties", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID), 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)
@@ -963,6 +1034,19 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *thirdPartyResolver:
if _, err := r.authorize(ctx, obj.ParentID, probo.ActionThirdPartyRelationList); err != nil {
return 0, err
}
count, err := r.probo.ThirdParties.CountForParentThirdPartyID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count child third parties", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
@@ -1145,3 +1229,15 @@ type thirdPartyContactResolver struct{ *Resolver }
type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver }
type thirdPartyRiskAssessmentResolver struct{ *Resolver }
type thirdPartyServiceResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
/*
func (r *mutationResolver) UncreateThirdPartyThirdPartyMapping(ctx context.Context, input types.UncreateThirdPartyThirdPartyMappingInput) (*types.UncreateThirdPartyThirdPartyMappingPayload, error) {
panic(fmt.Errorf("not implemented: UncreateThirdPartyThirdPartyMapping - uncreateThirdPartyThirdPartyMapping"))
}
*/

View File

@@ -84,6 +84,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
WebsiteURL: v.WebsiteURL,
Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter,
FirstLevel: v.FirstLevel,
Countries: v.Countries,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,

View File

@@ -70,7 +70,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel)
page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter)
if err != nil {
@@ -6148,6 +6148,59 @@ func (r *Resolver) MoveTrackerResourceToCategoryTool(ctx context.Context, req *m
return nil, types.MoveTrackerResourceToCategoryOutput{TrackerResource: types.NewTrackerResource(result.TrackerResource)}, nil
}
func (r *Resolver) CreateThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.CreateThirdPartyThirdPartyMappingOutput, error) {
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
if err != nil {
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, err
}
if _, err := r.proboSvc.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot create third party mapping: %w", err)
}
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, nil
}
func (r *Resolver) DeleteThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.DeleteThirdPartyThirdPartyMappingOutput, error) {
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
if err != nil {
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, err
}
if err := r.proboSvc.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot delete third party mapping: %w", err)
}
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, nil
}
func (r *Resolver) ListChildThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListChildThirdPartiesInput) (*mcp.CallToolResult, types.ListChildThirdPartiesOutput, error) {
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationList)
if err != nil {
return nil, types.ListChildThirdPartiesOutput{}, err
}
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
Field: coredata.ThirdPartyOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
}
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
page, err := r.proboSvc.ThirdParties.ListForParentThirdPartyID(ctx, scope, input.ParentThirdPartyID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list child third parties: %w", err))
}
return nil, types.NewListChildThirdPartiesOutput(page), nil
}
func (r *Resolver) ListRiskAssessmentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRiskAssessmentsInput) (*mcp.CallToolResult, types.ListRiskAssessmentsOutput, error) {
scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionRiskAssessmentList)
if err != nil {

View File

@@ -627,6 +627,9 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
first_level:
type: boolean
description: Filter by first-level status
order_by:
$ref: "#/components/schemas/ThirdPartyOrderBy"
description: ThirdParty order by
@@ -650,6 +653,69 @@ components:
items:
$ref: "#/components/schemas/ThirdParty"
CreateThirdPartyThirdPartyMappingInput:
type: object
required:
- parent_third_party_id
- child_third_party_id
properties:
parent_third_party_id:
$ref: "#/components/schemas/GID"
description: Parent third party ID
child_third_party_id:
$ref: "#/components/schemas/GID"
description: Child third party ID
CreateThirdPartyThirdPartyMappingOutput:
type: object
DeleteThirdPartyThirdPartyMappingInput:
type: object
required:
- parent_third_party_id
- child_third_party_id
properties:
parent_third_party_id:
$ref: "#/components/schemas/GID"
description: Parent third party ID
child_third_party_id:
$ref: "#/components/schemas/GID"
description: Child third party ID
DeleteThirdPartyThirdPartyMappingOutput:
type: object
ListChildThirdPartiesInput:
type: object
required:
- parent_third_party_id
properties:
parent_third_party_id:
$ref: "#/components/schemas/GID"
description: Parent third party ID
order_by:
$ref: "#/components/schemas/ThirdPartyOrderBy"
description: ThirdParty order by
size:
type: integer
description: Page size
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
ListChildThirdPartiesOutput:
type: object
required:
- thirdParties
properties:
next_cursor:
$ref: "#/components/schemas/CursorKey"
description: Next cursor
thirdParties:
type: array
items:
$ref: "#/components/schemas/ThirdParty"
ThirdParty:
type: object
required:
@@ -657,6 +723,7 @@ components:
- name
- organization_id
- category
- first_level
- created_at
- updated_at
properties:
@@ -780,6 +847,9 @@ components:
- string
- "null"
description: Trust page URL
first_level:
type: boolean
description: Whether this is a first-level third party
created_at:
type: string
format: date-time
@@ -11788,6 +11858,31 @@ tools:
$ref: "#/components/schemas/ListThirdPartiesInput"
outputSchema:
$ref: "#/components/schemas/ListThirdPartiesOutput"
- name: createThirdPartyThirdPartyMapping
description: Link a child third party to a parent third party
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingInput"
outputSchema:
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingOutput"
- name: deleteThirdPartyThirdPartyMapping
description: Unlink a child third party from a parent third party
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingInput"
outputSchema:
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingOutput"
- name: listChildThirdParties
description: List child third parties linked to a parent third party
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/ListChildThirdPartiesInput"
outputSchema:
$ref: "#/components/schemas/ListChildThirdPartiesOutput"
- name: listUsers
description: List all users for the organization
hints:

View File

@@ -87,6 +87,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
TermsOfServiceURL: v.TermsOfServiceURL,
SecurityPageURL: v.SecurityPageURL,
TrustPageURL: v.TrustPageURL,
FirstLevel: v.FirstLevel,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
@@ -111,6 +112,25 @@ func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, c
}
}
func NewListChildThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListChildThirdPartiesOutput {
thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data))
for _, v := range thirdPartyPage.Data {
thirdParties = append(thirdParties, NewThirdParty(v))
}
var nextCursor *page.CursorKey
if len(thirdPartyPage.Data) > 0 {
cursorKey := thirdPartyPage.Data[len(thirdPartyPage.Data)-1].CursorKey(thirdPartyPage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListChildThirdPartiesOutput{
NextCursor: nextCursor,
ThirdParties: thirdParties,
}
}
func NewAddThirdPartyOutput(v *coredata.ThirdParty) AddThirdPartyOutput {
return AddThirdPartyOutput{
ThirdParty: NewThirdParty(v),