Add async third-party vetting
Queue vetting on third_parties with PENDING, PROCESSING, COMPLETED, and FAILED states. Expose enqueue and status through GraphQL, MCP, CLI, and n8n, validate vet requests, tune the worker via config, and poll the detail page while vetting runs. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ThirdPartyGraphNodeQuery>(
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{isVetting && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-warning px-4 py-3 text-sm text-txt-warning">
|
||||
<div
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 animate-spin rounded-full border-2 border-border-warning/30 border-t-border-warning"
|
||||
/>
|
||||
{__("Vetting is in progress. Results will appear once the analysis is complete.")}
|
||||
</div>
|
||||
)}
|
||||
{isVettingFailed && (
|
||||
<div className="rounded-lg bg-danger px-4 py-3 text-sm text-txt-danger">
|
||||
{__("Vetting failed. You can start vetting again.")}
|
||||
</div>
|
||||
)}
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
@@ -104,12 +148,15 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{thirdParty.canAssess && (
|
||||
<ImportAssessmentDialog thirdPartyId={thirdParty.id}>
|
||||
{thirdParty.canVet && !isVetting && (
|
||||
<VettingDialog
|
||||
thirdPartyId={thirdParty.id}
|
||||
websiteUrl={thirdParty.websiteUrl}
|
||||
>
|
||||
<Button icon={IconPageTextLine} variant="secondary">
|
||||
{__("Assessment From Website")}
|
||||
{__("Start Vetting")}
|
||||
</Button>
|
||||
</ImportAssessmentDialog>
|
||||
</VettingDialog>
|
||||
)}
|
||||
{thirdParty.canDelete && (
|
||||
<ActionDropdown variant="secondary">
|
||||
|
||||
@@ -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<VettingDialogMutation>(vetMutation);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
await assess({
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
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) {
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={__("Assessment from website")}
|
||||
title={__("Start Vetting")}
|
||||
className="max-w-lg"
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded>
|
||||
<Field
|
||||
required
|
||||
label={__("URL")}
|
||||
label={__("Website URL")}
|
||||
type="text"
|
||||
{...register("url")}
|
||||
error={formState.errors.url?.message}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isAssessing}>
|
||||
{__("Assess")}
|
||||
<Button type="submit" disabled={isVetting}>
|
||||
{__("Start Vetting")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -214,7 +214,9 @@ function AssessmentRow(props: AssessmentRowProps) {
|
||||
{__("Notes")}
|
||||
:
|
||||
</div>
|
||||
<p className="text-txt-secondary">{assessment.notes}</p>
|
||||
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
|
||||
{assessment.notes}
|
||||
</p>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
102
packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts
vendored
Normal file
102
packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { proboApiRequest } from '../../GenericFunctions';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: '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<INodeExecutionData> {
|
||||
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<string, unknown> = {
|
||||
id: thirdPartyId,
|
||||
websiteUrl,
|
||||
};
|
||||
|
||||
if (procedure) {
|
||||
input.procedure = procedure;
|
||||
}
|
||||
|
||||
const responseData = await proboApiRequest.call(this, query, { input });
|
||||
|
||||
return {
|
||||
json: responseData,
|
||||
pairedItem: { item: itemIndex },
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 <thirdParty-id> --url <website-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 <thirdParty-id> --url <website-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
|
||||
@@ -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,
|
||||
|
||||
24
pkg/coredata/migrations/20260601T120000Z.sql
Normal file
24
pkg/coredata/migrations/20260601T120000Z.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
126
pkg/coredata/third_party_vetting.go
Normal file
126
pkg/coredata/third_party_vetting.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
}
|
||||
87
pkg/coredata/third_party_vetting_status.go
Normal file
87
pkg/coredata/third_party_vetting_status.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
mcpHandler: mcp_v1.NewMux(
|
||||
cfg.Logger.Named("mcp.v1"),
|
||||
cfg.Probo,
|
||||
cfg.ThirdParty,
|
||||
cfg.IAM,
|
||||
cfg.AccessReview,
|
||||
cfg.CookieBanner,
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
enum ThirdPartyVettingStatus
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatus") {
|
||||
PENDING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusPending"
|
||||
)
|
||||
PROCESSING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusProcessing"
|
||||
)
|
||||
COMPLETED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusCompleted"
|
||||
)
|
||||
FAILED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusFailed"
|
||||
)
|
||||
}
|
||||
|
||||
enum ThirdPartyCategory
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyCategory") {
|
||||
ANALYTICS
|
||||
@@ -293,6 +313,8 @@ type ThirdParty implements Node {
|
||||
orderBy: ThirdPartyOrder
|
||||
): ThirdPartyConnection! @goField(forceResolver: true)
|
||||
|
||||
vettingStatus: ThirdPartyVettingStatus @goField(forceResolver: true)
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
|
||||
@@ -480,7 +502,7 @@ extend type Mutation {
|
||||
createThirdPartyRiskAssessment(
|
||||
input: CreateThirdPartyRiskAssessmentInput!
|
||||
): CreateThirdPartyRiskAssessmentPayload!
|
||||
assessThirdParty(input: AssessThirdPartyInput!): AssessThirdPartyPayload!
|
||||
vetThirdParty(input: VetThirdPartyInput!): VetThirdPartyPayload!
|
||||
publishThirdPartyList(
|
||||
input: PublishThirdPartyListInput!
|
||||
): PublishThirdPartyListPayload!
|
||||
@@ -652,18 +674,12 @@ input CreateThirdPartyRiskAssessmentInput {
|
||||
notes: String
|
||||
}
|
||||
|
||||
input AssessThirdPartyInput {
|
||||
input VetThirdPartyInput {
|
||||
id: ID!
|
||||
websiteUrl: String!
|
||||
procedure: String
|
||||
}
|
||||
|
||||
type ThirdPartySubprocessor {
|
||||
name: String!
|
||||
country: String!
|
||||
purpose: String!
|
||||
}
|
||||
|
||||
type CreateThirdPartyPayload {
|
||||
thirdPartyEdge: ThirdPartyEdge!
|
||||
}
|
||||
@@ -736,10 +752,8 @@ type CreateThirdPartyRiskAssessmentPayload {
|
||||
thirdPartyRiskAssessmentEdge: ThirdPartyRiskAssessmentEdge!
|
||||
}
|
||||
|
||||
type AssessThirdPartyPayload {
|
||||
type VetThirdPartyPayload {
|
||||
thirdParty: ThirdParty!
|
||||
report: String!
|
||||
subprocessors: [ThirdPartySubprocessor!]!
|
||||
}
|
||||
|
||||
input CreateThirdPartyThirdPartyMappingInput {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||
"go.probo.inc/probo/pkg/thirdparty"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
@@ -536,35 +537,45 @@ func (r *mutationResolver) CreateThirdPartyRiskAssessment(ctx context.Context, i
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AssessThirdParty is the resolver for the assessThirdParty field.
|
||||
func (r *mutationResolver) AssessThirdParty(ctx context.Context, input types.AssessThirdPartyInput) (*types.AssessThirdPartyPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyAssess)
|
||||
// VetThirdParty is the resolver for the vetThirdParty field.
|
||||
func (r *mutationResolver) VetThirdParty(ctx context.Context, input types.VetThirdPartyInput) (*types.VetThirdPartyPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyVet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := r.probo.ThirdParties.Assess(
|
||||
thirdParty, err := r.thirdParty.Vet(
|
||||
ctx, scope,
|
||||
probo.AssessThirdPartyRequest{
|
||||
thirdparty.VetRequest{
|
||||
ID: input.ID,
|
||||
WebsiteURL: input.WebsiteURL,
|
||||
Procedure: input.Procedure,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, probo.ErrThirdPartyAssessmentDisabled) {
|
||||
return nil, gqlutils.Unavailable(ctx, probo.ErrThirdPartyAssessmentDisabled)
|
||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot assess thirdParty", log.Error(err))
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
if errors.Is(err, thirdparty.ErrVettingDisabled) {
|
||||
return nil, gqlutils.Unavailable(ctx, thirdparty.ErrVettingDisabled)
|
||||
}
|
||||
|
||||
if errors.Is(err, thirdparty.ErrVettingInProgress) {
|
||||
return nil, gqlutils.Conflict(ctx, thirdparty.ErrVettingInProgress)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.AssessThirdPartyPayload{
|
||||
ThirdParty: types.NewThirdParty(result.ThirdParty),
|
||||
Report: result.Report,
|
||||
Subprocessors: types.NewThirdPartySubprocessors(result.Subprocessors),
|
||||
return &types.VetThirdPartyPayload{
|
||||
ThirdParty: types.NewThirdParty(thirdParty),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -931,6 +942,22 @@ func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.T
|
||||
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
|
||||
}
|
||||
|
||||
// VettingStatus is the resolver for the vettingStatus field.
|
||||
func (r *thirdPartyResolver) VettingStatus(ctx context.Context, obj *types.ThirdParty) (*coredata.ThirdPartyVettingStatus, error) {
|
||||
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status, err := r.thirdParty.VettingStatus(ctx, scope, obj.ID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot get vetting status", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Permission is the resolver for the permission field.
|
||||
func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) {
|
||||
return r.Resolver.Permission(ctx, obj, action)
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -107,16 +106,3 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
|
||||
return object
|
||||
}
|
||||
|
||||
func NewThirdPartySubprocessors(sps []probo.Subprocessor) []*ThirdPartySubprocessor {
|
||||
result := make([]*ThirdPartySubprocessor, len(sps))
|
||||
for i, sp := range sps {
|
||||
result[i] = &ThirdPartySubprocessor{
|
||||
Name: sp.Name,
|
||||
Country: sp.Country,
|
||||
Purpose: sp.Purpose,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -32,10 +32,12 @@ import (
|
||||
"go.probo.inc/probo/pkg/prosemirror"
|
||||
"go.probo.inc/probo/pkg/riskmanagement"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/thirdparty"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
proboSvc *probo.Service
|
||||
thirdPartySvc *thirdparty.Service
|
||||
iamSvc *iam.Service
|
||||
accessReview *accessreview.Service
|
||||
cookieBanner *cookiebanner.Service
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview"
|
||||
"go.probo.inc/probo/pkg/cookiebanner"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
@@ -23,6 +24,8 @@ import (
|
||||
"go.probo.inc/probo/pkg/riskmanagement"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/types"
|
||||
"go.probo.inc/probo/pkg/thirdparty"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
// ListOrganizationsTool handles the listOrganizations tool
|
||||
@@ -5296,27 +5299,47 @@ func (r *Resolver) DeleteCustomDomainTool(ctx context.Context, req *mcp.CallTool
|
||||
return nil, types.DeleteCustomDomainOutput{DeletedCustomDomain: deletedDomain}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) AssessThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AssessThirdPartyInput) (*mcp.CallToolResult, types.AssessThirdPartyOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyAssess)
|
||||
func (r *Resolver) VetThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.VetThirdPartyInput) (*mcp.CallToolResult, types.VetThirdPartyOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyVet)
|
||||
if err != nil {
|
||||
return nil, types.AssessThirdPartyOutput{}, err
|
||||
return nil, types.VetThirdPartyOutput{}, err
|
||||
}
|
||||
|
||||
svc := r.proboSvc
|
||||
svc := r.thirdPartySvc
|
||||
|
||||
result, err := svc.ThirdParties.Assess(
|
||||
thirdParty, err := svc.Vet(
|
||||
ctx, scope,
|
||||
probo.AssessThirdPartyRequest{
|
||||
thirdparty.VetRequest{
|
||||
ID: input.ID,
|
||||
WebsiteURL: input.WebsiteURL,
|
||||
Procedure: input.Procedure,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, types.AssessThirdPartyOutput{}, fmt.Errorf("cannot assess thirdParty: %w", err)
|
||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return nil, types.VetThirdPartyOutput{}, validationErrors
|
||||
}
|
||||
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("resource not found")
|
||||
}
|
||||
|
||||
if errors.Is(err, thirdparty.ErrVettingDisabled) {
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is not configured")
|
||||
}
|
||||
|
||||
if errors.Is(err, thirdparty.ErrVettingInProgress) {
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is already in progress")
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err))
|
||||
|
||||
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("internal server error")
|
||||
}
|
||||
|
||||
return nil, types.NewAssessThirdPartyOutput(result), nil
|
||||
return nil, types.VetThirdPartyOutput{
|
||||
ThirdParty: types.NewThirdParty(thirdParty),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) PublishFindingListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishFindingListInput) (*mcp.CallToolResult, types.PublishFindingListOutput, error) {
|
||||
|
||||
@@ -1480,7 +1480,7 @@ components:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Deleted thirdParty service ID
|
||||
|
||||
AssessThirdPartyInput:
|
||||
VetThirdPartyInput:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
@@ -1488,48 +1488,21 @@ components:
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: ThirdParty ID to assess
|
||||
description: ThirdParty ID to vet
|
||||
website_url:
|
||||
type: string
|
||||
description: ThirdParty website URL to crawl and assess
|
||||
description: ThirdParty website URL to crawl and vet
|
||||
procedure:
|
||||
type: string
|
||||
description: Optional custom assessment procedure (overrides the default)
|
||||
description: Optional custom vetting procedure (overrides the default)
|
||||
|
||||
ThirdPartySubprocessor:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- country
|
||||
- purpose
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Sub-processor name
|
||||
country:
|
||||
type: string
|
||||
description: Country where the sub-processor operates
|
||||
purpose:
|
||||
type: string
|
||||
description: Purpose of the sub-processor
|
||||
|
||||
AssessThirdPartyOutput:
|
||||
VetThirdPartyOutput:
|
||||
type: object
|
||||
required:
|
||||
- thirdParty
|
||||
- report
|
||||
- subprocessors
|
||||
properties:
|
||||
thirdParty:
|
||||
$ref: "#/components/schemas/ThirdParty"
|
||||
report:
|
||||
type: string
|
||||
description: Markdown-formatted thirdParty assessment report
|
||||
subprocessors:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ThirdPartySubprocessor"
|
||||
description: Sub-processors discovered during the assessment
|
||||
|
||||
GetUserInput:
|
||||
type: object
|
||||
@@ -12096,14 +12069,14 @@ tools:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyServiceInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyServiceOutput"
|
||||
- name: assessThirdParty
|
||||
description: Run an AI-powered assessment on a thirdParty by crawling its website. Returns a markdown report, the discovered sub-processors, and an enriched thirdParty record. Long-running (up to 20 minutes).
|
||||
- name: vetThirdParty
|
||||
description: Start AI-powered vetting of a third party by crawling its website. Returns immediately; vetting runs in the background.
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/AssessThirdPartyInput"
|
||||
$ref: "#/components/schemas/VetThirdPartyInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/AssessThirdPartyOutput"
|
||||
$ref: "#/components/schemas/VetThirdPartyOutput"
|
||||
- name: listRisks
|
||||
description: List all risks for the organization
|
||||
hints:
|
||||
|
||||
@@ -17,7 +17,6 @@ package types
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
)
|
||||
|
||||
func NewThirdPartyRiskAssessment(v *coredata.ThirdPartyRiskAssessment) *ThirdPartyRiskAssessment {
|
||||
@@ -229,24 +228,3 @@ func NewListThirdPartyServicesOutput(p *page.Page[*coredata.ThirdPartyService, c
|
||||
ThirdPartyServices: services,
|
||||
}
|
||||
}
|
||||
|
||||
func NewThirdPartySubprocessors(sps []probo.Subprocessor) []*ThirdPartySubprocessor {
|
||||
result := make([]*ThirdPartySubprocessor, len(sps))
|
||||
for i, sp := range sps {
|
||||
result[i] = &ThirdPartySubprocessor{
|
||||
Name: sp.Name,
|
||||
Country: sp.Country,
|
||||
Purpose: sp.Purpose,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func NewAssessThirdPartyOutput(result *probo.AssessThirdPartyResult) AssessThirdPartyOutput {
|
||||
return AssessThirdPartyOutput{
|
||||
ThirdParty: NewThirdParty(result.ThirdParty),
|
||||
Report: result.Report,
|
||||
Subprocessors: NewThirdPartySubprocessors(result.Subprocessors),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,26 @@ import (
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/mcputils"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/server"
|
||||
"go.probo.inc/probo/pkg/thirdparty"
|
||||
)
|
||||
|
||||
func NewMux(logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service, accessReviewSvc *accessreview.Service, cookieBannerSvc *cookiebanner.Service, riskManagementSvc *riskmanagement.Service, tokenSecret string) *chi.Mux {
|
||||
func NewMux(
|
||||
logger *log.Logger,
|
||||
proboSvc *probo.Service,
|
||||
thirdPartySvc *thirdparty.Service,
|
||||
iamSvc *iam.Service,
|
||||
accessReviewSvc *accessreview.Service,
|
||||
cookieBannerSvc *cookiebanner.Service,
|
||||
riskManagementSvc *riskmanagement.Service,
|
||||
tokenSecret string,
|
||||
) *chi.Mux {
|
||||
logger = logger.Named("mcp.v1")
|
||||
|
||||
logger.Info("initializing MCP server")
|
||||
|
||||
resolver := &Resolver{
|
||||
proboSvc: proboSvc,
|
||||
thirdPartySvc: thirdPartySvc,
|
||||
iamSvc: iamSvc,
|
||||
accessReview: accessReviewSvc,
|
||||
cookieBanner: cookieBannerSvc,
|
||||
|
||||
16
pkg/thirdparty/service.go
vendored
16
pkg/thirdparty/service.go
vendored
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
171
pkg/thirdparty/vetting.go
vendored
Normal file
171
pkg/thirdparty/vetting.go
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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
|
||||
}
|
||||
119
pkg/thirdparty/vetting_test.go
vendored
Normal file
119
pkg/thirdparty/vetting_test.go
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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{}
|
||||
}
|
||||
238
pkg/thirdparty/vetting_worker.go
vendored
Normal file
238
pkg/thirdparty/vetting_worker.go
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
164
pkg/vetting/country_codes.go
Normal file
164
pkg/vetting/country_codes.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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), " ")
|
||||
}
|
||||
78
pkg/vetting/country_codes_test.go
Normal file
78
pkg/vetting/country_codes_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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"),
|
||||
)
|
||||
}
|
||||
189
pkg/vetting/openai_schema.go
Normal file
189
pkg/vetting/openai_schema.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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)
|
||||
}
|
||||
}
|
||||
51
pkg/vetting/openai_schema_test.go
Normal file
51
pkg/vetting/openai_schema_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
448
pkg/vetting/persist.go
Normal file
448
pkg/vetting/persist.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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
|
||||
}
|
||||
108
pkg/vetting/persist_test.go
Normal file
108
pkg/vetting/persist_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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"}),
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
</important>
|
||||
|
||||
@@ -26,6 +26,13 @@ If `research_third_party_externally` is available, use it for incidents, regulat
|
||||
{procedure}
|
||||
</assessment_procedure>
|
||||
|
||||
<persistence>
|
||||
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.
|
||||
</persistence>
|
||||
|
||||
<important>
|
||||
- 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
167
pkg/vetting/subprocessors.go
Normal file
167
pkg/vetting/subprocessors.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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))
|
||||
}
|
||||
178
pkg/vetting/subprocessors_test.go
Normal file
178
pkg/vetting/subprocessors_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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),
|
||||
)
|
||||
}
|
||||
146
pkg/vetting/tools.go
Normal file
146
pkg/vetting/tools.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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
|
||||
}
|
||||
Reference in New Issue
Block a user