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

@@ -24,6 +24,7 @@ type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
firstLevel?: boolean;
};
type Props<T extends FieldValues = FieldValues> = {
@@ -66,7 +67,7 @@ function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
const thirdParties = useThirdParties(organizationId);
const [isOpen, setIsOpen] = useState(false);
const allThirdParties = [...thirdParties];
const allThirdParties: ThirdParty[] = [...thirdParties];
if (props.disabled) {
selectedThirdParties.forEach((selectedThirdParty) => {
if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) {

View File

@@ -138,6 +138,7 @@ export const paginatedThirdPartiesFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
) {
thirdParties(
first: $first
@@ -145,7 +146,8 @@ export const paginatedThirdPartiesFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartiesListQuery_thirdParties") {
filter: $filter
) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) {
__id
edges {
node {
@@ -182,6 +184,7 @@ export const thirdPartyNodeQuery = graphql`
... on ThirdParty {
name
websiteUrl
firstLevel
canAssess: permission(action: "core:thirdParty:assess")
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
@@ -224,6 +227,7 @@ export const thirdPartiesSelectQuery = graphql`
id
name
websiteUrl
firstLevel
}
}
}

View File

@@ -26,12 +26,15 @@ import {
IconUpload,
PageHeader,
RiskBadge,
TabItem,
Tabs,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@probo/ui";
import { useState, useTransition } from "react";
import {
type PreloadedQuery,
usePaginationFragment,
@@ -76,9 +79,21 @@ export default function ThirdPartiesPage(props: Props) {
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__id;
const [, startTransition] = useTransition();
const [firstLevelFilter, setFirstLevelFilter] = useState<boolean | null>(true);
usePageTitle(__("Third parties"));
const handleFilterChange = (firstLevel: boolean | null) => {
setFirstLevelFilter(firstLevel);
startTransition(() => {
pagination.refetch(
{ filter: firstLevel !== null ? { firstLevel } : {} },
{ fetchPolicy: "store-and-network" },
);
});
};
const hasAnyAction
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
@@ -129,6 +144,20 @@ export default function ThirdPartiesPage(props: Props) {
)}
</div>
</PageHeader>
<Tabs>
<TabItem
active={firstLevelFilter === true}
onClick={() => handleFilterChange(true)}
>
{__("First Level")}
</TabItem>
<TabItem
active={firstLevelFilter === null}
onClick={() => handleFilterChange(null)}
>
{__("All")}
</TabItem>
</Tabs>
<SortableTable {...pagination}>
<Thead>
<Tr>

View File

@@ -16,6 +16,7 @@ import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Breadcrumb,
Button,
DropdownItem,
@@ -91,7 +92,12 @@ export default function ThirdPartyDetailPage(props: Props) {
className="shadow-mid rounded-2xl"
/>
)}
<div className="text-2xl">{thirdParty.name}</div>
<div className="flex items-center gap-3">
<div className="text-2xl">{thirdParty.name}</div>
<Badge variant={thirdParty.firstLevel ? "info" : "neutral"}>
{thirdParty.firstLevel ? __("First Level") : __("Indirect")}
</Badge>
</div>
</div>
<div className="flex gap-2 items-center">
{thirdParty.canAssess && (
@@ -127,6 +133,9 @@ export default function ThirdPartyDetailPage(props: Props) {
<TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/third-parties`}>
{__("Third Parties")}
</TabLink>
</Tabs>
<Outlet context={{ thirdParty }} />

View File

@@ -0,0 +1,187 @@
// 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.
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Combobox,
ComboboxItem,
Dialog,
DialogContent,
DialogFooter,
useDialogRef,
} from "@probo/ui";
import { type ReactNode, Suspense, useCallback, useState } from "react";
import { useMutation, useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type { AddChildThirdPartyDialogCreateMappingMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMappingMutation.graphql";
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
const createMappingMutation = graphql`
mutation AddChildThirdPartyDialogCreateMappingMutation(
$input: CreateThirdPartyThirdPartyMappingInput!
$connections: [ID!]!
) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
name
websiteUrl
category
}
}
}
}
`;
const createThirdPartyMutation = graphql`
mutation AddChildThirdPartyDialogCreateMutation(
$input: CreateThirdPartyInput!
) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
parentThirdPartyId: string;
organizationId: string;
connectionId: string;
existingChildIds: string[];
};
export function AddChildThirdPartyDialog({
children,
parentThirdPartyId,
organizationId,
connectionId,
existingChildIds,
}: Props) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const thirdParties = useThirdParties(organizationId);
const [createMapping] = useMutation<AddChildThirdPartyDialogCreateMappingMutation>(createMappingMutation);
const [createThirdParty] = useMutation<AddChildThirdPartyDialogCreateMutation>(createThirdPartyMutation);
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
const debouncedLoadQuery = useDebounceCallback(
useCallback(
(name: string) => {
loadQuery({ name });
},
[loadQuery],
),
500,
);
const handleSearch = (name: string) => {
setSearchQuery(name);
const trimmed = name.trim();
if (trimmed.length >= 2) {
debouncedLoadQuery(trimmed);
}
};
const existingThirdParties = thirdParties.filter(
tp =>
tp.id !== parentThirdPartyId
&& !existingChildIds.includes(tp.id)
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const handleSelectExisting = (childId: string) => {
createMapping({
variables: {
input: {
parentThirdPartyId,
childThirdPartyId: childId,
},
connections: [connectionId],
},
onCompleted: () => {
dialogRef.current?.close();
},
});
};
const handleSelectCommon = (common: Omit<CreateThirdPartyInput, "organizationId">) => {
createThirdParty({
variables: {
input: {
...common,
organizationId,
firstLevel: false,
},
},
onCompleted: (response) => {
const newId = response.createThirdParty.thirdPartyEdge.node.id;
createMapping({
variables: {
input: {
parentThirdPartyId,
childThirdPartyId: newId,
},
connections: [connectionId],
},
onCompleted: () => {
dialogRef.current?.close();
},
});
},
});
};
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
return (
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{existingThirdParties.map(tp => (
<ComboboxItem key={tp.id} onClick={() => handleSelectExisting(tp.id)}>
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
{tp.name}
</ComboboxItem>
))}
{searchQuery.trim().length >= 2 && queryRef && (
<Suspense>
<CommonThirdPartyCombobox
queryRef={queryRef}
excludeNames={existingNames}
onSelect={handleSelectCommon}
/>
</Suspense>
)}
</Combobox>
</DialogContent>
<DialogFooter />
</Dialog>
);
}

View File

@@ -27,7 +27,7 @@ import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraph
export type CommonThirdPartyRef
= CommonThirdPartyCombobox_commonThirdParty$data;
const commonThirdPartyFragment = graphql`
export const commonThirdPartyFragment = graphql`
fragment CommonThirdPartyCombobox_commonThirdParty on CommonThirdParty @inline {
name
logoUrl
@@ -57,20 +57,44 @@ export const commonThirdPartiesQuery = graphql`
}
`;
function toCreateInput(tp: CommonThirdPartyRef): Omit<CreateThirdPartyInput, "organizationId"> {
return {
name: tp.name,
headquarterAddress: tp.headquarterAddress,
legalName: tp.legalName,
websiteUrl: tp.websiteUrl,
category: tp.category,
privacyPolicyUrl: tp.privacyPolicyUrl,
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
certifications: tp.certifications,
securityPageUrl: tp.securityPageUrl,
trustPageUrl: tp.trustPageUrl,
statusPageUrl: tp.statusPageUrl,
termsOfServiceUrl: tp.termsOfServiceUrl,
};
}
interface CommonThirdPartyComboboxProps {
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
onSelect: (thridParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
onSelect: (thirdParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
excludeNames?: Set<string>;
}
export function CommonThirdPartyCombobox({
queryRef,
onSelect,
excludeNames,
}: CommonThirdPartyComboboxProps) {
const data = usePreloadedQuery(commonThirdPartiesQuery, queryRef);
const items = excludeNames
? data.commonThirdParties.filter(tp => !excludeNames.has(tp.name.toLowerCase()))
: data.commonThirdParties;
return (
<>
{data.commonThirdParties.map(thirdParty => (
{items.map(thirdParty => (
<ComboboxItem
key={thirdParty.id}
onClick={() => {
@@ -78,21 +102,7 @@ export function CommonThirdPartyCombobox({
commonThirdPartyFragment,
thirdParty,
);
onSelect({
name: tp.name,
headquarterAddress: tp.headquarterAddress,
legalName: tp.legalName,
websiteUrl: tp.websiteUrl,
category: tp.category,
privacyPolicyUrl: tp.privacyPolicyUrl,
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
certifications: tp.certifications,
securityPageUrl: tp.securityPageUrl,
trustPageUrl: tp.trustPageUrl,
statusPageUrl: tp.statusPageUrl,
termsOfServiceUrl: tp.termsOfServiceUrl,
});
onSelect(toCreateInput(tp));
}}
>
<Avatar

View File

@@ -12,8 +12,10 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Combobox,
ComboboxItem,
Dialog,
@@ -23,17 +25,30 @@ import {
useDialogRef,
} from "@probo/ui";
import { type ReactNode, Suspense, useCallback, useState } from "react";
import { useQueryLoader } from "react-relay";
import { useMutation, useQueryLoader } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyDialogPromoteMutation } from "#/__generated__/core/CreateThirdPartyDialogPromoteMutation.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
import { useCreateThirdPartyMutation, useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
import {
commonThirdPartiesQuery,
CommonThirdPartyCombobox,
} from "./CommonThirdPartyCombobox";
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
const promoteMutation = graphql`
mutation CreateThirdPartyDialogPromoteMutation(
$input: UpdateThirdPartyInput!
) {
updateThirdParty(input: $input) {
thirdParty {
id
name
firstLevel
}
}
}
`;
type Props = {
children: ReactNode;
@@ -48,12 +63,48 @@ export function CreateThirdPartyDialog({
}: Props) {
const { __ } = useTranslate();
const [createThirdParty] = useCreateThirdPartyMutation();
const [promoteThirdParty] = useMutation<CreateThirdPartyDialogPromoteMutation>(promoteMutation);
const thirdParties = useThirdParties(organizationId);
const dialogRef = useDialogRef();
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery]
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
const nonFirstLevelByName = new Map(
thirdParties
.filter(tp => !tp.firstLevel)
.map(tp => [tp.name.toLowerCase(), tp]),
);
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
const name = typeof thirdParty === "string" ? thirdParty : thirdParty.name;
const existing = nonFirstLevelByName.get(name.toLowerCase());
if (existing) {
promoteThirdParty({
variables: {
input: { id: existing.id, firstLevel: true },
},
updater: (store) => {
const payload = store.getRootField("updateThirdParty");
const node = payload?.getLinkedRecord("thirdParty");
if (!node) return;
const connectionRecord = store.get(connection);
if (!connectionRecord) return;
const edge = ConnectionHandler.createEdge(store, connectionRecord, node, "ThirdPartyEdge");
ConnectionHandler.insertEdgeBefore(connectionRecord, edge);
},
onCompleted: () => {
dialogRef.current?.close();
},
});
return;
}
const input
= typeof thirdParty === "string"
? {
@@ -98,10 +149,26 @@ export function CreateThirdPartyDialog({
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{searchQuery.trim().length >= 2 && (
<>
{thirdParties
.filter(tp =>
!tp.firstLevel
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
.map(tp => (
<ComboboxItem key={tp.id} onClick={() => void onSelect(tp.name)}>
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
{tp.name}
</ComboboxItem>
))}
</>
)}
{searchQuery.trim().length >= 2 && queryRef && (
<Suspense>
<CommonThirdPartyCombobox
queryRef={queryRef}
excludeNames={existingNames}
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
/>
</Suspense>

View File

@@ -0,0 +1,237 @@
// 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.
import { faviconUrl, formatDate } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Button,
IconPlusLarge,
IconTrashCan,
PageHeader,
RiskBadge,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import type { ComponentProps } from "react";
import {
type PreloadedQuery,
useMutation,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { graphql } from "relay-runtime";
import type { ThirdPartyThirdPartiesPageDeleteMappingMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMappingMutation.graphql";
import type { ThirdPartyThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartyThirdPartiesPageFragment.graphql";
import type { ThirdPartyThirdPartiesPagePaginationQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPagePaginationQuery.graphql";
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
export const thirdPartyThirdPartiesPageQuery = graphql`
query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
id
name
canUpdate: permission(action: "core:thirdParty:update")
...ThirdPartyThirdPartiesPageFragment
}
}
}
`;
const paginatedFragment = graphql`
fragment ThirdPartyThirdPartiesPageFragment on ThirdParty
@refetchable(queryName: "ThirdPartyThirdPartiesPagePaginationQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
childThirdParties(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyThirdPartiesPageFragment_childThirdParties", filters: []) {
__id
edges {
node {
id
name
websiteUrl
riskAssessments(
first: 1
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
id
createdAt
dataSensitivity
businessImpact
}
}
}
}
}
}
}
`;
const deleteMappingMutation = graphql`
mutation ThirdPartyThirdPartiesPageDeleteMappingMutation(
$input: DeleteThirdPartyThirdPartyMappingInput!
$connections: [ID!]!
) {
deleteThirdPartyThirdPartyMapping(input: $input) {
removedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
interface Props {
queryRef: PreloadedQuery<ThirdPartyThirdPartiesPageQuery>;
}
export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
const { node } = usePreloadedQuery(thirdPartyThirdPartiesPageQuery, queryRef);
const thirdParty = node.__typename === "ThirdParty" ? node : null;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const confirm = useConfirm();
const pagination = usePaginationFragment<
ThirdPartyThirdPartiesPagePaginationQuery,
ThirdPartyThirdPartiesPageFragment$key
>(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key);
const [deleteMapping] = useMutation<ThirdPartyThirdPartiesPageDeleteMappingMutation>(deleteMappingMutation);
usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties"));
if (!thirdParty) {
return null;
}
const connectionId = pagination.data.childThirdParties.__id;
const childThirdParties = pagination.data.childThirdParties.edges.map(edge => edge.node);
const handleRemove = (childId: string, childName: string) => {
confirm(
() =>
new Promise<void>((resolve, reject) => {
deleteMapping({
variables: {
input: {
parentThirdPartyId: thirdParty.id,
childThirdPartyId: childId,
},
connections: [connectionId],
},
onCompleted: () => resolve(),
onError: err => reject(err),
});
}),
{
message: `${__("Remove")} "${childName}" ${__("from this third party?")}`,
},
);
};
return (
<div className="space-y-6">
<PageHeader
title={__("Third Parties")}
description={__("Manage third parties linked to this third party.")}
>
{thirdParty.canUpdate && (
<AddChildThirdPartyDialog
parentThirdPartyId={thirdParty.id}
organizationId={organizationId}
connectionId={connectionId}
existingChildIds={childThirdParties.map(c => c.id)}
>
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
</AddChildThirdPartyDialog>
)}
</PageHeader>
<SortableTable
refetch={pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Third party")}</SortableTh>
<Th>{__("Accessed At")}</Th>
<Th>{__("Data Risk")}</Th>
<Th>{__("Business Risk")}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{childThirdParties.map((child) => {
const latestAssessment = child.riskAssessments?.edges[0]?.node;
return (
<Tr
key={child.id}
to={`/organizations/${organizationId}/third-parties/${child.id}/overview`}
>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={child.name} src={faviconUrl(child.websiteUrl)} />
<div>{child.name}</div>
</div>
</Td>
<Td>
{latestAssessment?.createdAt
? formatDate(latestAssessment.createdAt)
: __("Not assessed")}
</Td>
<Td>
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
</Td>
<Td>
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
</Td>
<Td noLink width={50} className="text-end">
{thirdParty.canUpdate && (
<Button
variant="tertiary"
icon={IconTrashCan}
onClick={() => handleRemove(child.id, child.name)}
/>
)}
</Td>
</Tr>
);
})}
</Tbody>
</SortableTable>
</div>
);
}

View File

@@ -0,0 +1,43 @@
// 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.
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import ThirdPartyThirdPartiesPage, { thirdPartyThirdPartiesPageQuery } from "./ThirdPartyThirdPartiesPage";
export default function ThirdPartyThirdPartiesPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery] = useQueryLoader<ThirdPartyThirdPartiesPageQuery>(thirdPartyThirdPartiesPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<ThirdPartyThirdPartiesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -97,6 +97,14 @@ export const thirdPartyRoutes = [
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
),
},
{
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
),
},
],
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,508 @@
// 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 console_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestThirdPartyRelation_AddAndList(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
parentID := factory.NewThirdParty(owner).WithName("Parent Corp").Create()
childID := factory.NewThirdParty(owner).WithName("Child Corp").Create()
addRelation(t, owner, parentID, childID)
t.Run("list child third parties", func(t *testing.T) {
t.Parallel()
const query = `
query($id: ID!) {
node(id: $id) {
... on ThirdParty {
childThirdParties(first: 10) {
totalCount
edges {
node {
id
name
}
}
}
}
}
}
`
var result struct {
Node struct {
ChildThirdParties struct {
TotalCount int `json:"totalCount"`
Edges []struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"node"`
} `json:"edges"`
} `json:"childThirdParties"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{"id": parentID}, &result)
require.NoError(t, err)
assert.Equal(t, 1, result.Node.ChildThirdParties.TotalCount)
require.Len(t, result.Node.ChildThirdParties.Edges, 1)
assert.Equal(t, childID, result.Node.ChildThirdParties.Edges[0].Node.ID)
})
}
func TestThirdPartyRelation_Remove(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
parentID := factory.NewThirdParty(owner).WithName("Parent Remove").Create()
childID := factory.NewThirdParty(owner).WithName("Child Remove").Create()
addRelation(t, owner, parentID, childID)
const removeQuery = `
mutation($input: DeleteThirdPartyThirdPartyMappingInput!) {
deleteThirdPartyThirdPartyMapping(input: $input) {
removedThirdPartyId
}
}
`
var result struct {
DeleteThirdPartyThirdPartyMapping struct {
RemovedThirdPartyID string `json:"removedThirdPartyId"`
} `json:"deleteThirdPartyThirdPartyMapping"`
}
err := owner.Execute(removeQuery, map[string]any{
"input": map[string]any{
"parentThirdPartyId": parentID,
"childThirdPartyId": childID,
},
}, &result)
require.NoError(t, err)
assert.Equal(t, childID, result.DeleteThirdPartyThirdPartyMapping.RemovedThirdPartyID)
count := countChildThirdParties(t, owner, parentID)
assert.Equal(t, 0, count)
}
func TestThirdPartyRelation_Bidirectional(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
aID := factory.NewThirdParty(owner).WithName("Company A").Create()
bID := factory.NewThirdParty(owner).WithName("Company B").Create()
addRelation(t, owner, aID, bID)
addRelation(t, owner, bID, aID)
t.Run("A has B as child", func(t *testing.T) {
t.Parallel()
count := countChildThirdParties(t, owner, aID)
assert.Equal(t, 1, count)
})
t.Run("B has A as child", func(t *testing.T) {
t.Parallel()
count := countChildThirdParties(t, owner, bID)
assert.Equal(t, 1, count)
})
}
func TestThirdPartyRelation_Idempotent(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
parentID := factory.NewThirdParty(owner).WithName("Idempotent Parent").Create()
childID := factory.NewThirdParty(owner).WithName("Idempotent Child").Create()
addRelation(t, owner, parentID, childID)
addRelation(t, owner, parentID, childID)
count := countChildThirdParties(t, owner, parentID)
assert.Equal(t, 1, count)
}
func TestThirdPartyRelation_CascadeOnDelete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
parentID := factory.NewThirdParty(owner).WithName("Cascade Parent").Create()
childID := factory.NewThirdParty(owner).WithName("Cascade Child").Create()
addRelation(t, owner, parentID, childID)
const deleteQuery = `
mutation($input: DeleteThirdPartyInput!) {
deleteThirdParty(input: $input) {
deletedThirdPartyId
}
}
`
var result struct {
DeleteThirdParty struct {
DeletedThirdPartyID string `json:"deletedThirdPartyId"`
} `json:"deleteThirdParty"`
}
err := owner.Execute(deleteQuery, map[string]any{
"input": map[string]any{
"thirdPartyId": childID,
},
}, &result)
require.NoError(t, err)
count := countChildThirdParties(t, owner, parentID)
assert.Equal(t, 0, count)
}
func TestThirdPartyRelation_Authorization(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
parentID := factory.NewThirdParty(owner).WithName("Auth Parent").Create()
childID := factory.NewThirdParty(owner).WithName("Auth Child").Create()
t.Run("viewer cannot add relation", func(t *testing.T) {
t.Parallel()
const query = `
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge {
node { id }
}
}
}
`
_, err := viewer.Do(query, map[string]any{
"input": map[string]any{
"parentThirdPartyId": parentID,
"childThirdPartyId": childID,
},
})
testutil.RequireForbiddenError(t, err)
})
t.Run("viewer cannot remove relation", func(t *testing.T) {
t.Parallel()
addRelation(t, owner, parentID, childID)
const query = `
mutation($input: DeleteThirdPartyThirdPartyMappingInput!) {
deleteThirdPartyThirdPartyMapping(input: $input) {
removedThirdPartyId
}
}
`
_, err := viewer.Do(query, map[string]any{
"input": map[string]any{
"parentThirdPartyId": parentID,
"childThirdPartyId": childID,
},
})
testutil.RequireForbiddenError(t, err)
})
t.Run("viewer can list child third parties", func(t *testing.T) {
t.Parallel()
count := countChildThirdParties(t, viewer, parentID)
assert.GreaterOrEqual(t, count, 0)
})
}
func TestThirdPartyRelation_TenantIsolation(t *testing.T) {
t.Parallel()
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
parentID := factory.NewThirdParty(org1Owner).WithName("Org1 Parent").Create()
childID := factory.NewThirdParty(org1Owner).WithName("Org1 Child").Create()
org2ChildID := factory.NewThirdParty(org2Owner).WithName("Org2 Child").Create()
addRelation(t, org1Owner, parentID, childID)
t.Run("cannot add cross-org relation", func(t *testing.T) {
t.Parallel()
const query = `
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge {
node { id }
}
}
}
`
_, err := org1Owner.Do(query, map[string]any{
"input": map[string]any{
"parentThirdPartyId": parentID,
"childThirdPartyId": org2ChildID,
},
})
require.Error(t, err)
})
t.Run("cannot list children of other org third party", func(t *testing.T) {
t.Parallel()
const query = `
query($id: ID!) {
node(id: $id) {
... on ThirdParty {
childThirdParties(first: 10) {
totalCount
}
}
}
}
`
var result struct {
Node *struct {
ChildThirdParties *struct {
TotalCount int `json:"totalCount"`
} `json:"childThirdParties"`
} `json:"node"`
}
err := org2Owner.Execute(query, map[string]any{"id": parentID}, &result)
nodeInaccessible := err != nil || result.Node == nil || result.Node.ChildThirdParties == nil
emptyResult := result.Node != nil && result.Node.ChildThirdParties != nil && result.Node.ChildThirdParties.TotalCount == 0
assert.True(t, nodeInaccessible || emptyResult, "expected either inaccessible node or zero children")
})
}
func TestThirdParty_DirectFilter(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.NewThirdParty(owner).WithName("Direct TP").Create()
const createNonDirect = `
mutation($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
firstLevel
}
}
}
}
`
var createResult struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
FirstLevel bool `json:"firstLevel"`
} `json:"node"`
} `json:"thirdPartyEdge"`
} `json:"createThirdParty"`
}
err := owner.Execute(createNonDirect, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": factory.SafeName("NonDirect TP"),
"firstLevel": false,
},
}, &createResult)
require.NoError(t, err)
assert.False(t, createResult.CreateThirdParty.ThirdPartyEdge.Node.FirstLevel)
t.Run("filter firstLevel only", func(t *testing.T) {
t.Parallel()
const query = `
query($orgId: ID!) {
node(id: $orgId) {
... on Organization {
thirdParties(first: 100, filter: { firstLevel: true }) {
edges {
node {
id
firstLevel
}
}
}
}
}
}
`
var result struct {
Node struct {
ThirdParties struct {
Edges []struct {
Node struct {
ID string `json:"id"`
FirstLevel bool `json:"firstLevel"`
} `json:"node"`
} `json:"edges"`
} `json:"thirdParties"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{
"orgId": owner.GetOrganizationID().String(),
}, &result)
require.NoError(t, err)
for _, edge := range result.Node.ThirdParties.Edges {
assert.True(t, edge.Node.FirstLevel, "expected all third parties to be firstLevel when filtering direct=true")
}
})
t.Run("filter all", func(t *testing.T) {
t.Parallel()
const query = `
query($orgId: ID!) {
node(id: $orgId) {
... on Organization {
thirdParties(first: 100) {
edges {
node {
id
firstLevel
}
}
}
}
}
}
`
var result struct {
Node struct {
ThirdParties struct {
Edges []struct {
Node struct {
ID string `json:"id"`
FirstLevel bool `json:"firstLevel"`
} `json:"node"`
} `json:"edges"`
} `json:"thirdParties"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{
"orgId": owner.GetOrganizationID().String(),
}, &result)
require.NoError(t, err)
hasFirstLevel := false
hasNonFirstLevel := false
for _, edge := range result.Node.ThirdParties.Edges {
if edge.Node.FirstLevel {
hasFirstLevel = true
} else {
hasNonFirstLevel = true
}
}
assert.True(t, hasFirstLevel, "expected at least one first-level third party")
assert.True(t, hasNonFirstLevel, "expected at least one non-first-level third party")
})
}
func addRelation(t *testing.T, c *testutil.Client, parentID, childID string) {
t.Helper()
const query = `
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge {
node { id }
}
}
}
`
var result struct {
CreateThirdPartyThirdPartyMapping struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"thirdPartyEdge"`
} `json:"createThirdPartyThirdPartyMapping"`
}
err := c.Execute(query, map[string]any{
"input": map[string]any{
"parentThirdPartyId": parentID,
"childThirdPartyId": childID,
},
}, &result)
require.NoError(t, err)
}
func countChildThirdParties(t *testing.T, c *testutil.Client, parentID string) int {
t.Helper()
const query = `
query($id: ID!) {
node(id: $id) {
... on ThirdParty {
childThirdParties(first: 1) {
totalCount
}
}
}
}
`
var result struct {
Node struct {
ChildThirdParties struct {
TotalCount int `json:"totalCount"`
} `json:"childThirdParties"`
} `json:"node"`
}
err := c.Execute(query, map[string]any{"id": parentID}, &result)
require.NoError(t, err)
return result.Node.ChildThirdParties.TotalCount
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
// 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
@@ -183,6 +183,13 @@ export const description: INodeProperties[] = [
type: 'string',
default: '',
},
{
displayName: 'Root',
name: 'firstLevel',
type: 'boolean',
default: false,
description: 'Whether this is a first-level third party',
},
{
displayName: 'Security Page URL',
name: 'securityPageUrl',
@@ -249,6 +256,7 @@ export async function execute(
trustPageUrl?: string;
certifications?: string;
countries?: string;
firstLevel?: boolean;
};
const query = `
@@ -275,6 +283,7 @@ export async function execute(
certifications
countries
showOnTrustCenter
firstLevel
createdAt
updatedAt
}
@@ -303,6 +312,7 @@ export async function execute(
if (additionalFields.subprocessorsListUrl) input.subprocessorsListUrl = additionalFields.subprocessorsListUrl;
if (additionalFields.securityPageUrl) input.securityPageUrl = additionalFields.securityPageUrl;
if (additionalFields.trustPageUrl) input.trustPageUrl = additionalFields.trustPageUrl;
if (additionalFields.firstLevel !== undefined) input.firstLevel = additionalFields.firstLevel;
if (additionalFields.certifications) {
input.certifications = additionalFields.certifications.split(',').map((c) => c.trim()).filter(Boolean);
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
// 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
@@ -73,6 +73,13 @@ export const description: INodeProperties[] = [
},
},
options: [
{
displayName: 'Filter by Root',
name: 'filterFirstLevel',
type: 'boolean',
default: false,
description: 'Whether to filter by first-level third parties only',
},
{
displayName: 'Include Organization',
name: 'includeOrganization',
@@ -106,6 +113,7 @@ export async function execute(
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
const options = this.getNodeParameter('options', itemIndex, {}) as {
filterFirstLevel?: boolean;
includeOrganization?: boolean;
includeBusinessOwner?: boolean;
includeSecurityOwner?: boolean;
@@ -134,11 +142,14 @@ export async function execute(
}`
: '';
const filterVariable = options.filterFirstLevel !== undefined ? ', $filter: ThirdPartyFilter' : '';
const filterArgument = options.filterFirstLevel !== undefined ? ', filter: $filter' : '';
const query = `
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey) {
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey${filterVariable}) {
node(id: $organizationId) {
... on Organization {
thirdParties(first: $first, after: $after) {
thirdParties(first: $first, after: $after${filterArgument}) {
edges {
node {
id
@@ -160,6 +171,7 @@ export async function execute(
certifications
countries
showOnTrustCenter
firstLevel
${organizationFragment}
${businessOwnerFragment}
${securityOwnerFragment}
@@ -177,10 +189,15 @@ export async function execute(
}
`;
const variables: IDataObject = { organizationId };
if (options.filterFirstLevel) {
variables.filter = { firstLevel: true };
}
const thirdParties = await proboApiRequestAllItems.call(
this,
query,
{ organizationId },
variables,
(response) => {
const data = response?.data as IDataObject | undefined;
const node = data?.node as IDataObject | undefined;

View File

@@ -39,6 +39,9 @@ import * as updateBusinessAssociateAgreementOp from './updateBusinessAssociateAg
import * as getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation';
import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation';
import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.operation';
import * as linkThirdPartyOp from './linkThirdParty.operation';
import * as unlinkThirdPartyOp from './unlinkThirdParty.operation';
import * as listChildThirdPartiesOp from './listChildThirdParties.operation';
import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [
@@ -143,6 +146,12 @@ export const description: INodeProperties[] = [
description: 'Get many third parties',
action: 'Get many third parties',
},
{
name: 'Get Many Child Third Parties',
value: 'listChildThirdParties',
description: 'Get child third parties linked to a parent',
action: 'Get many child third parties',
},
{
name: 'Get Many Compliance Reports',
value: 'getAllComplianceReports',
@@ -179,12 +188,24 @@ export const description: INodeProperties[] = [
description: 'Get a third party service',
action: 'Get a third party service',
},
{
name: 'Link Third Party',
value: 'linkThirdParty',
description: 'Link a child third party to a parent third party',
action: 'Link a child third party',
},
{
name: 'Publish List',
value: 'publish',
description: 'Publish the third party register as a document version',
action: 'Publish the third party register',
},
{
name: 'Unlink Third Party',
value: 'unlinkThirdParty',
description: 'Unlink a child third party from a parent third party',
action: 'Unlink a child third party',
},
{
name: 'Update',
value: 'update',
@@ -244,6 +265,9 @@ export const description: INodeProperties[] = [
...getDataPrivacyAgreementOp.description,
...deleteDataPrivacyAgreementOp.description,
...updateDataPrivacyAgreementOp.description,
...linkThirdPartyOp.description,
...unlinkThirdPartyOp.description,
...listChildThirdPartiesOp.description,
...publishOp.description,
];
@@ -274,5 +298,8 @@ export {
getDataPrivacyAgreementOp as getDataPrivacyAgreement,
deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement,
updateDataPrivacyAgreementOp as updateDataPrivacyAgreement,
linkThirdPartyOp as linkThirdParty,
unlinkThirdPartyOp as unlinkThirdParty,
listChildThirdPartiesOp as listChildThirdParties,
publishOp as publish,
};

View File

@@ -0,0 +1,75 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Parent Third Party ID',
name: 'parentThirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['linkThirdParty'],
},
},
default: '',
description: 'The ID of the parent third party',
required: true,
},
{
displayName: 'Child Third Party ID',
name: 'childThirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['linkThirdParty'],
},
},
default: '',
description: 'The ID of the child third party to link',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const parentThirdPartyId = this.getNodeParameter('parentThirdPartyId', itemIndex) as string;
const childThirdPartyId = this.getNodeParameter('childThirdPartyId', itemIndex) as string;
const query = `
mutation CreateThirdPartyThirdPartyMapping($input: CreateThirdPartyThirdPartyMappingInput!) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge {
node {
id
name
}
}
}
}
`;
const responseData = await proboApiRequest.call(this, query, { input: { parentThirdPartyId, childThirdPartyId } });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -0,0 +1,118 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow';
import { proboApiRequestAllItems } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Third Party ID',
name: 'thirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['listChildThirdParties'],
},
},
default: '',
description: 'The ID of the parent third party',
required: true,
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['listChildThirdParties'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['listChildThirdParties'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
},
default: 50,
description: 'Max number of results to return',
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
const query = `
query GetChildThirdParties($thirdPartyId: ID!, $first: Int, $after: CursorKey) {
node(id: $thirdPartyId) {
... on ThirdParty {
childThirdParties(first: $first, after: $after) {
edges {
node {
id
name
description
category
websiteUrl
legalName
headquarterAddress
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
const childThirdParties = await proboApiRequestAllItems.call(
this,
query,
{ thirdPartyId },
(response) => {
const data = response?.data as IDataObject | undefined;
const node = data?.node as IDataObject | undefined;
return node?.childThirdParties as IDataObject | undefined;
},
returnAll,
limit,
);
return {
json: { childThirdParties },
pairedItem: { item: itemIndex },
};
}

View File

@@ -0,0 +1,70 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Parent Third Party ID',
name: 'parentThirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['unlinkThirdParty'],
},
},
default: '',
description: 'The ID of the parent third party',
required: true,
},
{
displayName: 'Child Third Party ID',
name: 'childThirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['unlinkThirdParty'],
},
},
default: '',
description: 'The ID of the child third party to unlink',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const parentThirdPartyId = this.getNodeParameter('parentThirdPartyId', itemIndex) as string;
const childThirdPartyId = this.getNodeParameter('childThirdPartyId', itemIndex) as string;
const query = `
mutation DeleteThirdPartyThirdPartyMapping($input: DeleteThirdPartyThirdPartyMappingInput!) {
deleteThirdPartyThirdPartyMapping(input: $input) {
removedThirdPartyId
}
}
`;
const responseData = await proboApiRequest.call(this, query, { input: { parentThirdPartyId, childThirdPartyId } });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -0,0 +1,108 @@
// 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 link
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const linkMutation = `
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge {
node {
id
name
}
}
}
}
`
type linkResponse struct {
CreateThirdPartyThirdPartyMapping struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"node"`
} `json:"thirdPartyEdge"`
} `json:"createThirdPartyThirdPartyMapping"`
}
func NewCmdLink(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "link <parent-id> <child-id>",
Short: "Link a child thirdParty to a parent thirdParty",
Example: ` # Link a child third_party to a parent
prb thirdParty link <parent-id> <child-id>`,
Args: cobra.ExactArgs(2),
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),
)
data, err := client.Do(
linkMutation,
map[string]any{
"input": map[string]any{
"parentThirdPartyId": args[0],
"childThirdPartyId": args[1],
},
},
)
if err != nil {
return err
}
var resp linkResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
v := resp.CreateThirdPartyThirdPartyMapping.ThirdPartyEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Linked thirdParty %s (%s) as child of %s\n",
v.ID,
v.Name,
args[0],
)
return nil
},
}
return cmd
}

View File

@@ -24,11 +24,11 @@ import (
)
const listQuery = `
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ThirdPartyOrder) {
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ThirdPartyOrder, $filter: ThirdPartyFilter) {
node(id: $id) {
__typename
... on Organization {
third_parties(first: $first, after: $after, orderBy: $orderBy) {
third_parties(first: $first, after: $after, orderBy: $orderBy, filter: $filter) {
totalCount
edges {
node {
@@ -55,11 +55,12 @@ type thirdParty struct {
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagLimit int
flagOrderBy string
flagOrderDir string
flagOutput *string
flagOrg string
flagLimit int
flagOrderBy string
flagOrderDir string
flagFirstLevel bool
flagOutput *string
)
cmd := &cobra.Command{
@@ -107,6 +108,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
"id": flagOrg,
}
if cmd.Flags().Changed("first-level") {
variables["filter"] = map[string]any{
"first-level": flagFirstLevel,
}
}
if flagOrderBy != "" {
if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"NAME", "CREATED_AT", "UPDATED_AT"}); err != nil {
return err
@@ -188,6 +195,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of thirdParties to list")
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (NAME, CREATED_AT, UPDATED_AT)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
cmd.Flags().BoolVar(&flagFirstLevel, "first-level", false, "Filter by first-level thirdParties only")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd

View File

@@ -20,8 +20,10 @@ import (
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/assess"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/link"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/list"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/unlink"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/update"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view"
)
@@ -39,6 +41,8 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(assess.NewCmdAssess(f))
cmd.AddCommand(publish.NewCmdPublish(f))
cmd.AddCommand(link.NewCmdLink(f))
cmd.AddCommand(unlink.NewCmdUnlink(f))
return cmd
}

View File

@@ -0,0 +1,84 @@
// 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 unlink
import (
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const unlinkMutation = `
mutation($input: UncreateThirdPartyThirdPartyMappingInput!) {
uncreateThirdPartyThirdPartyMapping(input: $input) {
removedThirdPartyId
}
}
`
func NewCmdUnlink(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "unlink <parent-id> <child-id>",
Short: "Unlink a child thirdParty from a parent thirdParty",
Example: ` # Unlink a child third_party from a parent
prb thirdParty unlink <parent-id> <child-id>`,
Args: cobra.ExactArgs(2),
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),
)
_, err = client.Do(
unlinkMutation,
map[string]any{
"input": map[string]any{
"parentThirdPartyId": args[0],
"childThirdPartyId": args[1],
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Unlinked thirdParty %s from parent %s\n",
args[1],
args[0],
)
return nil
},
}
return cmd
}

View File

@@ -0,0 +1,25 @@
-- 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.
ALTER TABLE third_parties ADD COLUMN first_level boolean NOT NULL DEFAULT true;
ALTER TABLE third_parties ALTER COLUMN first_level DROP DEFAULT;
CREATE TABLE third_party_third_parties (
parent_third_party_id text NOT NULL REFERENCES third_parties(id) ON DELETE CASCADE,
child_third_party_id text NOT NULL REFERENCES third_parties(id) ON DELETE CASCADE,
tenant_id bytea NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (parent_third_party_id, child_third_party_id),
CHECK (parent_third_party_id <> child_third_party_id)
);

View File

@@ -162,6 +162,7 @@ type (
SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"`
FirstLevel bool `db:"first_level"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -253,6 +254,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -320,6 +322,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -381,6 +384,7 @@ INSERT INTO
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
)
@@ -409,6 +413,7 @@ VALUES (
@security_page_url,
@trust_page_url,
@show_on_trust_center,
@first_level,
@created_at,
@updated_at
)
@@ -439,6 +444,7 @@ VALUES (
"security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
"created_at": v.CreatedAt,
"updated_at": v.UpdatedAt,
}
@@ -534,6 +540,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -597,6 +604,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -657,6 +665,7 @@ SET
business_owner_profile_id = @business_owner_profile_id,
security_owner_profile_id = @security_owner_profile_id,
show_on_trust_center = @show_on_trust_center,
first_level = @first_level,
updated_at = @updated_at
WHERE %s
AND id = @third_party_id
@@ -686,6 +695,7 @@ WHERE %s
"business_owner_profile_id": v.BusinessOwnerID,
"security_owner_profile_id": v.SecurityOwnerID,
"show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
}
maps.Copy(args, scope.SQLArguments())
@@ -803,6 +813,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.created_at,
v.updated_at
FROM
@@ -837,6 +848,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -938,6 +950,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.created_at,
v.updated_at
FROM
@@ -972,6 +985,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -1033,6 +1047,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.created_at,
v.updated_at
FROM
@@ -1067,6 +1082,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -1129,6 +1145,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.created_at,
v.updated_at
FROM
@@ -1163,6 +1180,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -1293,6 +1311,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.created_at,
v.updated_at
FROM
@@ -1327,6 +1346,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
@@ -1387,6 +1407,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM

View File

@@ -21,12 +21,14 @@ import (
type (
ThirdPartyFilter struct {
showOnTrustCenter *bool
firstLevel *bool
}
)
func NewThirdPartyFilter(showOnTrustCenter *bool) *ThirdPartyFilter {
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool) *ThirdPartyFilter {
return &ThirdPartyFilter{
showOnTrustCenter: showOnTrustCenter,
firstLevel: firstLevel,
}
}
@@ -39,6 +41,12 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
args["show_on_trust_center"] = nil
}
if f.firstLevel != nil {
args["first_level"] = *f.firstLevel
} else {
args["first_level"] = nil
}
return args
}
@@ -50,5 +58,13 @@ func (f *ThirdPartyFilter) SQLFragment() string {
show_on_trust_center = @show_on_trust_center::boolean
ELSE TRUE
END
)
AND
(
CASE
WHEN @first_level::boolean IS NOT NULL THEN
first_level = @first_level::boolean
ELSE TRUE
END
)`
}

View File

@@ -0,0 +1,226 @@
// 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"
"go.probo.inc/probo/pkg/page"
)
type (
ThirdPartyThirdParty struct {
ParentThirdPartyID gid.GID `db:"parent_third_party_id"`
ChildThirdPartyID gid.GID `db:"child_third_party_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
ThirdPartyThirdParties []*ThirdPartyThirdParty
)
func (r *ThirdPartyThirdParty) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error {
q := `
INSERT INTO third_party_third_parties (
parent_third_party_id,
child_third_party_id,
tenant_id,
created_at
) VALUES (
@parent_third_party_id,
@child_third_party_id,
@tenant_id,
@created_at
)
ON CONFLICT (parent_third_party_id, child_third_party_id) DO NOTHING
`
args := pgx.StrictNamedArgs{
"parent_third_party_id": r.ParentThirdPartyID,
"child_third_party_id": r.ChildThirdPartyID,
"tenant_id": scope.GetTenantID(),
"created_at": r.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert third party third party: %w", err)
}
return nil
}
func (r *ThirdPartyThirdParty) Delete(ctx context.Context, conn pg.Tx, scope Scoper) error {
q := `
DELETE FROM third_party_third_parties
WHERE %s
AND parent_third_party_id = @parent_third_party_id
AND child_third_party_id = @child_third_party_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"parent_third_party_id": r.ParentThirdPartyID,
"child_third_party_id": r.ChildThirdPartyID,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
return err
}
func (v *ThirdParties) CountByParentThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentThirdPartyID gid.GID,
) (int, error) {
q := `
WITH children AS (
SELECT
tp.id,
tp.tenant_id
FROM
third_parties tp
INNER JOIN
third_party_third_parties tpr ON tp.id = tpr.child_third_party_id
WHERE
tpr.parent_third_party_id = @parent_third_party_id
)
SELECT
COUNT(id)
FROM
children
WHERE %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
maps.Copy(args, scope.SQLArguments())
var count int
err := conn.QueryRow(ctx, q, args).Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count child third parties: %w", err)
}
return count, nil
}
func (v *ThirdParties) LoadByParentThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentThirdPartyID gid.GID,
cursor *page.Cursor[ThirdPartyOrderField],
) error {
q := `
WITH children AS (
SELECT
tp.id,
tp.tenant_id,
tp.organization_id,
tp.common_third_party_id,
tp.name,
tp.description,
tp.category,
tp.headquarter_address,
tp.legal_name,
tp.website_url,
tp.privacy_policy_url,
tp.service_level_agreement_url,
tp.data_processing_agreement_url,
tp.business_associate_agreement_url,
tp.subprocessors_list_url,
tp.certifications,
tp.countries,
tp.business_owner_profile_id,
tp.security_owner_profile_id,
tp.status_page_url,
tp.terms_of_service_url,
tp.security_page_url,
tp.trust_page_url,
tp.show_on_trust_center,
tp.first_level,
tp.created_at,
tp.updated_at
FROM
third_parties tp
INNER JOIN
third_party_third_parties tpr ON tp.id = tpr.child_third_party_id
WHERE
tpr.parent_third_party_id = @parent_third_party_id
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
children
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
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 child third parties: %w", err)
}
thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
if err != nil {
return fmt.Errorf("cannot collect child third parties: %w", err)
}
*v = thirdParties
return nil
}

View File

@@ -92,6 +92,11 @@ const (
ActionThirdPartyAssess = "core:thirdParty:assess"
ActionThirdPartyPublish = "core:thirdParty:publish"
// ThirdPartyRelation actions
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete"
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
// ThirdPartyContact actions
ActionThirdPartyContactGet = "core:thirdParty-contact:get"
ActionThirdPartyContactList = "core:thirdParty-contact:list"

View File

@@ -55,6 +55,7 @@ var ViewerPolicy = policy.NewPolicy(
ActionThirdPartyBusinessAssociateAgreementGet,
ActionThirdPartyDataPrivacyAgreementGet,
ActionThirdPartyRiskAssessmentList,
ActionThirdPartyRelationList,
ActionFrameworkGet, ActionFrameworkList,
ActionControlGet, ActionControlList,
ActionMeasureGet, ActionMeasureList,
@@ -140,6 +141,7 @@ var AuditorPolicy = policy.NewPolicy(
ActionThirdPartyBusinessAssociateAgreementGet,
ActionThirdPartyDataPrivacyAgreementGet,
ActionThirdPartyRiskAssessmentList,
ActionThirdPartyRelationList,
ActionFrameworkGet, ActionFrameworkList,
ActionControlGet, ActionControlList,
ActionMeasureGet, ActionMeasureList,

View File

@@ -92,6 +92,7 @@ type (
StatusPageURL *string
BusinessOwnerID *gid.GID
SecurityOwnerID *gid.GID
FirstLevel *bool
}
UpdateThirdPartyRequest struct {
@@ -116,6 +117,7 @@ type (
BusinessOwnerID **gid.GID
SecurityOwnerID **gid.GID
ShowOnTrustCenter *bool
FirstLevel *bool
}
AssessThirdPartyRequest struct {
@@ -386,6 +388,10 @@ func (s ThirdPartyService) Update(
thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter
}
if req.FirstLevel != nil {
thirdParty.FirstLevel = *req.FirstLevel
}
if req.TrustPageURL != nil {
thirdParty.TrustPageURL = *req.TrustPageURL
}
@@ -573,6 +579,11 @@ func (s ThirdPartyService) Create(
StatusPageURL: req.StatusPageURL,
TermsOfServiceURL: req.TermsOfServiceURL,
ShowOnTrustCenter: false,
FirstLevel: true,
}
if req.FirstLevel != nil {
thirdParty.FirstLevel = *req.FirstLevel
}
err := s.svc.pg.WithTx(
@@ -947,3 +958,116 @@ func (s ThirdPartyService) Assess(
Subprocessors: subprocessors,
}, nil
}
func (s ThirdPartyService) CreateThirdPartyMapping(
ctx context.Context,
scope coredata.Scoper,
parentThirdPartyID gid.GID,
childThirdPartyID gid.GID,
) (*coredata.ThirdParty, error) {
childThirdParty := &coredata.ThirdParty{}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
parentThirdParty := &coredata.ThirdParty{}
if err := parentThirdParty.LoadByID(ctx, conn, scope, parentThirdPartyID); err != nil {
return fmt.Errorf("cannot load parent third party: %w", err)
}
if err := childThirdParty.LoadByID(ctx, conn, scope, childThirdPartyID); err != nil {
return fmt.Errorf("cannot load child third party: %w", err)
}
if parentThirdParty.OrganizationID != childThirdParty.OrganizationID {
return fmt.Errorf("cannot create mapping for third parties from different organizations: %w", coredata.ErrResourceNotFound)
}
relation := &coredata.ThirdPartyThirdParty{
ParentThirdPartyID: parentThirdPartyID,
ChildThirdPartyID: childThirdPartyID,
CreatedAt: time.Now(),
}
if err := relation.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot create third party mapping: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return childThirdParty, nil
}
func (s ThirdPartyService) DeleteThirdPartyMapping(
ctx context.Context,
scope coredata.Scoper,
parentThirdPartyID gid.GID,
childThirdPartyID gid.GID,
) error {
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
relation := &coredata.ThirdPartyThirdParty{
ParentThirdPartyID: parentThirdPartyID,
ChildThirdPartyID: childThirdPartyID,
}
if err := relation.Delete(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot delete third party mapping: %w", err)
}
return nil
},
)
}
func (s ThirdPartyService) CountForParentThirdPartyID(
ctx context.Context,
scope coredata.Scoper,
parentThirdPartyID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
thirdParties := coredata.ThirdParties{}
count, err = thirdParties.CountByParentThirdPartyID(ctx, conn, scope, parentThirdPartyID)
if err != nil {
return fmt.Errorf("cannot count child third parties: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s ThirdPartyService) ListForParentThirdPartyID(
ctx context.Context,
scope coredata.Scoper,
parentThirdPartyID gid.GID,
cursor *page.Cursor[coredata.ThirdPartyOrderField],
) (*page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], error) {
var thirdParties coredata.ThirdParties
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return thirdParties.LoadByParentThirdPartyID(ctx, conn, scope, parentThirdPartyID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(thirdParties, cursor), nil
}

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),

View File

@@ -65,7 +65,7 @@ func (s ThirdPartyService) ListForOrganizationId(
ctx,
func(ctx context.Context, conn pg.Querier) error {
showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter)
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
if err != nil {
@@ -99,7 +99,7 @@ func (s ThirdPartyService) CountForTrustCenterId(
thirdParties := &coredata.ThirdParties{}
showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter)
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter)
if err != nil {