Scope sub-third-parties per parent
Replace the many-to-many junction table with a direct parent_third_party_id foreign key on third_parties. Each sub-third-party now belongs to exactly one parent, making duplicates across parents independent entities. Replace the firstLevel boolean with an integer level field (1 = direct, 2+ = parent level + 1) to support arbitrary nesting depth. Remove the createThirdPartyThirdPartyMapping and deleteThirdPartyThirdPartyMapping mutations, the CLI link/unlink commands, and the corresponding MCP tools. Creating a child third party now just requires passing parentThirdPartyId on the existing createThirdParty mutation. The frontend walks the parentThirdParty chain to build display names like "Name (Ancestor1/Ancestor2)" and shows clickable ancestor links on the detail page. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package link
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/api"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
)
|
||||
|
||||
const linkMutation = `
|
||||
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
|
||||
createThirdPartyThirdPartyMapping(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type linkResponse struct {
|
||||
CreateThirdPartyThirdPartyMapping struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"node"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdPartyThirdPartyMapping"`
|
||||
}
|
||||
|
||||
func NewCmdLink(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "link <parent-id> <child-id>",
|
||||
Short: "Link a child thirdParty to a parent thirdParty",
|
||||
Example: ` # Link a child third_party to a parent
|
||||
prb thirdParty link <parent-id> <child-id>`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host, hc, err := cfg.DefaultHost()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewClient(
|
||||
host,
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
linkMutation,
|
||||
map[string]any{
|
||||
"input": map[string]any{
|
||||
"parentThirdPartyId": args[0],
|
||||
"childThirdPartyId": args[1],
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resp linkResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
v := resp.CreateThirdPartyThirdPartyMapping.ThirdPartyEdge.Node
|
||||
_, _ = fmt.Fprintf(
|
||||
f.IOStreams.Out,
|
||||
"Linked thirdParty %s (%s) as child of %s\n",
|
||||
v.ID,
|
||||
v.Name,
|
||||
args[0],
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -55,12 +55,12 @@ type thirdParty struct {
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagFirstLevel bool
|
||||
flagOutput *string
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagLevel int
|
||||
flagOutput *string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -108,9 +108,13 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
"id": flagOrg,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("first-level") {
|
||||
if cmd.Flags().Changed("level") {
|
||||
if flagLevel < 1 {
|
||||
return fmt.Errorf("invalid --level value %d: must be greater than or equal to 1", flagLevel)
|
||||
}
|
||||
|
||||
variables["filter"] = map[string]any{
|
||||
"first-level": flagFirstLevel,
|
||||
"level": flagLevel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +199,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of thirdParties to list")
|
||||
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (NAME, CREATED_AT, UPDATED_AT)")
|
||||
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
||||
cmd.Flags().BoolVar(&flagFirstLevel, "first-level", false, "Filter by first-level thirdParties only")
|
||||
cmd.Flags().IntVar(&flagLevel, "level", 0, "Filter by third party level (1 = direct, 2+ = indirect)")
|
||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -19,10 +19,8 @@ import (
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/link"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/list"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/unlink"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/update"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/vet"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view"
|
||||
@@ -41,8 +39,6 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(delete.NewCmdDelete(f))
|
||||
cmd.AddCommand(vet.NewCmdVet(f))
|
||||
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||
cmd.AddCommand(link.NewCmdLink(f))
|
||||
cmd.AddCommand(unlink.NewCmdUnlink(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package unlink
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/api"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
)
|
||||
|
||||
const unlinkMutation = `
|
||||
mutation($input: UncreateThirdPartyThirdPartyMappingInput!) {
|
||||
uncreateThirdPartyThirdPartyMapping(input: $input) {
|
||||
removedThirdPartyId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func NewCmdUnlink(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "unlink <parent-id> <child-id>",
|
||||
Short: "Unlink a child thirdParty from a parent thirdParty",
|
||||
Example: ` # Unlink a child third_party from a parent
|
||||
prb thirdParty unlink <parent-id> <child-id>`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host, hc, err := cfg.DefaultHost()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewClient(
|
||||
host,
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
unlinkMutation,
|
||||
map[string]any{
|
||||
"input": map[string]any{
|
||||
"parentThirdPartyId": args[0],
|
||||
"childThirdPartyId": args[1],
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
f.IOStreams.Out,
|
||||
"Unlinked thirdParty %s from parent %s\n",
|
||||
args[1],
|
||||
args[0],
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1141,8 +1141,16 @@ func (h *trackerMappingHandler) prepareOrgThirdParty(
|
||||
return prep, fmt.Errorf("cannot load common third party domains: %w", err)
|
||||
}
|
||||
|
||||
firstLevel := 1
|
||||
|
||||
var orgThirdParties coredata.ThirdParties
|
||||
if err := orgThirdParties.LoadAllByOrganizationID(ctx, conn, scope, tp.OrganizationID); err != nil {
|
||||
if err := orgThirdParties.LoadAllByOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
tp.OrganizationID,
|
||||
coredata.NewThirdPartyFilter(nil, &firstLevel, nil),
|
||||
); err != nil {
|
||||
return prep, fmt.Errorf("cannot load org third parties: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ func TestPromoteThirdParty_FallbackCreate(t *testing.T) {
|
||||
require.NotNil(t, reloaded.CommonThirdPartyID)
|
||||
assert.Equal(t, fx.commonThirdPartyID, *reloaded.CommonThirdPartyID)
|
||||
assert.Equal(t, coredata.ThirdPartyCategoryAnalytics, reloaded.Category)
|
||||
assert.True(t, reloaded.FirstLevel)
|
||||
assert.Equal(t, 1, reloaded.Level)
|
||||
assert.False(t, reloaded.ShowOnTrustCenter)
|
||||
}
|
||||
|
||||
|
||||
251
pkg/coredata/migrations/20260608T120000Z.sql
Normal file
251
pkg/coredata/migrations/20260608T120000Z.sql
Normal file
@@ -0,0 +1,251 @@
|
||||
-- Copyright (c) 2025-2026 Probo Inc <hello@probo.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.
|
||||
|
||||
-- ADD COLUMN ... DEFAULT 1 already backfills every existing row to level 1, so
|
||||
-- only the sub-third-parties (first_level = false) need correcting to level 2.
|
||||
-- Scoping the UPDATE to them leaves first-level rows entirely untouched.
|
||||
ALTER TABLE third_parties ADD COLUMN level integer NOT NULL DEFAULT 1;
|
||||
UPDATE third_parties SET level = 2 WHERE first_level = false;
|
||||
ALTER TABLE third_parties ALTER COLUMN level DROP DEFAULT;
|
||||
|
||||
-- Keep first_level around instead of dropping it so the change is reversible and
|
||||
-- old code paths keep working during a rolling deploy. New inserts no longer set
|
||||
-- it, so give it a default to satisfy the NOT NULL constraint.
|
||||
ALTER TABLE third_parties ALTER COLUMN first_level SET DEFAULT true;
|
||||
|
||||
ALTER TABLE third_parties ADD COLUMN parent_third_party_id text REFERENCES third_parties(id) ON DELETE CASCADE;
|
||||
|
||||
-- Pick each non-first-level child's primary parent: the parent it will be
|
||||
-- re-parented to in place. Only first_level = false rows are eligible — a
|
||||
-- first-level row stays a root (its links become copies below, never an
|
||||
-- in-place move). Prefer a top-level (first_level = true) parent so the child
|
||||
-- anchors to a real root, then fall back to the relationship that was created
|
||||
-- first. IDs are text GIDs, so MIN() would order them lexicographically rather
|
||||
-- than chronologically — sort by the junction row's created_at instead, with
|
||||
-- the parent id as a deterministic tie-break.
|
||||
CREATE TEMP TABLE _tp_primary_parent AS
|
||||
SELECT DISTINCT ON (tpr.child_third_party_id)
|
||||
tpr.child_third_party_id,
|
||||
tpr.parent_third_party_id
|
||||
FROM third_party_third_parties tpr
|
||||
JOIN third_parties parent ON parent.id = tpr.parent_third_party_id
|
||||
JOIN third_parties child ON child.id = tpr.child_third_party_id
|
||||
WHERE child.first_level = false
|
||||
ORDER BY
|
||||
tpr.child_third_party_id,
|
||||
parent.first_level DESC,
|
||||
tpr.created_at ASC,
|
||||
tpr.parent_third_party_id ASC;
|
||||
|
||||
-- Assign every existing child its primary parent in place. Updating the row in
|
||||
-- place preserves its ID and therefore every dependent record (risk
|
||||
-- assessments, services, contacts, measure links, …) — nothing is deleted or
|
||||
-- re-keyed for the common one-parent case.
|
||||
-- Restricted to first_level = false: only sub-third-parties may gain a parent;
|
||||
-- a top-level (first_level = true) row must stay at level 1 with no parent even
|
||||
-- if it appears in the junction table by accident.
|
||||
UPDATE third_parties tp
|
||||
SET parent_third_party_id = primary_parent.parent_third_party_id
|
||||
FROM _tp_primary_parent AS primary_parent
|
||||
WHERE tp.id = primary_parent.child_third_party_id
|
||||
AND tp.first_level = false;
|
||||
|
||||
-- Re-qualify the names of the reparented sub-third-parties to the hierarchy
|
||||
-- convention used by the console/vetting ("base (root/.../parent)"), matching
|
||||
-- the copies created below. Names are rebuilt from base names (trailing " (…)"
|
||||
-- stripped) so already-qualified rows are normalized rather than double-suffixed.
|
||||
WITH RECURSIVE tp_path AS (
|
||||
SELECT
|
||||
id,
|
||||
trim(regexp_replace(name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||
FROM third_parties
|
||||
WHERE parent_third_party_id IS NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
c.id,
|
||||
p.path || '/' || trim(regexp_replace(c.name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||
FROM third_parties c
|
||||
JOIN tp_path p ON p.id = c.parent_third_party_id
|
||||
)
|
||||
UPDATE third_parties tp
|
||||
SET name = trim(regexp_replace(tp.name, '\s*\([^)]*\)\s*$', '')) || ' (' || pp.path || ')'
|
||||
FROM tp_path pp
|
||||
WHERE pp.id = tp.parent_third_party_id
|
||||
AND tp.first_level = false;
|
||||
|
||||
-- Every junction row that is NOT an in-place reparent becomes its own copy: the
|
||||
-- extra parents of a non-first-level child, plus every link whose child is
|
||||
-- first_level = true (the root is preserved and a fresh sub-third-party copy is
|
||||
-- created under the parent). Generate a fresh GID for each (child, parent) pair.
|
||||
-- generate_gid() and parse_tenant_id() are defined in migration 20250420T120000Z.
|
||||
CREATE TEMP TABLE _tp_copy_map AS
|
||||
SELECT
|
||||
tpr.child_third_party_id AS old_id,
|
||||
tpr.parent_third_party_id AS parent_id,
|
||||
generate_gid(parse_tenant_id(tp.tenant_id), 7) AS new_id
|
||||
FROM third_party_third_parties tpr
|
||||
JOIN third_parties tp ON tp.id = tpr.child_third_party_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM _tp_primary_parent pp
|
||||
WHERE pp.child_third_party_id = tpr.child_third_party_id
|
||||
AND pp.parent_third_party_id = tpr.parent_third_party_id
|
||||
);
|
||||
|
||||
-- Resolve each third party's hierarchy-qualified path (root → self, base names
|
||||
-- joined by "/"), mirroring the console/vetting naming convention. A copy under
|
||||
-- parent P is named "<child base> (<path of P>)", e.g. "Google Workspace (Probo)".
|
||||
-- The base name strips any trailing " (…)" suffix exactly like the app regex.
|
||||
WITH RECURSIVE tp_path AS (
|
||||
SELECT
|
||||
id,
|
||||
level,
|
||||
trim(regexp_replace(name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||
FROM third_parties
|
||||
WHERE parent_third_party_id IS NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
c.id,
|
||||
c.level,
|
||||
p.path || '/' || trim(regexp_replace(c.name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||
FROM third_parties c
|
||||
JOIN tp_path p ON p.id = c.parent_third_party_id
|
||||
)
|
||||
INSERT INTO third_parties (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
parent_third_party_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,
|
||||
level,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
m.new_id,
|
||||
tp.tenant_id,
|
||||
tp.organization_id,
|
||||
-- The parent is always an existing row that needs no remapping.
|
||||
m.parent_id,
|
||||
-- A copy is a relationship/subprocessor record, not the canonical
|
||||
-- catalog-linked vendor. Leave common_third_party_id NULL so the original
|
||||
-- root stays the single third party resolved for a common catalog entry
|
||||
-- (e.g. cookie-banner tracker mapping by common_third_party_id).
|
||||
NULL,
|
||||
-- Hierarchy-qualified name: child base name suffixed with the parent's path.
|
||||
trim(regexp_replace(tp.name, '\s*\([^)]*\)\s*$', '')) || ' (' || pp.path || ')',
|
||||
tp.description,
|
||||
tp.category,
|
||||
tp.headquarter_address,
|
||||
tp.legal_name,
|
||||
tp.website_url,
|
||||
tp.privacy_policy_url,
|
||||
tp.service_level_agreement_url,
|
||||
tp.data_processing_agreement_url,
|
||||
tp.business_associate_agreement_url,
|
||||
tp.subprocessors_list_url,
|
||||
tp.certifications,
|
||||
tp.countries,
|
||||
tp.business_owner_profile_id,
|
||||
tp.security_owner_profile_id,
|
||||
tp.status_page_url,
|
||||
tp.terms_of_service_url,
|
||||
tp.security_page_url,
|
||||
tp.trust_page_url,
|
||||
tp.show_on_trust_center,
|
||||
-- Copies are sub-third-parties (level >= 2), never first-level roots.
|
||||
false,
|
||||
-- Level follows the parent, not the copied child: a first-level child
|
||||
-- (level 1) copied under Probo (level 1) becomes a level-2 sub-third-party.
|
||||
pp.level + 1,
|
||||
tp.created_at,
|
||||
tp.updated_at
|
||||
FROM _tp_copy_map m
|
||||
JOIN third_parties tp ON tp.id = m.old_id
|
||||
JOIN tp_path pp ON pp.id = m.parent_id;
|
||||
|
||||
INSERT INTO third_party_services (
|
||||
tenant_id,
|
||||
id,
|
||||
organization_id,
|
||||
third_party_id,
|
||||
name,
|
||||
description,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
tp.tenant_id,
|
||||
generate_gid(parse_tenant_id(tp.tenant_id), 30),
|
||||
s.organization_id,
|
||||
m.new_id,
|
||||
s.name,
|
||||
s.description,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM _tp_copy_map m
|
||||
JOIN third_parties tp ON tp.id = m.old_id
|
||||
JOIN third_party_services s ON s.third_party_id = m.old_id;
|
||||
|
||||
INSERT INTO third_party_contacts (
|
||||
tenant_id,
|
||||
id,
|
||||
organization_id,
|
||||
third_party_id,
|
||||
full_name,
|
||||
email,
|
||||
phone,
|
||||
role,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
tp.tenant_id,
|
||||
generate_gid(parse_tenant_id(tp.tenant_id), 26),
|
||||
c.organization_id,
|
||||
m.new_id,
|
||||
c.full_name,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.role,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM _tp_copy_map m
|
||||
JOIN third_parties tp ON tp.id = m.old_id
|
||||
JOIN third_party_contacts c ON c.third_party_id = m.old_id;
|
||||
|
||||
DROP TABLE _tp_copy_map;
|
||||
DROP TABLE _tp_primary_parent;
|
||||
DROP TABLE third_party_third_parties;
|
||||
@@ -28,6 +28,11 @@ import (
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
// MaxThirdPartyLevel is the deepest sub-third-party nesting allowed. Level 1 is
|
||||
// a direct third party; each descendant adds one level, so the chain may not go
|
||||
// beyond level 4.
|
||||
const MaxThirdPartyLevel = 4
|
||||
|
||||
func (v ThirdParty) GetGeneratedDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
@@ -140,6 +145,7 @@ type (
|
||||
ThirdParty struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
ParentThirdPartyID *gid.GID `db:"parent_third_party_id"`
|
||||
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
|
||||
Name string `db:"name"`
|
||||
Description *string `db:"description"`
|
||||
@@ -161,7 +167,7 @@ type (
|
||||
SecurityPageURL *string `db:"security_page_url"`
|
||||
TrustPageURL *string `db:"trust_page_url"`
|
||||
ShowOnTrustCenter bool `db:"show_on_trust_center"`
|
||||
FirstLevel bool `db:"first_level"`
|
||||
Level int `db:"level"`
|
||||
VettingStatus *ThirdPartyVettingStatus `db:"vetting_status"`
|
||||
VettingWebsiteURL *string `db:"vetting_website_url"`
|
||||
VettingProcedure *string `db:"vetting_procedure"`
|
||||
@@ -236,6 +242,7 @@ func (v *ThirdParty) LoadByID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -257,7 +264,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -308,6 +315,7 @@ func (v *ThirdParty) LoadByIDForUpdate(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -329,7 +337,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -382,6 +390,7 @@ func (v *ThirdParty) LoadByNameAndOrganizationID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -403,7 +412,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -448,16 +457,18 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *ThirdParties) LoadByIDs(
|
||||
func (v *ThirdParty) LoadByNameAndParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
thirdPartyIDs []gid.GID,
|
||||
name string,
|
||||
parentThirdPartyID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -479,7 +490,84 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
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 parent_third_party_id = @parent_third_party_id
|
||||
AND name = @name
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"parent_third_party_id": parentThirdPartyID,
|
||||
"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 and parent: %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,
|
||||
parent_third_party_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,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -525,6 +613,7 @@ INSERT INTO
|
||||
tenant_id,
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -546,7 +635,7 @@ INSERT INTO
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -559,6 +648,7 @@ VALUES (
|
||||
@tenant_id,
|
||||
@third_party_id,
|
||||
@organization_id,
|
||||
@parent_third_party_id,
|
||||
@common_third_party_id,
|
||||
@name,
|
||||
@description,
|
||||
@@ -580,7 +670,7 @@ VALUES (
|
||||
@security_page_url,
|
||||
@trust_page_url,
|
||||
@show_on_trust_center,
|
||||
@first_level,
|
||||
@level,
|
||||
@vetting_status,
|
||||
@vetting_website_url,
|
||||
@vetting_procedure,
|
||||
@@ -595,6 +685,7 @@ VALUES (
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"third_party_id": v.ID,
|
||||
"organization_id": v.OrganizationID,
|
||||
"parent_third_party_id": v.ParentThirdPartyID,
|
||||
"common_third_party_id": v.CommonThirdPartyID,
|
||||
"name": v.Name,
|
||||
"description": v.Description,
|
||||
@@ -616,7 +707,7 @@ VALUES (
|
||||
"security_page_url": v.SecurityPageURL,
|
||||
"trust_page_url": v.TrustPageURL,
|
||||
"show_on_trust_center": v.ShowOnTrustCenter,
|
||||
"first_level": v.FirstLevel,
|
||||
"level": v.Level,
|
||||
"vetting_status": v.VettingStatus,
|
||||
"vetting_website_url": v.VettingWebsiteURL,
|
||||
"vetting_procedure": v.VettingProcedure,
|
||||
@@ -690,11 +781,13 @@ func (v *ThirdParties) LoadAllByOrganizationID(
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
filter *ThirdPartyFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -716,7 +809,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -729,12 +822,14 @@ FROM
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
ORDER BY name ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
@@ -763,6 +858,7 @@ func (v *ThirdParties) LoadByOrganizationID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -784,7 +880,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -834,6 +930,7 @@ SET
|
||||
name = @name,
|
||||
description = @description,
|
||||
category = @category,
|
||||
parent_third_party_id = @parent_third_party_id,
|
||||
headquarter_address = @headquarter_address,
|
||||
legal_name = @legal_name,
|
||||
website_url = @website_url,
|
||||
@@ -851,7 +948,7 @@ SET
|
||||
business_owner_profile_id = @business_owner_profile_id,
|
||||
security_owner_profile_id = @security_owner_profile_id,
|
||||
show_on_trust_center = @show_on_trust_center,
|
||||
first_level = @first_level,
|
||||
level = @level,
|
||||
vetting_status = @vetting_status,
|
||||
vetting_website_url = @vetting_website_url,
|
||||
vetting_procedure = @vetting_procedure,
|
||||
@@ -870,6 +967,7 @@ WHERE %s
|
||||
"name": v.Name,
|
||||
"description": v.Description,
|
||||
"category": v.Category,
|
||||
"parent_third_party_id": v.ParentThirdPartyID,
|
||||
"headquarter_address": v.HeadquarterAddress,
|
||||
"legal_name": v.LegalName,
|
||||
"website_url": v.WebsiteURL,
|
||||
@@ -887,7 +985,7 @@ WHERE %s
|
||||
"business_owner_profile_id": v.BusinessOwnerID,
|
||||
"security_owner_profile_id": v.SecurityOwnerID,
|
||||
"show_on_trust_center": v.ShowOnTrustCenter,
|
||||
"first_level": v.FirstLevel,
|
||||
"level": v.Level,
|
||||
"vetting_status": v.VettingStatus,
|
||||
"vetting_website_url": v.VettingWebsiteURL,
|
||||
"vetting_procedure": v.VettingProcedure,
|
||||
@@ -996,6 +1094,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1017,7 +1116,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1035,6 +1134,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1056,7 +1156,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1142,6 +1242,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1163,7 +1264,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1181,6 +1282,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1202,7 +1304,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1248,6 +1350,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1269,7 +1372,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1287,6 +1390,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1308,7 +1412,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1355,6 +1459,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1376,7 +1481,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1394,6 +1499,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1415,7 +1521,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1530,6 +1636,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1551,7 +1658,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1569,6 +1676,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1590,7 +1698,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1634,6 +1742,7 @@ func (v *ThirdParty) LoadByOrganizationIDAndCommonThirdPartyID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1655,7 +1764,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1754,6 +1863,7 @@ WITH tps AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1775,7 +1885,7 @@ WITH tps AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1793,6 +1903,7 @@ WITH tps AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1814,7 +1925,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1847,3 +1958,254 @@ WHERE %s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *ThirdParties) CountByParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
third_parties
|
||||
WHERE
|
||||
%s
|
||||
AND parent_third_party_id = @parent_third_party_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
|
||||
err := conn.QueryRow(ctx, q, args).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count child third parties: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (v *ThirdParties) LoadAllAncestorsByThirdPartyID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
thirdPartyID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH RECURSIVE ancestor_chain AS (
|
||||
SELECT
|
||||
tp.id,
|
||||
tp.tenant_id,
|
||||
tp.organization_id,
|
||||
tp.parent_third_party_id,
|
||||
tp.common_third_party_id,
|
||||
tp.name,
|
||||
tp.description,
|
||||
tp.category,
|
||||
tp.headquarter_address,
|
||||
tp.legal_name,
|
||||
tp.website_url,
|
||||
tp.privacy_policy_url,
|
||||
tp.service_level_agreement_url,
|
||||
tp.data_processing_agreement_url,
|
||||
tp.business_associate_agreement_url,
|
||||
tp.subprocessors_list_url,
|
||||
tp.certifications,
|
||||
tp.countries,
|
||||
tp.business_owner_profile_id,
|
||||
tp.security_owner_profile_id,
|
||||
tp.status_page_url,
|
||||
tp.terms_of_service_url,
|
||||
tp.security_page_url,
|
||||
tp.trust_page_url,
|
||||
tp.show_on_trust_center,
|
||||
tp.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,
|
||||
1 AS depth
|
||||
FROM third_parties tp
|
||||
WHERE %s
|
||||
AND tp.id = (
|
||||
SELECT parent_third_party_id
|
||||
FROM third_parties
|
||||
WHERE id = @third_party_id
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
tp.id,
|
||||
tp.tenant_id,
|
||||
tp.organization_id,
|
||||
tp.parent_third_party_id,
|
||||
tp.common_third_party_id,
|
||||
tp.name,
|
||||
tp.description,
|
||||
tp.category,
|
||||
tp.headquarter_address,
|
||||
tp.legal_name,
|
||||
tp.website_url,
|
||||
tp.privacy_policy_url,
|
||||
tp.service_level_agreement_url,
|
||||
tp.data_processing_agreement_url,
|
||||
tp.business_associate_agreement_url,
|
||||
tp.subprocessors_list_url,
|
||||
tp.certifications,
|
||||
tp.countries,
|
||||
tp.business_owner_profile_id,
|
||||
tp.security_owner_profile_id,
|
||||
tp.status_page_url,
|
||||
tp.terms_of_service_url,
|
||||
tp.security_page_url,
|
||||
tp.trust_page_url,
|
||||
tp.show_on_trust_center,
|
||||
tp.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,
|
||||
ac.depth + 1
|
||||
FROM third_parties tp
|
||||
JOIN ancestor_chain ac ON tp.id = ac.parent_third_party_id
|
||||
WHERE ac.depth < @max_depth
|
||||
AND tp.tenant_id = ac.tenant_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_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,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
vetting_processing_started_at,
|
||||
vetting_error_message,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM ancestor_chain
|
||||
ORDER BY depth DESC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"third_party_id": thirdPartyID,
|
||||
"max_depth": MaxThirdPartyLevel,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query ancestors: %w", err)
|
||||
}
|
||||
|
||||
ancestors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect ancestors: %w", err)
|
||||
}
|
||||
|
||||
*v = ancestors
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *ThirdParties) LoadByParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
cursor *page.Cursor[ThirdPartyOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_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,
|
||||
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 parent_third_party_id = @parent_third_party_id
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query child third parties: %w", err)
|
||||
}
|
||||
|
||||
thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect child third parties: %w", err)
|
||||
}
|
||||
|
||||
*v = thirdParties
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@ import (
|
||||
type (
|
||||
ThirdPartyFilter struct {
|
||||
showOnTrustCenter *bool
|
||||
firstLevel *bool
|
||||
level *int
|
||||
query *string
|
||||
}
|
||||
)
|
||||
|
||||
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool, query *string) *ThirdPartyFilter {
|
||||
func NewThirdPartyFilter(showOnTrustCenter *bool, level *int, query *string) *ThirdPartyFilter {
|
||||
return &ThirdPartyFilter{
|
||||
showOnTrustCenter: showOnTrustCenter,
|
||||
firstLevel: firstLevel,
|
||||
level: level,
|
||||
query: query,
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
args := pgx.StrictNamedArgs{
|
||||
"show_on_trust_center": nil,
|
||||
"filter_query": nil,
|
||||
"level": nil,
|
||||
}
|
||||
|
||||
if f.showOnTrustCenter != nil {
|
||||
@@ -48,10 +49,8 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
args["filter_query"] = *f.query
|
||||
}
|
||||
|
||||
if f.firstLevel != nil {
|
||||
args["first_level"] = *f.firstLevel
|
||||
} else {
|
||||
args["first_level"] = nil
|
||||
if f.level != nil {
|
||||
args["level"] = *f.level
|
||||
}
|
||||
|
||||
return args
|
||||
@@ -66,8 +65,8 @@ func (f *ThirdPartyFilter) SQLFragment() string {
|
||||
ELSE TRUE
|
||||
END
|
||||
AND CASE
|
||||
WHEN @first_level::boolean IS NOT NULL THEN
|
||||
first_level = @first_level::boolean
|
||||
WHEN @level::integer IS NOT NULL THEN
|
||||
level = @level::integer
|
||||
ELSE TRUE
|
||||
END
|
||||
AND CASE
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
ThirdPartyThirdParty struct {
|
||||
ParentThirdPartyID gid.GID `db:"parent_third_party_id"`
|
||||
ChildThirdPartyID gid.GID `db:"child_third_party_id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
Purpose *string `db:"purpose"`
|
||||
}
|
||||
|
||||
ThirdPartyThirdParties []*ThirdPartyThirdParty
|
||||
)
|
||||
|
||||
func (r *ThirdPartyThirdParty) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error {
|
||||
q := `
|
||||
INSERT INTO third_party_third_parties (
|
||||
parent_third_party_id,
|
||||
child_third_party_id,
|
||||
tenant_id,
|
||||
created_at,
|
||||
purpose
|
||||
) VALUES (
|
||||
@parent_third_party_id,
|
||||
@child_third_party_id,
|
||||
@tenant_id,
|
||||
@created_at,
|
||||
@purpose
|
||||
)
|
||||
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{
|
||||
"parent_third_party_id": r.ParentThirdPartyID,
|
||||
"child_third_party_id": r.ChildThirdPartyID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"created_at": r.CreatedAt,
|
||||
"purpose": r.Purpose,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert third party third party: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ThirdPartyThirdParty) Delete(ctx context.Context, conn pg.Tx, scope Scoper) error {
|
||||
q := `
|
||||
DELETE FROM third_party_third_parties
|
||||
WHERE %s
|
||||
AND parent_third_party_id = @parent_third_party_id
|
||||
AND child_third_party_id = @child_third_party_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"parent_third_party_id": r.ParentThirdPartyID,
|
||||
"child_third_party_id": r.ChildThirdPartyID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (v *ThirdParties) CountByParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
WITH children AS (
|
||||
SELECT
|
||||
tp.id,
|
||||
tp.tenant_id
|
||||
FROM
|
||||
third_parties tp
|
||||
INNER JOIN
|
||||
third_party_third_parties tpr ON tp.id = tpr.child_third_party_id
|
||||
WHERE
|
||||
tpr.parent_third_party_id = @parent_third_party_id
|
||||
)
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
children
|
||||
WHERE %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
|
||||
err := conn.QueryRow(ctx, q, args).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count child third parties: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (v *ThirdParties) LoadByParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
cursor *page.Cursor[ThirdPartyOrderField],
|
||||
) error {
|
||||
q := `
|
||||
WITH children AS (
|
||||
SELECT
|
||||
tp.id,
|
||||
tp.tenant_id,
|
||||
tp.organization_id,
|
||||
tp.common_third_party_id,
|
||||
tp.name,
|
||||
tp.description,
|
||||
tp.category,
|
||||
tp.headquarter_address,
|
||||
tp.legal_name,
|
||||
tp.website_url,
|
||||
tp.privacy_policy_url,
|
||||
tp.service_level_agreement_url,
|
||||
tp.data_processing_agreement_url,
|
||||
tp.business_associate_agreement_url,
|
||||
tp.subprocessors_list_url,
|
||||
tp.certifications,
|
||||
tp.countries,
|
||||
tp.business_owner_profile_id,
|
||||
tp.security_owner_profile_id,
|
||||
tp.status_page_url,
|
||||
tp.terms_of_service_url,
|
||||
tp.security_page_url,
|
||||
tp.trust_page_url,
|
||||
tp.show_on_trust_center,
|
||||
tp.first_level,
|
||||
tp.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
|
||||
third_parties tp
|
||||
INNER JOIN
|
||||
third_party_third_parties tpr ON tp.id = tpr.child_third_party_id
|
||||
WHERE
|
||||
tpr.parent_third_party_id = @parent_third_party_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
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
|
||||
children
|
||||
WHERE %s
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query child third parties: %w", err)
|
||||
}
|
||||
|
||||
thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect child third parties: %w", err)
|
||||
}
|
||||
|
||||
*v = thirdParties
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func (v *ThirdParty) LoadNextPendingVettingForUpdateSkipLocked(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -53,7 +54,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
|
||||
@@ -767,6 +767,7 @@ func (s *OrganizationService) CreateOrganization(
|
||||
TermsOfServiceURL: &proboThirdParty.TermsOfServiceURL,
|
||||
SubprocessorsListURL: &proboThirdParty.SubprocessorsListURL,
|
||||
ShowOnTrustCenter: false,
|
||||
Level: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ const (
|
||||
|
||||
// ThirdPartyRelation actions
|
||||
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
|
||||
ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete"
|
||||
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
|
||||
|
||||
// ThirdPartyContact actions
|
||||
|
||||
@@ -2357,8 +2357,16 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
|
||||
conn pg.Querier,
|
||||
organization *coredata.Organization,
|
||||
) (docgen.ThirdPartyListData, error) {
|
||||
firstLevel := 1
|
||||
|
||||
var thirdParties coredata.ThirdParties
|
||||
if err := thirdParties.LoadAllByOrganizationID(ctx, conn, scope, organization.ID); err != nil {
|
||||
if err := thirdParties.LoadAllByOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
organization.ID,
|
||||
coredata.NewThirdPartyFilter(nil, &firstLevel, nil),
|
||||
); err != nil {
|
||||
return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load thirdParties: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ type (
|
||||
StatusPageURL *string
|
||||
BusinessOwnerID *gid.GID
|
||||
SecurityOwnerID *gid.GID
|
||||
FirstLevel *bool
|
||||
ParentThirdPartyID *gid.GID
|
||||
}
|
||||
|
||||
UpdateThirdPartyRequest struct {
|
||||
@@ -79,7 +79,6 @@ type (
|
||||
BusinessOwnerID **gid.GID
|
||||
SecurityOwnerID **gid.GID
|
||||
ShowOnTrustCenter *bool
|
||||
FirstLevel *bool
|
||||
}
|
||||
|
||||
CreateThirdPartyRiskAssessmentRequest struct {
|
||||
@@ -398,10 +397,6 @@ func (s ThirdPartyService) Update(
|
||||
thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter
|
||||
}
|
||||
|
||||
if req.FirstLevel != nil {
|
||||
thirdParty.FirstLevel = *req.FirstLevel
|
||||
}
|
||||
|
||||
if req.TrustPageURL != nil {
|
||||
thirdParty.TrustPageURL = *req.TrustPageURL
|
||||
}
|
||||
@@ -589,11 +584,7 @@ func (s ThirdPartyService) Create(
|
||||
StatusPageURL: req.StatusPageURL,
|
||||
TermsOfServiceURL: req.TermsOfServiceURL,
|
||||
ShowOnTrustCenter: false,
|
||||
FirstLevel: true,
|
||||
}
|
||||
|
||||
if req.FirstLevel != nil {
|
||||
thirdParty.FirstLevel = *req.FirstLevel
|
||||
Level: 1,
|
||||
}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
@@ -606,6 +597,29 @@ func (s ThirdPartyService) Create(
|
||||
|
||||
thirdParty.OrganizationID = organization.ID
|
||||
|
||||
if req.ParentThirdPartyID != nil {
|
||||
parent := &coredata.ThirdParty{}
|
||||
if err := parent.LoadByID(ctx, conn, scope, *req.ParentThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load parent third party: %w", err)
|
||||
}
|
||||
|
||||
if parent.OrganizationID != organization.ID {
|
||||
return fmt.Errorf("parent third party belongs to a different organization: %w", coredata.ErrResourceNotFound)
|
||||
}
|
||||
|
||||
thirdParty.ParentThirdPartyID = &parent.ID
|
||||
// The level always follows the parent chain; ignore any
|
||||
// client-supplied level so it cannot desync from the hierarchy.
|
||||
thirdParty.Level = parent.Level + 1
|
||||
}
|
||||
|
||||
levelValidator := validator.New()
|
||||
levelValidator.Check(thirdParty.Level, "level", validator.Max(coredata.MaxThirdPartyLevel))
|
||||
|
||||
if err := levelValidator.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if req.BusinessOwnerID != nil {
|
||||
businessOwner := &coredata.MembershipProfile{}
|
||||
if err := businessOwner.LoadByID(ctx, conn, scope, *req.BusinessOwnerID); err != nil {
|
||||
@@ -848,69 +862,24 @@ func (s ThirdPartyService) GetByRiskAssessmentID(
|
||||
return thirdParty, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) CreateThirdPartyMapping(
|
||||
func (s ThirdPartyService) GetAncestors(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
childThirdPartyID gid.GID,
|
||||
) (*coredata.ThirdParty, error) {
|
||||
childThirdParty := &coredata.ThirdParty{}
|
||||
thirdPartyID gid.GID,
|
||||
) (coredata.ThirdParties, error) {
|
||||
var ancestors coredata.ThirdParties
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
parentThirdParty := &coredata.ThirdParty{}
|
||||
if err := parentThirdParty.LoadByID(ctx, conn, scope, parentThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load parent third party: %w", err)
|
||||
}
|
||||
|
||||
if err := childThirdParty.LoadByID(ctx, conn, scope, childThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load child third party: %w", err)
|
||||
}
|
||||
|
||||
if parentThirdParty.OrganizationID != childThirdParty.OrganizationID {
|
||||
return fmt.Errorf("cannot create mapping for third parties from different organizations: %w", coredata.ErrResourceNotFound)
|
||||
}
|
||||
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: parentThirdPartyID,
|
||||
ChildThirdPartyID: childThirdPartyID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := relation.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot create third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
return ancestors.LoadAllAncestorsByThirdPartyID(ctx, conn, scope, thirdPartyID)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return childThirdParty, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) DeleteThirdPartyMapping(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
childThirdPartyID gid.GID,
|
||||
) error {
|
||||
return s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: parentThirdPartyID,
|
||||
ChildThirdPartyID: childThirdPartyID,
|
||||
}
|
||||
if err := relation.Delete(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot delete third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
return ancestors, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) CountForParentThirdPartyID(
|
||||
|
||||
@@ -198,7 +198,7 @@ input ThirdPartyOrder
|
||||
}
|
||||
|
||||
input ThirdPartyFilter {
|
||||
firstLevel: Boolean
|
||||
level: Int
|
||||
query: String
|
||||
}
|
||||
|
||||
@@ -303,7 +303,10 @@ type ThirdParty implements Node {
|
||||
legalName: String
|
||||
websiteUrl: String
|
||||
showOnTrustCenter: Boolean!
|
||||
firstLevel: Boolean!
|
||||
level: Int!
|
||||
|
||||
parentThirdParty: ThirdParty @goField(forceResolver: true)
|
||||
ancestors: [ThirdParty!]! @goField(forceResolver: true)
|
||||
|
||||
childThirdParties(
|
||||
first: Int
|
||||
@@ -506,12 +509,6 @@ extend type Mutation {
|
||||
publishThirdPartyList(
|
||||
input: PublishThirdPartyListInput!
|
||||
): PublishThirdPartyListPayload!
|
||||
createThirdPartyThirdPartyMapping(
|
||||
input: CreateThirdPartyThirdPartyMappingInput!
|
||||
): CreateThirdPartyThirdPartyMappingPayload!
|
||||
deleteThirdPartyThirdPartyMapping(
|
||||
input: DeleteThirdPartyThirdPartyMappingInput!
|
||||
): DeleteThirdPartyThirdPartyMappingPayload!
|
||||
}
|
||||
|
||||
input PublishThirdPartyListInput {
|
||||
@@ -546,7 +543,7 @@ input CreateThirdPartyInput {
|
||||
termsOfServiceUrl: String
|
||||
businessOwnerId: ID
|
||||
securityOwnerId: ID
|
||||
firstLevel: Boolean
|
||||
parentThirdPartyId: ID
|
||||
}
|
||||
|
||||
input UpdateThirdPartyInput {
|
||||
@@ -571,7 +568,6 @@ input UpdateThirdPartyInput {
|
||||
businessOwnerId: ID @goField(omittable: true)
|
||||
securityOwnerId: ID @goField(omittable: true)
|
||||
showOnTrustCenter: Boolean
|
||||
firstLevel: Boolean
|
||||
}
|
||||
|
||||
input DeleteThirdPartyInput {
|
||||
@@ -755,21 +751,3 @@ type CreateThirdPartyRiskAssessmentPayload {
|
||||
type VetThirdPartyPayload {
|
||||
thirdParty: ThirdParty!
|
||||
}
|
||||
|
||||
input CreateThirdPartyThirdPartyMappingInput {
|
||||
parentThirdPartyId: ID!
|
||||
childThirdPartyId: ID!
|
||||
}
|
||||
|
||||
type CreateThirdPartyThirdPartyMappingPayload {
|
||||
thirdPartyEdge: ThirdPartyEdge!
|
||||
}
|
||||
|
||||
input DeleteThirdPartyThirdPartyMappingInput {
|
||||
parentThirdPartyId: ID!
|
||||
childThirdPartyId: ID!
|
||||
}
|
||||
|
||||
type DeleteThirdPartyThirdPartyMappingPayload {
|
||||
removedThirdPartyId: ID!
|
||||
}
|
||||
|
||||
@@ -1291,15 +1291,15 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
var (
|
||||
firstLevel *bool
|
||||
query *string
|
||||
level *int
|
||||
query *string
|
||||
)
|
||||
if filter != nil {
|
||||
firstLevel = filter.FirstLevel
|
||||
level = filter.Level
|
||||
query = filter.Query
|
||||
}
|
||||
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel, query)
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, level, query)
|
||||
|
||||
page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,6 +33,12 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.ParentThirdPartyID != nil {
|
||||
if _, err := r.authorize(ctx, *input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
thirdParty, err := r.probo.ThirdParties.Create(
|
||||
ctx, scope,
|
||||
probo.CreateThirdPartyRequest{
|
||||
@@ -56,7 +62,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
|
||||
BusinessOwnerID: input.BusinessOwnerID,
|
||||
SecurityOwnerID: input.SecurityOwnerID,
|
||||
Countries: input.Countries,
|
||||
FirstLevel: input.FirstLevel,
|
||||
ParentThirdPartyID: input.ParentThirdPartyID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -108,7 +114,6 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
|
||||
BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID),
|
||||
SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID),
|
||||
ShowOnTrustCenter: input.ShowOnTrustCenter,
|
||||
FirstLevel: input.FirstLevel,
|
||||
Countries: input.Countries,
|
||||
},
|
||||
)
|
||||
@@ -603,46 +608,6 @@ func (r *mutationResolver) PublishThirdPartyList(ctx context.Context, input type
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateThirdPartyThirdPartyMapping is the resolver for the linkThirdPartyThirdParty field.
|
||||
func (r *mutationResolver) CreateThirdPartyThirdPartyMapping(ctx context.Context, input types.CreateThirdPartyThirdPartyMappingInput) (*types.CreateThirdPartyThirdPartyMappingPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childThirdParty, err := r.probo.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot create third party mapping", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.CreateThirdPartyThirdPartyMappingPayload{
|
||||
ThirdPartyEdge: types.NewThirdPartyEdge(childThirdParty, coredata.ThirdPartyOrderFieldName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteThirdPartyThirdPartyMapping is the resolver for the deleteThirdPartyThirdPartyMapping field.
|
||||
func (r *mutationResolver) DeleteThirdPartyThirdPartyMapping(ctx context.Context, input types.DeleteThirdPartyThirdPartyMappingInput) (*types.DeleteThirdPartyThirdPartyMappingPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.probo.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot delete third party mapping", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.DeleteThirdPartyThirdPartyMappingPayload{
|
||||
RemovedThirdPartyID: input.ChildThirdPartyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) {
|
||||
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
||||
@@ -913,6 +878,53 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third
|
||||
return types.NewProfile(securityOwner), nil
|
||||
}
|
||||
|
||||
// ParentThirdParty is the resolver for the parentThirdParty field.
|
||||
func (r *thirdPartyResolver) ParentThirdParty(ctx context.Context, obj *types.ThirdParty) (*types.ThirdParty, error) {
|
||||
if obj.ParentThirdParty == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loaders := dataloader.FromContext(ctx)
|
||||
|
||||
parent, err := loaders.ThirdParty.Load(ctx, obj.ParentThirdParty.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot load parent third party", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return types.NewThirdParty(parent), nil
|
||||
}
|
||||
|
||||
// Ancestors is the resolver for the ancestors field.
|
||||
func (r *thirdPartyResolver) Ancestors(ctx context.Context, obj *types.ThirdParty) ([]*types.ThirdParty, error) {
|
||||
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ancestors, err := r.probo.ThirdParties.GetAncestors(ctx, scope, obj.ID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot load ancestors", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
result := make([]*types.ThirdParty, len(ancestors))
|
||||
for i, a := range ancestors {
|
||||
result[i] = types.NewThirdParty(a)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ChildThirdParties is the resolver for the childThirdParties field.
|
||||
func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.ThirdParty, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
|
||||
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyRelationList)
|
||||
|
||||
@@ -86,7 +86,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
WebsiteURL: v.WebsiteURL,
|
||||
Category: v.Category,
|
||||
ShowOnTrustCenter: v.ShowOnTrustCenter,
|
||||
FirstLevel: v.FirstLevel,
|
||||
Level: v.Level,
|
||||
Countries: v.Countries,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
CreatedAt: v.CreatedAt,
|
||||
@@ -104,5 +104,11 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
}
|
||||
}
|
||||
|
||||
if v.ParentThirdPartyID != nil {
|
||||
object.ParentThirdParty = &ThirdParty{
|
||||
ID: *v.ParentThirdPartyID,
|
||||
}
|
||||
}
|
||||
|
||||
return object
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel, nil)
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.Level, nil)
|
||||
|
||||
page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter)
|
||||
if err != nil {
|
||||
@@ -6215,32 +6215,6 @@ func (r *Resolver) MoveTrackerResourceToCategoryTool(ctx context.Context, req *m
|
||||
return nil, types.MoveTrackerResourceToCategoryOutput{TrackerResource: types.NewTrackerResource(result.TrackerResource)}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) CreateThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.CreateThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
|
||||
if err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if _, err := r.proboSvc.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot create third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) DeleteThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.DeleteThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
|
||||
if err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if err := r.proboSvc.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot delete third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ListChildThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListChildThirdPartiesInput) (*mcp.CallToolResult, types.ListChildThirdPartiesOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationList)
|
||||
if err != nil {
|
||||
|
||||
@@ -641,9 +641,9 @@ components:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Filter by first-level status
|
||||
level:
|
||||
type: integer
|
||||
description: Filter by level
|
||||
order_by:
|
||||
$ref: "#/components/schemas/ThirdPartyOrderBy"
|
||||
description: ThirdParty order by
|
||||
@@ -667,38 +667,6 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/ThirdParty"
|
||||
|
||||
CreateThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
CreateThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
ListChildThirdPartiesInput:
|
||||
type: object
|
||||
required:
|
||||
@@ -737,7 +705,7 @@ components:
|
||||
- name
|
||||
- organization_id
|
||||
- category
|
||||
- first_level
|
||||
- level
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
@@ -861,9 +829,9 @@ components:
|
||||
- string
|
||||
- "null"
|
||||
description: Trust page URL
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Whether this is a first-level third party
|
||||
level:
|
||||
type: integer
|
||||
description: Level of this third party (1 = direct, 2+ = indirect)
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -12055,22 +12023,6 @@ tools:
|
||||
$ref: "#/components/schemas/ListThirdPartiesInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListThirdPartiesOutput"
|
||||
- name: createThirdPartyThirdPartyMapping
|
||||
description: Link a child third party to a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingOutput"
|
||||
- name: deleteThirdPartyThirdPartyMapping
|
||||
description: Unlink a child third party from a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingOutput"
|
||||
- name: listChildThirdParties
|
||||
description: List child third parties linked to a parent third party
|
||||
hints:
|
||||
|
||||
@@ -86,7 +86,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
TermsOfServiceURL: v.TermsOfServiceURL,
|
||||
SecurityPageURL: v.SecurityPageURL,
|
||||
TrustPageURL: v.TrustPageURL,
|
||||
FirstLevel: v.FirstLevel,
|
||||
Level: v.Level,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
|
||||
4
pkg/thirdparty/match.go
vendored
4
pkg/thirdparty/match.go
vendored
@@ -220,7 +220,7 @@ func LinkToCommon(
|
||||
// CreateFromCommon inserts a new org ThirdParty seeded from the catalog
|
||||
// row (name, category, addresses, URLs, certifications, …). The new row
|
||||
// has common_third_party_id pointed at commonParty, an empty Countries
|
||||
// list, ShowOnTrustCenter false, and FirstLevel true — the caller has
|
||||
// list, ShowOnTrustCenter false, and Level 1 — the caller has
|
||||
// already confirmed the vendor is actively present on the
|
||||
// organization's cookie banner, which makes it a first-level third
|
||||
// party by definition.
|
||||
@@ -259,7 +259,7 @@ func CreateFromCommon(
|
||||
SecurityPageURL: commonParty.SecurityPageURL,
|
||||
TrustPageURL: commonParty.TrustPageURL,
|
||||
ShowOnTrustCenter: false,
|
||||
FirstLevel: true,
|
||||
Level: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,6 +27,11 @@ import (
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
// nameSuffixPattern matches a trailing " (path)" suffix on a stored third party
|
||||
// name. It mirrors the console UI regex so backend and frontend agree on how a
|
||||
// hierarchy-qualified name is split back into its bare base.
|
||||
var nameSuffixPattern = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
|
||||
|
||||
const (
|
||||
vettingRiskAssessmentValidity = 365 * 24 * time.Hour
|
||||
maxVettingNotesGaps = 5
|
||||
@@ -51,13 +57,28 @@ func PersistAssessmentResult(
|
||||
return fmt.Errorf("cannot load third party: %w", err)
|
||||
}
|
||||
|
||||
applySaveParams(thirdParty, pc.WebsiteURL, saveParamsFromInfo(result.Info))
|
||||
// Sub third parties store hierarchy-qualified names ("aws (Probo)").
|
||||
// Load the ancestor chain so the vetted third party and any
|
||||
// discovered sub-processors are named consistently with the console.
|
||||
ancestorBaseNames, err := loadAncestorBaseNames(ctx, conn, scope, thirdParty.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applySaveParams(thirdParty, pc.WebsiteURL, saveParamsFromInfo(result.Info), ancestorBaseNames)
|
||||
thirdParty.UpdatedAt = time.Now()
|
||||
|
||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot update third party: %w", err)
|
||||
}
|
||||
|
||||
// The suffix for children of this third party is the ancestor path
|
||||
// plus the third party itself (computed after applySaveParams so a
|
||||
// freshly canonicalized name is reflected).
|
||||
childNamePath := make([]string, 0, len(ancestorBaseNames)+1)
|
||||
childNamePath = append(childNamePath, ancestorBaseNames...)
|
||||
childNamePath = append(childNamePath, baseThirdPartyName(thirdParty.Name))
|
||||
|
||||
for _, sub := range result.Info.Subprocessors {
|
||||
if sub.Name == "" {
|
||||
continue
|
||||
@@ -68,6 +89,8 @@ func PersistAssessmentResult(
|
||||
conn,
|
||||
scope,
|
||||
pc,
|
||||
thirdParty.Level,
|
||||
childNamePath,
|
||||
linkSubThirdPartyParams{
|
||||
Name: sub.Name,
|
||||
Country: sub.Country,
|
||||
@@ -296,9 +319,12 @@ func applySaveParams(
|
||||
thirdParty *coredata.ThirdParty,
|
||||
websiteURL string,
|
||||
p saveThirdPartyInfoParams,
|
||||
nameSuffixPath []string,
|
||||
) {
|
||||
if p.Name != "" {
|
||||
thirdParty.Name = p.Name
|
||||
// Keep the name hierarchy-qualified for sub third parties; a top-level
|
||||
// third party (empty suffix path) keeps the bare name.
|
||||
thirdParty.Name = qualifyThirdPartyName(p.Name, nameSuffixPath)
|
||||
}
|
||||
|
||||
thirdParty.WebsiteURL = &websiteURL
|
||||
@@ -371,78 +397,122 @@ func linkSubThirdParty(
|
||||
conn pg.Tx,
|
||||
scope coredata.Scoper,
|
||||
pc *PersistenceContext,
|
||||
parentLevel int,
|
||||
parentNamePath []string,
|
||||
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 {
|
||||
// Auto-discovered subprocessors must not nest beyond the maximum level.
|
||||
// Stop descending here rather than creating an invalid child.
|
||||
if parentLevel+1 > coredata.MaxThirdPartyLevel {
|
||||
return nil
|
||||
}
|
||||
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: pc.ThirdPartyID,
|
||||
ChildThirdPartyID: child.ID,
|
||||
CreatedAt: time.Now(),
|
||||
// Store and match the child under its hierarchy-qualified name so vetting
|
||||
// agrees with names created from the console (e.g. "aws (Probo)").
|
||||
qualifiedName := qualifyThirdPartyName(p.Name, parentNamePath)
|
||||
|
||||
child := &coredata.ThirdParty{}
|
||||
|
||||
// Sub-third-parties are scoped per parent, so a child is matched by name
|
||||
// within this parent only — a same-named third party under a different
|
||||
// parent is an independent entity and must be created here too.
|
||||
err := child.LoadByNameAndParentThirdPartyID(ctx, conn, scope, qualifiedName, pc.ThirdPartyID)
|
||||
switch {
|
||||
case err == nil:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
case !errors.Is(err, coredata.ErrResourceNotFound):
|
||||
return fmt.Errorf("cannot find child third party %q: %w", p.Name, err)
|
||||
}
|
||||
|
||||
if p.Purpose != "" {
|
||||
relation.Purpose = &p.Purpose
|
||||
now := time.Now()
|
||||
parentID := pc.ThirdPartyID
|
||||
child = &coredata.ThirdParty{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
||||
OrganizationID: pc.OrganizationID,
|
||||
ParentThirdPartyID: &parentID,
|
||||
Name: qualifiedName,
|
||||
Category: coredata.ThirdPartyCategoryOther,
|
||||
Level: parentLevel + 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := relation.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert third party relation: %w", err)
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// baseThirdPartyName strips a trailing " (path)" suffix so a stored,
|
||||
// hierarchy-qualified name is reduced to its bare base, mirroring the console
|
||||
// UI convention.
|
||||
func baseThirdPartyName(name string) string {
|
||||
return strings.TrimSpace(nameSuffixPattern.ReplaceAllString(name, ""))
|
||||
}
|
||||
|
||||
// qualifyThirdPartyName appends the parent path as a parenthesized suffix, e.g.
|
||||
// ("aws", ["Probo", "Acme"]) → "aws (Probo/Acme)". An empty path leaves the
|
||||
// name unchanged, so top-level third parties are never suffixed.
|
||||
func qualifyThirdPartyName(base string, path []string) string {
|
||||
if len(path) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s (%s)", base, strings.Join(path, "/"))
|
||||
}
|
||||
|
||||
// loadAncestorBaseNames returns the base names of a third party's ancestors,
|
||||
// ordered root → immediate parent. It is the suffix path used to qualify the
|
||||
// third party's own name; append the third party's own base name to it to get
|
||||
// the suffix path for its children.
|
||||
func loadAncestorBaseNames(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope coredata.Scoper,
|
||||
thirdPartyID gid.GID,
|
||||
) ([]string, error) {
|
||||
var ancestors coredata.ThirdParties
|
||||
|
||||
if err := ancestors.LoadAllAncestorsByThirdPartyID(ctx, conn, scope, thirdPartyID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load ancestors: %w", err)
|
||||
}
|
||||
|
||||
names := make([]string, len(ancestors))
|
||||
for i, ancestor := range ancestors {
|
||||
names[i] = baseThirdPartyName(ancestor.Name)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
@@ -89,9 +89,14 @@ func SaveThirdPartyInfoTool(pc *PersistenceContext) agent.Tool {
|
||||
}
|
||||
}
|
||||
|
||||
ancestorBaseNames, err := loadAncestorBaseNames(ctx, conn, scope, thirdParty.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applySaveParams(thirdParty, pc.WebsiteURL, saveThirdPartyInfoParams{
|
||||
saveThirdPartyInfoToolParams: p,
|
||||
})
|
||||
}, ancestorBaseNames)
|
||||
thirdParty.UpdatedAt = time.Now()
|
||||
|
||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||
@@ -124,7 +129,20 @@ func LinkSubThirdPartyTool(pc *PersistenceContext) agent.Tool {
|
||||
err := pc.PG.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
return linkSubThirdParty(ctx, conn, scope, pc, p)
|
||||
parent := &coredata.ThirdParty{}
|
||||
if err := parent.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load parent third party: %w", err)
|
||||
}
|
||||
|
||||
ancestorBaseNames, err := loadAncestorBaseNames(ctx, conn, scope, pc.ThirdPartyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Child suffix path is the parent's ancestors plus the parent itself.
|
||||
childNamePath := append(ancestorBaseNames, baseThirdPartyName(parent.Name))
|
||||
|
||||
return linkSubThirdParty(ctx, conn, scope, pc, parent.Level, childNamePath, p)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user