From 6e7c96732f10efe3be30a024f26fbbcb34920f7c Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Wed, 27 May 2026 13:34:49 +0200 Subject: [PATCH] Add async third-party vetting Queue vetting on third_parties with PENDING, PROCESSING, COMPLETED, and FAILED states. Expose enqueue and status through GraphQL, MCP, CLI, and n8n, validate vet requests, tune the worker via config, and poll the detail page while vetting runs. Signed-off-by: Sacha Al Himdani --- .env.example | 5 + GNUmakefile | 1 + .../src/hooks/graph/ThirdPartyGraph.ts | 3 +- .../third-parties/ThirdPartyDetailPage.tsx | 57 ++- ...AssessmentDialog.tsx => VettingDialog.tsx} | 70 ++- .../tabs/ThirdPartyRiskAssessmentTab.tsx | 4 +- .../charts/probo/templates/deployment.yaml | 30 ++ .../probo/values-production.yaml.example | 13 + contrib/helm/charts/probo/values.yaml | 17 +- e2e/console/third_party_test.go | 44 +- .../nodes/Probo/actions/thirdParty/index.ts | 9 + .../Probo/actions/thirdParty/vet.operation.ts | 102 ++++ pkg/agent/tool_test.go | 4 +- pkg/bootstrap/builder.go | 11 + pkg/bootstrap/builder_test.go | 23 + pkg/cmd/thirdpartymgmt/thirdpartymgmt.go | 4 +- .../{assess/assess.go => vet/vet.go} | 66 +-- .../pattern_analysis_worker_process_test.go | 1 - pkg/coredata/migrations/20260601T120000Z.sql | 24 + pkg/coredata/third_party.go | 346 ++++++++++++-- pkg/coredata/third_party_third_party.go | 22 +- pkg/coredata/third_party_vetting.go | 126 +++++ pkg/coredata/third_party_vetting_status.go | 87 ++++ pkg/iam/organization_service.go | 1 - pkg/probo/actions.go | 2 +- pkg/probo/service.go | 3 - pkg/probo/third_party_service.go | 177 ------- pkg/probod/aliases.go | 49 +- pkg/probod/probod.go | 31 +- ...arty_assessor.go => third_party_vetter.go} | 23 +- pkg/probodconfig/config.go | 23 +- pkg/probodconfig/llm_config.go | 23 +- pkg/server/api/api.go | 1 + .../console/v1/graphql/third_party.graphql | 36 +- .../api/console/v1/third_party_resolvers.go | 51 +- .../api/console/v1/types/third_party.go | 14 - pkg/server/api/mcp/v1/resolver.go | 2 + pkg/server/api/mcp/v1/schema.resolvers.go | 39 +- pkg/server/api/mcp/v1/specification.yaml | 45 +- pkg/server/api/mcp/v1/types/third_party.go | 22 - pkg/server/api/mcp/v1/v1_handler.go | 13 +- pkg/thirdparty/service.go | 16 +- pkg/thirdparty/vetting.go | 171 +++++++ pkg/thirdparty/vetting_test.go | 119 +++++ pkg/thirdparty/vetting_worker.go | 238 ++++++++++ pkg/vetting/assessment.go | 44 +- pkg/vetting/country_codes.go | 164 +++++++ pkg/vetting/country_codes_test.go | 78 +++ pkg/vetting/openai_schema.go | 189 ++++++++ pkg/vetting/openai_schema_test.go | 51 ++ pkg/vetting/orchestrator.go | 19 +- pkg/vetting/persist.go | 448 ++++++++++++++++++ pkg/vetting/persist_test.go | 108 +++++ pkg/vetting/prompts/extraction.txt | 2 + pkg/vetting/prompts/orchestrator_base.txt | 7 + pkg/vetting/sub_agent.go | 2 +- pkg/vetting/subprocessors.go | 167 +++++++ pkg/vetting/subprocessors_test.go | 178 +++++++ pkg/vetting/tools.go | 146 ++++++ 59 files changed, 3251 insertions(+), 520 deletions(-) rename apps/console/src/pages/organizations/third-parties/dialogs/{ImportAssessmentDialog.tsx => VettingDialog.tsx} (58%) create mode 100644 packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts rename pkg/cmd/thirdpartymgmt/{assess/assess.go => vet/vet.go} (57%) create mode 100644 pkg/coredata/migrations/20260601T120000Z.sql create mode 100644 pkg/coredata/third_party_vetting.go create mode 100644 pkg/coredata/third_party_vetting_status.go rename pkg/probod/{third_party_assessor.go => third_party_vetter.go} (66%) create mode 100644 pkg/thirdparty/vetting.go create mode 100644 pkg/thirdparty/vetting_test.go create mode 100644 pkg/thirdparty/vetting_worker.go create mode 100644 pkg/vetting/country_codes.go create mode 100644 pkg/vetting/country_codes_test.go create mode 100644 pkg/vetting/openai_schema.go create mode 100644 pkg/vetting/openai_schema_test.go create mode 100644 pkg/vetting/persist.go create mode 100644 pkg/vetting/persist_test.go create mode 100644 pkg/vetting/subprocessors.go create mode 100644 pkg/vetting/subprocessors_test.go create mode 100644 pkg/vetting/tools.go diff --git a/.env.example b/.env.example index f2235f2f6..5a2f0b705 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,11 @@ # PROBO_AGENT_MODEL_NAME=claude-sonnet-4-6 # EVIDENCE_DESCRIBER_PROVIDER=openai # EVIDENCE_DESCRIBER_MODEL_NAME=gpt-4o-mini +# AGENT_THIRD_PARTY_VETTER_PROVIDER=openai # inherits AGENT_DEFAULT_PROVIDER when unset +# AGENT_THIRD_PARTY_VETTER_MODEL_NAME=gpt-4o # inherits AGENT_DEFAULT_MODEL_NAME when unset +# THIRD_PARTY_VETTING_INTERVAL=10 +# THIRD_PARTY_VETTING_STALE_AFTER=1500 +# THIRD_PARTY_VETTING_MAX_CONCURRENCY=1 # ── OIDC sign-in providers ──────────────────────────────────────────── # AUTH_GOOGLE_CLIENT_ID= diff --git a/GNUmakefile b/GNUmakefile index a5e9488e4..f265ed06d 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -214,6 +214,7 @@ cfg/dev.yaml: bin/probod-bootstrap $(CFG_DEV_OAUTH2_KEY) compose/pebble/certs/ro AWS_SECRET_ACCESS_KEY=thisisnotasecret; \ AWS_ENDPOINT=http://127.0.0.1:8333; \ OPENAI_API_KEY=thisisnotasecret; \ + AGENT_THIRD_PARTY_VETTER_PROVIDER=openai; \ ACME_DIRECTORY=https://localhost:14000/dir; \ ACME_ROOT_CA="$$($(CAT) compose/pebble/certs/rootCA.pem)"; \ if [ -f $(DEV_ENV) ]; then . $(DEV_ENV); fi; \ diff --git a/apps/console/src/hooks/graph/ThirdPartyGraph.ts b/apps/console/src/hooks/graph/ThirdPartyGraph.ts index 6951b3191..07ae43356 100644 --- a/apps/console/src/hooks/graph/ThirdPartyGraph.ts +++ b/apps/console/src/hooks/graph/ThirdPartyGraph.ts @@ -185,7 +185,8 @@ export const thirdPartyNodeQuery = graphql` name websiteUrl firstLevel - canAssess: permission(action: "core:thirdParty:assess") + vettingStatus + canVet: permission(action: "core:thirdParty:vet") canUpdate: permission(action: "core:thirdParty:update") canDelete: permission(action: "core:thirdParty:delete") canUploadComplianceReport: permission( diff --git a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx index 45a684b80..5a74de0fd 100644 --- a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx @@ -26,13 +26,16 @@ import { TabLink, Tabs, } from "@probo/ui"; +import { useEffect, useRef } from "react"; import { ConnectionHandler, type PreloadedQuery, useFragment, usePreloadedQuery, + useRelayEnvironment, } from "react-relay"; import { Outlet } from "react-router"; +import { fetchQuery } from "relay-runtime"; import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql"; import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql"; @@ -43,7 +46,7 @@ import { } from "#/hooks/graph/ThirdPartyGraph"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog"; +import { VettingDialog } from "./dialogs/VettingDialog"; import { measuresFragment } from "./measures/ThirdPartyMeasuresPage"; import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab"; @@ -54,9 +57,34 @@ type Props = { }; export default function ThirdPartyDetailPage(props: Props) { + const environment = useRelayEnvironment(); const { node: thirdParty } = usePreloadedQuery(thirdPartyNodeQuery, props.queryRef); const { __ } = useTranslate(); const organizationId = useOrganizationId(); + const thirdPartyIdRef = useRef(thirdParty.id); + + useEffect(() => { + thirdPartyIdRef.current = thirdParty.id; + }, [thirdParty.id]); + + const isVetting = thirdParty.vettingStatus === "PENDING" || thirdParty.vettingStatus === "PROCESSING"; + + useEffect(() => { + if (!isVetting) return; + + const interval = setInterval(() => { + if (document.hidden) return; + + fetchQuery( + environment, + thirdPartyNodeQuery, + { thirdPartyId: thirdPartyIdRef.current }, + { fetchPolicy: "network-only" }, + ).subscribe({}); + }, 5000); + + return () => clearInterval(interval); + }, [isVetting, environment]); const deleteThirdParty = useDeleteThirdParty( thirdParty, @@ -74,8 +102,24 @@ export default function ThirdPartyDetailPage(props: Props) { const baseThirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}`; + const isVettingFailed = thirdParty.vettingStatus === "FAILED"; + return (
+ {isVetting && ( +
+
+ {__("Vetting is in progress. Results will appear once the analysis is complete.")} +
+ )} + {isVettingFailed && ( +
+ {__("Vetting failed. You can start vetting again.")} +
+ )}
- {thirdParty.canAssess && ( - + {thirdParty.canVet && !isVetting && ( + - + )} {thirdParty.canDelete && ( diff --git a/apps/console/src/pages/organizations/third-parties/dialogs/ImportAssessmentDialog.tsx b/apps/console/src/pages/organizations/third-parties/dialogs/VettingDialog.tsx similarity index 58% rename from apps/console/src/pages/organizations/third-parties/dialogs/ImportAssessmentDialog.tsx rename to apps/console/src/pages/organizations/third-parties/dialogs/VettingDialog.tsx index 4ba7a1f57..66dee03aa 100644 --- a/apps/console/src/pages/organizations/third-parties/dialogs/ImportAssessmentDialog.tsx +++ b/apps/console/src/pages/organizations/third-parties/dialogs/VettingDialog.tsx @@ -12,6 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { formatError, type GraphQLError } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { Button, @@ -20,25 +21,28 @@ import { DialogFooter, Field, useDialogRef, + useToast, } from "@probo/ui"; import type { ReactNode } from "react"; +import { useMutation } from "react-relay"; import { graphql } from "relay-runtime"; import { z } from "zod"; +import type { VettingDialogMutation } from "#/__generated__/core/VettingDialogMutation.graphql"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; -import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; const schema = z.object({ url: z.string().url(), }); -const importAssessmentMutation = graphql` - mutation ImportAssessmentDialogMutation($input: AssessThirdPartyInput!) { - assessThirdParty(input: $input) { +const vetMutation = graphql` + mutation VettingDialogMutation($input: VetThirdPartyInput!) { + vetThirdParty(input: $input) { thirdParty { id name websiteUrl + vettingStatus ...useThirdPartyFormFragment ...ThirdPartyComplianceTabFragment ...ThirdPartyRiskAssessmentTabFragment @@ -47,42 +51,64 @@ const importAssessmentMutation = graphql` } `; -type Props = { +interface VettingDialogProps { thirdPartyId: string; + websiteUrl?: string | null; children: ReactNode; -}; +} -export function ImportAssessmentDialog({ thirdPartyId, children }: Props) { +export function VettingDialog({ thirdPartyId, websiteUrl, children }: VettingDialogProps) { const { __ } = useTranslate(); + const { toast } = useToast(); const dialogRef = useDialogRef(); const { register, handleSubmit, reset, formState } = useFormWithSchema( schema, { defaultValues: { - url: "", + url: websiteUrl ?? "", }, }, ); - const [assess, isAssessing] = useMutationWithToasts( - importAssessmentMutation, - { - successMessage: __("Third party assessed successfully."), - errorMessage: __("Failed to assess third party"), - }, - ); + const [vet, isVetting] = useMutation(vetMutation); - const onSubmit = async (data: z.infer) => { - await assess({ + const onSubmit = (data: z.infer) => { + vet({ variables: { input: { id: thirdPartyId, websiteUrl: data.url, }, }, - onSuccess: () => { + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to start vetting."), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("The third party is being vetted in the background."), + variant: "success", + }); dialogRef.current?.close(); reset(); }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to start vetting."), + error as GraphQLError, + ), + variant: "error", + }); + }, }); }; @@ -90,22 +116,22 @@ export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
void handleSubmit(onSubmit)(e)}> -
diff --git a/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx b/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx index 0780c2e13..4f88b07b3 100644 --- a/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx +++ b/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx @@ -214,7 +214,9 @@ function AssessmentRow(props: AssessmentRowProps) { {__("Notes")} :
-

{assessment.notes}

+

+ {assessment.notes} +

diff --git a/contrib/helm/charts/probo/templates/deployment.yaml b/contrib/helm/charts/probo/templates/deployment.yaml index 03ec276f5..b1ed6f468 100644 --- a/contrib/helm/charts/probo/templates/deployment.yaml +++ b/contrib/helm/charts/probo/templates/deployment.yaml @@ -247,6 +247,36 @@ spec: name: {{ include "probo.fullname" . }} key: firecrawl-api-key {{- end }} + # Third-Party Vetter Agent + {{- if .Values.probo.thirdPartyVetter.provider }} + - name: AGENT_THIRD_PARTY_VETTER_PROVIDER + value: {{ .Values.probo.thirdPartyVetter.provider | quote }} + {{- end }} + {{- if .Values.probo.thirdPartyVetter.modelName }} + - name: AGENT_THIRD_PARTY_VETTER_MODEL_NAME + value: {{ .Values.probo.thirdPartyVetter.modelName | quote }} + {{- end }} + {{- if .Values.probo.thirdPartyVetter.temperature }} + - name: AGENT_THIRD_PARTY_VETTER_TEMPERATURE + value: {{ .Values.probo.thirdPartyVetter.temperature | quote }} + {{- end }} + {{- if .Values.probo.thirdPartyVetter.maxTokens }} + - name: AGENT_THIRD_PARTY_VETTER_MAX_TOKENS + value: {{ .Values.probo.thirdPartyVetter.maxTokens | quote }} + {{- end }} + # Third-Party Vetting Worker + {{- if .Values.probo.thirdPartyVettingWorker.interval }} + - name: THIRD_PARTY_VETTING_INTERVAL + value: {{ .Values.probo.thirdPartyVettingWorker.interval | quote }} + {{- end }} + {{- if .Values.probo.thirdPartyVettingWorker.maxConcurrency }} + - name: THIRD_PARTY_VETTING_MAX_CONCURRENCY + value: {{ .Values.probo.thirdPartyVettingWorker.maxConcurrency | quote }} + {{- end }} + {{- if .Values.probo.thirdPartyVettingWorker.staleAfter }} + - name: THIRD_PARTY_VETTING_STALE_AFTER + value: {{ .Values.probo.thirdPartyVettingWorker.staleAfter | quote }} + {{- end }} # Tracker Mapping Agent {{- if .Values.probo.trackerMapping.provider }} - name: AGENT_TRACKER_MAPPING_PROVIDER diff --git a/contrib/helm/charts/probo/values-production.yaml.example b/contrib/helm/charts/probo/values-production.yaml.example index e3788428c..ce60edc88 100644 --- a/contrib/helm/charts/probo/values-production.yaml.example +++ b/contrib/helm/charts/probo/values-production.yaml.example @@ -169,6 +169,19 @@ probo: # agentTools: # firecrawlApiKey: "CHANGE_ME_FIRECRAWL_API_KEY" + # Third-party vetter agent (optional, AI-powered third-party vetting) + # thirdPartyVetter: + # provider: "openai" + # modelName: "gpt-4o" + # temperature: "0.3" + # maxTokens: "8192" + + # Third-party vetting worker tuning (optional; seconds for interval/staleAfter). + # thirdPartyVettingWorker: + # interval: 10 + # maxConcurrency: 1 + # staleAfter: 1500 + # Tracker mapping agent (optional, auto-links tracker patterns to vendors) # trackerMapping: # provider: "openai" diff --git a/contrib/helm/charts/probo/values.yaml b/contrib/helm/charts/probo/values.yaml index ba7be9c3e..ead9d261e 100644 --- a/contrib/helm/charts/probo/values.yaml +++ b/contrib/helm/charts/probo/values.yaml @@ -266,9 +266,24 @@ probo: # Agent tools (optional, shared across agents) agentTools: - # Firecrawl web search API key (used by tracker mapping and third-party assessor agents) + # Firecrawl web search API key (used by tracker mapping and third-party vetter agents) firecrawlApiKey: "" + # Third-party vetter agent (optional, requires openai.apiKey or anthropic key) + thirdPartyVetter: + provider: "" + modelName: "" + temperature: "" + maxTokens: "" + + # Third-party vetting background worker tuning (optional). interval and + # staleAfter are in seconds. Keep concurrency modest to stay under LLM + # rate limits and the database connection pool. + thirdPartyVettingWorker: + interval: 10 + maxConcurrency: 1 + staleAfter: 1500 + # Tracker mapping agent (optional, requires openai.apiKey or anthropic key) trackerMapping: provider: "" diff --git a/e2e/console/third_party_test.go b/e2e/console/third_party_test.go index cb7208e82..7f1feb1e1 100644 --- a/e2e/console/third_party_test.go +++ b/e2e/console/third_party_test.go @@ -987,18 +987,17 @@ func TestThirdParty_OmittableWebsiteUrl(t *testing.T) { }) } -// TestThirdParty_Assess exercises the assessThirdParty mutation through authorization -// and tenant-isolation paths without running the real LLM/browser pipeline. -// The e2e config deliberately omits `llm.third-party-assessor.provider`, so an -// authorized call reaches DisabledThirdPartyAssessor and surfaces a stable -// UNAVAILABLE error. Happy-path payload shape is covered by unit tests in -// pkg/probo. -func TestThirdParty_Assess(t *testing.T) { +// TestThirdParty_Vet exercises the vetThirdParty mutation through authorization +// and tenant-isolation paths without running the real LLM/browser pipeline to +// completion. The e2e config sets OPENAI_API_KEY and inherits the default +// agent provider, so authorized calls enqueue vetting and return the third +// party. Request validation is covered by unit tests in pkg/thirdparty. +func TestThirdParty_Vet(t *testing.T) { t.Parallel() const query = ` - mutation AssessThirdParty($input: AssessThirdPartyInput!) { - assessThirdParty(input: $input) { + mutation VetThirdParty($input: VetThirdPartyInput!) { + vetThirdParty(input: $input) { thirdParty { id } @@ -1007,18 +1006,18 @@ func TestThirdParty_Assess(t *testing.T) { ` type resultShape struct { - AssessThirdParty struct { + VetThirdParty struct { ThirdParty struct { ID string `json:"id"` } `json:"thirdParty"` - } `json:"assessThirdParty"` + } `json:"vetThirdParty"` } - t.Run("owner call surfaces the disabled error", func(t *testing.T) { + t.Run("owner call enqueues vetting", func(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) - thirdPartyID := factory.NewThirdParty(owner).WithName("Unconfigured assess").Create() + thirdPartyID := factory.NewThirdParty(owner).WithName("Unconfigured vet").Create() var result resultShape @@ -1028,15 +1027,16 @@ func TestThirdParty_Assess(t *testing.T) { "websiteUrl": "https://thirdParty.example.com", }, }, &result) - testutil.RequireErrorCode(t, err, "UNAVAILABLE") + require.NoError(t, err) + assert.Equal(t, thirdPartyID, result.VetThirdParty.ThirdParty.ID) }) - t.Run("admin call surfaces the disabled error", func(t *testing.T) { + t.Run("admin call enqueues vetting", func(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner) - thirdPartyID := factory.NewThirdParty(owner).WithName("Admin-assessed thirdParty").Create() + thirdPartyID := factory.NewThirdParty(owner).WithName("Admin-vetted thirdParty").Create() var result resultShape @@ -1046,10 +1046,11 @@ func TestThirdParty_Assess(t *testing.T) { "websiteUrl": "https://admin.example.com", }, }, &result) - testutil.RequireErrorCode(t, err, "UNAVAILABLE") + require.NoError(t, err) + assert.Equal(t, thirdPartyID, result.VetThirdParty.ThirdParty.ID) }) - t.Run("viewer cannot assess a thirdParty", func(t *testing.T) { + t.Run("viewer cannot vet a thirdParty", func(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) @@ -1067,7 +1068,7 @@ func TestThirdParty_Assess(t *testing.T) { testutil.RequireForbiddenError(t, err) }) - t.Run("cannot assess thirdParty from another organization", func(t *testing.T) { + t.Run("cannot vet thirdParty from another organization", func(t *testing.T) { t.Parallel() org1Owner := testutil.NewClient(t, testutil.RoleOwner) @@ -1082,7 +1083,7 @@ func TestThirdParty_Assess(t *testing.T) { "websiteUrl": "https://cross-tenant.example.com", }, }, &result) - require.Error(t, err, "thirdParty assess must not cross tenant boundaries") + require.Error(t, err, "thirdParty vet must not cross tenant boundaries") }) t.Run("procedure is accepted on the input", func(t *testing.T) { @@ -1100,7 +1101,8 @@ func TestThirdParty_Assess(t *testing.T) { "procedure": "Focus on SOC 2 controls and data residency", }, }, &result) - testutil.RequireErrorCode(t, err, "UNAVAILABLE") + require.NoError(t, err) + assert.Equal(t, thirdPartyID, result.VetThirdParty.ThirdParty.ID) }) } diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts index 2f4fdedf9..37decb46b 100644 --- a/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts @@ -43,6 +43,7 @@ import * as linkThirdPartyOp from './linkThirdParty.operation'; import * as unlinkThirdPartyOp from './unlinkThirdParty.operation'; import * as listChildThirdPartiesOp from './listChildThirdParties.operation'; import * as publishOp from './publish.operation'; +import * as vetOp from './vet.operation'; export const description: INodeProperties[] = [ { @@ -236,6 +237,12 @@ export const description: INodeProperties[] = [ description: 'Update an existing third party service', action: 'Update a third party service', }, + { + name: 'Vet', + value: 'vet', + description: 'Start AI-powered vetting of a third party from its website', + action: 'Vet a third party', + }, ], default: 'create', }, @@ -269,6 +276,7 @@ export const description: INodeProperties[] = [ ...unlinkThirdPartyOp.description, ...listChildThirdPartiesOp.description, ...publishOp.description, + ...vetOp.description, ]; export { @@ -302,4 +310,5 @@ export { unlinkThirdPartyOp as unlinkThirdParty, listChildThirdPartiesOp as listChildThirdParties, publishOp as publish, + vetOp as vet, }; diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts new file mode 100644 index 000000000..4d3fc75a0 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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: 'ThirdParty ID', + name: 'thirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['vet'], + }, + }, + default: '', + description: 'The ID of the third party to vet', + required: true, + }, + { + displayName: 'Website URL', + name: 'websiteUrl', + type: 'string', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['vet'], + }, + }, + default: '', + description: 'The website URL to crawl for vetting', + required: true, + }, + { + displayName: 'Procedure', + name: 'procedure', + type: 'string', + typeOptions: { + rows: 4, + }, + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['vet'], + }, + }, + default: '', + description: 'Optional custom vetting procedure instructions', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string; + const websiteUrl = this.getNodeParameter('websiteUrl', itemIndex) as string; + const procedure = this.getNodeParameter('procedure', itemIndex, '') as string; + + const query = ` + mutation VetThirdParty($input: VetThirdPartyInput!) { + vetThirdParty(input: $input) { + thirdParty { + id + name + websiteUrl + vettingStatus + updatedAt + } + } + } + `; + + const input: Record = { + id: thirdPartyId, + websiteUrl, + }; + + if (procedure) { + input.procedure = procedure; + } + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/agent/tool_test.go b/pkg/agent/tool_test.go index d30e7ce64..eea320376 100644 --- a/pkg/agent/tool_test.go +++ b/pkg/agent/tool_test.go @@ -356,7 +356,7 @@ func TestFunctionTool_Execute(t *testing.T) { ) t.Run( - "optional fields can be omitted", + "optional fields may be empty but must be present", func(t *testing.T) { t.Parallel() @@ -373,7 +373,7 @@ func TestFunctionTool_Execute(t *testing.T) { }, ) - result, err := tool.Execute(context.Background(), `{"city":"Paris"}`) + result, err := tool.Execute(context.Background(), `{"city":"Paris","units":""}`) require.NoError(t, err) assert.False(t, result.IsError) assert.Equal(t, "sunny in Paris", result.Content) diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index ea49a6bd6..aa480c76a 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -206,6 +206,12 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { Temperature: b.getEnvFloatPtr("AGENT_EVIDENCE_DESCRIBER_TEMPERATURE"), MaxTokens: b.getEnvIntPtr("AGENT_EVIDENCE_DESCRIBER_MAX_TOKENS"), }, + ThirdPartyVetter: probodconfig.LLMAgentConfig{ + Provider: b.getEnvOrDefault("AGENT_THIRD_PARTY_VETTER_PROVIDER", ""), + ModelName: b.getEnvOrDefault("AGENT_THIRD_PARTY_VETTER_MODEL_NAME", ""), + Temperature: b.getEnvFloatPtr("AGENT_THIRD_PARTY_VETTER_TEMPERATURE"), + MaxTokens: b.getEnvIntPtr("AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"), + }, TrackerMapping: probodconfig.LLMAgentConfig{ Provider: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_PROVIDER", ""), ModelName: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_MODEL_NAME", ""), @@ -246,6 +252,11 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { StaleAfter: b.getEnvIntOrDefault("EVIDENCE_DESCRIBER_STALE_AFTER", 300), MaxConcurrency: b.getEnvIntOrDefault("EVIDENCE_DESCRIBER_MAX_CONCURRENCY", 10), }, + ThirdPartyVetting: probodconfig.ThirdPartyVettingWorkerConfig{ + Interval: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_INTERVAL", 10), + StaleAfter: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_STALE_AFTER", 1500), + MaxConcurrency: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_MAX_CONCURRENCY", 1), + }, TrackerMappingWorker: probodconfig.TrackerMappingWorkerConfig{ Interval: b.getEnvIntOrDefault("TRACKER_MAPPING_INTERVAL", 10), MaxConcurrency: b.getEnvIntOrDefault("TRACKER_MAPPING_MAX_CONCURRENCY", 3), diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index c7e928231..295848d2d 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -215,6 +215,10 @@ func TestBuilder_Build_Defaults(t *testing.T) { assert.Empty(t, cfg.Probod.Agents.EvidenceDescriber.ModelName) assert.Nil(t, cfg.Probod.Agents.EvidenceDescriber.Temperature) assert.Nil(t, cfg.Probod.Agents.EvidenceDescriber.MaxTokens) + assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.Provider) + assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.ModelName) + assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.Temperature) + assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.MaxTokens) assert.Empty(t, cfg.Probod.Agents.TrackerMapping.Provider) assert.Empty(t, cfg.Probod.Agents.TrackerMapping.ModelName) assert.Nil(t, cfg.Probod.Agents.TrackerMapping.Temperature) @@ -231,6 +235,9 @@ func TestBuilder_Build_Defaults(t *testing.T) { assert.Equal(t, 600, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter) assert.Equal(t, 45, cfg.Probod.CommonPatternEnrichmentWorker.AgentTimeout) assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.AgentMaxTurns) + assert.Equal(t, 10, cfg.Probod.ThirdPartyVetting.Interval) + assert.Equal(t, 1500, cfg.Probod.ThirdPartyVetting.StaleAfter) + assert.Equal(t, 1, cfg.Probod.ThirdPartyVetting.MaxConcurrency) // Custom domains config assert.Equal(t, 3600, cfg.Probod.CustomDomains.RenewalInterval) @@ -320,6 +327,11 @@ func TestBuilder_Build_CustomValues(t *testing.T) { env["AGENT_EVIDENCE_DESCRIBER_MODEL_NAME"] = "claude-sonnet-4-20250514" env["AGENT_EVIDENCE_DESCRIBER_TEMPERATURE"] = "0.2" env["AGENT_EVIDENCE_DESCRIBER_MAX_TOKENS"] = "4096" + // Agents — third-party-vetter override + env["AGENT_THIRD_PARTY_VETTER_PROVIDER"] = "openai" + env["AGENT_THIRD_PARTY_VETTER_MODEL_NAME"] = "gpt-4o" + env["AGENT_THIRD_PARTY_VETTER_TEMPERATURE"] = "0.3" + env["AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"] = "8192" // Agents — tracker-mapping override env["AGENT_TRACKER_MAPPING_PROVIDER"] = "openai" env["AGENT_TRACKER_MAPPING_MODEL_NAME"] = "gpt-4o-mini" @@ -336,6 +348,9 @@ func TestBuilder_Build_CustomValues(t *testing.T) { env["COMMON_PATTERN_ENRICHMENT_STALE_AFTER"] = "900" env["COMMON_PATTERN_ENRICHMENT_AGENT_TIMEOUT"] = "50" env["COMMON_PATTERN_ENRICHMENT_AGENT_MAX_TURNS"] = "5" + env["THIRD_PARTY_VETTING_INTERVAL"] = "15" + env["THIRD_PARTY_VETTING_STALE_AFTER"] = "1800" + env["THIRD_PARTY_VETTING_MAX_CONCURRENCY"] = "2" // Custom domains env["CUSTOM_DOMAINS_RESOLVER_ADDR"] = "1.1.1.1:53" env["ACME_ACCOUNT_KEY"] = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----" @@ -422,6 +437,11 @@ func TestBuilder_Build_CustomValues(t *testing.T) { assert.Equal(t, "claude-sonnet-4-20250514", cfg.Probod.Agents.EvidenceDescriber.ModelName) assert.Equal(t, new(0.2), cfg.Probod.Agents.EvidenceDescriber.Temperature) assert.Equal(t, new(4096), cfg.Probod.Agents.EvidenceDescriber.MaxTokens) + // Agents — third-party-vetter overrides + assert.Equal(t, "openai", cfg.Probod.Agents.ThirdPartyVetter.Provider) + assert.Equal(t, "gpt-4o", cfg.Probod.Agents.ThirdPartyVetter.ModelName) + assert.Equal(t, new(0.3), cfg.Probod.Agents.ThirdPartyVetter.Temperature) + assert.Equal(t, new(8192), cfg.Probod.Agents.ThirdPartyVetter.MaxTokens) // Agents — tracker-mapping overrides assert.Equal(t, "openai", cfg.Probod.Agents.TrackerMapping.Provider) assert.Equal(t, "gpt-4o-mini", cfg.Probod.Agents.TrackerMapping.ModelName) @@ -438,6 +458,9 @@ func TestBuilder_Build_CustomValues(t *testing.T) { assert.Equal(t, 900, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter) assert.Equal(t, 50, cfg.Probod.CommonPatternEnrichmentWorker.AgentTimeout) assert.Equal(t, 5, cfg.Probod.CommonPatternEnrichmentWorker.AgentMaxTurns) + assert.Equal(t, 15, cfg.Probod.ThirdPartyVetting.Interval) + assert.Equal(t, 1800, cfg.Probod.ThirdPartyVetting.StaleAfter) + assert.Equal(t, 2, cfg.Probod.ThirdPartyVetting.MaxConcurrency) // Custom domains assert.Equal(t, "1.1.1.1:53", cfg.Probod.CustomDomains.ResolverAddr) assert.Equal(t, "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----", cfg.Probod.CustomDomains.ACME.AccountKey) diff --git a/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go b/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go index 928eb2dbe..431547b1c 100644 --- a/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go +++ b/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go @@ -17,7 +17,6 @@ package thirdpartymgmt import ( "github.com/spf13/cobra" "go.probo.inc/probo/pkg/cmd/cmdutil" - "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" @@ -25,6 +24,7 @@ import ( "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/vet" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view" ) @@ -39,7 +39,7 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(view.NewCmdView(f)) cmd.AddCommand(update.NewCmdUpdate(f)) cmd.AddCommand(delete.NewCmdDelete(f)) - cmd.AddCommand(assess.NewCmdAssess(f)) + cmd.AddCommand(vet.NewCmdVet(f)) cmd.AddCommand(publish.NewCmdPublish(f)) cmd.AddCommand(link.NewCmdLink(f)) cmd.AddCommand(unlink.NewCmdUnlink(f)) diff --git a/pkg/cmd/thirdpartymgmt/assess/assess.go b/pkg/cmd/thirdpartymgmt/vet/vet.go similarity index 57% rename from pkg/cmd/thirdpartymgmt/assess/assess.go rename to pkg/cmd/thirdpartymgmt/vet/vet.go index 1565a5887..aa56976d2 100644 --- a/pkg/cmd/thirdpartymgmt/assess/assess.go +++ b/pkg/cmd/thirdpartymgmt/vet/vet.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package assess +package vet import ( "encoding/json" @@ -25,16 +25,10 @@ import ( "go.probo.inc/probo/pkg/cmd/cmdutil" ) -const assessMutation = ` -mutation($input: AssessThirdPartyInput!) { - assessThirdParty(input: $input) { - report - subprocessors { - name - country - purpose - } - third_party { +const vetMutation = ` +mutation($input: VetThirdPartyInput!) { + vetThirdParty(input: $input) { + thirdParty { id name } @@ -42,38 +36,32 @@ mutation($input: AssessThirdPartyInput!) { } ` -type assessResponse struct { - AssessThirdParty struct { - Report string `json:"report"` - Subprocessors []struct { - Name string `json:"name"` - Country string `json:"country"` - Purpose string `json:"purpose"` - } `json:"subprocessors"` +type vetResponse struct { + VetThirdParty struct { ThirdParty struct { ID string `json:"id"` Name string `json:"name"` - } `json:"third_party"` - } `json:"assessThirdParty"` + } `json:"thirdParty"` + } `json:"vetThirdParty"` } -func NewCmdAssess(f *cmdutil.Factory) *cobra.Command { +func NewCmdVet(f *cmdutil.Factory) *cobra.Command { var ( flagOutput *string ) cmd := &cobra.Command{ - Use: "assess --url ", - Short: "Run AI assessment on a thirdParty from its website", - Long: "Analyze a thirdParty's website using AI agents to extract security, compliance, and business information.", - Example: ` # Assess a third_party by website URL - prb third_party assess VND_123 --url https://example.com + Use: "vet --url ", + Short: "Start AI vetting of a third party from its website", + Long: "Queue a vetting job that crawls a third party's website using AI agents to extract security, compliance, and business information.", + Example: ` # Vet a third party by website URL + prb third-party vet VND_123 --url https://example.com - # Assess with a custom procedure file - prb third_party assess VND_123 --url https://example.com --procedure-file ./my-procedure.txt + # Vet with a custom procedure file + prb third-party vet VND_123 --url https://example.com --procedure-file ./my-procedure.txt # Output as JSON - prb third_party assess VND_123 --url https://example.com -o json`, + prb third-party vet VND_123 --url https://example.com -o json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { @@ -107,19 +95,17 @@ func NewCmdAssess(f *cmdutil.Factory) *cobra.Command { input["procedure"] = string(data) } - // The CLI timeout must outlast the server-side assessment - // timeout (vetting.AssessmentTimeout = 20m) plus HTTP overhead. client := api.NewClient( host, hc.Token, "/api/console/v1/graphql", - 22*time.Minute, + 30*time.Second, ) - _, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Assessing thirdParty from %s (this may take a few minutes)...\n", flagURL) + _, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Starting vetting for %s...\n", flagURL) data, err := client.Do( - assessMutation, + vetMutation, map[string]any{ "input": input, }, @@ -128,24 +114,24 @@ func NewCmdAssess(f *cmdutil.Factory) *cobra.Command { return err } - var resp assessResponse + var resp vetResponse if err := json.Unmarshal(data, &resp); err != nil { return fmt.Errorf("cannot parse response: %w", err) } if *flagOutput == cmdutil.OutputJSON { - return cmdutil.PrintJSON(f.IOStreams.Out, resp.AssessThirdParty) + return cmdutil.PrintJSON(f.IOStreams.Out, resp.VetThirdParty) } - _, _ = fmt.Fprintln(f.IOStreams.Out, resp.AssessThirdParty.Report) + _, _ = fmt.Fprintf(f.IOStreams.Out, "Vetting started for %s\n", resp.VetThirdParty.ThirdParty.Name) return nil }, } - cmd.Flags().String("url", "", "ThirdParty website URL to assess (required)") + cmd.Flags().String("url", "", "Third party website URL to vet (required)") _ = cmd.MarkFlagRequired("url") - cmd.Flags().String("procedure-file", "", "Path to a custom assessment procedure file") + cmd.Flags().String("procedure-file", "", "Path to a custom vetting procedure file") flagOutput = cmdutil.AddOutputFlag(cmd) return cmd diff --git a/pkg/cookiebanner/pattern_analysis_worker_process_test.go b/pkg/cookiebanner/pattern_analysis_worker_process_test.go index 98a52be36..52f466bfc 100644 --- a/pkg/cookiebanner/pattern_analysis_worker_process_test.go +++ b/pkg/cookiebanner/pattern_analysis_worker_process_test.go @@ -281,7 +281,6 @@ func seedThirdParty(t *testing.T, ctx context.Context, client *pg.Client, fx wor party := coredata.ThirdParty{ ID: id, - TenantID: fx.scope.GetTenantID(), OrganizationID: fx.organizationID, Name: name, Category: coredata.ThirdPartyCategoryAnalytics, diff --git a/pkg/coredata/migrations/20260601T120000Z.sql b/pkg/coredata/migrations/20260601T120000Z.sql new file mode 100644 index 000000000..371e85559 --- /dev/null +++ b/pkg/coredata/migrations/20260601T120000Z.sql @@ -0,0 +1,24 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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. + +CREATE TYPE third_party_vetting_status AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'); + +ALTER TABLE third_parties + ADD COLUMN vetting_status third_party_vetting_status, + ADD COLUMN vetting_website_url TEXT, + ADD COLUMN vetting_procedure TEXT, + ADD COLUMN vetting_processing_started_at TIMESTAMPTZ, + ADD COLUMN vetting_error_message TEXT; + +ALTER TABLE third_party_third_parties ADD COLUMN purpose TEXT; diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index 43919b989..f6bc44f82 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -138,33 +138,37 @@ WHERE type ( ThirdParty struct { - ID gid.GID `db:"id"` - TenantID gid.TenantID `db:"tenant_id"` - OrganizationID gid.GID `db:"organization_id"` - CommonThirdPartyID *gid.GID `db:"common_third_party_id"` - Name string `db:"name"` - Description *string `db:"description"` - Category ThirdPartyCategory `db:"category"` - HeadquarterAddress *string `db:"headquarter_address"` - LegalName *string `db:"legal_name"` - WebsiteURL *string `db:"website_url"` - PrivacyPolicyURL *string `db:"privacy_policy_url"` - ServiceLevelAgreementURL *string `db:"service_level_agreement_url"` - DataProcessingAgreementURL *string `db:"data_processing_agreement_url"` - BusinessAssociateAgreementURL *string `db:"business_associate_agreement_url"` - SubprocessorsListURL *string `db:"subprocessors_list_url"` - Certifications []string `db:"certifications"` - Countries CountryCodes `db:"countries"` - BusinessOwnerID *gid.GID `db:"business_owner_profile_id"` - SecurityOwnerID *gid.GID `db:"security_owner_profile_id"` - StatusPageURL *string `db:"status_page_url"` - TermsOfServiceURL *string `db:"terms_of_service_url"` - 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"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + CommonThirdPartyID *gid.GID `db:"common_third_party_id"` + Name string `db:"name"` + Description *string `db:"description"` + Category ThirdPartyCategory `db:"category"` + HeadquarterAddress *string `db:"headquarter_address"` + LegalName *string `db:"legal_name"` + WebsiteURL *string `db:"website_url"` + PrivacyPolicyURL *string `db:"privacy_policy_url"` + ServiceLevelAgreementURL *string `db:"service_level_agreement_url"` + DataProcessingAgreementURL *string `db:"data_processing_agreement_url"` + BusinessAssociateAgreementURL *string `db:"business_associate_agreement_url"` + SubprocessorsListURL *string `db:"subprocessors_list_url"` + Certifications []string `db:"certifications"` + Countries CountryCodes `db:"countries"` + BusinessOwnerID *gid.GID `db:"business_owner_profile_id"` + SecurityOwnerID *gid.GID `db:"security_owner_profile_id"` + StatusPageURL *string `db:"status_page_url"` + TermsOfServiceURL *string `db:"terms_of_service_url"` + SecurityPageURL *string `db:"security_page_url"` + TrustPageURL *string `db:"trust_page_url"` + ShowOnTrustCenter bool `db:"show_on_trust_center"` + FirstLevel bool `db:"first_level"` + VettingStatus *ThirdPartyVettingStatus `db:"vetting_status"` + VettingWebsiteURL *string `db:"vetting_website_url"` + VettingProcedure *string `db:"vetting_procedure"` + VettingProcessingStartedAt *time.Time `db:"vetting_processing_started_at"` + VettingErrorMessage *string `db:"vetting_error_message"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } ThirdParties []*ThirdParty @@ -231,7 +235,6 @@ func (v *ThirdParty) LoadByID( q := ` SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -255,6 +258,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -290,16 +298,15 @@ LIMIT 1; return nil } -func (v *ThirdParties) LoadByIDs( +func (v *ThirdParty) LoadByIDForUpdate( ctx context.Context, - conn pg.Querier, + conn pg.Tx, scope Scoper, - thirdPartyIDs []gid.GID, + thirdPartyID gid.GID, ) error { q := ` SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -323,6 +330,161 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, + created_at, + updated_at +FROM + third_parties +WHERE + %s + AND id = @third_party_id +LIMIT 1 +FOR UPDATE; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"third_party_id": thirdPartyID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query thirdParty: %w", err) + } + defer rows.Close() + + thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect thirdParty: %w", err) + } + + *v = thirdParty + + return nil +} + +func (v *ThirdParty) LoadByNameAndOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + name string, + organizationID gid.GID, +) error { + q := ` +SELECT + 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, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, + created_at, + updated_at +FROM + third_parties +WHERE + %s + AND organization_id = @organization_id + AND name = @name +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "organization_id": organizationID, + "name": name, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query thirdParty by name: %w", err) + } + defer rows.Close() + + thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect thirdParty: %w", err) + } + + *v = thirdParty + + return nil +} + +func (v *ThirdParties) LoadByIDs( + ctx context.Context, + conn pg.Querier, + scope Scoper, + thirdPartyIDs []gid.GID, +) error { + q := ` +SELECT + 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, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -385,6 +547,11 @@ INSERT INTO trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at ) @@ -414,6 +581,11 @@ VALUES ( @trust_page_url, @show_on_trust_center, @first_level, + @vetting_status, + @vetting_website_url, + @vetting_procedure, + @vetting_processing_started_at, + @vetting_error_message, @created_at, @updated_at ) @@ -445,6 +617,11 @@ VALUES ( "trust_page_url": v.TrustPageURL, "show_on_trust_center": v.ShowOnTrustCenter, "first_level": v.FirstLevel, + "vetting_status": v.VettingStatus, + "vetting_website_url": v.VettingWebsiteURL, + "vetting_procedure": v.VettingProcedure, + "vetting_processing_started_at": v.VettingProcessingStartedAt, + "vetting_error_message": v.VettingErrorMessage, "created_at": v.CreatedAt, "updated_at": v.UpdatedAt, } @@ -517,7 +694,6 @@ func (v *ThirdParties) LoadAllByOrganizationID( q := ` SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -541,6 +717,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -581,7 +762,6 @@ func (v *ThirdParties) LoadByOrganizationID( q := ` SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -605,6 +785,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -667,6 +852,11 @@ SET security_owner_profile_id = @security_owner_profile_id, show_on_trust_center = @show_on_trust_center, first_level = @first_level, + vetting_status = @vetting_status, + vetting_website_url = @vetting_website_url, + vetting_procedure = @vetting_procedure, + vetting_processing_started_at = @vetting_processing_started_at, + vetting_error_message = @vetting_error_message, updated_at = @updated_at WHERE %s AND id = @third_party_id @@ -698,13 +888,25 @@ WHERE %s "security_owner_profile_id": v.SecurityOwnerID, "show_on_trust_center": v.ShowOnTrustCenter, "first_level": v.FirstLevel, + "vetting_status": v.VettingStatus, + "vetting_website_url": v.VettingWebsiteURL, + "vetting_procedure": v.VettingProcedure, + "vetting_processing_started_at": v.VettingProcessingStartedAt, + "vetting_error_message": v.VettingErrorMessage, } maps.Copy(args, scope.SQLArguments()) - _, err := conn.Exec(ctx, q, args) + result, err := conn.Exec(ctx, q, args) + if err != nil { + return err + } - return err + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil } func (v ThirdParty) ExpireNonExpiredRiskAssessments( @@ -816,6 +1018,11 @@ WITH vend AS ( v.trust_page_url, v.show_on_trust_center, v.first_level, + v.vetting_status, + v.vetting_website_url, + v.vetting_procedure, + v.vetting_processing_started_at, + v.vetting_error_message, v.created_at, v.updated_at FROM @@ -827,7 +1034,6 @@ WITH vend AS ( ) SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -851,6 +1057,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -953,6 +1164,11 @@ WITH vend AS ( v.trust_page_url, v.show_on_trust_center, v.first_level, + v.vetting_status, + v.vetting_website_url, + v.vetting_procedure, + v.vetting_processing_started_at, + v.vetting_error_message, v.created_at, v.updated_at FROM @@ -964,7 +1180,6 @@ WITH vend AS ( ) SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -988,6 +1203,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -1050,6 +1270,11 @@ WITH vend AS ( v.trust_page_url, v.show_on_trust_center, v.first_level, + v.vetting_status, + v.vetting_website_url, + v.vetting_procedure, + v.vetting_processing_started_at, + v.vetting_error_message, v.created_at, v.updated_at FROM @@ -1061,7 +1286,6 @@ WITH vend AS ( ) SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -1085,6 +1309,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -1148,6 +1377,11 @@ WITH vend AS ( v.trust_page_url, v.show_on_trust_center, v.first_level, + v.vetting_status, + v.vetting_website_url, + v.vetting_procedure, + v.vetting_processing_started_at, + v.vetting_error_message, v.created_at, v.updated_at FROM @@ -1159,7 +1393,6 @@ WITH vend AS ( ) SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -1183,6 +1416,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -1314,6 +1552,11 @@ WITH vend AS ( v.trust_page_url, v.show_on_trust_center, v.first_level, + v.vetting_status, + v.vetting_website_url, + v.vetting_procedure, + v.vetting_processing_started_at, + v.vetting_error_message, v.created_at, v.updated_at FROM @@ -1325,7 +1568,6 @@ WITH vend AS ( ) SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -1349,6 +1591,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -1386,7 +1633,6 @@ func (v *ThirdParty) LoadByOrganizationIDAndCommonThirdPartyID( q := ` SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -1410,6 +1656,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM @@ -1525,6 +1776,11 @@ WITH tps AS ( v.trust_page_url, v.show_on_trust_center, v.first_level, + v.vetting_status, + v.vetting_website_url, + v.vetting_procedure, + v.vetting_processing_started_at, + v.vetting_error_message, v.created_at, v.updated_at FROM @@ -1536,7 +1792,6 @@ WITH tps AS ( ) SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -1560,6 +1815,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM diff --git a/pkg/coredata/third_party_third_party.go b/pkg/coredata/third_party_third_party.go index ed0ed23f6..1da5261d3 100644 --- a/pkg/coredata/third_party_third_party.go +++ b/pkg/coredata/third_party_third_party.go @@ -32,6 +32,7 @@ type ( ChildThirdPartyID gid.GID `db:"child_third_party_id"` TenantID gid.TenantID `db:"tenant_id"` CreatedAt time.Time `db:"created_at"` + Purpose *string `db:"purpose"` } ThirdPartyThirdParties []*ThirdPartyThirdParty @@ -43,14 +44,17 @@ INSERT INTO third_party_third_parties ( parent_third_party_id, child_third_party_id, tenant_id, - created_at + created_at, + purpose ) VALUES ( @parent_third_party_id, @child_third_party_id, @tenant_id, - @created_at + @created_at, + @purpose ) -ON CONFLICT (parent_third_party_id, child_third_party_id) DO NOTHING +ON CONFLICT (parent_third_party_id, child_third_party_id) DO UPDATE SET + purpose = COALESCE(EXCLUDED.purpose, third_party_third_parties.purpose) ` args := pgx.StrictNamedArgs{ @@ -58,6 +62,7 @@ ON CONFLICT (parent_third_party_id, child_third_party_id) DO NOTHING "child_third_party_id": r.ChildThirdPartyID, "tenant_id": scope.GetTenantID(), "created_at": r.CreatedAt, + "purpose": r.Purpose, } _, err := conn.Exec(ctx, q, args) @@ -162,6 +167,11 @@ WITH children AS ( tp.trust_page_url, tp.show_on_trust_center, tp.first_level, + tp.vetting_status, + tp.vetting_website_url, + tp.vetting_procedure, + tp.vetting_processing_started_at, + tp.vetting_error_message, tp.created_at, tp.updated_at FROM @@ -173,7 +183,6 @@ WITH children AS ( ) SELECT id, - tenant_id, organization_id, common_third_party_id, name, @@ -197,6 +206,11 @@ SELECT trust_page_url, show_on_trust_center, first_level, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, created_at, updated_at FROM diff --git a/pkg/coredata/third_party_vetting.go b/pkg/coredata/third_party_vetting.go new file mode 100644 index 000000000..c558b6e7a --- /dev/null +++ b/pkg/coredata/third_party_vetting.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" +) + +func (v *ThirdParty) LoadNextPendingVettingForUpdateSkipLocked( + ctx context.Context, + tx pg.Tx, +) error { + q := ` +SELECT + 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, + vetting_status, + vetting_website_url, + vetting_procedure, + vetting_processing_started_at, + vetting_error_message, + created_at, + updated_at +FROM + third_parties +WHERE + vetting_status = @vetting_status + AND vetting_website_url IS NOT NULL +ORDER BY + created_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED; +` + + rows, err := tx.Query( + ctx, + q, + pgx.StrictNamedArgs{"vetting_status": ThirdPartyVettingStatusPending}, + ) + if err != nil { + return fmt.Errorf("cannot query third party vetting queue: %w", err) + } + + thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect third party: %w", err) + } + + *v = thirdParty + + return nil +} + +func ResetStaleVettingProcessing( + ctx context.Context, + conn pg.Querier, + staleAfter time.Duration, +) error { + q := ` +UPDATE third_parties +SET + vetting_status = @pending_status, + vetting_processing_started_at = NULL, + updated_at = @now +WHERE + vetting_status = @processing_status + AND vetting_processing_started_at < @stale_before; +` + + _, err := conn.Exec( + ctx, + q, + pgx.StrictNamedArgs{ + "pending_status": ThirdPartyVettingStatusPending, + "processing_status": ThirdPartyVettingStatusProcessing, + "now": time.Now(), + "stale_before": time.Now().Add(-staleAfter), + }, + ) + + return err +} diff --git a/pkg/coredata/third_party_vetting_status.go b/pkg/coredata/third_party_vetting_status.go new file mode 100644 index 000000000..d9ace7db9 --- /dev/null +++ b/pkg/coredata/third_party_vetting_status.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + "encoding" + "fmt" +) + +type ( + ThirdPartyVettingStatus string +) + +const ( + ThirdPartyVettingStatusPending ThirdPartyVettingStatus = "PENDING" + ThirdPartyVettingStatusProcessing ThirdPartyVettingStatus = "PROCESSING" + ThirdPartyVettingStatusCompleted ThirdPartyVettingStatus = "COMPLETED" + ThirdPartyVettingStatusFailed ThirdPartyVettingStatus = "FAILED" +) + +var ( + _ fmt.Stringer = ThirdPartyVettingStatus("") + _ encoding.TextMarshaler = ThirdPartyVettingStatus("") + _ encoding.TextUnmarshaler = (*ThirdPartyVettingStatus)(nil) +) + +func ThirdPartyVettingStatuses() []ThirdPartyVettingStatus { + return []ThirdPartyVettingStatus{ + ThirdPartyVettingStatusPending, + ThirdPartyVettingStatusProcessing, + ThirdPartyVettingStatusCompleted, + ThirdPartyVettingStatusFailed, + } +} + +func (v ThirdPartyVettingStatus) IsValid() bool { + switch v { + case + ThirdPartyVettingStatusPending, + ThirdPartyVettingStatusProcessing, + ThirdPartyVettingStatusCompleted, + ThirdPartyVettingStatusFailed: + return true + } + + return false +} + +func (v ThirdPartyVettingStatus) IsActive() bool { + switch v { + case ThirdPartyVettingStatusPending, ThirdPartyVettingStatusProcessing: + return true + } + + return false +} + +func (v ThirdPartyVettingStatus) String() string { + return string(v) +} + +func (v ThirdPartyVettingStatus) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *ThirdPartyVettingStatus) UnmarshalText(text []byte) error { + val := ThirdPartyVettingStatus(text) + if !val.IsValid() { + return fmt.Errorf("invalid ThirdPartyVettingStatus value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 78eaf1a07..028b83fc4 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -754,7 +754,6 @@ func (s *OrganizationService) CreateOrganization( proboData := &coredata.ThirdParty{ ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType), - TenantID: organization.TenantID, OrganizationID: organization.ID, Name: proboThirdParty.Name, Description: &proboThirdParty.Description, diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 43a1e0db8..e5530151b 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -89,7 +89,7 @@ const ( ActionThirdPartyCreate = "core:thirdParty:create" ActionThirdPartyUpdate = "core:thirdParty:update" ActionThirdPartyDelete = "core:thirdParty:delete" - ActionThirdPartyAssess = "core:thirdParty:assess" + ActionThirdPartyVet = "core:thirdParty:vet" ActionThirdPartyPublish = "core:thirdParty:publish" // ThirdPartyRelation actions diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 2a95f0856..5639a4025 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -84,7 +84,6 @@ type ( esign *esign.Service connectorRegistry *connector.ConnectorRegistry invitationTokenValidity time.Duration - thirdPartyAssessor ThirdPartyAssessor Frameworks *FrameworkService Measures *MeasureService Tasks *TaskService @@ -145,7 +144,6 @@ func NewService( esignService *esign.Service, connectorRegistry *connector.ConnectorRegistry, invitationTokenValidity time.Duration, - thirdPartyAssessor ThirdPartyAssessor, ) (*Service, error) { if bucket == "" { return nil, fmt.Errorf("bucket is required") @@ -170,7 +168,6 @@ func NewService( esign: esignService, connectorRegistry: connectorRegistry, invitationTokenValidity: invitationTokenValidity, - thirdPartyAssessor: thirdPartyAssessor, } svc.Frameworks = &FrameworkService{ diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go index 7282be344..88d6fa3d4 100644 --- a/pkg/probo/third_party_service.go +++ b/pkg/probo/third_party_service.go @@ -16,56 +16,18 @@ package probo import ( "context" - "errors" "fmt" "time" "go.gearno.de/kit/pg" - "go.gearno.de/x/ref" - "go.probo.inc/probo/pkg/agent" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/validator" - "go.probo.inc/probo/pkg/vetting" "go.probo.inc/probo/pkg/webhook" webhooktypes "go.probo.inc/probo/pkg/webhook/types" ) -// ErrThirdPartyAssessmentDisabled is returned by ThirdPartyAssessor.Assess when the -// deployment has not configured an LLM provider for thirdParty assessment. -var ErrThirdPartyAssessmentDisabled = errors.New("thirdParty assessment is not configured on this deployment") - -// ThirdPartyAssessor produces a thirdParty assessment report from a website URL and -// an optional procedure description. Implementations that cannot perform -// assessment (missing LLM credentials, misconfigured provider) must return -// ErrThirdPartyAssessmentDisabled from Assess so callers can surface a stable -// "feature unavailable" error instead of a generic internal error. -type ThirdPartyAssessor interface { - Assess( - ctx context.Context, - websiteURL string, - procedure string, - reporter agent.ProgressReporter, - ) (*vetting.Result, error) -} - -// DisabledThirdPartyAssessor is the ThirdPartyAssessor implementation used when no -// LLM provider is configured for the third-party-assessor agent. Its Assess -// method always returns ErrThirdPartyAssessmentDisabled. -type DisabledThirdPartyAssessor struct{} - -var _ ThirdPartyAssessor = DisabledThirdPartyAssessor{} - -func (DisabledThirdPartyAssessor) Assess( - _ context.Context, - _ string, - _ string, - _ agent.ProgressReporter, -) (*vetting.Result, error) { - return nil, ErrThirdPartyAssessmentDisabled -} - type ( ThirdPartyService struct { svc *Service @@ -120,24 +82,6 @@ type ( FirstLevel *bool } - AssessThirdPartyRequest struct { - ID gid.GID - WebsiteURL string - Procedure *string - } - - AssessThirdPartyResult struct { - ThirdParty *coredata.ThirdParty - Report string - Subprocessors []Subprocessor - } - - Subprocessor struct { - Name string - Country string - Purpose string - } - CreateThirdPartyRiskAssessmentRequest struct { ThirdPartyID gid.GID ExpiresAt time.Time @@ -904,127 +848,6 @@ func (s ThirdPartyService) GetByRiskAssessmentID( return thirdParty, nil } -func (s ThirdPartyService) Assess( - ctx context.Context, scope coredata.Scoper, - req AssessThirdPartyRequest, -) (*AssessThirdPartyResult, error) { - result, err := s.svc.thirdPartyAssessor.Assess(ctx, req.WebsiteURL, ref.UnrefOrZero(req.Procedure), nil) - if err != nil { - return nil, fmt.Errorf("cannot assess thirdParty: %w", err) - } - - thirdParty := &coredata.ThirdParty{} - - err = s.svc.pg.WithTx( - ctx, - func(ctx context.Context, conn pg.Tx) error { - if err := thirdParty.LoadByID(ctx, conn, scope, req.ID); err != nil { - return fmt.Errorf("cannot load thirdParty %q: %w", req.ID, err) - } - - info := result.Info - - if info.Name != "" { - thirdParty.Name = info.Name - } - - thirdParty.WebsiteURL = &req.WebsiteURL - if info.Category != "" { - thirdParty.Category = coredata.ThirdPartyCategory(info.Category) - } - - thirdParty.UpdatedAt = time.Now() - - if info.Description != "" { - thirdParty.Description = &info.Description - } - - if info.HeadquarterAddress != "" { - thirdParty.HeadquarterAddress = &info.HeadquarterAddress - } - - if info.LegalName != "" { - thirdParty.LegalName = &info.LegalName - } - - if info.PrivacyPolicyURL != "" { - thirdParty.PrivacyPolicyURL = &info.PrivacyPolicyURL - } - - if info.ServiceLevelAgreementURL != "" { - thirdParty.ServiceLevelAgreementURL = &info.ServiceLevelAgreementURL - } - - if info.DataProcessingAgreementURL != "" { - thirdParty.DataProcessingAgreementURL = &info.DataProcessingAgreementURL - } - - if info.BusinessAssociateAgreementURL != "" { - thirdParty.BusinessAssociateAgreementURL = &info.BusinessAssociateAgreementURL - } - - if info.SubprocessorsListURL != "" { - thirdParty.SubprocessorsListURL = &info.SubprocessorsListURL - } - - if info.SecurityPageURL != "" { - thirdParty.SecurityPageURL = &info.SecurityPageURL - } - - if info.TrustPageURL != "" { - thirdParty.TrustPageURL = &info.TrustPageURL - } - - if info.TermsOfServiceURL != "" { - thirdParty.TermsOfServiceURL = &info.TermsOfServiceURL - } - - if info.StatusPageURL != "" { - thirdParty.StatusPageURL = &info.StatusPageURL - } - - if len(info.Certifications) > 0 { - thirdParty.Certifications = info.Certifications - } - - if err := thirdParty.Update(ctx, conn, scope); err != nil { - return fmt.Errorf("cannot update thirdParty: %w", err) - } - - if err := webhook.InsertData( - ctx, - conn, - scope, - thirdParty.OrganizationID, - coredata.WebhookEventTypeThirdPartyUpdated, - webhooktypes.NewThirdParty(thirdParty), - ); err != nil { - return fmt.Errorf("cannot insert webhook event: %w", err) - } - - return nil - }, - ) - if err != nil { - return nil, err - } - - subprocessors := make([]Subprocessor, len(result.Info.Subprocessors)) - for i, sp := range result.Info.Subprocessors { - subprocessors[i] = Subprocessor{ - Name: sp.Name, - Country: sp.Country, - Purpose: sp.Purpose, - } - } - - return &AssessThirdPartyResult{ - ThirdParty: thirdParty, - Report: result.Document, - Subprocessors: subprocessors, - }, nil -} - func (s ThirdPartyService) CreateThirdPartyMapping( ctx context.Context, scope coredata.Scoper, diff --git a/pkg/probod/aliases.go b/pkg/probod/aliases.go index 9a81e4c2a..d2a30d28d 100644 --- a/pkg/probod/aliases.go +++ b/pkg/probod/aliases.go @@ -17,30 +17,31 @@ package probod import "go.probo.inc/probo/pkg/probodconfig" type ( - FullConfig = probodconfig.FullConfig - Config = probodconfig.Config - UnitConfig = probodconfig.UnitConfig - MetricsConfig = probodconfig.MetricsConfig - TracingConfig = probodconfig.TracingConfig - ESignConfig = probodconfig.ESignConfig - TrustCenterConfig = probodconfig.TrustCenterConfig - APIConfig = probodconfig.APIConfig - CorsConfig = probodconfig.CorsConfig - ProxyProtocolConfig = probodconfig.ProxyProtocolConfig - AuthConfig = probodconfig.AuthConfig - OAuth2ServerConfig = probodconfig.OAuth2ServerConfig - OAuth2SigningKeyConfig = probodconfig.OAuth2SigningKeyConfig - CookieConfig = probodconfig.CookieConfig - PasswordConfig = probodconfig.PasswordConfig - AWSConfig = probodconfig.AWSConfig - ConnectorConfig = probodconfig.ConnectorConfig - ConnectorConfigOAuth2 = probodconfig.ConnectorConfigOAuth2 - CustomDomainsConfig = probodconfig.CustomDomainsConfig - ACMEConfig = probodconfig.ACMEConfig - LLMProviderConfig = probodconfig.LLMProviderConfig - LLMAgentConfig = probodconfig.LLMAgentConfig - EvidenceDescriberConfig = probodconfig.EvidenceDescriberConfig - AgentsConfig = probodconfig.AgentsConfig + FullConfig = probodconfig.FullConfig + Config = probodconfig.Config + UnitConfig = probodconfig.UnitConfig + MetricsConfig = probodconfig.MetricsConfig + TracingConfig = probodconfig.TracingConfig + ESignConfig = probodconfig.ESignConfig + TrustCenterConfig = probodconfig.TrustCenterConfig + APIConfig = probodconfig.APIConfig + CorsConfig = probodconfig.CorsConfig + ProxyProtocolConfig = probodconfig.ProxyProtocolConfig + AuthConfig = probodconfig.AuthConfig + OAuth2ServerConfig = probodconfig.OAuth2ServerConfig + OAuth2SigningKeyConfig = probodconfig.OAuth2SigningKeyConfig + CookieConfig = probodconfig.CookieConfig + PasswordConfig = probodconfig.PasswordConfig + AWSConfig = probodconfig.AWSConfig + ConnectorConfig = probodconfig.ConnectorConfig + ConnectorConfigOAuth2 = probodconfig.ConnectorConfigOAuth2 + CustomDomainsConfig = probodconfig.CustomDomainsConfig + ACMEConfig = probodconfig.ACMEConfig + LLMProviderConfig = probodconfig.LLMProviderConfig + LLMAgentConfig = probodconfig.LLMAgentConfig + EvidenceDescriberConfig = probodconfig.EvidenceDescriberConfig + ThirdPartyVettingWorkerConfig = probodconfig.ThirdPartyVettingWorkerConfig + AgentsConfig = probodconfig.AgentsConfig TrackerMappingWorkerConfig = probodconfig.TrackerMappingWorkerConfig CommonPatternEnrichmentWorkerConfig = probodconfig.CommonPatternEnrichmentWorkerConfig diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 619b6db75..f1246762c 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -177,6 +177,11 @@ func New() *Implm { StaleAfter: 300, MaxConcurrency: 10, }, + ThirdPartyVetting: ThirdPartyVettingWorkerConfig{ + Interval: 10, + StaleAfter: 1500, + MaxConcurrency: 1, + }, }, } } @@ -309,7 +314,7 @@ func (impl *Implm) Run( return err } - thirdPartyAssessor, err := impl.buildThirdPartyAssessor(l, tp, r) + thirdPartyVetter, err := impl.buildThirdPartyVetter(l, tp, r) if err != nil { return err } @@ -522,7 +527,6 @@ func (impl *Implm) Run( esignService, defaultConnectorRegistry, time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second, - thirdPartyAssessor, ) if err != nil { return fmt.Errorf("cannot create probo service: %w", err) @@ -552,7 +556,7 @@ func (impl *Implm) Run( l.Named("access-review"), ) - thirdPartyService := thirdparty.NewService(pgClient, fileService) + thirdPartyService := thirdparty.NewService(pgClient, fileService, thirdPartyVetter) riskManagementService := riskmanagement.NewService(pgClient) serverHandler, err := server.NewServer( @@ -818,6 +822,26 @@ func (impl *Implm) Run( }, ) + vettingWorker := thirdparty.NewVettingWorker( + pgClient, + thirdPartyVetter, + l.Named("vetting-worker"), + thirdparty.VettingWorkerConfig{ + StaleAfter: time.Duration(impl.cfg.ThirdPartyVetting.StaleAfter) * time.Second, + }, + worker.WithInterval(time.Duration(impl.cfg.ThirdPartyVetting.Interval)*time.Second), + worker.WithMaxConcurrency(impl.cfg.ThirdPartyVetting.MaxConcurrency), + ) + vettingWorkerCtx, stopVettingWorker := context.WithCancel(context.Background()) + + wg.Go( + func() { + if err := vettingWorker.Run(vettingWorkerCtx); err != nil { + cancel(fmt.Errorf("vetting worker crashed: %w", err)) + } + }, + ) + trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background()) defer stopTrustCenterServer() @@ -849,6 +873,7 @@ func (impl *Implm) Run( stopTrackerMappingWorker() stopCommonPatternEnrichmentWorker() stopMailingListWorker() + stopVettingWorker() stopEvidenceDescriptionWorker() stopDocumentPDFWorker() stopExportJobExporter() diff --git a/pkg/probod/third_party_assessor.go b/pkg/probod/third_party_vetter.go similarity index 66% rename from pkg/probod/third_party_assessor.go rename to pkg/probod/third_party_vetter.go index cc1628062..ba041676a 100644 --- a/pkg/probod/third_party_assessor.go +++ b/pkg/probod/third_party_vetter.go @@ -18,26 +18,19 @@ import ( "github.com/prometheus/client_golang/prometheus" "go.gearno.de/kit/log" "go.opentelemetry.io/otel/trace" - "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/thirdparty" "go.probo.inc/probo/pkg/vetting" ) -// buildThirdPartyAssessor wires the thirdParty assessment agent. It is an opt-in -// feature: deployments that do not set `llm.third-party-assessor.provider` get a -// DisabledThirdPartyAssessor that reports the feature as unavailable. The -// third-party-assessor does not inherit the default provider because its -// pipeline (LLM + browser + search) is expensive and should not be enabled -// implicitly. -func (impl *Implm) buildThirdPartyAssessor( +// buildThirdPartyVetter wires the third-party vetting agent. Unset +// third-party-vetter fields inherit from the default agent config +// (AGENT_DEFAULT_*), same as evidence-describer and probo. +func (impl *Implm) buildThirdPartyVetter( l *log.Logger, tp trace.TracerProvider, r prometheus.Registerer, -) (probo.ThirdPartyAssessor, error) { - if impl.cfg.Agents.ThirdPartyAssessor.Provider == "" { - return probo.DisabledThirdPartyAssessor{}, nil - } - - agentCfg, llmClient, err := impl.resolveAgentClient("third-party-assessor", impl.cfg.Agents.ThirdPartyAssessor, l, tp, r) +) (thirdparty.Vetter, error) { + agentCfg, llmClient, err := impl.resolveAgentClient("third-party-vetter", impl.cfg.Agents.ThirdPartyVetter, l, tp, r) if err != nil { return nil, err } @@ -53,6 +46,6 @@ func (impl *Implm) buildThirdPartyAssessor( MaxTokens: maxTokens, ChromeAddr: impl.cfg.ChromeDPAddr, FirecrawlAPIKey: impl.cfg.Agents.Tools.FirecrawlAPIKey, - Logger: l.Named("third-party-assessor"), + Logger: l.Named("third-party-vetter"), }), nil } diff --git a/pkg/probodconfig/config.go b/pkg/probodconfig/config.go index 7eedc602f..2334d8d6f 100644 --- a/pkg/probodconfig/config.go +++ b/pkg/probodconfig/config.go @@ -49,17 +49,18 @@ type ( // Config represents the probod application configuration. Config struct { - BaseURL string `json:"base-url"` - EncryptionKey string `json:"encryption-key"` - Pg PgConfig `json:"pg"` - Api APIConfig `json:"api"` - Auth AuthConfig `json:"auth"` - TrustCenter TrustCenterConfig `json:"trust-center"` - AWS AWSConfig `json:"aws"` - Notifications NotificationsConfig `json:"notifications"` - Connectors []ConnectorConfig `json:"connectors"` - Agents AgentsConfig `json:"llm"` - EvidenceDescriber EvidenceDescriberConfig `json:"evidence-describer"` + BaseURL string `json:"base-url"` + EncryptionKey string `json:"encryption-key"` + Pg PgConfig `json:"pg"` + Api APIConfig `json:"api"` + Auth AuthConfig `json:"auth"` + TrustCenter TrustCenterConfig `json:"trust-center"` + AWS AWSConfig `json:"aws"` + Notifications NotificationsConfig `json:"notifications"` + Connectors []ConnectorConfig `json:"connectors"` + Agents AgentsConfig `json:"llm"` + EvidenceDescriber EvidenceDescriberConfig `json:"evidence-describer"` + ThirdPartyVetting ThirdPartyVettingWorkerConfig `json:"third-party-vetting-worker"` TrackerMappingWorker TrackerMappingWorkerConfig `json:"tracker-mapping-worker"` CommonPatternEnrichmentWorker CommonPatternEnrichmentWorkerConfig `json:"common-pattern-enrichment-worker"` diff --git a/pkg/probodconfig/llm_config.go b/pkg/probodconfig/llm_config.go index 3cda73fde..2dda1df55 100644 --- a/pkg/probodconfig/llm_config.go +++ b/pkg/probodconfig/llm_config.go @@ -40,6 +40,15 @@ type ( MaxConcurrency int `json:"max-concurrency"` } + // ThirdPartyVettingWorkerConfig holds worker-side tuning for the + // third-party vetting background worker. LLM parameters for the + // vetter live under AgentsConfig.ThirdPartyVetter. + ThirdPartyVettingWorkerConfig struct { + Interval int `json:"interval"` // seconds between polls + StaleAfter int `json:"stale-after"` // seconds before a claim is recycled + MaxConcurrency int `json:"max-concurrency"` + } + // TrackerMappingWorkerConfig holds worker-side tuning for the // tracker-mapping background worker. LLM parameters for the agents // it runs live under AgentsConfig.TrackerMapping. AgentTimeout and @@ -75,13 +84,13 @@ type ( // settings. Default is used as a fallback when an agent-specific field // is zero-valued. AgentsConfig struct { - Providers map[string]LLMProviderConfig `json:"providers"` - Default LLMAgentConfig `json:"defaults"` - Probo LLMAgentConfig `json:"probo"` - EvidenceDescriber LLMAgentConfig `json:"evidence-describer"` - ThirdPartyAssessor LLMAgentConfig `json:"third-party-assessor"` - TrackerMapping LLMAgentConfig `json:"tracker-mapping"` - Tools AgentToolsConfig `json:"tools"` + Providers map[string]LLMProviderConfig `json:"providers"` + Default LLMAgentConfig `json:"defaults"` + Probo LLMAgentConfig `json:"probo"` + EvidenceDescriber LLMAgentConfig `json:"evidence-describer"` + ThirdPartyVetter LLMAgentConfig `json:"third-party-vetter"` + TrackerMapping LLMAgentConfig `json:"tracker-mapping"` + Tools AgentToolsConfig `json:"tools"` } ) diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 49e83425c..2544e62e7 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -212,6 +212,7 @@ func NewServer(cfg Config) (*Server, error) { mcpHandler: mcp_v1.NewMux( cfg.Logger.Named("mcp.v1"), cfg.Probo, + cfg.ThirdParty, cfg.IAM, cfg.AccessReview, cfg.CookieBanner, diff --git a/pkg/server/api/console/v1/graphql/third_party.graphql b/pkg/server/api/console/v1/graphql/third_party.graphql index 1f801f099..715aaac74 100644 --- a/pkg/server/api/console/v1/graphql/third_party.graphql +++ b/pkg/server/api/console/v1/graphql/third_party.graphql @@ -1,3 +1,23 @@ +enum ThirdPartyVettingStatus + @goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatus") { + PENDING + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusPending" + ) + PROCESSING + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusProcessing" + ) + COMPLETED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusCompleted" + ) + FAILED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusFailed" + ) +} + enum ThirdPartyCategory @goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyCategory") { ANALYTICS @@ -293,6 +313,8 @@ type ThirdParty implements Node { orderBy: ThirdPartyOrder ): ThirdPartyConnection! @goField(forceResolver: true) + vettingStatus: ThirdPartyVettingStatus @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -480,7 +502,7 @@ extend type Mutation { createThirdPartyRiskAssessment( input: CreateThirdPartyRiskAssessmentInput! ): CreateThirdPartyRiskAssessmentPayload! - assessThirdParty(input: AssessThirdPartyInput!): AssessThirdPartyPayload! + vetThirdParty(input: VetThirdPartyInput!): VetThirdPartyPayload! publishThirdPartyList( input: PublishThirdPartyListInput! ): PublishThirdPartyListPayload! @@ -652,18 +674,12 @@ input CreateThirdPartyRiskAssessmentInput { notes: String } -input AssessThirdPartyInput { +input VetThirdPartyInput { id: ID! websiteUrl: String! procedure: String } -type ThirdPartySubprocessor { - name: String! - country: String! - purpose: String! -} - type CreateThirdPartyPayload { thirdPartyEdge: ThirdPartyEdge! } @@ -736,10 +752,8 @@ type CreateThirdPartyRiskAssessmentPayload { thirdPartyRiskAssessmentEdge: ThirdPartyRiskAssessmentEdge! } -type AssessThirdPartyPayload { +type VetThirdPartyPayload { thirdParty: ThirdParty! - report: String! - subprocessors: [ThirdPartySubprocessor!]! } input CreateThirdPartyThirdPartyMappingInput { diff --git a/pkg/server/api/console/v1/third_party_resolvers.go b/pkg/server/api/console/v1/third_party_resolvers.go index b4b879d93..4a4355195 100644 --- a/pkg/server/api/console/v1/third_party_resolvers.go +++ b/pkg/server/api/console/v1/third_party_resolvers.go @@ -22,6 +22,7 @@ import ( "go.probo.inc/probo/pkg/server/api/console/v1/schema" "go.probo.inc/probo/pkg/server/api/console/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" + "go.probo.inc/probo/pkg/thirdparty" "go.probo.inc/probo/pkg/validator" ) @@ -536,35 +537,45 @@ func (r *mutationResolver) CreateThirdPartyRiskAssessment(ctx context.Context, i }, nil } -// AssessThirdParty is the resolver for the assessThirdParty field. -func (r *mutationResolver) AssessThirdParty(ctx context.Context, input types.AssessThirdPartyInput) (*types.AssessThirdPartyPayload, error) { - scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyAssess) +// VetThirdParty is the resolver for the vetThirdParty field. +func (r *mutationResolver) VetThirdParty(ctx context.Context, input types.VetThirdPartyInput) (*types.VetThirdPartyPayload, error) { + scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyVet) if err != nil { return nil, err } - result, err := r.probo.ThirdParties.Assess( + thirdParty, err := r.thirdParty.Vet( ctx, scope, - probo.AssessThirdPartyRequest{ + thirdparty.VetRequest{ ID: input.ID, WebsiteURL: input.WebsiteURL, Procedure: input.Procedure, }, ) if err != nil { - if errors.Is(err, probo.ErrThirdPartyAssessmentDisabled) { - return nil, gqlutils.Unavailable(ctx, probo.ErrThirdPartyAssessmentDisabled) + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) } - r.logger.ErrorCtx(ctx, "cannot assess thirdParty", log.Error(err)) + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + if errors.Is(err, thirdparty.ErrVettingDisabled) { + return nil, gqlutils.Unavailable(ctx, thirdparty.ErrVettingDisabled) + } + + if errors.Is(err, thirdparty.ErrVettingInProgress) { + return nil, gqlutils.Conflict(ctx, thirdparty.ErrVettingInProgress) + } + + r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return &types.AssessThirdPartyPayload{ - ThirdParty: types.NewThirdParty(result.ThirdParty), - Report: result.Report, - Subprocessors: types.NewThirdPartySubprocessors(result.Subprocessors), + return &types.VetThirdPartyPayload{ + ThirdParty: types.NewThirdParty(thirdParty), }, nil } @@ -931,6 +942,22 @@ func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.T return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil } +// VettingStatus is the resolver for the vettingStatus field. +func (r *thirdPartyResolver) VettingStatus(ctx context.Context, obj *types.ThirdParty) (*coredata.ThirdPartyVettingStatus, error) { + scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet) + if err != nil { + return nil, err + } + + status, err := r.thirdParty.VettingStatus(ctx, scope, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get vetting status", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return status, nil +} + // Permission is the resolver for the permission field. func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) diff --git a/pkg/server/api/console/v1/types/third_party.go b/pkg/server/api/console/v1/types/third_party.go index d9fd6af9f..fc2c13218 100644 --- a/pkg/server/api/console/v1/types/third_party.go +++ b/pkg/server/api/console/v1/types/third_party.go @@ -18,7 +18,6 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" - "go.probo.inc/probo/pkg/probo" ) type ( @@ -107,16 +106,3 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty { return object } - -func NewThirdPartySubprocessors(sps []probo.Subprocessor) []*ThirdPartySubprocessor { - result := make([]*ThirdPartySubprocessor, len(sps)) - for i, sp := range sps { - result[i] = &ThirdPartySubprocessor{ - Name: sp.Name, - Country: sp.Country, - Purpose: sp.Purpose, - } - } - - return result -} diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index edc0410b5..105a7e0b2 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -32,10 +32,12 @@ import ( "go.probo.inc/probo/pkg/prosemirror" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/thirdparty" ) type Resolver struct { proboSvc *probo.Service + thirdPartySvc *thirdparty.Service iamSvc *iam.Service accessReview *accessreview.Service cookieBanner *cookiebanner.Service diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 7768f965d..e0cecd90b 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -12,6 +12,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" @@ -23,6 +24,8 @@ import ( "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/mcp/v1/types" + "go.probo.inc/probo/pkg/thirdparty" + "go.probo.inc/probo/pkg/validator" ) // ListOrganizationsTool handles the listOrganizations tool @@ -5296,27 +5299,47 @@ func (r *Resolver) DeleteCustomDomainTool(ctx context.Context, req *mcp.CallTool return nil, types.DeleteCustomDomainOutput{DeletedCustomDomain: deletedDomain}, nil } -func (r *Resolver) AssessThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AssessThirdPartyInput) (*mcp.CallToolResult, types.AssessThirdPartyOutput, error) { - scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyAssess) +func (r *Resolver) VetThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.VetThirdPartyInput) (*mcp.CallToolResult, types.VetThirdPartyOutput, error) { + scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyVet) if err != nil { - return nil, types.AssessThirdPartyOutput{}, err + return nil, types.VetThirdPartyOutput{}, err } - svc := r.proboSvc + svc := r.thirdPartySvc - result, err := svc.ThirdParties.Assess( + thirdParty, err := svc.Vet( ctx, scope, - probo.AssessThirdPartyRequest{ + thirdparty.VetRequest{ ID: input.ID, WebsiteURL: input.WebsiteURL, Procedure: input.Procedure, }, ) if err != nil { - return nil, types.AssessThirdPartyOutput{}, fmt.Errorf("cannot assess thirdParty: %w", err) + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, types.VetThirdPartyOutput{}, validationErrors + } + + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, types.VetThirdPartyOutput{}, fmt.Errorf("resource not found") + } + + if errors.Is(err, thirdparty.ErrVettingDisabled) { + return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is not configured") + } + + if errors.Is(err, thirdparty.ErrVettingInProgress) { + return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is already in progress") + } + + r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err)) + + return nil, types.VetThirdPartyOutput{}, fmt.Errorf("internal server error") } - return nil, types.NewAssessThirdPartyOutput(result), nil + return nil, types.VetThirdPartyOutput{ + ThirdParty: types.NewThirdParty(thirdParty), + }, nil } func (r *Resolver) PublishFindingListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishFindingListInput) (*mcp.CallToolResult, types.PublishFindingListOutput, error) { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index e2b4d6d78..1db8fb95d 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -1480,7 +1480,7 @@ components: $ref: "#/components/schemas/GID" description: Deleted thirdParty service ID - AssessThirdPartyInput: + VetThirdPartyInput: type: object required: - id @@ -1488,48 +1488,21 @@ components: properties: id: $ref: "#/components/schemas/GID" - description: ThirdParty ID to assess + description: ThirdParty ID to vet website_url: type: string - description: ThirdParty website URL to crawl and assess + description: ThirdParty website URL to crawl and vet procedure: type: string - description: Optional custom assessment procedure (overrides the default) + description: Optional custom vetting procedure (overrides the default) - ThirdPartySubprocessor: - type: object - required: - - name - - country - - purpose - properties: - name: - type: string - description: Sub-processor name - country: - type: string - description: Country where the sub-processor operates - purpose: - type: string - description: Purpose of the sub-processor - - AssessThirdPartyOutput: + VetThirdPartyOutput: type: object required: - thirdParty - - report - - subprocessors properties: thirdParty: $ref: "#/components/schemas/ThirdParty" - report: - type: string - description: Markdown-formatted thirdParty assessment report - subprocessors: - type: array - items: - $ref: "#/components/schemas/ThirdPartySubprocessor" - description: Sub-processors discovered during the assessment GetUserInput: type: object @@ -12096,14 +12069,14 @@ tools: $ref: "#/components/schemas/DeleteThirdPartyServiceInput" outputSchema: $ref: "#/components/schemas/DeleteThirdPartyServiceOutput" - - name: assessThirdParty - description: Run an AI-powered assessment on a thirdParty by crawling its website. Returns a markdown report, the discovered sub-processors, and an enriched thirdParty record. Long-running (up to 20 minutes). + - name: vetThirdParty + description: Start AI-powered vetting of a third party by crawling its website. Returns immediately; vetting runs in the background. hints: readonly: false inputSchema: - $ref: "#/components/schemas/AssessThirdPartyInput" + $ref: "#/components/schemas/VetThirdPartyInput" outputSchema: - $ref: "#/components/schemas/AssessThirdPartyOutput" + $ref: "#/components/schemas/VetThirdPartyOutput" - name: listRisks description: List all risks for the organization hints: diff --git a/pkg/server/api/mcp/v1/types/third_party.go b/pkg/server/api/mcp/v1/types/third_party.go index 9832961bb..39f9d9cfa 100644 --- a/pkg/server/api/mcp/v1/types/third_party.go +++ b/pkg/server/api/mcp/v1/types/third_party.go @@ -17,7 +17,6 @@ package types import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/page" - "go.probo.inc/probo/pkg/probo" ) func NewThirdPartyRiskAssessment(v *coredata.ThirdPartyRiskAssessment) *ThirdPartyRiskAssessment { @@ -229,24 +228,3 @@ func NewListThirdPartyServicesOutput(p *page.Page[*coredata.ThirdPartyService, c ThirdPartyServices: services, } } - -func NewThirdPartySubprocessors(sps []probo.Subprocessor) []*ThirdPartySubprocessor { - result := make([]*ThirdPartySubprocessor, len(sps)) - for i, sp := range sps { - result[i] = &ThirdPartySubprocessor{ - Name: sp.Name, - Country: sp.Country, - Purpose: sp.Purpose, - } - } - - return result -} - -func NewAssessThirdPartyOutput(result *probo.AssessThirdPartyResult) AssessThirdPartyOutput { - return AssessThirdPartyOutput{ - ThirdParty: NewThirdParty(result.ThirdParty), - Report: result.Report, - Subprocessors: NewThirdPartySubprocessors(result.Subprocessors), - } -} diff --git a/pkg/server/api/mcp/v1/v1_handler.go b/pkg/server/api/mcp/v1/v1_handler.go index 8d15b5071..923ce188b 100644 --- a/pkg/server/api/mcp/v1/v1_handler.go +++ b/pkg/server/api/mcp/v1/v1_handler.go @@ -29,15 +29,26 @@ import ( "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/mcp/mcputils" "go.probo.inc/probo/pkg/server/api/mcp/v1/server" + "go.probo.inc/probo/pkg/thirdparty" ) -func NewMux(logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service, accessReviewSvc *accessreview.Service, cookieBannerSvc *cookiebanner.Service, riskManagementSvc *riskmanagement.Service, tokenSecret string) *chi.Mux { +func NewMux( + logger *log.Logger, + proboSvc *probo.Service, + thirdPartySvc *thirdparty.Service, + iamSvc *iam.Service, + accessReviewSvc *accessreview.Service, + cookieBannerSvc *cookiebanner.Service, + riskManagementSvc *riskmanagement.Service, + tokenSecret string, +) *chi.Mux { logger = logger.Named("mcp.v1") logger.Info("initializing MCP server") resolver := &Resolver{ proboSvc: proboSvc, + thirdPartySvc: thirdPartySvc, iamSvc: iamSvc, accessReview: accessReviewSvc, cookieBanner: cookieBannerSvc, diff --git a/pkg/thirdparty/service.go b/pkg/thirdparty/service.go index 459eec661..1fd230456 100644 --- a/pkg/thirdparty/service.go +++ b/pkg/thirdparty/service.go @@ -26,14 +26,20 @@ import ( ) type Service struct { - pg *pg.Client - file *file.Service + pg *pg.Client + file *file.Service + vetter Vetter + vettingEnabled bool } -func NewService(pgClient *pg.Client, fileSvc *file.Service) *Service { +func NewService(pgClient *pg.Client, fileSvc *file.Service, vetter Vetter) *Service { + _, disabled := vetter.(DisabledVetter) + return &Service{ - pg: pgClient, - file: fileSvc, + pg: pgClient, + file: fileSvc, + vetter: vetter, + vettingEnabled: !disabled, } } diff --git a/pkg/thirdparty/vetting.go b/pkg/thirdparty/vetting.go new file mode 100644 index 000000000..aace68df5 --- /dev/null +++ b/pkg/thirdparty/vetting.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 thirdparty + +import ( + "context" + "errors" + "fmt" + "time" + "unicode/utf8" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/agent" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/validator" + "go.probo.inc/probo/pkg/vetting" +) + +const ( + vettingErrorMessageMaxLen = 512 + vettingWebsiteURLMaxLength = 2048 + vettingProcedureMaxLength = 5000 +) + +var ( + ErrVettingDisabled = errors.New("thirdParty vetting is not configured on this deployment") + ErrVettingInProgress = errors.New("a vetting job is already in progress for this third party") +) + +type ( + Vetter interface { + Assess( + ctx context.Context, + websiteURL string, + procedure string, + reporter agent.ProgressReporter, + extraTools []agent.Tool, + ) (*vetting.Result, error) + } + + DisabledVetter struct{} + + VetRequest struct { + ID gid.GID + WebsiteURL string + Procedure *string + } +) + +var _ Vetter = DisabledVetter{} + +func (DisabledVetter) Assess( + _ context.Context, + _ string, + _ string, + _ agent.ProgressReporter, + _ []agent.Tool, +) (*vetting.Result, error) { + return nil, ErrVettingDisabled +} + +func (req VetRequest) Validate() error { + v := validator.New() + + v.Check(req.ID, "id", validator.Required(), validator.GID(coredata.ThirdPartyEntityType)) + v.Check(req.WebsiteURL, "website_url", validator.Required(), validator.SafeText(vettingWebsiteURLMaxLength)) + v.Check(req.Procedure, "procedure", validator.SafeText(vettingProcedureMaxLength)) + + return v.Error() +} + +func sanitizeVettingError(err error) string { + msg := err.Error() + if len(msg) <= vettingErrorMessageMaxLen { + return msg + } + + cut := vettingErrorMessageMaxLen + for cut > 0 && !utf8.RuneStart(msg[cut]) { + cut-- + } + + return msg[:cut] + "…" +} + +func (s *Service) Vet( + ctx context.Context, + scope coredata.Scoper, + req VetRequest, +) (*coredata.ThirdParty, error) { + if err := req.Validate(); err != nil { + return nil, err + } + + if !s.vettingEnabled { + return nil, ErrVettingDisabled + } + + thirdParty := &coredata.ThirdParty{} + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + if err := thirdParty.LoadByIDForUpdate(ctx, conn, scope, req.ID); err != nil { + return fmt.Errorf("cannot load thirdParty %q: %w", req.ID, err) + } + + if thirdParty.VettingStatus != nil && thirdParty.VettingStatus.IsActive() { + return ErrVettingInProgress + } + + pending := coredata.ThirdPartyVettingStatusPending + websiteURL := req.WebsiteURL + + thirdParty.VettingStatus = &pending + thirdParty.VettingWebsiteURL = &websiteURL + thirdParty.VettingProcedure = req.Procedure + thirdParty.VettingProcessingStartedAt = nil + thirdParty.VettingErrorMessage = nil + thirdParty.UpdatedAt = time.Now() + + if err := thirdParty.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot enqueue vetting: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return thirdParty, nil +} + +func (s *Service) VettingStatus( + ctx context.Context, + scope coredata.Scoper, + thirdPartyID gid.GID, +) (*coredata.ThirdPartyVettingStatus, error) { + thirdParty := &coredata.ThirdParty{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return thirdParty.LoadByID(ctx, conn, scope, thirdPartyID) + }, + ) + if err != nil { + return nil, err + } + + if thirdParty.VettingStatus == nil { + return nil, nil + } + + return thirdParty.VettingStatus, nil +} diff --git a/pkg/thirdparty/vetting_test.go b/pkg/thirdparty/vetting_test.go new file mode 100644 index 000000000..17b07d2d6 --- /dev/null +++ b/pkg/thirdparty/vetting_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 thirdparty + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/validator" +) + +func TestVetRequest_Validate(t *testing.T) { + t.Parallel() + + validID := gid.New(gid.NewTenantID(), coredata.ThirdPartyEntityType) + + t.Run("accepts a valid request", func(t *testing.T) { + t.Parallel() + + procedure := "Focus on SOC 2" + + err := VetRequest{ + ID: validID, + WebsiteURL: "https://example.com", + Procedure: &procedure, + }.Validate() + require.NoError(t, err) + }) + + t.Run("requires id", func(t *testing.T) { + t.Parallel() + + err := VetRequest{ + WebsiteURL: "https://example.com", + }.Validate() + require.Error(t, err) + + validationErrors, ok := errors.AsType[validator.ValidationErrors](err) + require.True(t, ok) + assert.NotEmpty(t, validationErrors.ByField("id")) + }) + + t.Run("requires website url", func(t *testing.T) { + t.Parallel() + + err := VetRequest{ID: validID}.Validate() + require.Error(t, err) + + validationErrors, ok := errors.AsType[validator.ValidationErrors](err) + require.True(t, ok) + assert.NotEmpty(t, validationErrors.ByField("website_url")) + }) + + t.Run("rejects an invalid third party id", func(t *testing.T) { + t.Parallel() + + err := VetRequest{ + ID: gid.New(gid.NewTenantID(), coredata.OrganizationEntityType), + WebsiteURL: "https://example.com", + }.Validate() + require.Error(t, err) + + validationErrors, ok := errors.AsType[validator.ValidationErrors](err) + require.True(t, ok) + assert.NotEmpty(t, validationErrors.ByField("id")) + }) +} + +func TestSanitizeVettingError(t *testing.T) { + t.Parallel() + + t.Run("returns short messages unchanged", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, "cannot vet third party", sanitizeVettingError(errors.New("cannot vet third party"))) + }) + + t.Run("truncates long messages on a rune boundary", func(t *testing.T) { + t.Parallel() + + msg := strings.Repeat("x", vettingErrorMessageMaxLen+10) + + sanitized := sanitizeVettingError(errors.New(msg)) + + assert.LessOrEqual(t, len(sanitized), vettingErrorMessageMaxLen+len("…")) + assert.True(t, strings.HasSuffix(sanitized, "…")) + }) +} + +func TestDisabledVetter_Assess(t *testing.T) { + t.Parallel() + + _, err := DisabledVetter{}.Assess(context.Background(), "https://example.com", "", nil, nil) + require.ErrorIs(t, err, ErrVettingDisabled) +} + +func TestDisabledVetter_ImplementsVetter(t *testing.T) { + t.Parallel() + + var _ Vetter = DisabledVetter{} +} diff --git a/pkg/thirdparty/vetting_worker.go b/pkg/thirdparty/vetting_worker.go new file mode 100644 index 000000000..74829088c --- /dev/null +++ b/pkg/thirdparty/vetting_worker.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 thirdparty + +import ( + "context" + "errors" + "fmt" + "time" + + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/kit/worker" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/vetting" +) + +type ( + vettingHandler struct { + pg *pg.Client + vetter Vetter + logger *log.Logger + staleAfter time.Duration + } + + VettingWorkerConfig struct { + StaleAfter time.Duration + } +) + +var ( + _ worker.Handler[coredata.ThirdParty] = (*vettingHandler)(nil) + _ worker.StaleRecoverer = (*vettingHandler)(nil) +) + +func NewVettingWorker( + pgClient *pg.Client, + vetter Vetter, + logger *log.Logger, + cfg VettingWorkerConfig, + opts ...worker.Option, +) *worker.Worker[coredata.ThirdParty] { + staleAfter := cfg.StaleAfter + if staleAfter <= 0 { + staleAfter = 25 * time.Minute + } + + h := &vettingHandler{ + pg: pgClient, + vetter: vetter, + logger: logger, + staleAfter: staleAfter, + } + + return worker.New( + "vetting-worker", + h, + logger, + opts..., + ) +} + +func (h *vettingHandler) Claim(ctx context.Context) (coredata.ThirdParty, error) { + var thirdParty coredata.ThirdParty + + if err := h.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := thirdParty.LoadNextPendingVettingForUpdateSkipLocked(ctx, tx); err != nil { + return err + } + + now := time.Now() + processing := coredata.ThirdPartyVettingStatusProcessing + + thirdParty.VettingStatus = &processing + thirdParty.VettingProcessingStartedAt = &now + thirdParty.VettingErrorMessage = nil + thirdParty.UpdatedAt = now + + if err := thirdParty.Update(ctx, tx, coredata.NewNoScope()); err != nil { + return fmt.Errorf("cannot update third party: %w", err) + } + + return nil + }, + ); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return coredata.ThirdParty{}, worker.ErrNoTask + } + + return coredata.ThirdParty{}, err + } + + return thirdParty, nil +} + +func (h *vettingHandler) Process(ctx context.Context, thirdParty coredata.ThirdParty) error { + if err := h.processThirdParty(ctx, &thirdParty); err != nil { + h.logger.ErrorCtx( + ctx, + "vetting worker failure", + log.Error(err), + log.String("third_party_id", thirdParty.ID.String()), + ) + + if failErr := h.failThirdParty(ctx, &thirdParty, err); failErr != nil { + h.logger.ErrorCtx(ctx, "cannot mark third party vetting as failed", log.Error(failErr)) + } + + return err + } + + return nil +} + +func (h *vettingHandler) RecoverStale(ctx context.Context) error { + return h.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := coredata.ResetStaleVettingProcessing(ctx, conn, h.staleAfter); err != nil { + return fmt.Errorf("cannot reset stale vetting processing: %w", err) + } + + return nil + }, + ) +} + +func (h *vettingHandler) processThirdParty( + ctx context.Context, + thirdParty *coredata.ThirdParty, +) error { + if thirdParty.VettingWebsiteURL == nil { + return fmt.Errorf("third party %s has no vetting website URL", thirdParty.ID) + } + + procedure := "" + if thirdParty.VettingProcedure != nil { + procedure = *thirdParty.VettingProcedure + } + + pc := &vetting.PersistenceContext{ + PG: h.pg, + ThirdPartyID: thirdParty.ID, + OrganizationID: thirdParty.OrganizationID, + WebsiteURL: *thirdParty.VettingWebsiteURL, + } + + // Assessment runs outside any database transaction. Persistence tools + // are not passed in so the agent cannot open DB transactions during + // the long LLM/browser phase; results are written afterward. + result, err := h.vetter.Assess( + ctx, + *thirdParty.VettingWebsiteURL, + procedure, + nil, + nil, + ) + if err != nil { + return fmt.Errorf("cannot vet third party: %w", err) + } + + if err := vetting.PersistAssessmentResult(ctx, pc, *result); err != nil { + return fmt.Errorf("cannot persist vetting results: %w", err) + } + + return h.commitVettingOutcome( + ctx, + thirdParty.ID, + func(fresh *coredata.ThirdParty) { + completed := coredata.ThirdPartyVettingStatusCompleted + + fresh.VettingStatus = &completed + fresh.VettingProcessingStartedAt = nil + fresh.VettingErrorMessage = nil + }, + ) +} + +func (h *vettingHandler) failThirdParty( + ctx context.Context, + thirdParty *coredata.ThirdParty, + reason error, +) error { + errMsg := sanitizeVettingError(reason) + + return h.commitVettingOutcome( + ctx, + thirdParty.ID, + func(fresh *coredata.ThirdParty) { + failed := coredata.ThirdPartyVettingStatusFailed + + fresh.VettingStatus = &failed + fresh.VettingProcessingStartedAt = nil + fresh.VettingErrorMessage = &errMsg + }, + ) +} + +func (h *vettingHandler) commitVettingOutcome( + ctx context.Context, + thirdPartyID gid.GID, + apply func(*coredata.ThirdParty), +) error { + return h.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + thirdParty := &coredata.ThirdParty{} + + if err := thirdParty.LoadByID(ctx, tx, coredata.NewNoScope(), thirdPartyID); err != nil { + return fmt.Errorf("cannot reload third party: %w", err) + } + + apply(thirdParty) + thirdParty.UpdatedAt = time.Now() + + if err := thirdParty.Update(ctx, tx, coredata.NewNoScope()); err != nil { + return fmt.Errorf("cannot update third party: %w", err) + } + + return nil + }, + ) +} diff --git a/pkg/vetting/assessment.go b/pkg/vetting/assessment.go index 633b86fc0..a9413f06f 100644 --- a/pkg/vetting/assessment.go +++ b/pkg/vetting/assessment.go @@ -30,7 +30,7 @@ import ( const ( // DefaultMaxTokens is the fallback max-tokens budget used when the - // third-party-assessor agent config does not specify a value. Sized to + // third-party-vetter agent config does not specify a value. Sized to // leave headroom above the orchestrator's thinking budget on // Anthropic models. DefaultMaxTokens = 16384 @@ -173,7 +173,7 @@ func NewAssessor(cfg Config) *Assessor { return &Assessor{cfg: cfg} } -func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure string, reporter agent.ProgressReporter) (*Result, error) { +func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure string, reporter agent.ProgressReporter, extraTools []agent.Tool) (*Result, error) { u, err := url.Parse(websiteURL) if err != nil { return nil, fmt.Errorf("cannot parse website URL %q: %w", websiteURL, err) @@ -193,15 +193,12 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), AssessmentTimeout) defer cancel() - thirdPartyBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr) - defer thirdPartyBrowser.Close() - - thirdPartyBrowser.SetAllowedDomain(u.Hostname()) - - // Create an unrestricted browser for web search agents that need to - // follow links to external sites (news, reviews, etc.). - researchBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr) - defer researchBrowser.Close() + // One shared remote Chrome allocator for all sub-agents. Sub-agents + // that need external links (subprocessor hosts, research) share it + // with vendor-site crawlers. Navigation is still gated by public-IP + // checks; we do not pin an allowed domain so external follows work. + webBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr) + defer webBrowser.Close() orchestrator, err := newOrchestratorAgent( a.cfg.Client, @@ -209,16 +206,16 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri a.cfg.MaxTokens, procedure, a.cfg.Logger, - thirdPartyBrowser, - researchBrowser, + webBrowser, a.cfg.FirecrawlAPIKey, reporter, + extraTools, ) if err != nil { return nil, fmt.Errorf("cannot create orchestrator agent: %w", err) } - result, err := orchestrator.Run( + orchestratorResult, err := orchestrator.Run( ctx, []llm.Message{ { @@ -231,7 +228,10 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri return nil, fmt.Errorf("cannot assess thirdParty: %w", err) } - document := result.FinalMessage().Text() + document := orchestratorResult.FinalMessage().Text() + + // Extraction is LLM-only; release Chrome before it runs. + webBrowser.Close() reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepStarted) @@ -241,6 +241,13 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri return nil, fmt.Errorf("cannot extract thirdParty info: %w", err) } + toolSubprocessors := subprocessorsFromOrchestratorMessages(orchestratorResult.Messages) + info.Subprocessors = mergeSubprocessors(toolSubprocessors, info.Subprocessors) + + if info.SubprocessorsListURL == "" { + info.SubprocessorsListURL = subprocessorListURLFromOrchestratorMessages(orchestratorResult.Messages) + } + reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepCompleted) return &Result{ @@ -335,7 +342,12 @@ func thirdPartyInfoOutputType() (*agent.OutputType, error) { return nil, fmt.Errorf("cannot marshal decorated thirdParty info schema: %w", err) } - outputType.Schema = decorated + strict, err := enforceStrictJSONSchema(decorated) + if err != nil { + return nil, fmt.Errorf("cannot enforce strict thirdParty info schema: %w", err) + } + + outputType.Schema = strict return outputType, nil } diff --git a/pkg/vetting/country_codes.go b/pkg/vetting/country_codes.go new file mode 100644 index 000000000..ad15da22c --- /dev/null +++ b/pkg/vetting/country_codes.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +var countryAliases = map[string]coredata.CountryCode{ + "global": coredata.CountryCodeGlobal, + "global presence": coredata.CountryCodeGlobal, + "worldwide": coredata.CountryCodeGlobal, + "international": coredata.CountryCodeGlobal, + "multiple regions": coredata.CountryCodeGlobal, + "eu": coredata.CountryCodeEU, + "european union": coredata.CountryCodeEU, + "europe": coredata.CountryCodeEU, + "united states": coredata.CountryCodeUS, + "united states usa": coredata.CountryCodeUS, + "usa": coredata.CountryCodeUS, + "u.s.": coredata.CountryCodeUS, + "u.s.a.": coredata.CountryCodeUS, + "us": coredata.CountryCodeUS, + "united kingdom": coredata.CountryCodeGB, + "uk": coredata.CountryCodeGB, + "great britain": coredata.CountryCodeGB, + "germany": coredata.CountryCodeDE, + "france": coredata.CountryCodeFR, + "canada": coredata.CountryCodeCA, + "australia": coredata.CountryCodeAU, + "japan": coredata.CountryCodeJP, + "china": coredata.CountryCodeCN, + "india": coredata.CountryCodeIN, + "ireland": coredata.CountryCodeIE, + "netherlands": coredata.CountryCodeNL, + "singapore": coredata.CountryCodeSG, + "switzerland": coredata.CountryCodeCH, + "sweden": coredata.CountryCodeSE, + "spain": coredata.CountryCodeES, + "italy": coredata.CountryCodeIT, + "brazil": coredata.CountryCodeBR, + "mexico": coredata.CountryCodeMX, + "south korea": coredata.CountryCodeKR, + "korea": coredata.CountryCodeKR, +} + +func parseOptionalCountryCodes(raw string) coredata.CountryCodes { + code, ok := parseCountryLocation(raw) + if !ok { + return nil + } + + return coredata.CountryCodes{code} +} + +func countriesFromInfo(info ThirdPartyInfo) coredata.CountryCodes { + raw := append([]string{}, info.DataLocations...) + if info.HeadquarterAddress != "" { + raw = append(raw, info.HeadquarterAddress) + } + + return parseCountryLocations(raw...) +} + +func parseCountryLocations(raw ...string) coredata.CountryCodes { + seen := make(map[coredata.CountryCode]struct{}) + out := make(coredata.CountryCodes, 0, len(raw)) + + for _, value := range raw { + for _, part := range splitCountryList(value) { + code, ok := parseCountryLocation(part) + if !ok { + continue + } + + if _, exists := seen[code]; exists { + continue + } + + seen[code] = struct{}{} + out = append(out, code) + } + } + + return out +} + +func parseCountryLocation(raw string) (coredata.CountryCode, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false + } + + code := coredata.CountryCode(strings.ToUpper(raw)) + if code.IsValid() { + return code, true + } + + if mapped, ok := countryAliases[normalizeCountryKey(raw)]; ok { + return mapped, true + } + + if strings.Contains(raw, ",") { + parts := strings.Split(raw, ",") + last := strings.TrimSpace(parts[len(parts)-1]) + + if mapped, ok := countryAliases[normalizeCountryKey(last)]; ok { + return mapped, true + } + + lastCode := coredata.CountryCode(strings.ToUpper(last)) + if lastCode.IsValid() { + return lastCode, true + } + } + + return "", false +} + +func splitCountryList(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + for _, sep := range []string{";", "|", "/", " and ", " & "} { + if strings.Contains(strings.ToLower(raw), sep) { + parts := strings.Split(raw, sep) + out := make([]string, 0, len(parts)) + + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + + return out + } + } + + return []string{raw} +} + +func normalizeCountryKey(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + raw = strings.TrimPrefix(raw, "the ") + + return strings.Join(strings.Fields(raw), " ") +} diff --git a/pkg/vetting/country_codes_test.go b/pkg/vetting/country_codes_test.go new file mode 100644 index 000000000..482b1c116 --- /dev/null +++ b/pkg/vetting/country_codes_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" +) + +func TestParseCountryLocation(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + expected coredata.CountryCode + }{ + {raw: "US", expected: coredata.CountryCodeUS}, + {raw: "usa", expected: coredata.CountryCodeUS}, + {raw: "United States", expected: coredata.CountryCodeUS}, + {raw: "Seattle, Washington, USA", expected: coredata.CountryCodeUS}, + {raw: "Global presence", expected: coredata.CountryCodeGlobal}, + {raw: "EU", expected: coredata.CountryCodeEU}, + {raw: "Germany", expected: coredata.CountryCodeDE}, + } + + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + t.Parallel() + + code, ok := parseCountryLocation(tt.raw) + assert.True(t, ok) + assert.Equal(t, tt.expected, code) + }) + } +} + +func TestCountriesFromInfo(t *testing.T) { + t.Parallel() + + countries := countriesFromInfo(ThirdPartyInfo{ + HeadquarterAddress: "Seattle, Washington, USA", + DataLocations: []string{"Germany", "EU"}, + }) + + assert.Equal( + t, + coredata.CountryCodes{ + coredata.CountryCodeDE, + coredata.CountryCodeEU, + coredata.CountryCodeUS, + }, + countries, + ) +} + +func TestParseOptionalCountryCodes(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + coredata.CountryCodes{coredata.CountryCodeFR}, + parseOptionalCountryCodes("France"), + ) +} diff --git a/pkg/vetting/openai_schema.go b/pkg/vetting/openai_schema.go new file mode 100644 index 000000000..a27046d0b --- /dev/null +++ b/pkg/vetting/openai_schema.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + "go.probo.inc/probo/pkg/agent" + "go.probo.inc/probo/pkg/llm" +) + +type strictFunctionTool[P any] struct { + name string + description string + fn func(ctx context.Context, params P) (agent.ToolResult, error) + schema json.RawMessage + requiredFields []string +} + +// jsonSchemaForTool builds an OpenAI strict-mode JSON schema for vetting tools +// and structured outputs. OpenAI requires every property in required and +// additionalProperties=false; the shared agent schema generator does not. +func jsonSchemaForTool[T any]() (json.RawMessage, error) { + outputType, err := agent.NewOutputType[T]("_") + if err != nil { + return nil, fmt.Errorf("cannot generate schema: %w", err) + } + + return enforceStrictJSONSchema(outputType.Schema) +} + +func newVettingOutputType[T any](name string) (*agent.OutputType, error) { + outputType, err := agent.NewOutputType[T](name) + if err != nil { + return nil, err + } + + schema, err := enforceStrictJSONSchema(outputType.Schema) + if err != nil { + return nil, fmt.Errorf("cannot enforce strict schema for %q: %w", name, err) + } + + outputType.Schema = schema + + return outputType, nil +} + +func vettingFunctionTool[P any]( + name string, + description string, + fn func(ctx context.Context, params P) (agent.ToolResult, error), +) agent.Tool { + schema, err := jsonSchemaForTool[P]() + if err != nil { + panic(fmt.Sprintf("vetting: cannot generate JSON schema for tool %q: %s", name, err)) + } + + var parsed struct { + Required []string `json:"required"` + } + if err := json.Unmarshal(schema, &parsed); err != nil { + panic(fmt.Sprintf("vetting: cannot parse generated schema for tool %q: %s", name, err)) + } + + return &strictFunctionTool[P]{ + name: name, + description: description, + fn: fn, + schema: schema, + requiredFields: parsed.Required, + } +} + +func (t *strictFunctionTool[P]) Name() string { return t.name } + +func (t *strictFunctionTool[P]) Definition() llm.Tool { + return llm.Tool{ + Name: t.name, + Description: t.description, + Parameters: t.schema, + } +} + +func (t *strictFunctionTool[P]) Execute(ctx context.Context, arguments string) (agent.ToolResult, error) { + if len(t.requiredFields) > 0 { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(arguments), &fields); err != nil { + return agent.ToolResult{ + Content: fmt.Sprintf("Invalid parameters: %s", err.Error()), + IsError: true, + }, nil + } + + var missing []string + + for _, f := range t.requiredFields { + if _, ok := fields[f]; !ok { + missing = append(missing, f) + } + } + + if len(missing) > 0 { + return agent.ToolResult{ + Content: fmt.Sprintf( + "Missing required parameters: %s", + strings.Join(missing, ", "), + ), + IsError: true, + }, nil + } + } + + var params P + if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil { + return agent.ToolResult{ + Content: fmt.Sprintf("Invalid parameters: %s", err.Error()), + IsError: true, + }, nil + } + + return t.fn(ctx, params) +} + +func enforceStrictJSONSchema(raw json.RawMessage) (json.RawMessage, error) { + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + return nil, fmt.Errorf("cannot unmarshal schema: %w", err) + } + + normalizeStrictObject(schema) + + data, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("cannot marshal strict schema: %w", err) + } + + return json.RawMessage(data), nil +} + +func normalizeStrictObject(schema map[string]any) { + if schema == nil { + return + } + + if props, ok := schema["properties"].(map[string]any); ok && len(props) > 0 { + required := make([]string, 0, len(props)) + for name, prop := range props { + required = append(required, name) + + if nested, ok := prop.(map[string]any); ok { + normalizeStrictObject(nested) + } + } + + slices.Sort(required) + + requiredAny := make([]any, len(required)) + for i, name := range required { + requiredAny[i] = name + } + + schema["required"] = requiredAny + schema["additionalProperties"] = false + } + + if items, ok := schema["items"].(map[string]any); ok { + normalizeStrictObject(items) + } + + if additional, ok := schema["additionalProperties"].(map[string]any); ok { + normalizeStrictObject(additional) + } +} diff --git a/pkg/vetting/openai_schema_test.go b/pkg/vetting/openai_schema_test.go new file mode 100644 index 000000000..b4700b75f --- /dev/null +++ b/pkg/vetting/openai_schema_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONSchemaForTool_EnforcesOpenAIStrictMode(t *testing.T) { + t.Parallel() + + raw, err := jsonSchemaForTool[saveThirdPartyInfoToolParams]() + require.NoError(t, err) + + var schema map[string]any + require.NoError(t, json.Unmarshal(raw, &schema)) + + required := schema["required"].([]any) + assert.Contains(t, required, "name") + assert.Contains(t, required, "description") + assert.Equal(t, false, schema["additionalProperties"]) +} + +func TestNewVettingOutputType_EnforcesOpenAIStrictMode(t *testing.T) { + t.Parallel() + + outputType, err := newVettingOutputType[CrawlerOutput]("crawler") + require.NoError(t, err) + + var schema map[string]any + require.NoError(t, json.Unmarshal(outputType.Schema, &schema)) + + assert.Equal(t, false, schema["additionalProperties"]) + assert.NotEmpty(t, schema["required"]) +} diff --git a/pkg/vetting/orchestrator.go b/pkg/vetting/orchestrator.go index a28c1d736..838f81443 100644 --- a/pkg/vetting/orchestrator.go +++ b/pkg/vetting/orchestrator.go @@ -63,17 +63,16 @@ func newOrchestratorAgent( maxTokens int, procedure string, logger *log.Logger, - thirdPartyBrowser *browser.Browser, - researchBrowser *browser.Browser, + webBrowser *browser.Browser, firecrawlAPIKey string, reporter agent.ProgressReporter, + extraTools []agent.Tool, ) (*agent.Agent, error) { - readOnlyBrowserTools := browser.NewReadOnlyToolset(thirdPartyBrowser).Tools() + readOnlyBrowserTools := browser.NewReadOnlyToolset(webBrowser).Tools() - // Unrestricted browser tools for sub-agents that need to follow links - // to external sites (subprocessor lists hosted on OneTrust/Transcend, - // research, thirdParty comparison). - unrestrictedBrowserTools := browser.NewInteractiveToolset(researchBrowser).Tools() + // Interactive browser tools for sub-agents that follow links off the + // vendor site (subprocessor lists on OneTrust/Transcend, research). + unrestrictedBrowserTools := browser.NewInteractiveToolset(webBrowser).Tools() securityTools := security.NewToolset().Tools() @@ -176,7 +175,7 @@ func newOrchestratorAgent( // Optional sub-agents: only added when Firecrawl is configured. if hasFirecrawl { - researchBrowserTools := browser.NewInteractiveToolset(researchBrowser).Tools() + researchBrowserTools := browser.NewInteractiveToolset(webBrowser).Tools() searchTool := search.FirecrawlSearchTool(firecrawlAPIKey) govDBTool := search.CheckGovernmentDBTool(firecrawlAPIKey) @@ -228,7 +227,7 @@ func newOrchestratorAgent( ) } - tools := make([]agent.Tool, 0, len(entries)) + tools := make([]agent.Tool, 0, len(entries)+len(extraTools)) for _, e := range entries { ag, err := e.build(client, model, e.tools, subAgentOpts(e.toolName)...) if err != nil { @@ -238,6 +237,8 @@ func newOrchestratorAgent( tools = append(tools, ag.AsTool(e.toolName, e.description)) } + tools = append(tools, extraTools...) + if procedure == "" { procedure = defaultProcedure } diff --git a/pkg/vetting/persist.go b/pkg/vetting/persist.go new file mode 100644 index 000000000..4eca484f2 --- /dev/null +++ b/pkg/vetting/persist.go @@ -0,0 +1,448 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +const ( + vettingRiskAssessmentValidity = 365 * 24 * time.Hour + maxVettingNotesGaps = 5 +) + +// PersistAssessmentResult writes extracted assessment metadata onto the parent +// third party, links any sub-processors, and stores the risk assessment in one +// short transaction after the long assess phase completes. The assess run +// itself does not touch the database. +func PersistAssessmentResult( + ctx context.Context, + pc *PersistenceContext, + result Result, +) error { + scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID) + + return pc.PG.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + thirdParty := &coredata.ThirdParty{} + + if err := thirdParty.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil { + return fmt.Errorf("cannot load third party: %w", err) + } + + applySaveParams(thirdParty, pc.WebsiteURL, saveParamsFromInfo(result.Info)) + thirdParty.UpdatedAt = time.Now() + + if err := thirdParty.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot update third party: %w", err) + } + + for _, sub := range result.Info.Subprocessors { + if sub.Name == "" { + continue + } + + if err := linkSubThirdParty( + ctx, + conn, + scope, + pc, + linkSubThirdPartyParams{ + Name: sub.Name, + Country: sub.Country, + Purpose: sub.Purpose, + }, + ); err != nil { + return fmt.Errorf("cannot link sub third party %q: %w", sub.Name, err) + } + } + + if err := persistVettingRiskAssessment( + ctx, + conn, + scope, + pc, + thirdParty, + result, + ); err != nil { + return fmt.Errorf("cannot persist vetting risk assessment: %w", err) + } + + return nil + }, + ) +} + +func persistVettingRiskAssessment( + ctx context.Context, + conn pg.Tx, + scope coredata.Scoper, + pc *PersistenceContext, + thirdParty *coredata.ThirdParty, + result Result, +) error { + if err := thirdParty.ExpireNonExpiredRiskAssessments(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot expire existing risk assessments: %w", err) + } + + now := time.Now() + notes := buildRiskAssessmentNotes(result.Info) + + assessment := &coredata.ThirdPartyRiskAssessment{ + ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyRiskAssessmentEntityType), + OrganizationID: pc.OrganizationID, + ThirdPartyID: pc.ThirdPartyID, + ExpiresAt: now.Add(vettingRiskAssessmentValidity), + DataSensitivity: mapVettingDataSensitivity(result.Info), + BusinessImpact: mapVettingBusinessImpact(result.Info), + Notes: ¬es, + CreatedAt: now, + UpdatedAt: now, + } + + if err := assessment.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert risk assessment: %w", err) + } + + return nil +} + +func buildRiskAssessmentNotes(info ThirdPartyInfo) string { + var b strings.Builder + + b.WriteString("Automated vetting\n\n") + + switch { + case info.OverallRiskRating != "" && info.OverallRiskScore > 0: + fmt.Fprintf( + &b, + "Overall risk: %d/100 (%s)\n", + info.OverallRiskScore, + info.OverallRiskRating, + ) + case info.OverallRiskScore > 0: + fmt.Fprintf(&b, "Overall risk: %d/100\n", info.OverallRiskScore) + case info.OverallRiskRating != "": + fmt.Fprintf(&b, "Overall risk: %s\n", info.OverallRiskRating) + } + + if info.Recommendation != "" { + fmt.Fprintf(&b, "Recommendation: %s\n", formatVettingRecommendation(info.Recommendation)) + } + + var scoreParts []string + + if info.SecurityRiskScore > 0 { + scoreParts = append(scoreParts, fmt.Sprintf("Security %d/100", info.SecurityRiskScore)) + } + + if info.PrivacyRiskScore > 0 { + scoreParts = append(scoreParts, fmt.Sprintf("Privacy %d/100", info.PrivacyRiskScore)) + } + + if info.InvolvesAI || info.AIRiskScore > 0 { + scoreParts = append(scoreParts, fmt.Sprintf("AI %d/100", info.AIRiskScore)) + } + + if len(scoreParts) > 0 { + b.WriteByte('\n') + b.WriteString(strings.Join(scoreParts, " · ")) + b.WriteByte('\n') + } + + if len(info.InformationGaps) > 0 { + b.WriteString("\nGaps\n") + + gaps := info.InformationGaps + if len(gaps) > maxVettingNotesGaps { + gaps = gaps[:maxVettingNotesGaps] + } + + for _, gap := range gaps { + fmt.Fprintf(&b, "· %s\n", strings.TrimSpace(gap)) + } + } + + return strings.TrimSpace(b.String()) +} + +func formatVettingRecommendation(recommendation string) string { + switch strings.ToUpper(strings.TrimSpace(recommendation)) { + case "APPROVE": + return "Approve" + case "APPROVE_WITH_CONDITIONS": + return "Approve with conditions" + case "ESCALATE": + return "Escalate" + case "REJECT": + return "Reject" + default: + return recommendation + } +} + +func mapVettingDataSensitivity(info ThirdPartyInfo) coredata.DataSensitivity { + if !info.ProcessesPII && info.PrivacyRiskScore == 0 { + return coredata.DataSensitivityNone + } + + score := info.PrivacyRiskScore + if score == 0 { + score = overallScoreFromRating(info.OverallRiskRating) + } + + return scoreToDataSensitivity(score) +} + +func mapVettingBusinessImpact(info ThirdPartyInfo) coredata.BusinessImpact { + score := info.OverallRiskScore + if score == 0 { + score = info.SecurityRiskScore + } + + if score == 0 { + score = overallScoreFromRating(info.OverallRiskRating) + } + + return scoreToBusinessImpact(score) +} + +func overallScoreFromRating(rating string) int { + switch strings.ToLower(strings.TrimSpace(rating)) { + case "low": + return 25 + case "medium": + return 50 + case "high": + return 75 + default: + return 0 + } +} + +func scoreToDataSensitivity(score int) coredata.DataSensitivity { + switch { + case score <= 0: + return coredata.DataSensitivityNone + case score <= 25: + return coredata.DataSensitivityLow + case score <= 50: + return coredata.DataSensitivityMedium + case score <= 75: + return coredata.DataSensitivityHigh + default: + return coredata.DataSensitivityCritical + } +} + +func scoreToBusinessImpact(score int) coredata.BusinessImpact { + switch { + case score <= 25: + return coredata.BusinessImpactLow + case score <= 50: + return coredata.BusinessImpactMedium + case score <= 75: + return coredata.BusinessImpactHigh + default: + return coredata.BusinessImpactCritical + } +} + +func saveParamsFromInfo(info ThirdPartyInfo) saveThirdPartyInfoParams { + return saveThirdPartyInfoParams{ + saveThirdPartyInfoToolParams: saveThirdPartyInfoToolParams{ + Name: info.Name, + Description: info.Description, + Category: info.Category, + HeadquarterAddress: info.HeadquarterAddress, + LegalName: info.LegalName, + PrivacyPolicyURL: info.PrivacyPolicyURL, + ServiceLevelAgreementURL: info.ServiceLevelAgreementURL, + DataProcessingAgreementURL: info.DataProcessingAgreementURL, + BusinessAssociateAgreementURL: info.BusinessAssociateAgreementURL, + SubprocessorsListURL: info.SubprocessorsListURL, + SecurityPageURL: info.SecurityPageURL, + TrustPageURL: info.TrustPageURL, + TermsOfServiceURL: info.TermsOfServiceURL, + StatusPageURL: info.StatusPageURL, + Certifications: info.Certifications, + }, + Countries: countriesFromInfo(info), + } +} + +func applySaveParams( + thirdParty *coredata.ThirdParty, + websiteURL string, + p saveThirdPartyInfoParams, +) { + if p.Name != "" { + thirdParty.Name = p.Name + } + + thirdParty.WebsiteURL = &websiteURL + + if p.Category != "" { + if category, err := parseThirdPartyCategory(p.Category); err == nil { + thirdParty.Category = category + } + } + + if p.Description != "" { + thirdParty.Description = &p.Description + } + + if p.HeadquarterAddress != "" { + thirdParty.HeadquarterAddress = &p.HeadquarterAddress + } + + if p.LegalName != "" { + thirdParty.LegalName = &p.LegalName + } + + if p.PrivacyPolicyURL != "" { + thirdParty.PrivacyPolicyURL = &p.PrivacyPolicyURL + } + + if p.ServiceLevelAgreementURL != "" { + thirdParty.ServiceLevelAgreementURL = &p.ServiceLevelAgreementURL + } + + if p.DataProcessingAgreementURL != "" { + thirdParty.DataProcessingAgreementURL = &p.DataProcessingAgreementURL + } + + if p.BusinessAssociateAgreementURL != "" { + thirdParty.BusinessAssociateAgreementURL = &p.BusinessAssociateAgreementURL + } + + if p.SubprocessorsListURL != "" { + thirdParty.SubprocessorsListURL = &p.SubprocessorsListURL + } + + if p.SecurityPageURL != "" { + thirdParty.SecurityPageURL = &p.SecurityPageURL + } + + if p.TrustPageURL != "" { + thirdParty.TrustPageURL = &p.TrustPageURL + } + + if p.TermsOfServiceURL != "" { + thirdParty.TermsOfServiceURL = &p.TermsOfServiceURL + } + + if p.StatusPageURL != "" { + thirdParty.StatusPageURL = &p.StatusPageURL + } + + if len(p.Certifications) > 0 { + thirdParty.Certifications = p.Certifications + } + + if len(p.Countries) > 0 { + thirdParty.Countries = p.Countries + } +} + +func linkSubThirdParty( + ctx context.Context, + conn pg.Tx, + scope coredata.Scoper, + pc *PersistenceContext, + p linkSubThirdPartyParams, +) error { + if p.Name == "" { + return nil + } + + child := &coredata.ThirdParty{} + + err := child.LoadByNameAndOrganizationID(ctx, conn, scope, p.Name, pc.OrganizationID) + if err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot find child third party %q: %w", p.Name, err) + } + + now := time.Now() + child = &coredata.ThirdParty{ + ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType), + OrganizationID: pc.OrganizationID, + Name: p.Name, + Category: coredata.ThirdPartyCategoryOther, + FirstLevel: false, + CreatedAt: now, + UpdatedAt: now, + } + + if p.Description != "" { + child.Description = &p.Description + } + + if p.Category != "" { + if category, err := parseThirdPartyCategory(p.Category); err == nil { + child.Category = category + } + } + + if p.WebsiteURL != "" { + child.WebsiteURL = &p.WebsiteURL + } + + if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 { + child.Countries = countries + } + + if err := child.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot create child third party %q: %w", p.Name, err) + } + } else if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 && len(child.Countries) == 0 { + child.Countries = countries + child.UpdatedAt = time.Now() + + if err := child.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot update child third party %q countries: %w", p.Name, err) + } + } + + if child.ID == pc.ThirdPartyID { + return nil + } + + relation := &coredata.ThirdPartyThirdParty{ + ParentThirdPartyID: pc.ThirdPartyID, + ChildThirdPartyID: child.ID, + CreatedAt: time.Now(), + } + + if p.Purpose != "" { + relation.Purpose = &p.Purpose + } + + if err := relation.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert third party relation: %w", err) + } + + return nil +} diff --git a/pkg/vetting/persist_test.go b/pkg/vetting/persist_test.go new file mode 100644 index 000000000..c60ff0b9c --- /dev/null +++ b/pkg/vetting/persist_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" +) + +func TestBuildRiskAssessmentNotes(t *testing.T) { + t.Parallel() + + info := ThirdPartyInfo{ + OverallRiskRating: "Medium", + OverallRiskScore: 62, + Recommendation: "APPROVE_WITH_CONDITIONS", + SecurityRiskScore: 45, + PrivacyRiskScore: 70, + AIRiskScore: 10, + InvolvesAI: true, + RiskScores: []RiskScore{ + {Category: "Security", Rating: "Medium", Notes: "Missing SOC 2"}, + }, + InformationGaps: []string{"No public DPA", "Sub-processor list inaccessible"}, + } + + notes := buildRiskAssessmentNotes(info) + + assert.Equal( + t, + `Automated vetting + +Overall risk: 62/100 (Medium) +Recommendation: Approve with conditions + +Security 45/100 · Privacy 70/100 · AI 10/100 + +Gaps +· No public DPA +· Sub-processor list inaccessible`, + notes, + ) + assert.NotContains(t, notes, "**") + assert.NotContains(t, notes, "#") +} + +func TestBuildRiskAssessmentNotes_LimitsGaps(t *testing.T) { + t.Parallel() + + gaps := make([]string, maxVettingNotesGaps+2) + for i := range gaps { + gaps[i] = "gap" + } + + notes := buildRiskAssessmentNotes(ThirdPartyInfo{InformationGaps: gaps}) + + assert.Equal(t, maxVettingNotesGaps, strings.Count(notes, "· gap")) +} + +func TestFormatVettingRecommendation(t *testing.T) { + t.Parallel() + + assert.Equal(t, "Approve with conditions", formatVettingRecommendation("APPROVE_WITH_CONDITIONS")) + assert.Equal(t, "Reject", formatVettingRecommendation("reject")) +} + +func TestMapVettingRiskLevels(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + coredata.DataSensitivityNone, + mapVettingDataSensitivity(ThirdPartyInfo{ProcessesPII: false}), + ) + assert.Equal( + t, + coredata.DataSensitivityHigh, + mapVettingDataSensitivity(ThirdPartyInfo{ + ProcessesPII: true, + PrivacyRiskScore: 70, + }), + ) + assert.Equal( + t, + coredata.BusinessImpactMedium, + mapVettingBusinessImpact(ThirdPartyInfo{OverallRiskScore: 40}), + ) + assert.Equal( + t, + coredata.BusinessImpactHigh, + mapVettingBusinessImpact(ThirdPartyInfo{OverallRiskRating: "High"}), + ) +} diff --git a/pkg/vetting/prompts/extraction.txt b/pkg/vetting/prompts/extraction.txt index 862be0e68..ed63988c6 100644 --- a/pkg/vetting/prompts/extraction.txt +++ b/pkg/vetting/prompts/extraction.txt @@ -10,4 +10,6 @@ Given a third party assessment markdown report, extract the third party informat - Extract only information explicitly present in the report. - Use empty strings for fields not mentioned, empty arrays for missing lists, false for missing booleans. - Never infer or fabricate; if the report does not state something, leave the field empty. +- Populate data_locations with countries or regions where data is processed or stored. Prefer ISO 3166-1 alpha-2 codes (US, DE, EU, GLOBAL) when the report states them; otherwise use the country or region names from the report. +- Include the headquarters country in data_locations when it is stated in the report. diff --git a/pkg/vetting/prompts/orchestrator_base.txt b/pkg/vetting/prompts/orchestrator_base.txt index 7908d4de4..5e9d2a4b7 100644 --- a/pkg/vetting/prompts/orchestrator_base.txt +++ b/pkg/vetting/prompts/orchestrator_base.txt @@ -26,6 +26,13 @@ If `research_third_party_externally` is available, use it for incidents, regulat {procedure} + +After completing your analysis and writing the report: + +1. Call `save_third_party_info` once with all metadata you discovered (name, description, category, URLs, certifications). Use an empty string for fields you could not find. +2. For each sub-processor or vendor dependency discovered, call `link_sub_third_party` with the name, description, category, website URL, country, and purpose. If a third party with the same name already exists it is linked without duplication; otherwise a new one is created with the info you provide. + + - Only report information actually discovered through the tools — never fabricate URLs, certifications, or findings. - Note tool failures and inaccessible pages in the report rather than omitting the section. diff --git a/pkg/vetting/sub_agent.go b/pkg/vetting/sub_agent.go index 3e331d8a7..e580ce9d1 100644 --- a/pkg/vetting/sub_agent.go +++ b/pkg/vetting/sub_agent.go @@ -58,7 +58,7 @@ func newSubAgent[T any]( tools []agent.Tool, extraOpts ...agent.Option, ) (*agent.Agent, error) { - outputType, err := agent.NewOutputType[T](spec.outputName) + outputType, err := newVettingOutputType[T](spec.outputName) if err != nil { return nil, fmt.Errorf("cannot create output type %q: %w", spec.outputName, err) } diff --git a/pkg/vetting/subprocessors.go b/pkg/vetting/subprocessors.go new file mode 100644 index 000000000..a76ee8cfe --- /dev/null +++ b/pkg/vetting/subprocessors.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "encoding/json" + "strings" + + "go.probo.inc/probo/pkg/llm" +) + +const extractSubprocessorsToolName = "extract_subprocessors" + +// subprocessorsFromOrchestratorMessages collects sub-processors from every +// extract_subprocessors sub-agent tool result in the orchestrator transcript. +// Later tool calls win when the same name appears more than once. +func subprocessorsFromOrchestratorMessages(messages []llm.Message) []Subprocessor { + toolNames := toolCallNamesByID(messages) + + byName := make(map[string]Subprocessor) + order := make([]string, 0) + + for _, msg := range messages { + if msg.Role != llm.RoleTool { + continue + } + + if toolNames[msg.ToolCallID] != extractSubprocessorsToolName { + continue + } + + text := strings.TrimSpace(msg.Text()) + if text == "" || !json.Valid([]byte(text)) { + continue + } + + var output SubprocessorOutput + if err := json.Unmarshal([]byte(text), &output); err != nil { + continue + } + + for _, sub := range output.Subprocessors { + if sub.Name == "" { + continue + } + + key := normalizeSubprocessorName(sub.Name) + if _, exists := byName[key]; !exists { + order = append(order, key) + } + + byName[key] = sub + } + } + + if len(order) == 0 { + return nil + } + + subs := make([]Subprocessor, 0, len(order)) + for _, key := range order { + subs = append(subs, byName[key]) + } + + return subs +} + +// mergeSubprocessors prefers entries from primary (tool output). Names only +// present in secondary (markdown extraction) are appended afterward. +func mergeSubprocessors(primary, secondary []Subprocessor) []Subprocessor { + if len(primary) == 0 { + return secondary + } + + if len(secondary) == 0 { + return primary + } + + merged := make([]Subprocessor, len(primary), len(primary)+len(secondary)) + copy(merged, primary) + + seen := make(map[string]struct{}, len(primary)) + for _, sub := range primary { + seen[normalizeSubprocessorName(sub.Name)] = struct{}{} + } + + for _, sub := range secondary { + if sub.Name == "" { + continue + } + + key := normalizeSubprocessorName(sub.Name) + if _, exists := seen[key]; exists { + continue + } + + seen[key] = struct{}{} + + merged = append(merged, sub) + } + + return merged +} + +func subprocessorListURLFromOrchestratorMessages(messages []llm.Message) string { + toolNames := toolCallNamesByID(messages) + + var source string + + for _, msg := range messages { + if msg.Role != llm.RoleTool { + continue + } + + if toolNames[msg.ToolCallID] != extractSubprocessorsToolName { + continue + } + + text := strings.TrimSpace(msg.Text()) + if text == "" || !json.Valid([]byte(text)) { + continue + } + + var output SubprocessorOutput + if err := json.Unmarshal([]byte(text), &output); err != nil { + continue + } + + if strings.TrimSpace(output.Source) != "" { + source = strings.TrimSpace(output.Source) + } + } + + return source +} + +func toolCallNamesByID(messages []llm.Message) map[string]string { + toolNames := make(map[string]string) + + for _, msg := range messages { + if msg.Role != llm.RoleAssistant { + continue + } + + for _, tc := range msg.ToolCalls { + toolNames[tc.ID] = tc.Function.Name + } + } + + return toolNames +} + +func normalizeSubprocessorName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} diff --git a/pkg/vetting/subprocessors_test.go b/pkg/vetting/subprocessors_test.go new file mode 100644 index 000000000..55ea17c6c --- /dev/null +++ b/pkg/vetting/subprocessors_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/llm" +) + +func TestSubprocessorsFromOrchestratorMessages(t *testing.T) { + t.Parallel() + + toolJSON := `{"subprocessors":[{"name":"Amazon Web Services","country":"US","purpose":"Cloud hosting"}],"total_count":1,"source":"https://example.com/subprocessors","is_complete":true}` + + messages := []llm.Message{ + { + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{{ + ID: "call-1", + Function: llm.FunctionCall{ + Name: extractSubprocessorsToolName, + }, + }}, + }, + { + Role: llm.RoleTool, + ToolCallID: "call-1", + Parts: []llm.Part{llm.TextPart{Text: toolJSON}}, + }, + } + + subs := subprocessorsFromOrchestratorMessages(messages) + + assert.Equal( + t, + []Subprocessor{{ + Name: "Amazon Web Services", + Country: "US", + Purpose: "Cloud hosting", + }}, + subs, + ) +} + +func TestSubprocessorsFromOrchestratorMessages_LatestCallWins(t *testing.T) { + t.Parallel() + + messages := []llm.Message{ + { + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call-1", + Function: llm.FunctionCall{ + Name: extractSubprocessorsToolName, + }, + }, + { + ID: "call-2", + Function: llm.FunctionCall{ + Name: extractSubprocessorsToolName, + }, + }, + }, + }, + { + Role: llm.RoleTool, + ToolCallID: "call-1", + Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Stripe","country":"US","purpose":"Payments"}]}`}}, + }, + { + Role: llm.RoleTool, + ToolCallID: "call-2", + Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Stripe","country":"IE","purpose":"Payment processing"}]}`}}, + }, + } + + subs := subprocessorsFromOrchestratorMessages(messages) + + assert.Equal( + t, + []Subprocessor{{ + Name: "Stripe", + Country: "IE", + Purpose: "Payment processing", + }}, + subs, + ) +} + +func TestSubprocessorsFromOrchestratorMessages_IgnoresOtherTools(t *testing.T) { + t.Parallel() + + messages := []llm.Message{ + { + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{{ + ID: "call-1", + Function: llm.FunctionCall{ + Name: "assess_security", + }, + }}, + }, + { + Role: llm.RoleTool, + ToolCallID: "call-1", + Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Ignored"}]}`}}, + }, + } + + assert.Nil(t, subprocessorsFromOrchestratorMessages(messages)) +} + +func TestMergeSubprocessors(t *testing.T) { + t.Parallel() + + toolSubs := []Subprocessor{{ + Name: "AWS", + Country: "US", + Purpose: "Hosting", + }} + extractedSubs := []Subprocessor{ + {Name: "AWS", Country: "DE", Purpose: "Wrong"}, + {Name: "SendGrid", Country: "US", Purpose: "Email"}, + } + + merged := mergeSubprocessors(toolSubs, extractedSubs) + + assert.Equal( + t, + []Subprocessor{ + {Name: "AWS", Country: "US", Purpose: "Hosting"}, + {Name: "SendGrid", Country: "US", Purpose: "Email"}, + }, + merged, + ) +} + +func TestSubprocessorListURLFromOrchestratorMessages(t *testing.T) { + t.Parallel() + + messages := []llm.Message{ + { + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{{ + ID: "call-1", + Function: llm.FunctionCall{ + Name: extractSubprocessorsToolName, + }, + }}, + }, + { + Role: llm.RoleTool, + ToolCallID: "call-1", + Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[],"source":"https://example.com/legal/subprocessors"}`}}, + }, + } + + assert.Equal( + t, + "https://example.com/legal/subprocessors", + subprocessorListURLFromOrchestratorMessages(messages), + ) +} diff --git a/pkg/vetting/tools.go b/pkg/vetting/tools.go new file mode 100644 index 000000000..ca1b52709 --- /dev/null +++ b/pkg/vetting/tools.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 vetting + +import ( + "context" + "fmt" + "time" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/agent" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +type ( + saveThirdPartyInfoToolParams struct { + Name string `json:"name" jsonschema:"Third party display name"` + Description string `json:"description" jsonschema:"One-sentence description"` + Category string `json:"category" jsonschema:"Category: ANALYTICS, CLOUD_PROVIDER, SECURITY, etc."` + HeadquarterAddress string `json:"headquarter_address" jsonschema:"Headquarters city and country"` + LegalName string `json:"legal_name" jsonschema:"Legal entity name"` + PrivacyPolicyURL string `json:"privacy_policy_url" jsonschema:"Privacy policy URL"` + ServiceLevelAgreementURL string `json:"service_level_agreement_url" jsonschema:"SLA URL"` + DataProcessingAgreementURL string `json:"data_processing_agreement_url" jsonschema:"DPA URL"` + BusinessAssociateAgreementURL string `json:"business_associate_agreement_url" jsonschema:"BAA URL"` + SubprocessorsListURL string `json:"subprocessors_list_url" jsonschema:"Subprocessors list URL"` + SecurityPageURL string `json:"security_page_url" jsonschema:"Security page URL"` + TrustPageURL string `json:"trust_page_url" jsonschema:"Trust center URL"` + TermsOfServiceURL string `json:"terms_of_service_url" jsonschema:"Terms of service URL"` + StatusPageURL string `json:"status_page_url" jsonschema:"Status page URL"` + Certifications []string `json:"certifications" jsonschema:"Compliance certifications found"` + } + + saveThirdPartyInfoParams struct { + saveThirdPartyInfoToolParams + Countries coredata.CountryCodes + } + + linkSubThirdPartyParams struct { + Name string `json:"name" jsonschema:"Sub-third-party company name"` + Description string `json:"description,omitempty" jsonschema:"One-sentence description of what this third party does"` + Category string `json:"category,omitempty" jsonschema:"Category: ANALYTICS, CLOUD_PROVIDER, SECURITY, etc."` + WebsiteURL string `json:"website_url,omitempty" jsonschema:"Website URL if known"` + Country string `json:"country,omitempty" jsonschema:"Country where the sub-third-party operates"` + Purpose string `json:"purpose,omitempty" jsonschema:"Purpose or role of this sub-third-party"` + } + + // PersistenceContext holds the DB and entity references the tools need. + PersistenceContext struct { + PG *pg.Client + ThirdPartyID gid.GID + OrganizationID gid.GID + WebsiteURL string + } +) + +func SaveThirdPartyInfoTool(pc *PersistenceContext) agent.Tool { + return vettingFunctionTool( + "save_third_party_info", + "Persist the discovered third party metadata to the database. Call this once after completing the analysis. Use an empty string for any field you could not discover.", + func(ctx context.Context, p saveThirdPartyInfoToolParams) (agent.ToolResult, error) { + scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID) + + err := pc.PG.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + thirdParty := &coredata.ThirdParty{} + + if err := thirdParty.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil { + return fmt.Errorf("cannot load third party: %w", err) + } + + if p.Category != "" { + if _, err := parseThirdPartyCategory(p.Category); err != nil { + return err + } + } + + applySaveParams(thirdParty, pc.WebsiteURL, saveThirdPartyInfoParams{ + saveThirdPartyInfoToolParams: p, + }) + thirdParty.UpdatedAt = time.Now() + + if err := thirdParty.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot update third party: %w", err) + } + + return nil + }, + ) + if err != nil { + return agent.ToolResult{}, fmt.Errorf("cannot save third party info: %w", err) + } + + return agent.ToolResult{Content: "Third party info saved successfully."}, nil + }, + ) +} + +func LinkSubThirdPartyTool(pc *PersistenceContext) agent.Tool { + return vettingFunctionTool( + "link_sub_third_party", + "Link a discovered sub-third-party (sub-processor, vendor dependency) to the parent. If a third party with the same name already exists in the organization it is linked as-is; otherwise a new one is created with the provided info. Call once per sub-third-party discovered.", + func(ctx context.Context, p linkSubThirdPartyParams) (agent.ToolResult, error) { + if p.Name == "" { + return agent.ToolResult{Content: "Skipped: empty name."}, nil + } + + scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID) + + err := pc.PG.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + return linkSubThirdParty(ctx, conn, scope, pc, p) + }, + ) + if err != nil { + return agent.ToolResult{}, fmt.Errorf("cannot link sub third party: %w", err) + } + + return agent.ToolResult{Content: fmt.Sprintf("Linked %q as sub third party.", p.Name)}, nil + }, + ) +} + +func parseThirdPartyCategory(raw string) (coredata.ThirdPartyCategory, error) { + category := coredata.ThirdPartyCategory(raw) + if !category.IsValid() { + return "", fmt.Errorf("invalid third party category %q", raw) + } + + return category, nil +}