diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json
index 04f3a81b7..22cd8fd31 100644
--- a/apps/console/src/_locales/en-US.json
+++ b/apps/console/src/_locales/en-US.json
@@ -2462,9 +2462,9 @@
},
"thirdPartyOverviewPage": {
"pageTitle": "{{name}} - Overview",
- "sections": { "details": "Third party details", "countries": "Countries", "ownership": "Ownership details", "links": "Links", "dataAgreements": "Data agreements" },
- "fields": { "name": "Name", "description": "Description", "category": "Category", "legalName": "Legal name", "headquarterAddress": "Headquarter address", "websiteUrl": "Website URL", "businessOwner": "Business owner", "securityOwner": "Security owner" },
- "placeholders": { "category": "Select a category" },
+ "sections": { "details": "Third party details", "countries": "Countries", "administrators": "Administrators", "links": "Links", "dataAgreements": "Data agreements" },
+ "fields": { "name": "Name", "description": "Description", "category": "Category", "legalName": "Legal name", "headquarterAddress": "Headquarter address", "websiteUrl": "Website URL", "administrators": "Administrators" },
+ "placeholders": { "category": "Select a category", "administrators": "Add administrators..." },
"categories": { "analytics": "Analytics", "cloudMonitoring": "Cloud Monitoring", "cloudProvider": "Cloud Provider", "collaboration": "Collaboration", "customerSupport": "Customer Support", "dataStorageAndProcessing": "Data Storage and Processing", "documentManagement": "Document Management", "employeeManagement": "Employee Management", "engineering": "Engineering", "finance": "Finance", "identityProvider": "Identity Provider", "it": "IT", "marketing": "Marketing", "officeOperations": "Office Operations", "other": "Other", "passwordManagement": "Password Management", "productAndDesign": "Product and Design", "professionalServices": "Professional Services", "recruiting": "Recruiting", "sales": "Sales", "security": "Security", "versionControl": "Version Control" },
"urlLabels": { "statusPage": "Status page URL", "termsOfService": "Terms of service URL", "privacyPolicy": "Privacy document URL", "serviceLevelAgreement": "Service level agreement URL", "dataProcessingAgreement": "Data processing agreement URL", "securityPage": "Security page URL", "trustPage": "Trust page URL" },
"agreements": { "businessAssociate": "Business Associate Agreement", "dataPrivacy": "Data Privacy Agreement", "noBusinessAssociate": "No business associate agreement available", "noDataPrivacy": "No data privacy agreement available", "validity": { "range": "Valid from {{from}} until {{until}}", "from": "Valid from {{date}}", "until": "Valid until {{date}}" } },
diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json
index b438f96bd..f2974559c 100644
--- a/apps/console/src/_locales/fr-FR.json
+++ b/apps/console/src/_locales/fr-FR.json
@@ -5743,7 +5743,7 @@
"sections": {
"details": "Détails du tiers",
"countries": "Pays",
- "ownership": "Détails de la propriété",
+ "administrators": "Administrateurs",
"links": "Liens",
"dataAgreements": "Accords de données"
},
@@ -5754,11 +5754,11 @@
"legalName": "Nom légal",
"headquarterAddress": "Adresse du siège",
"websiteUrl": "URL du site web",
- "businessOwner": "Propriétaire métier",
- "securityOwner": "Propriétaire sécurité"
+ "administrators": "Administrateurs"
},
"placeholders": {
- "category": "Sélectionner une catégorie"
+ "category": "Sélectionner une catégorie",
+ "administrators": "Ajouter des administrateurs..."
},
"categories": {
"analytics": "Analytique",
diff --git a/apps/console/src/hooks/forms/useThirdPartyForm.tsx b/apps/console/src/hooks/forms/useThirdPartyForm.tsx
index 5e13cf270..d0a3a7904 100644
--- a/apps/console/src/hooks/forms/useThirdPartyForm.tsx
+++ b/apps/console/src/hooks/forms/useThirdPartyForm.tsx
@@ -45,8 +45,7 @@ const schema = z.object({
countries: z.array(z.string()),
securityPageUrl: z.string().optional().nullable(),
trustPageUrl: z.string().optional().nullable(),
- businessOwnerId: z.string().nullish(),
- securityOwnerId: z.string().nullish(),
+ administratorIds: z.array(z.string()),
});
const thirdPartyFormFragment = graphql`
@@ -67,11 +66,10 @@ const thirdPartyFormFragment = graphql`
countries
securityPageUrl
trustPageUrl
- businessOwner {
- id
- }
- securityOwner {
+ administrators {
id
+ fullName
+ emailAddress
}
}
`;
@@ -112,8 +110,7 @@ export function useThirdPartyForm(thirdPartyKey: useThirdPartyFormFragment$key)
countries: [...(thirdParty.countries ?? [])],
securityPageUrl: thirdParty.securityPageUrl || null,
trustPageUrl: thirdParty.trustPageUrl || null,
- businessOwnerId: thirdParty.businessOwner?.id,
- securityOwnerId: thirdParty.securityOwner?.id,
+ administratorIds: thirdParty.administrators.map(a => a.id),
}),
[thirdParty],
);
@@ -151,5 +148,6 @@ export function useThirdPartyForm(thirdPartyKey: useThirdPartyFormFragment$key)
return {
...form,
handleSubmit,
+ administrators: thirdParty.administrators,
};
}
diff --git a/apps/console/src/pages/organizations/third-parties/overview/ThirdPartyOverviewPage.tsx b/apps/console/src/pages/organizations/third-parties/overview/ThirdPartyOverviewPage.tsx
index 7b5827677..74c15f615 100644
--- a/apps/console/src/pages/organizations/third-parties/overview/ThirdPartyOverviewPage.tsx
+++ b/apps/console/src/pages/organizations/third-parties/overview/ThirdPartyOverviewPage.tsx
@@ -41,7 +41,7 @@ import type { ThirdPartyOverviewPageQuery } from "#/__generated__/core/ThirdPart
import type { ThirdPartyCategory } from "#/__generated__/core/useThirdPartyFormFragment.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { CountriesField } from "#/components/form/CountriesField";
-import { PeopleSelectField } from "#/components/form/PeopleSelectField";
+import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -156,6 +156,7 @@ export default function ThirdPartyOverviewPage(props: ThirdPartyOverviewPageProp
register,
handleSubmit,
formState: { errors, isSubmitting },
+ administrators,
} = useThirdPartyForm(thirdParty);
const thirdPartyWithBAA
@@ -271,25 +272,21 @@ export default function ThirdPartyOverviewPage(props: ThirdPartyOverviewPageProp
-
{t("thirdPartyOverviewPage.sections.ownership")}
+
{t("thirdPartyOverviewPage.sections.administrators")}
-
- ({
+ id: a.id,
+ fullName: a.fullName,
+ emailAddress: a.emailAddress,
+ }))}
+ placeholder={t("thirdPartyOverviewPage.placeholders.administrators")}
/>
diff --git a/contrib/claude/ownership.md b/contrib/claude/ownership.md
index fc04c547f..a3980b6db 100644
--- a/contrib/claude/ownership.md
+++ b/contrib/claude/ownership.md
@@ -35,7 +35,6 @@ Use the same shape across DB, services, GraphQL, and console pickers.
### Resources that follow this pattern
- Assets, data (datum), risks, obligations, findings
-- Third parties (`business_owner_profile_id`, `security_owner_profile_id`)
- Devices (ITAM) — aligned with compliance resources as of the devices table
introduction
@@ -47,7 +46,7 @@ resource for the `owner` field (parent access is already established).
Default for new work is a **nullable** owner (`OwnerID *gid.GID`, GraphQL
`owner: Profile`). Set the embedded profile only when present so the resolver
-nil guard is live (device, risk, finding, third-party owners):
+nil guard is live (device, risk, finding):
```go
// types — set only when present
diff --git a/e2e/console/security_read_gap_owners_test.go b/e2e/console/security_read_gap_owners_test.go
index 077733846..eb904bf17 100644
--- a/e2e/console/security_read_gap_owners_test.go
+++ b/e2e/console/security_read_gap_owners_test.go
@@ -25,12 +25,11 @@
// authorization shape (authorizing the parent obj.ID with the child's
// ActionMembershipProfileGet, then loading the child through the scope-by-key
// Profile dataloader) also existed on asset.owner, datum.owner, finding.owner,
-// obligation.owner, risk.owner, task.assignedTo, thirdParty.businessOwner and
-// thirdParty.securityOwner. Each of those write paths validates the owner FK
-// today, so these tests use injectCrossTenantFK to plant a foreign profile id
-// directly in the row -- proving the read resolver now authorizes the actual
-// child profile id and refuses cross-tenant PII independently of the write
-// check (a future write regression, migration bug, or direct DB access).
+// obligation.owner, risk.owner, task.assignedTo, and
+// thirdParty.administrators. Each of those write paths validates the owner FK
+// today, so these tests plant a foreign profile id directly -- proving the
+// read resolver refuses cross-tenant PII independently of the write check
+// (a future write regression, migration bug, or direct DB access).
package console_test
import (
@@ -333,24 +332,24 @@ func TestSecurity_ReadGap_TaskAssignedTo(t *testing.T) {
testutil.AssertNodeNotAccessible(t, err, readResult.Node.AssignedTo == nil, "cross-tenant profile PII via task.assignedTo")
}
-func TestSecurity_ReadGap_ThirdPartyBusinessOwner(t *testing.T) {
+func TestSecurity_ReadGap_ThirdPartyAdministrators(t *testing.T) {
t.Parallel()
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
- org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Business Owner (read-gap probe)"})
+ org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Administrator (read-gap probe)"})
thirdPartyID := factory.CreateThirdParty(org1Owner, factory.Attrs{"name": "Org1 ThirdParty for read-gap probe"})
- injectCrossTenantFK(t, "third_parties", "business_owner_profile_id", thirdPartyID, org2ProfileID)
+ factory.InjectCrossTenantThirdPartyAdministrator(t, thirdPartyID, org2ProfileID)
var readResult struct {
Node struct {
- BusinessOwner *struct {
+ Administrators []struct {
ID string `json:"id"`
FullName string `json:"fullName"`
- } `json:"businessOwner"`
+ } `json:"administrators"`
} `json:"node"`
}
@@ -358,45 +357,20 @@ func TestSecurity_ReadGap_ThirdPartyBusinessOwner(t *testing.T) {
query($id: ID!) {
node(id: $id) {
... on ThirdParty {
- businessOwner { id fullName }
+ administrators { id fullName }
}
}
}
`, map[string]any{"id": thirdPartyID}, &readResult)
- testutil.AssertNodeNotAccessible(t, err, readResult.Node.BusinessOwner == nil, "cross-tenant profile PII via thirdParty.businessOwner")
-}
+ leaked := false
-func TestSecurity_ReadGap_ThirdPartySecurityOwner(t *testing.T) {
- t.Parallel()
-
- org1Owner := testutil.NewClient(t, testutil.RoleOwner)
- org2Owner := testutil.NewClient(t, testutil.RoleOwner)
-
- org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Security Owner (read-gap probe)"})
-
- thirdPartyID := factory.CreateThirdParty(org1Owner, factory.Attrs{"name": "Org1 ThirdParty for read-gap probe"})
-
- injectCrossTenantFK(t, "third_parties", "security_owner_profile_id", thirdPartyID, org2ProfileID)
-
- var readResult struct {
- Node struct {
- SecurityOwner *struct {
- ID string `json:"id"`
- FullName string `json:"fullName"`
- } `json:"securityOwner"`
- } `json:"node"`
+ for _, a := range readResult.Node.Administrators {
+ if a.ID == org2ProfileID || a.FullName != "" && a.ID == org2ProfileID {
+ leaked = true
+ break
+ }
}
- err := org1Owner.Execute(`
- query($id: ID!) {
- node(id: $id) {
- ... on ThirdParty {
- securityOwner { id fullName }
- }
- }
- }
- `, map[string]any{"id": thirdPartyID}, &readResult)
-
- testutil.AssertNodeNotAccessible(t, err, readResult.Node.SecurityOwner == nil, "cross-tenant profile PII via thirdParty.securityOwner")
+ testutil.AssertNodeNotAccessible(t, err, !leaked && len(readResult.Node.Administrators) == 0, "cross-tenant profile PII via thirdParty.administrators")
}
diff --git a/e2e/console/third_party_test.go b/e2e/console/third_party_test.go
index 394c84075..27f0f0689 100644
--- a/e2e/console/third_party_test.go
+++ b/e2e/console/third_party_test.go
@@ -506,13 +506,13 @@ func TestThirdParty_SubResolvers(t *testing.T) {
assert.NotNil(t, result.Node.Services.Edges)
})
- t.Run("businessOwner sub-resolver (null)", func(t *testing.T) {
+ t.Run("administrators sub-resolver (empty)", func(t *testing.T) {
query := `
query($id: ID!) {
node(id: $id) {
... on ThirdParty {
id
- businessOwner {
+ administrators {
id
fullName
}
@@ -523,47 +523,17 @@ func TestThirdParty_SubResolvers(t *testing.T) {
var result struct {
Node struct {
- ID string `json:"id"`
- BusinessOwner *struct {
+ ID string `json:"id"`
+ Administrators []struct {
ID string `json:"id"`
FullName string `json:"fullName"`
- } `json:"businessOwner"`
+ } `json:"administrators"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{"id": thirdPartyID}, &result)
require.NoError(t, err)
- assert.Nil(t, result.Node.BusinessOwner)
- })
-
- t.Run("securityOwner sub-resolver (null)", func(t *testing.T) {
- query := `
- query($id: ID!) {
- node(id: $id) {
- ... on ThirdParty {
- id
- securityOwner {
- id
- fullName
- }
- }
- }
- }
- `
-
- var result struct {
- Node struct {
- ID string `json:"id"`
- SecurityOwner *struct {
- ID string `json:"id"`
- FullName string `json:"fullName"`
- } `json:"securityOwner"`
- } `json:"node"`
- }
-
- err := owner.Execute(query, map[string]any{"id": thirdPartyID}, &result)
- require.NoError(t, err)
- assert.Nil(t, result.Node.SecurityOwner)
+ assert.Empty(t, result.Node.Administrators)
})
}
@@ -756,23 +726,22 @@ func TestThirdParty_OmittableDescription(t *testing.T) {
})
}
-func TestThirdParty_OmittableBusinessOwner(t *testing.T) {
+func TestThirdParty_Administrators(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
- // Create a profile for owner assignment
profileID := factory.CreateUser(owner)
thirdPartyID := factory.NewThirdParty(owner).
- WithName("BusinessOwner Test ThirdParty").
+ WithName("Administrators Test ThirdParty").
Create()
- t.Run("set business owner", func(t *testing.T) {
+ t.Run("set administrators", func(t *testing.T) {
query := `
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty {
id
- businessOwner {
+ administrators {
id
fullName
}
@@ -784,32 +753,33 @@ func TestThirdParty_OmittableBusinessOwner(t *testing.T) {
var result struct {
UpdateThirdParty struct {
ThirdParty struct {
- ID string `json:"id"`
- BusinessOwner struct {
+ ID string `json:"id"`
+ Administrators []struct {
ID string `json:"id"`
FullName string `json:"fullName"`
- } `json:"businessOwner"`
+ } `json:"administrators"`
} `json:"thirdParty"`
} `json:"updateThirdParty"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
- "id": thirdPartyID,
- "businessOwnerId": profileID,
+ "id": thirdPartyID,
+ "administratorIds": []string{profileID},
},
}, &result)
require.NoError(t, err)
- assert.Equal(t, profileID, result.UpdateThirdParty.ThirdParty.BusinessOwner.ID)
+ require.Len(t, result.UpdateThirdParty.ThirdParty.Administrators, 1)
+ assert.Equal(t, profileID, result.UpdateThirdParty.ThirdParty.Administrators[0].ID)
})
- t.Run("clear business owner with null", func(t *testing.T) {
+ t.Run("clear administrators with empty list", func(t *testing.T) {
query := `
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty {
id
- businessOwner {
+ administrators {
id
}
}
@@ -820,103 +790,22 @@ func TestThirdParty_OmittableBusinessOwner(t *testing.T) {
var result struct {
UpdateThirdParty struct {
ThirdParty struct {
- ID string `json:"id"`
- BusinessOwner *struct {
+ ID string `json:"id"`
+ Administrators []struct {
ID string `json:"id"`
- } `json:"businessOwner"`
+ } `json:"administrators"`
} `json:"thirdParty"`
} `json:"updateThirdParty"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
- "id": thirdPartyID,
- "businessOwnerId": nil,
+ "id": thirdPartyID,
+ "administratorIds": []string{},
},
}, &result)
require.NoError(t, err)
- assert.Nil(t, result.UpdateThirdParty.ThirdParty.BusinessOwner)
- })
-}
-
-func TestThirdParty_OmittableSecurityOwner(t *testing.T) {
- t.Parallel()
- owner := testutil.NewClient(t, testutil.RoleOwner)
-
- // Create a profile for owner assignment
- profileID := factory.CreateUser(owner)
- thirdPartyID := factory.NewThirdParty(owner).WithName("SecurityOwner Test ThirdParty").Create()
-
- t.Run("set security owner", func(t *testing.T) {
- query := `
- mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
- updateThirdParty(input: $input) {
- thirdParty {
- id
- securityOwner {
- id
- fullName
- }
- }
- }
- }
- `
-
- var result struct {
- UpdateThirdParty struct {
- ThirdParty struct {
- ID string `json:"id"`
- SecurityOwner struct {
- ID string `json:"id"`
- FullName string `json:"fullName"`
- } `json:"securityOwner"`
- } `json:"thirdParty"`
- } `json:"updateThirdParty"`
- }
-
- err := owner.Execute(query, map[string]any{
- "input": map[string]any{
- "id": thirdPartyID,
- "securityOwnerId": profileID,
- },
- }, &result)
- require.NoError(t, err)
- assert.Equal(t, profileID, result.UpdateThirdParty.ThirdParty.SecurityOwner.ID)
- })
-
- t.Run("clear security owner with null", func(t *testing.T) {
- query := `
- mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
- updateThirdParty(input: $input) {
- thirdParty {
- id
- securityOwner {
- id
- }
- }
- }
- }
- `
-
- var result struct {
- UpdateThirdParty struct {
- ThirdParty struct {
- ID string `json:"id"`
- SecurityOwner *struct {
- ID string `json:"id"`
- } `json:"securityOwner"`
- } `json:"thirdParty"`
- } `json:"updateThirdParty"`
- }
-
- err := owner.Execute(query, map[string]any{
- "input": map[string]any{
- "id": thirdPartyID,
- "securityOwnerId": nil,
- },
- }, &result)
- require.NoError(t, err)
- assert.Nil(t, result.UpdateThirdParty.ThirdParty.SecurityOwner)
+ assert.Empty(t, result.UpdateThirdParty.ThirdParty.Administrators)
})
}
@@ -1178,7 +1067,7 @@ func TestThirdParty_TenantIsolation(t *testing.T) {
require.Error(t, err, "Should not be able to delete thirdParty from another org")
})
- t.Run("cannot create thirdParty referencing a business owner from another organization", func(t *testing.T) {
+ t.Run("cannot create thirdParty referencing an administrator from another organization", func(t *testing.T) {
org2ProfileID := factory.CreateUser(org2Owner)
_, err := org1Owner.Do(`
@@ -1189,17 +1078,17 @@ func TestThirdParty_TenantIsolation(t *testing.T) {
}
`, map[string]any{
"input": map[string]any{
- "organizationId": org1Owner.GetOrganizationID().String(),
- "name": factory.SafeName("ThirdParty"),
- "businessOwnerId": org2ProfileID,
+ "organizationId": org1Owner.GetOrganizationID().String(),
+ "name": factory.SafeName("ThirdParty"),
+ "administratorIds": []string{org2ProfileID},
},
})
- require.Error(t, err, "must not accept a businessOwnerId belonging to another organization")
+ require.Error(t, err, "must not accept an administratorId belonging to another organization")
})
- t.Run("cannot update thirdParty to reference a security owner from another organization", func(t *testing.T) {
+ t.Run("cannot update thirdParty to reference an administrator from another organization", func(t *testing.T) {
org2ProfileID := factory.CreateUser(org2Owner)
- otherThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for SecurityOwner").Create()
+ otherThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for Administrators").Create()
_, err := org1Owner.Do(`
mutation($input: UpdateThirdPartyInput!) {
@@ -1209,11 +1098,11 @@ func TestThirdParty_TenantIsolation(t *testing.T) {
}
`, map[string]any{
"input": map[string]any{
- "id": otherThirdPartyID,
- "securityOwnerId": org2ProfileID,
+ "id": otherThirdPartyID,
+ "administratorIds": []string{org2ProfileID},
},
})
- require.Error(t, err, "must not accept a securityOwnerId belonging to another organization")
+ require.Error(t, err, "must not accept an administratorId belonging to another organization")
})
t.Run("cannot create thirdParty referencing a parent thirdParty from another organization", func(t *testing.T) {
diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go
index c00d40b8f..b7390db06 100644
--- a/e2e/internal/factory/factory.go
+++ b/e2e/internal/factory/factory.go
@@ -23,15 +23,19 @@ package factory
import (
"bytes"
+ "context"
"encoding/json"
"fmt"
"maps"
"net/http"
"strings"
+ "testing"
"github.com/brianvoe/gofakeit/v7"
"github.com/stretchr/testify/require"
+ "go.gearno.de/kit/pg"
"go.probo.inc/probo/e2e/internal/testutil"
+ "go.probo.inc/probo/internal/test"
)
func SafeName(prefix string) string {
@@ -225,6 +229,10 @@ func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
input["category"] = *cat
}
+ if v, ok := a["administratorIds"]; ok {
+ input["administratorIds"] = v
+ }
+
var result struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
@@ -241,6 +249,41 @@ func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
return result.CreateThirdParty.ThirdPartyEdge.Node.ID
}
+// InjectCrossTenantThirdPartyAdministrator bypasses the application and writes a
+// third_party_administrators row for a foreign profile, for read-gap security tests.
+func InjectCrossTenantThirdPartyAdministrator(t *testing.T, thirdPartyID, foreignProfileID string) {
+ t.Helper()
+
+ client := test.PGClient(t)
+ ctx := context.Background()
+
+ err := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
+ _, err := conn.Exec(ctx, `
+INSERT INTO third_party_administrators (
+ third_party_id,
+ administrator_profile_id,
+ tenant_id,
+ organization_id,
+ created_at,
+ updated_at
+)
+SELECT
+ id,
+ $1,
+ tenant_id,
+ organization_id,
+ NOW(),
+ NOW()
+FROM third_parties
+WHERE id = $2
+ON CONFLICT DO NOTHING
+`, foreignProfileID, thirdPartyID)
+
+ return err
+ })
+ require.NoError(t, err, "test setup: cannot inject cross-tenant third party administrator")
+}
+
func CreateFramework(c *testutil.Client, attrs ...Attrs) string {
c.T.Helper()
diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts
index ed1832384..ea4ee3df1 100644
--- a/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts
+++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts
@@ -119,8 +119,8 @@ export const description: INodeProperties[] = [
description: 'The headquarter address of the thirdParty',
},
{
- displayName: 'Business Owner ID',
- name: 'businessOwnerId',
+ displayName: 'Administrator IDs',
+ name: 'administratorIds',
type: 'string',
displayOptions: {
show: {
@@ -129,20 +129,7 @@ export const description: INodeProperties[] = [
},
},
default: '',
- description: 'The ID of the business owner (People ID)',
- },
- {
- displayName: 'Security Owner ID',
- name: 'securityOwnerId',
- type: 'string',
- displayOptions: {
- show: {
- resource: ['thirdParty'],
- operation: ['create'],
- },
- },
- default: '',
- description: 'The ID of the security owner (People ID)',
+ description: 'Comma-separated administrator profile IDs',
},
{
displayName: 'Additional Fields',
@@ -248,8 +235,7 @@ export async function execute(
const websiteUrl = this.getNodeParameter('websiteUrl', itemIndex, '') as string;
const legalName = this.getNodeParameter('legalName', itemIndex, '') as string;
const headquarterAddress = this.getNodeParameter('headquarterAddress', itemIndex, '') as string;
- const businessOwnerId = this.getNodeParameter('businessOwnerId', itemIndex, '') as string;
- const securityOwnerId = this.getNodeParameter('securityOwnerId', itemIndex, '') as string;
+ const administratorIds = this.getNodeParameter('administratorIds', itemIndex, '') as string;
const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as {
statusPageUrl?: string;
termsOfServiceUrl?: string;
@@ -307,8 +293,7 @@ export async function execute(
if (websiteUrl) input.websiteUrl = websiteUrl;
if (legalName) input.legalName = legalName;
if (headquarterAddress) input.headquarterAddress = headquarterAddress;
- if (businessOwnerId) input.businessOwnerId = businessOwnerId;
- if (securityOwnerId) input.securityOwnerId = securityOwnerId;
+ if (administratorIds) input.administratorIds = administratorIds.split(',').map(id => id.trim()).filter(Boolean);
if (additionalFields.statusPageUrl) input.statusPageUrl = additionalFields.statusPageUrl;
if (additionalFields.termsOfServiceUrl) input.termsOfServiceUrl = additionalFields.termsOfServiceUrl;
if (additionalFields.privacyPolicyUrl) input.privacyPolicyUrl = additionalFields.privacyPolicyUrl;
diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/get.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/get.operation.ts
index 6aef20750..a8c4b47bf 100644
--- a/packages/n8n-node/nodes/Probo/actions/thirdParty/get.operation.ts
+++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/get.operation.ts
@@ -57,18 +57,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include organization in the response',
},
{
- displayName: 'Include Business Owner',
- name: 'includeBusinessOwner',
+ displayName: 'Include Administrators',
+ name: 'includeAdministrators',
type: 'boolean',
default: false,
- description: 'Whether to include business owner in the response',
- },
- {
- displayName: 'Include Security Owner',
- name: 'includeSecurityOwner',
- type: 'boolean',
- default: false,
- description: 'Whether to include security owner in the response',
+ description: 'Whether to include administrators details',
},
],
},
@@ -81,8 +74,7 @@ export async function execute(
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOrganization?: boolean;
- includeBusinessOwner?: boolean;
- includeSecurityOwner?: boolean;
+ includeAdministrators?: boolean;
};
const organizationFragment = options.includeOrganization
@@ -92,16 +84,8 @@ export async function execute(
}`
: '';
- const businessOwnerFragment = options.includeBusinessOwner
- ? `businessOwner {
- id
- fullName
- emailAddress
- }`
- : '';
-
- const securityOwnerFragment = options.includeSecurityOwner
- ? `securityOwner {
+ const administratorsFragment = options.includeAdministrators
+ ? `administrators {
id
fullName
emailAddress
@@ -132,8 +116,7 @@ export async function execute(
countries
showOnCompliancePortal
${organizationFragment}
- ${businessOwnerFragment}
- ${securityOwnerFragment}
+ ${administratorsFragment}
createdAt
updatedAt
}
diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts
index bffdd256c..73ad94ee6 100644
--- a/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts
+++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts
@@ -94,18 +94,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include organization in the response',
},
{
- displayName: 'Include Business Owner',
- name: 'includeBusinessOwner',
+ displayName: 'Include Administrators',
+ name: 'includeAdministrators',
type: 'boolean',
default: false,
- description: 'Whether to include business owner in the response',
- },
- {
- displayName: 'Include Security Owner',
- name: 'includeSecurityOwner',
- type: 'boolean',
- default: false,
- description: 'Whether to include security owner in the response',
+ description: 'Whether to include administrators details',
},
],
},
@@ -121,8 +114,7 @@ export async function execute(
const options = this.getNodeParameter('options', itemIndex, {}) as {
filterLevel?: number;
includeOrganization?: boolean;
- includeBusinessOwner?: boolean;
- includeSecurityOwner?: boolean;
+ includeAdministrators?: boolean;
};
const organizationFragment = options.includeOrganization
@@ -132,16 +124,8 @@ export async function execute(
}`
: '';
- const businessOwnerFragment = options.includeBusinessOwner
- ? `businessOwner {
- id
- fullName
- emailAddress
- }`
- : '';
-
- const securityOwnerFragment = options.includeSecurityOwner
- ? `securityOwner {
+ const administratorsFragment = options.includeAdministrators
+ ? `administrators {
id
fullName
emailAddress
@@ -183,8 +167,7 @@ export async function execute(
name
}
${organizationFragment}
- ${businessOwnerFragment}
- ${securityOwnerFragment}
+ ${administratorsFragment}
createdAt
updatedAt
}
diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/update.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/update.operation.ts
index 00dbaff1e..ef261a124 100644
--- a/packages/n8n-node/nodes/Probo/actions/thirdParty/update.operation.ts
+++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/update.operation.ts
@@ -118,8 +118,8 @@ export const description: INodeProperties[] = [
description: 'The headquarter address of the thirdParty',
},
{
- displayName: 'Business Owner ID',
- name: 'businessOwnerId',
+ displayName: 'Administrator IDs',
+ name: 'administratorIds',
type: 'string',
displayOptions: {
show: {
@@ -128,20 +128,7 @@ export const description: INodeProperties[] = [
},
},
default: '',
- description: 'The ID of the business owner (People ID)',
- },
- {
- displayName: 'Security Owner ID',
- name: 'securityOwnerId',
- type: 'string',
- displayOptions: {
- show: {
- resource: ['thirdParty'],
- operation: ['update'],
- },
- },
- default: '',
- description: 'The ID of the security owner (People ID)',
+ description: 'Comma-separated administrator profile IDs (omit to leave unchanged)',
},
{
displayName: 'Show on Compliance Portal',
@@ -253,8 +240,7 @@ export async function execute(
const websiteUrl = this.getNodeParameter('websiteUrl', itemIndex, '') as string;
const legalName = this.getNodeParameter('legalName', itemIndex, '') as string;
const headquarterAddress = this.getNodeParameter('headquarterAddress', itemIndex, '') as string;
- const businessOwnerId = this.getNodeParameter('businessOwnerId', itemIndex, '') as string;
- const securityOwnerId = this.getNodeParameter('securityOwnerId', itemIndex, '') as string;
+ const administratorIds = this.getNodeParameter('administratorIds', itemIndex, '') as string;
const showOnCompliancePortal = this.getNodeParameter('showOnCompliancePortal', itemIndex) as boolean | undefined;
const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as {
statusPageUrl?: string;
@@ -307,8 +293,9 @@ export async function execute(
if (websiteUrl !== undefined) input.websiteUrl = websiteUrl === '' ? null : websiteUrl;
if (legalName !== undefined) input.legalName = legalName === '' ? null : legalName;
if (headquarterAddress !== undefined) input.headquarterAddress = headquarterAddress === '' ? null : headquarterAddress;
- if (businessOwnerId !== undefined) input.businessOwnerId = businessOwnerId === '' ? null : businessOwnerId;
- if (securityOwnerId !== undefined) input.securityOwnerId = securityOwnerId === '' ? null : securityOwnerId;
+ if (administratorIds) {
+ input.administratorIds = administratorIds.split(',').map(id => id.trim()).filter(Boolean);
+ }
if (showOnCompliancePortal !== undefined) input.showOnCompliancePortal = showOnCompliancePortal;
if (additionalFields.statusPageUrl !== undefined) input.statusPageUrl = additionalFields.statusPageUrl === '' ? null : additionalFields.statusPageUrl;
if (additionalFields.termsOfServiceUrl !== undefined) input.termsOfServiceUrl = additionalFields.termsOfServiceUrl === '' ? null : additionalFields.termsOfServiceUrl;
diff --git a/pkg/cmd/thirdpartymgmt/create/create.go b/pkg/cmd/thirdpartymgmt/create/create.go
index dda53bf5a..2deb89004 100644
--- a/pkg/cmd/thirdpartymgmt/create/create.go
+++ b/pkg/cmd/thirdpartymgmt/create/create.go
@@ -58,13 +58,14 @@ type createResponse struct {
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
var (
- flagOrg string
- flagName string
- flagCategory string
- flagDescription string
- flagLegalName string
- flagAddress string
- flagWebsite string
+ flagOrg string
+ flagName string
+ flagCategory string
+ flagDescription string
+ flagLegalName string
+ flagAddress string
+ flagWebsite string
+ flagAdministratorID []string
)
cmd := &cobra.Command{
@@ -178,6 +179,10 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
input["websiteUrl"] = flagWebsite
}
+ if len(flagAdministratorID) > 0 {
+ input["administratorIds"] = flagAdministratorID
+ }
+
data, err := client.Do(
createMutation,
map[string]any{"input": input},
@@ -210,6 +215,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&flagLegalName, "legal-name", "", "Legal name")
cmd.Flags().StringVar(&flagAddress, "address", "", "Headquarter address")
cmd.Flags().StringVar(&flagWebsite, "website", "", "Website URL")
+ cmd.Flags().StringArrayVar(&flagAdministratorID, "administrator-id", nil, "Administrator profile ID (can be repeated)")
return cmd
}
diff --git a/pkg/cmd/thirdpartymgmt/update/update.go b/pkg/cmd/thirdpartymgmt/update/update.go
index ccb4f163d..d35717795 100644
--- a/pkg/cmd/thirdpartymgmt/update/update.go
+++ b/pkg/cmd/thirdpartymgmt/update/update.go
@@ -32,7 +32,7 @@ import (
const updateMutation = `
mutation($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
- third_party {
+ thirdParty {
id
name
category
@@ -47,18 +47,19 @@ type updateResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
- } `json:"third_party"`
+ } `json:"thirdParty"`
} `json:"updateThirdParty"`
}
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
var (
- flagName string
- flagDescription string
- flagCategory string
- flagLegalName string
- flagAddress string
- flagWebsite string
+ flagName string
+ flagDescription string
+ flagCategory string
+ flagLegalName string
+ flagAddress string
+ flagWebsite string
+ flagAdministratorID []string
)
cmd := &cobra.Command{
@@ -112,6 +113,17 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
input["websiteUrl"] = flagWebsite
}
+ if cmd.Flags().Changed("administrator-id") {
+ administratorIDs := make([]string, 0, len(flagAdministratorID))
+ for _, id := range flagAdministratorID {
+ if id != "" {
+ administratorIDs = append(administratorIDs, id)
+ }
+ }
+
+ input["administratorIds"] = administratorIDs
+ }
+
if len(input) == 1 {
return fmt.Errorf("at least one field must be specified for update")
}
@@ -147,6 +159,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&flagLegalName, "legal-name", "", "Legal name")
cmd.Flags().StringVar(&flagAddress, "address", "", "Headquarter address")
cmd.Flags().StringVar(&flagWebsite, "website", "", "Website URL")
+ cmd.Flags().StringArrayVar(&flagAdministratorID, "administrator-id", nil, "Administrator profile ID (can be repeated; empty clears)")
return cmd
}
diff --git a/pkg/cmd/thirdpartymgmt/view/view.go b/pkg/cmd/thirdpartymgmt/view/view.go
index 0d070c67b..8b7d82bd2 100644
--- a/pkg/cmd/thirdpartymgmt/view/view.go
+++ b/pkg/cmd/thirdpartymgmt/view/view.go
@@ -42,6 +42,10 @@ query($id: ID!) {
legalName
headquarterAddress
websiteUrl
+ administrators {
+ id
+ fullName
+ }
createdAt
updatedAt
}
@@ -59,8 +63,12 @@ type viewResponse struct {
LegalName *string `json:"legalName"`
HeadquarterAddress *string `json:"headquarterAddress"`
WebsiteUrl *string `json:"websiteUrl"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ Administrators []struct {
+ ID string `json:"id"`
+ FullName string `json:"fullName"`
+ } `json:"administrators"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
} `json:"node"`
}
@@ -146,6 +154,24 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Website:"), *v.WebsiteUrl)
}
+ if len(v.Administrators) > 0 {
+ _, _ = fmt.Fprintf(out, "%s", label.Render("Administrators:"))
+
+ for i, a := range v.Administrators {
+ if i > 0 {
+ _, _ = fmt.Fprint(out, ", ")
+ }
+
+ if a.FullName != "" {
+ _, _ = fmt.Fprintf(out, "%s (%s)", a.FullName, a.ID)
+ } else {
+ _, _ = fmt.Fprint(out, a.ID)
+ }
+ }
+
+ _, _ = fmt.Fprintln(out)
+ }
+
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(v.CreatedAt))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(v.UpdatedAt))
diff --git a/pkg/coredata/migrations/20260727T100635Z.sql b/pkg/coredata/migrations/20260727T100635Z.sql
new file mode 100644
index 000000000..2da9d144c
--- /dev/null
+++ b/pkg/coredata/migrations/20260727T100635Z.sql
@@ -0,0 +1,70 @@
+-- Copyright (c) 2025-2026 Probo Inc .
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+-- SOFTWARE.
+
+CREATE TABLE third_party_administrators (
+ third_party_id text NOT NULL,
+ administrator_profile_id text NOT NULL,
+ tenant_id text NOT NULL,
+ organization_id text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ PRIMARY KEY (third_party_id, administrator_profile_id),
+ FOREIGN KEY (third_party_id) REFERENCES third_parties(id) ON UPDATE CASCADE ON DELETE CASCADE,
+ FOREIGN KEY (administrator_profile_id) REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT
+);
+
+-- Migrate existing business and security owners into administrators (dedupe).
+INSERT INTO third_party_administrators (
+ third_party_id,
+ administrator_profile_id,
+ tenant_id,
+ organization_id,
+ created_at,
+ updated_at
+)
+SELECT
+ id,
+ business_owner_profile_id,
+ tenant_id,
+ organization_id,
+ created_at,
+ updated_at
+FROM third_parties
+WHERE business_owner_profile_id IS NOT NULL
+ON CONFLICT (third_party_id, administrator_profile_id) DO NOTHING;
+
+INSERT INTO third_party_administrators (
+ third_party_id,
+ administrator_profile_id,
+ tenant_id,
+ organization_id,
+ created_at,
+ updated_at
+)
+SELECT
+ id,
+ security_owner_profile_id,
+ tenant_id,
+ organization_id,
+ created_at,
+ updated_at
+FROM third_parties
+WHERE security_owner_profile_id IS NOT NULL
+ON CONFLICT (third_party_id, administrator_profile_id) DO NOTHING;
diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go
index 41cb143de..3fb187eba 100644
--- a/pkg/coredata/third_party.go
+++ b/pkg/coredata/third_party.go
@@ -166,8 +166,6 @@ type (
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"`
@@ -263,8 +261,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -336,8 +332,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -411,8 +405,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -489,8 +481,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -566,8 +556,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -638,8 +626,6 @@ INSERT INTO
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -673,8 +659,6 @@ VALUES (
@subprocessors_list_url,
@certifications,
@countries,
- @business_owner_profile_id,
- @security_owner_profile_id,
@status_page_url,
@terms_of_service_url,
@security_page_url,
@@ -710,8 +694,6 @@ VALUES (
"subprocessors_list_url": v.SubprocessorsListURL,
"certifications": v.Certifications,
"countries": v.Countries,
- "business_owner_profile_id": v.BusinessOwnerID,
- "security_owner_profile_id": v.SecurityOwnerID,
"status_page_url": v.StatusPageURL,
"terms_of_service_url": v.TermsOfServiceURL,
"security_page_url": v.SecurityPageURL,
@@ -885,8 +867,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -957,8 +937,6 @@ SET
terms_of_service_url = @terms_of_service_url,
security_page_url = @security_page_url,
trust_page_url = @trust_page_url,
- business_owner_profile_id = @business_owner_profile_id,
- security_owner_profile_id = @security_owner_profile_id,
show_on_trust_center = @show_on_trust_center,
level = @level,
vetting_status = @vetting_status,
@@ -994,8 +972,6 @@ WHERE %s
"terms_of_service_url": v.TermsOfServiceURL,
"security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL,
- "business_owner_profile_id": v.BusinessOwnerID,
- "security_owner_profile_id": v.SecurityOwnerID,
"show_on_trust_center": v.ShowOnCompliancePortal,
"level": v.Level,
"vetting_status": v.VettingStatus,
@@ -1121,8 +1097,6 @@ WITH vend AS (
v.subprocessors_list_url,
v.certifications,
v.countries,
- v.business_owner_profile_id,
- v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
@@ -1161,8 +1135,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -1270,8 +1242,6 @@ WITH vend AS (
v.subprocessors_list_url,
v.certifications,
v.countries,
- v.business_owner_profile_id,
- v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
@@ -1310,8 +1280,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -1379,8 +1347,6 @@ WITH vend AS (
v.subprocessors_list_url,
v.certifications,
v.countries,
- v.business_owner_profile_id,
- v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
@@ -1419,8 +1385,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -1555,8 +1519,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -1676,8 +1638,6 @@ WITH tps AS (
v.subprocessors_list_url,
v.certifications,
v.countries,
- v.business_owner_profile_id,
- v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
@@ -1716,8 +1676,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -1814,8 +1772,6 @@ WITH RECURSIVE ancestor_chain AS (
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,
@@ -1859,8 +1815,6 @@ WITH RECURSIVE ancestor_chain AS (
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,
@@ -1898,8 +1852,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
@@ -1965,8 +1917,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
diff --git a/pkg/coredata/third_party_administrator.go b/pkg/coredata/third_party_administrator.go
new file mode 100644
index 000000000..be9be98c6
--- /dev/null
+++ b/pkg/coredata/third_party_administrator.go
@@ -0,0 +1,200 @@
+// Copyright (c) 2025-2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package coredata
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "go.gearno.de/kit/pg"
+ "go.probo.inc/probo/pkg/gid"
+)
+
+type (
+ ThirdPartyAdministrator struct {
+ ThirdPartyID gid.GID `db:"third_party_id"`
+ AdministratorProfileID gid.GID `db:"administrator_profile_id"`
+ OrganizationID gid.GID `db:"organization_id"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+ }
+
+ ThirdPartyAdministrators []*ThirdPartyAdministrator
+)
+
+// LoadByThirdPartyID loads all administrators for a third party.
+func (as *ThirdPartyAdministrators) LoadByThirdPartyID(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ thirdPartyID gid.GID,
+) error {
+ q := `
+SELECT
+ third_party_id,
+ administrator_profile_id,
+ organization_id,
+ created_at,
+ updated_at
+FROM third_party_administrators
+WHERE
+ %s
+ AND third_party_id = @third_party_id
+ORDER BY created_at ASC, administrator_profile_id ASC;
+`
+
+ 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 third party administrators: %w", err)
+ }
+
+ result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdPartyAdministrator])
+ if err != nil {
+ return fmt.Errorf("cannot collect third party administrators: %w", err)
+ }
+
+ *as = result
+
+ return nil
+}
+
+// LoadByThirdPartyIDs loads all administrators for the given third parties.
+func (as *ThirdPartyAdministrators) LoadByThirdPartyIDs(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ thirdPartyIDs []gid.GID,
+) error {
+ if len(thirdPartyIDs) == 0 {
+ *as = ThirdPartyAdministrators{}
+ return nil
+ }
+
+ q := `
+SELECT
+ third_party_id,
+ administrator_profile_id,
+ organization_id,
+ created_at,
+ updated_at
+FROM third_party_administrators
+WHERE
+ %s
+ AND third_party_id = ANY(@third_party_ids)
+ORDER BY created_at ASC, administrator_profile_id ASC;
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ args := pgx.StrictNamedArgs{"third_party_ids": thirdPartyIDs}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query third party administrators: %w", err)
+ }
+
+ result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdPartyAdministrator])
+ if err != nil {
+ return fmt.Errorf("cannot collect third party administrators: %w", err)
+ }
+
+ *as = result
+
+ return nil
+}
+
+// MergeByThirdPartyID merges the given administrator profile IDs for a third party,
+// inserting new ones, keeping existing ones, and deleting removed ones.
+func (as *ThirdPartyAdministrators) MergeByThirdPartyID(
+ ctx context.Context,
+ conn pg.Tx,
+ scope Scoper,
+ thirdPartyID gid.GID,
+ organizationID gid.GID,
+ administratorProfileIDs []gid.GID,
+) error {
+ q := `
+MERGE INTO third_party_administrators AS target
+USING (
+ SELECT unnest(@administrator_profile_ids::text[]) AS administrator_profile_id
+) AS source
+ON
+ %[1]s
+ AND target.third_party_id = @third_party_id
+ AND target.administrator_profile_id = source.administrator_profile_id
+WHEN NOT MATCHED THEN
+ INSERT (third_party_id, administrator_profile_id, tenant_id, organization_id, created_at, updated_at)
+ VALUES (@third_party_id, source.administrator_profile_id, @tenant_id, @organization_id, @now, @now)
+WHEN NOT MATCHED BY SOURCE
+ AND %[1]s
+ AND target.third_party_id = @third_party_id THEN
+ DELETE;
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ now := time.Now()
+
+ ids := make([]string, len(administratorProfileIDs))
+ for i, id := range administratorProfileIDs {
+ ids[i] = id.String()
+ }
+
+ args := pgx.StrictNamedArgs{
+ "third_party_id": thirdPartyID,
+ "administrator_profile_ids": ids,
+ "tenant_id": scope.GetTenantID(),
+ "organization_id": organizationID,
+ "now": now,
+ }
+ maps.Copy(args, scope.SQLArguments())
+
+ if _, err := conn.Exec(ctx, q, args); err != nil {
+ return fmt.Errorf("cannot merge third party administrators: %w", err)
+ }
+
+ result := make(ThirdPartyAdministrators, 0, len(administratorProfileIDs))
+ for _, profileID := range administratorProfileIDs {
+ result = append(
+ result,
+ &ThirdPartyAdministrator{
+ ThirdPartyID: thirdPartyID,
+ AdministratorProfileID: profileID,
+ OrganizationID: organizationID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ )
+ }
+
+ *as = result
+
+ return nil
+}
diff --git a/pkg/coredata/third_party_vetting.go b/pkg/coredata/third_party_vetting.go
index 4a2d4658d..2092a06cc 100644
--- a/pkg/coredata/third_party_vetting.go
+++ b/pkg/coredata/third_party_vetting.go
@@ -53,8 +53,6 @@ SELECT
subprocessors_list_url,
certifications,
countries,
- business_owner_profile_id,
- security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go
index a3c32b56f..0ba27e829 100644
--- a/pkg/docgen/generator.go
+++ b/pkg/docgen/generator.go
@@ -435,8 +435,7 @@ type (
TrustPageURL string
Certifications string
Countries string
- BusinessOwner string
- SecurityOwner string
+ Administrators string
Services []ThirdPartyListService
Contacts []ThirdPartyListContact
RiskAssessments []ThirdPartyListRiskAssessment
diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go
index ca0cccb19..220618251 100644
--- a/pkg/probo/generated_document_service.go
+++ b/pkg/probo/generated_document_service.go
@@ -2534,31 +2534,34 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
}, nil
}
- ownerIDSet := make(map[gid.GID]struct{})
- ownerIDs := make([]gid.GID, 0)
+ thirdPartyIDs := make([]gid.GID, len(thirdParties))
+ for i, v := range thirdParties {
+ thirdPartyIDs[i] = v.ID
+ }
- for _, v := range thirdParties {
- if v.BusinessOwnerID != nil {
- if _, ok := ownerIDSet[*v.BusinessOwnerID]; !ok {
- ownerIDs = append(ownerIDs, *v.BusinessOwnerID)
- ownerIDSet[*v.BusinessOwnerID] = struct{}{}
- }
- }
+ var allAdministrators coredata.ThirdPartyAdministrators
+ if err := allAdministrators.LoadByThirdPartyIDs(ctx, conn, scope, thirdPartyIDs); err != nil {
+ return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
- if v.SecurityOwnerID != nil {
- if _, ok := ownerIDSet[*v.SecurityOwnerID]; !ok {
- ownerIDs = append(ownerIDs, *v.SecurityOwnerID)
- ownerIDSet[*v.SecurityOwnerID] = struct{}{}
- }
+ administratorsByThirdParty := make(map[gid.GID][]gid.GID, len(thirdParties))
+ adminIDSet := make(map[gid.GID]struct{})
+ adminIDs := make([]gid.GID, 0)
+
+ for _, a := range allAdministrators {
+ administratorsByThirdParty[a.ThirdPartyID] = append(administratorsByThirdParty[a.ThirdPartyID], a.AdministratorProfileID)
+ if _, ok := adminIDSet[a.AdministratorProfileID]; !ok {
+ adminIDs = append(adminIDs, a.AdministratorProfileID)
+ adminIDSet[a.AdministratorProfileID] = struct{}{}
}
}
profileMap := make(map[gid.GID]*coredata.MembershipProfile)
- if len(ownerIDs) > 0 {
+ if len(adminIDs) > 0 {
var profiles coredata.MembershipProfiles
- if err := profiles.LoadByIDs(ctx, conn, scope, ownerIDs); err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
- return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load owner profiles: %w", err)
+ if err := profiles.LoadByIDs(ctx, conn, scope, adminIDs); err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
+ return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load administrator profiles: %w", err)
}
for _, p := range profiles {
@@ -2566,11 +2569,6 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
}
}
- thirdPartyIDs := make([]gid.GID, len(thirdParties))
- for i, v := range thirdParties {
- thirdPartyIDs[i] = v.ID
- }
-
var allServices coredata.ThirdPartyServices
if err := allServices.LoadByThirdPartyIDs(ctx, conn, scope, thirdPartyIDs); err != nil {
return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load thirdParty services: %w", err)
@@ -2651,8 +2649,7 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
TrustPageURL: derefStringOrNotSpecified(v.TrustPageURL),
Certifications: joinOrNotSpecified(v.Certifications),
Countries: formatCountries(v.Countries),
- BusinessOwner: lookupProfileName(profileMap, v.BusinessOwnerID),
- SecurityOwner: lookupProfileName(profileMap, v.SecurityOwnerID),
+ Administrators: lookupProfileNames(profileMap, administratorsByThirdParty[v.ID]),
}
for _, vs := range servicesByThirdParty[v.ID] {
@@ -2769,6 +2766,25 @@ func lookupProfileName(profiles map[gid.GID]*coredata.MembershipProfile, id *gid
return "Not assigned"
}
+func lookupProfileNames(profiles map[gid.GID]*coredata.MembershipProfile, ids []gid.GID) string {
+ if len(ids) == 0 {
+ return "Not assigned"
+ }
+
+ names := make([]string, 0, len(ids))
+ for _, id := range ids {
+ if p, ok := profiles[id]; ok && p.FullName != "" {
+ names = append(names, p.FullName)
+ }
+ }
+
+ if len(names) == 0 {
+ return "Not assigned"
+ }
+
+ return strings.Join(names, ", ")
+}
+
func formatDataSensitivity(s coredata.DataSensitivity) string {
switch s {
case coredata.DataSensitivityNone:
diff --git a/pkg/probo/templates/third_party_list.json.tmpl b/pkg/probo/templates/third_party_list.json.tmpl
index 7cba5bf55..f2de06a98 100644
--- a/pkg/probo/templates/third_party_list.json.tmpl
+++ b/pkg/probo/templates/third_party_list.json.tmpl
@@ -146,20 +146,13 @@
{
"type": "heading",
"attrs": { "level": 3 },
- "content": [{ "type": "text", "text": {{json (printf "2.%d.3 Owners" (add $i 1))}} }]
+ "content": [{ "type": "text", "text": {{json (printf "2.%d.3 Administrators" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
- { "type": "text", "text": "Business Owner: ", "marks": [{ "type": "bold" }] },
- { "type": "text", "text": {{json (default "—" $r.BusinessOwner)}} }
- ]
- },
- {
- "type": "paragraph",
- "content": [
- { "type": "text", "text": "Security Owner: ", "marks": [{ "type": "bold" }] },
- { "type": "text", "text": {{json (default "—" $r.SecurityOwner)}} }
+ { "type": "text", "text": "Administrators: ", "marks": [{ "type": "bold" }] },
+ { "type": "text", "text": {{json (default "—" $r.Administrators)}} }
]
},
{
diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go
index dd131ebe8..dc1cff645 100644
--- a/pkg/probo/third_party_service.go
+++ b/pkg/probo/third_party_service.go
@@ -59,8 +59,7 @@ type (
TrustPageURL *string
TermsOfServiceURL *string
StatusPageURL *string
- BusinessOwnerID *gid.GID
- SecurityOwnerID *gid.GID
+ AdministratorIDs []gid.GID
ParentThirdPartyID *gid.GID
}
@@ -83,8 +82,7 @@ type (
SecurityPageURL **string
TrustPageURL **string
StatusPageURL **string
- BusinessOwnerID **gid.GID
- SecurityOwnerID **gid.GID
+ AdministratorIDs *[]gid.GID
ShowOnCompliancePortal *bool
}
@@ -130,8 +128,11 @@ func (cvr *CreateThirdPartyRequest) Validate() error {
v.Check(cvr.TrustPageURL, "trust_page_url", validator.SafeText(2048))
v.Check(cvr.TermsOfServiceURL, "terms_of_service_url", validator.SafeText(2048))
v.Check(cvr.StatusPageURL, "status_page_url", validator.SafeText(2048))
- v.Check(cvr.BusinessOwnerID, "business_owner_id", validator.GID(coredata.MembershipProfileEntityType))
- v.Check(cvr.SecurityOwnerID, "security_owner_id", validator.GID(coredata.MembershipProfileEntityType))
+ v.Check(len(cvr.AdministratorIDs), "administrator_ids", validator.Max(100))
+ v.Check(cvr.AdministratorIDs, "administrator_ids", validator.NoDuplicates())
+ v.CheckEach(cvr.AdministratorIDs, "administrator_ids", func(_ int, item any) {
+ v.Check(item, "administrator_ids", validator.GID(coredata.MembershipProfileEntityType))
+ })
return v.Error()
}
@@ -155,8 +156,14 @@ func (uvr *UpdateThirdPartyRequest) Validate() error {
v.Check(uvr.TrustPageURL, "trust_page_url", validator.SafeText(2048))
v.Check(uvr.TermsOfServiceURL, "terms_of_service_url", validator.SafeText(2048))
v.Check(uvr.StatusPageURL, "status_page_url", validator.SafeText(2048))
- v.Check(uvr.BusinessOwnerID, "business_owner_id", validator.GID(coredata.MembershipProfileEntityType))
- v.Check(uvr.SecurityOwnerID, "security_owner_id", validator.GID(coredata.MembershipProfileEntityType))
+
+ if uvr.AdministratorIDs != nil {
+ v.Check(len(*uvr.AdministratorIDs), "administrator_ids", validator.Max(100))
+ v.Check(*uvr.AdministratorIDs, "administrator_ids", validator.NoDuplicates())
+ v.CheckEach(*uvr.AdministratorIDs, "administrator_ids", func(_ int, item any) {
+ v.Check(item, "administrator_ids", validator.GID(coredata.MembershipProfileEntityType))
+ })
+ }
return v.Error()
}
@@ -368,7 +375,12 @@ func (s ThirdPartyService) Update(
return fmt.Errorf("cannot load thirdParty %q: %w", req.ID, err)
}
- previousThirdParty := webhooktypes.NewThirdParty(thirdParty)
+ previousAdministratorIDs, err := loadThirdPartyAdministratorIDs(ctx, conn, scope, thirdParty.ID)
+ if err != nil {
+ return err
+ }
+
+ previousThirdParty := webhooktypes.NewThirdParty(thirdParty, previousAdministratorIDs)
if req.Name != nil {
thirdParty.Name = *req.Name
@@ -446,29 +458,24 @@ func (s ThirdPartyService) Update(
thirdParty.Countries = req.Countries
}
- if req.BusinessOwnerID != nil {
- if *req.BusinessOwnerID != nil {
- businessOwner := &coredata.MembershipProfile{}
- if err := businessOwner.LoadByID(ctx, conn, scope, **req.BusinessOwnerID); err != nil {
- return fmt.Errorf("cannot load business owner profile: %w", err)
+ if req.AdministratorIDs != nil {
+ if len(*req.AdministratorIDs) > 0 {
+ profiles := coredata.MembershipProfiles{}
+ if err := profiles.LoadByIDs(ctx, conn, scope, *req.AdministratorIDs); err != nil {
+ return fmt.Errorf("cannot load administrator profiles: %w", err)
}
-
- thirdParty.BusinessOwnerID = &businessOwner.ID
- } else {
- thirdParty.BusinessOwnerID = nil
}
- }
- if req.SecurityOwnerID != nil {
- if *req.SecurityOwnerID != nil {
- securityOwner := &coredata.MembershipProfile{}
- if err := securityOwner.LoadByID(ctx, conn, scope, **req.SecurityOwnerID); err != nil {
- return fmt.Errorf("cannot load security owner profile: %w", err)
- }
-
- thirdParty.SecurityOwnerID = &securityOwner.ID
- } else {
- thirdParty.SecurityOwnerID = nil
+ administrators := &coredata.ThirdPartyAdministrators{}
+ if err := administrators.MergeByThirdPartyID(
+ ctx,
+ conn,
+ scope,
+ thirdParty.ID,
+ thirdParty.OrganizationID,
+ *req.AdministratorIDs,
+ ); err != nil {
+ return fmt.Errorf("cannot merge third party administrators: %w", err)
}
}
@@ -478,13 +485,18 @@ func (s ThirdPartyService) Update(
return fmt.Errorf("cannot update thirdParty: %w", err)
}
+ administratorIDs, err := loadThirdPartyAdministratorIDs(ctx, conn, scope, thirdParty.ID)
+ if err != nil {
+ return err
+ }
+
if err := webhook.InsertUpdateData(
ctx,
conn,
scope,
thirdParty.OrganizationID,
coredata.WebhookEventTypeThirdPartyUpdated,
- webhooktypes.NewThirdParty(thirdParty),
+ webhooktypes.NewThirdParty(thirdParty, administratorIDs),
previousThirdParty,
); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
@@ -560,13 +572,18 @@ func (s ThirdPartyService) Delete(
return fmt.Errorf("cannot load thirdParty: %w", err)
}
+ administratorIDs, err := loadThirdPartyAdministratorIDs(ctx, conn, scope, thirdParty.ID)
+ if err != nil {
+ return err
+ }
+
if err := webhook.InsertData(
ctx,
conn,
scope,
thirdParty.OrganizationID,
coredata.WebhookEventTypeThirdPartyDeleted,
- webhooktypes.NewThirdParty(thirdParty),
+ webhooktypes.NewThirdParty(thirdParty, administratorIDs),
); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}
@@ -642,24 +659,6 @@ func (s ThirdPartyService) Create(
return err
}
- if req.BusinessOwnerID != nil {
- businessOwner := &coredata.MembershipProfile{}
- if err := businessOwner.LoadByID(ctx, conn, scope, *req.BusinessOwnerID); err != nil {
- return fmt.Errorf("cannot load business owner profile: %w", err)
- }
-
- thirdParty.BusinessOwnerID = &businessOwner.ID
- }
-
- if req.SecurityOwnerID != nil {
- securityOwner := &coredata.MembershipProfile{}
- if err := securityOwner.LoadByID(ctx, conn, scope, *req.SecurityOwnerID); err != nil {
- return fmt.Errorf("cannot load security owner profile: %w", err)
- }
-
- thirdParty.SecurityOwnerID = &securityOwner.ID
- }
-
if req.Category != nil {
thirdParty.Category = *req.Category
} else {
@@ -670,13 +669,32 @@ func (s ThirdPartyService) Create(
return fmt.Errorf("cannot insert thirdParty: %w", err)
}
+ if len(req.AdministratorIDs) > 0 {
+ profiles := coredata.MembershipProfiles{}
+ if err := profiles.LoadByIDs(ctx, conn, scope, req.AdministratorIDs); err != nil {
+ return fmt.Errorf("cannot load administrator profiles: %w", err)
+ }
+
+ administrators := &coredata.ThirdPartyAdministrators{}
+ if err := administrators.MergeByThirdPartyID(
+ ctx,
+ conn,
+ scope,
+ thirdParty.ID,
+ organization.ID,
+ req.AdministratorIDs,
+ ); err != nil {
+ return fmt.Errorf("cannot merge third party administrators: %w", err)
+ }
+ }
+
if err := webhook.InsertData(
ctx,
conn,
scope,
organization.ID,
coredata.WebhookEventTypeThirdPartyCreated,
- webhooktypes.NewThirdParty(thirdParty),
+ webhooktypes.NewThirdParty(thirdParty, req.AdministratorIDs),
); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}
@@ -786,7 +804,7 @@ func (s ThirdPartyService) ImportFromCommon(
scope,
organization.ID,
coredata.WebhookEventTypeThirdPartyCreated,
- webhooktypes.NewThirdParty(thirdParty),
+ webhooktypes.NewThirdParty(thirdParty, nil),
); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}
@@ -1076,3 +1094,50 @@ func (s ThirdPartyService) ListForParentThirdPartyID(
return page.NewPage(thirdParties, cursor), nil
}
+
+func (s ThirdPartyService) MapAdministratorIDsForThirdPartyIDs(
+ ctx context.Context, scope coredata.Scoper,
+ thirdPartyIDs []gid.GID,
+) (map[gid.GID][]gid.GID, error) {
+ result := make(map[gid.GID][]gid.GID, len(thirdPartyIDs))
+ if len(thirdPartyIDs) == 0 {
+ return result, nil
+ }
+
+ var administrators coredata.ThirdPartyAdministrators
+
+ err := s.svc.pg.WithConn(
+ ctx,
+ func(ctx context.Context, conn pg.Querier) error {
+ return administrators.LoadByThirdPartyIDs(ctx, conn, scope, thirdPartyIDs)
+ },
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
+
+ for _, a := range administrators {
+ result[a.ThirdPartyID] = append(result[a.ThirdPartyID], a.AdministratorProfileID)
+ }
+
+ return result, nil
+}
+
+func loadThirdPartyAdministratorIDs(
+ ctx context.Context,
+ conn pg.Querier,
+ scope coredata.Scoper,
+ thirdPartyID gid.GID,
+) ([]gid.GID, error) {
+ administrators := &coredata.ThirdPartyAdministrators{}
+ if err := administrators.LoadByThirdPartyID(ctx, conn, scope, thirdPartyID); err != nil {
+ return nil, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
+
+ ids := make([]gid.GID, len(*administrators))
+ for i, a := range *administrators {
+ ids[i] = a.AdministratorProfileID
+ }
+
+ return ids, nil
+}
diff --git a/pkg/server/api/console/v1/dataloader/dataloader.go b/pkg/server/api/console/v1/dataloader/dataloader.go
index f8f121319..51c710868 100644
--- a/pkg/server/api/console/v1/dataloader/dataloader.go
+++ b/pkg/server/api/console/v1/dataloader/dataloader.go
@@ -58,21 +58,22 @@ type (
}
Loaders struct {
- Organization *dataloadgen.Loader[gid.GID, *coredata.Organization]
- Framework *dataloadgen.Loader[gid.GID, *coredata.Framework]
- Control *dataloadgen.Loader[gid.GID, *coredata.Control]
- ThirdParty *dataloadgen.Loader[gid.GID, *coredata.ThirdParty]
- Document *dataloadgen.Loader[gid.GID, *coredata.Document]
- Profile *dataloadgen.Loader[gid.GID, *coredata.MembershipProfile]
- Risk *dataloadgen.Loader[gid.GID, *coredata.Risk]
- Measure *dataloadgen.Loader[gid.GID, *coredata.Measure]
- Task *dataloadgen.Loader[gid.GID, *coredata.Task]
- File *dataloadgen.Loader[gid.GID, *coredata.File]
- CookieBanner *dataloadgen.Loader[gid.GID, *coredata.CookieBanner]
- CookieCategory *dataloadgen.Loader[gid.GID, *coredata.CookieCategory]
- CommonTrackerPattern *dataloadgen.Loader[gid.GID, *coredata.CommonTrackerPattern]
- CommonThirdParty *dataloadgen.Loader[gid.GID, *coredata.CommonThirdParty]
- Authorize *dataloadgen.Loader[AuthorizeKey, AuthorizeResult]
+ Organization *dataloadgen.Loader[gid.GID, *coredata.Organization]
+ Framework *dataloadgen.Loader[gid.GID, *coredata.Framework]
+ Control *dataloadgen.Loader[gid.GID, *coredata.Control]
+ ThirdParty *dataloadgen.Loader[gid.GID, *coredata.ThirdParty]
+ Document *dataloadgen.Loader[gid.GID, *coredata.Document]
+ Profile *dataloadgen.Loader[gid.GID, *coredata.MembershipProfile]
+ Risk *dataloadgen.Loader[gid.GID, *coredata.Risk]
+ Measure *dataloadgen.Loader[gid.GID, *coredata.Measure]
+ Task *dataloadgen.Loader[gid.GID, *coredata.Task]
+ File *dataloadgen.Loader[gid.GID, *coredata.File]
+ CookieBanner *dataloadgen.Loader[gid.GID, *coredata.CookieBanner]
+ CookieCategory *dataloadgen.Loader[gid.GID, *coredata.CookieCategory]
+ CommonTrackerPattern *dataloadgen.Loader[gid.GID, *coredata.CommonTrackerPattern]
+ CommonThirdParty *dataloadgen.Loader[gid.GID, *coredata.CommonThirdParty]
+ ThirdPartyAdministratorIDs *dataloadgen.Loader[gid.GID, []gid.GID]
+ Authorize *dataloadgen.Loader[AuthorizeKey, AuthorizeResult]
}
batchFetcher struct {
@@ -109,20 +110,21 @@ func NewMiddleware(proboSvc *probo.Service, iamSvc *iam.Service, cookieBannerSvc
func (f *batchFetcher) newLoaders() *Loaders {
return &Loaders{
- Organization: dataloadgen.NewMappedLoader(f.fetchOrganizations),
- Framework: dataloadgen.NewMappedLoader(f.fetchFrameworks),
- Control: dataloadgen.NewMappedLoader(f.fetchControls),
- ThirdParty: dataloadgen.NewMappedLoader(f.fetchThirdParties),
- Document: dataloadgen.NewMappedLoader(f.fetchDocuments),
- Profile: dataloadgen.NewMappedLoader(f.fetchProfiles),
- Risk: dataloadgen.NewMappedLoader(f.fetchRisks),
- Measure: dataloadgen.NewMappedLoader(f.fetchMeasures),
- Task: dataloadgen.NewMappedLoader(f.fetchTasks),
- File: dataloadgen.NewMappedLoader(f.fetchFiles),
- CookieBanner: dataloadgen.NewMappedLoader(f.fetchCookieBanners),
- CookieCategory: dataloadgen.NewMappedLoader(f.fetchCookieCategories),
- CommonTrackerPattern: dataloadgen.NewMappedLoader(f.fetchCommonTrackerPatterns),
- CommonThirdParty: dataloadgen.NewMappedLoader(f.fetchCommonThirdParties),
+ Organization: dataloadgen.NewMappedLoader(f.fetchOrganizations),
+ Framework: dataloadgen.NewMappedLoader(f.fetchFrameworks),
+ Control: dataloadgen.NewMappedLoader(f.fetchControls),
+ ThirdParty: dataloadgen.NewMappedLoader(f.fetchThirdParties),
+ Document: dataloadgen.NewMappedLoader(f.fetchDocuments),
+ Profile: dataloadgen.NewMappedLoader(f.fetchProfiles),
+ Risk: dataloadgen.NewMappedLoader(f.fetchRisks),
+ Measure: dataloadgen.NewMappedLoader(f.fetchMeasures),
+ Task: dataloadgen.NewMappedLoader(f.fetchTasks),
+ File: dataloadgen.NewMappedLoader(f.fetchFiles),
+ CookieBanner: dataloadgen.NewMappedLoader(f.fetchCookieBanners),
+ CookieCategory: dataloadgen.NewMappedLoader(f.fetchCookieCategories),
+ CommonTrackerPattern: dataloadgen.NewMappedLoader(f.fetchCommonTrackerPatterns),
+ CommonThirdParty: dataloadgen.NewMappedLoader(f.fetchCommonThirdParties),
+ ThirdPartyAdministratorIDs: dataloadgen.NewMappedLoader(f.fetchThirdPartyAdministratorIDs),
Authorize: dataloadgen.NewMappedLoader(
f.fetchAuthorizes,
dataloadgen.WithoutCache(),
@@ -350,6 +352,26 @@ func (f *batchFetcher) fetchCommonThirdParties(ctx context.Context, keys []gid.G
return result, nil
}
+func (f *batchFetcher) fetchThirdPartyAdministratorIDs(ctx context.Context, keys []gid.GID) (map[gid.GID][]gid.GID, error) {
+ scope := coredata.NewScopeFromObjectID(keys[0])
+
+ administratorIDsByThirdPartyID, err := f.probo.ThirdParties.MapAdministratorIDsForThirdPartyIDs(ctx, scope, keys)
+ if err != nil {
+ return nil, fmt.Errorf("cannot batch load third party administrator ids: %w", err)
+ }
+
+ result := make(map[gid.GID][]gid.GID, len(keys))
+ for _, id := range keys {
+ if ids, ok := administratorIDsByThirdPartyID[id]; ok {
+ result[id] = ids
+ } else {
+ result[id] = []gid.GID{}
+ }
+ }
+
+ return result, nil
+}
+
// fetchAuthorizes evaluates the batch with a single AuthorizeMulti call and
// surfaces per-key denials via dataloadgen.MappedFetchError. When
// AuthorizeMulti cannot evaluate the batch as a whole (e.g. mixed
diff --git a/pkg/server/api/console/v1/graphql/third_party.graphql b/pkg/server/api/console/v1/graphql/third_party.graphql
index 6664ba535..a1bfd6f2a 100644
--- a/pkg/server/api/console/v1/graphql/third_party.graphql
+++ b/pkg/server/api/console/v1/graphql/third_party.graphql
@@ -285,8 +285,7 @@ type ThirdParty implements Node {
filter: MeasureFilter
): MeasureConnection! @goField(forceResolver: true)
- businessOwner: Profile @goField(forceResolver: true)
- securityOwner: Profile @goField(forceResolver: true)
+ administrators: [Profile!]! @goField(forceResolver: true)
statusPageUrl: String
termsOfServiceUrl: String
@@ -540,8 +539,7 @@ input CreateThirdPartyInput {
trustPageUrl: String
statusPageUrl: String
termsOfServiceUrl: String
- businessOwnerId: ID
- securityOwnerId: ID
+ administratorIds: [ID!]
parentThirdPartyId: ID
}
@@ -564,8 +562,7 @@ input UpdateThirdPartyInput {
countries: [CountryCode!]
securityPageUrl: String @goField(omittable: true)
trustPageUrl: String @goField(omittable: true)
- businessOwnerId: ID @goField(omittable: true)
- securityOwnerId: ID @goField(omittable: true)
+ administratorIds: [ID!]
showOnCompliancePortal: Boolean
}
diff --git a/pkg/server/api/console/v1/third_party_resolvers.go b/pkg/server/api/console/v1/third_party_resolvers.go
index fe67ef62b..93e1b9a47 100644
--- a/pkg/server/api/console/v1/third_party_resolvers.go
+++ b/pkg/server/api/console/v1/third_party_resolvers.go
@@ -14,6 +14,7 @@ import (
"github.com/vikstrous/dataloadgen"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
+ "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
@@ -58,8 +59,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
Certifications: input.Certifications,
SecurityPageURL: input.SecurityPageURL,
TrustPageURL: input.TrustPageURL,
- BusinessOwnerID: input.BusinessOwnerID,
- SecurityOwnerID: input.SecurityOwnerID,
+ AdministratorIDs: input.AdministratorIds,
Countries: input.Countries,
ParentThirdPartyID: input.ParentThirdPartyID,
},
@@ -120,6 +120,11 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
return nil, err
}
+ var administratorIDs *[]gid.GID
+ if input.AdministratorIds != nil {
+ administratorIDs = &input.AdministratorIds
+ }
+
thirdParty, err := r.probo.ThirdParties.Update(
ctx, scope,
probo.UpdateThirdPartyRequest{
@@ -140,8 +145,7 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
WebsiteURL: gqlutils.UnwrapOmittable(input.WebsiteURL),
Category: input.Category,
Certifications: input.Certifications,
- BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID),
- SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID),
+ AdministratorIDs: administratorIDs,
ShowOnCompliancePortal: input.ShowOnCompliancePortal,
Countries: input.Countries,
},
@@ -855,56 +859,45 @@ func (r *thirdPartyResolver) Measures(ctx context.Context, obj *types.ThirdParty
return types.NewMeasureConnection(page, r, obj.ID, measureFilter), nil
}
-// BusinessOwner is the resolver for the businessOwner field.
-func (r *thirdPartyResolver) BusinessOwner(ctx context.Context, obj *types.ThirdParty) (*types.Profile, error) {
- if obj.BusinessOwner == nil {
- return nil, nil
- }
-
- if _, err := r.authorize(ctx, obj.BusinessOwner.ID, iam.ActionMembershipProfileGet); err != nil {
+// Administrators is the resolver for the administrators field.
+func (r *thirdPartyResolver) Administrators(ctx context.Context, obj *types.ThirdParty) ([]*types.Profile, error) {
+ if _, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet); err != nil {
return nil, err
}
loaders := dataloader.FromContext(ctx)
- businessOwner, err := loaders.Profile.Load(ctx, obj.BusinessOwner.ID)
+ administratorIDs, err := loaders.ThirdPartyAdministratorIDs.Load(ctx, obj.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 get business owner", log.Error(err))
-
+ r.logger.ErrorCtx(ctx, "cannot get third party administrator ids", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
- return types.NewProfile(businessOwner), nil
-}
-
-// SecurityOwner is the resolver for the securityOwner field.
-func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.ThirdParty) (*types.Profile, error) {
- if obj.SecurityOwner == nil {
- return nil, nil
+ if len(administratorIDs) == 0 {
+ return []*types.Profile{}, nil
}
- if _, err := r.authorize(ctx, obj.SecurityOwner.ID, iam.ActionMembershipProfileGet); err != nil {
+ if _, err := r.batchAuthorize(ctx, iam.ActionMembershipProfileGet, administratorIDs); err != nil {
return nil, err
}
- loaders := dataloader.FromContext(ctx)
-
- securityOwner, err := loaders.Profile.Load(ctx, obj.SecurityOwner.ID)
+ profiles, err := loaders.Profile.LoadAll(ctx, administratorIDs)
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 get security owner", log.Error(err))
+ r.logger.ErrorCtx(ctx, "cannot get third party administrators", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
- return types.NewProfile(securityOwner), nil
+ result := make([]*types.Profile, len(profiles))
+ for i, p := range profiles {
+ result[i] = types.NewProfile(p)
+ }
+
+ return result, nil
}
// ParentThirdParty is the resolver for the parentThirdParty field.
diff --git a/pkg/server/api/console/v1/types/third_party.go b/pkg/server/api/console/v1/types/third_party.go
index c3d432388..7642ef02b 100644
--- a/pkg/server/api/console/v1/types/third_party.go
+++ b/pkg/server/api/console/v1/types/third_party.go
@@ -98,18 +98,6 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
CreatedAt: v.CreatedAt,
}
- if v.BusinessOwnerID != nil {
- object.BusinessOwner = &Profile{
- ID: *v.BusinessOwnerID,
- }
- }
-
- if v.SecurityOwnerID != nil {
- object.SecurityOwner = &Profile{
- ID: *v.SecurityOwnerID,
- }
- }
-
if v.ParentThirdPartyID != nil {
object.ParentThirdParty = &ThirdParty{
ID: *v.ParentThirdPartyID,
diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go
index cd7d34ba2..387123b03 100644
--- a/pkg/server/api/mcp/v1/schema.resolvers.go
+++ b/pkg/server/api/mcp/v1/schema.resolvers.go
@@ -90,7 +90,17 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
panic(fmt.Errorf("cannot list organization thirdParties: %w", err))
}
- return nil, types.NewListThirdPartiesOutput(page), nil
+ thirdPartyIDs := make([]gid.GID, len(page.Data))
+ for i, tp := range page.Data {
+ thirdPartyIDs[i] = tp.ID
+ }
+
+ administratorIDsByThirdPartyID, err := prb.ThirdParties.MapAdministratorIDsForThirdPartyIDs(ctx, scope, thirdPartyIDs)
+ if err != nil {
+ return nil, types.ListThirdPartiesOutput{}, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
+
+ return nil, types.NewListThirdPartiesOutput(page, administratorIDsByThirdPartyID), nil
}
// AddThirdPartyTool handles the addThirdParty tool
@@ -135,8 +145,7 @@ func (r *Resolver) AddThirdPartyTool(ctx context.Context, req *mcp.CallToolReque
SubprocessorsListURL: input.SubprocessorsListURL,
Certifications: input.Certifications,
Countries: countries,
- BusinessOwnerID: input.BusinessOwnerID,
- SecurityOwnerID: input.SecurityOwnerID,
+ AdministratorIDs: input.AdministratorIds,
StatusPageURL: input.StatusPageURL,
TermsOfServiceURL: input.TermsOfServiceURL,
SecurityPageURL: input.SecurityPageURL,
@@ -147,7 +156,7 @@ func (r *Resolver) AddThirdPartyTool(ctx context.Context, req *mcp.CallToolReque
return nil, types.AddThirdPartyOutput{}, fmt.Errorf("failed to create thirdParty: %w", err)
}
- return nil, types.NewAddThirdPartyOutput(thirdParty), nil
+ return nil, types.NewAddThirdPartyOutput(thirdParty, input.AdministratorIds), nil
}
// UpdateThirdPartyTool handles the updateThirdParty tool
@@ -225,14 +234,9 @@ func (r *Resolver) UpdateThirdPartyTool(ctx context.Context, req *mcp.CallToolRe
trustPageURL = &input.TrustPageURL
}
- var businessOwnerID **gid.GID
- if input.BusinessOwnerID != nil {
- businessOwnerID = &input.BusinessOwnerID
- }
-
- var securityOwnerID **gid.GID
- if input.SecurityOwnerID != nil {
- securityOwnerID = &input.SecurityOwnerID
+ var administratorIDs *[]gid.GID
+ if input.AdministratorIds != nil {
+ administratorIDs = &input.AdministratorIds
}
var category *coredata.ThirdPartyCategory
@@ -267,8 +271,7 @@ func (r *Resolver) UpdateThirdPartyTool(ctx context.Context, req *mcp.CallToolRe
SubprocessorsListURL: subprocessorsListURL,
Certifications: input.Certifications,
Countries: countries,
- BusinessOwnerID: businessOwnerID,
- SecurityOwnerID: securityOwnerID,
+ AdministratorIDs: administratorIDs,
StatusPageURL: statusPageURL,
TermsOfServiceURL: termsOfServiceURL,
SecurityPageURL: securityPageURL,
@@ -279,7 +282,12 @@ func (r *Resolver) UpdateThirdPartyTool(ctx context.Context, req *mcp.CallToolRe
return nil, types.UpdateThirdPartyOutput{}, fmt.Errorf("failed to update thirdParty: %w", err)
}
- return nil, types.NewUpdateThirdPartyOutput(thirdParty), nil
+ administratorIDsByThirdPartyID, err := svc.ThirdParties.MapAdministratorIDsForThirdPartyIDs(ctx, scope, []gid.GID{thirdParty.ID})
+ if err != nil {
+ return nil, types.UpdateThirdPartyOutput{}, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
+
+ return nil, types.NewUpdateThirdPartyOutput(thirdParty, administratorIDsByThirdPartyID[thirdParty.ID]), nil
}
func (r *Resolver) ListRisksTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRisksInput) (*mcp.CallToolResult, types.ListRisksOutput, error) {
@@ -5414,8 +5422,13 @@ func (r *Resolver) VetThirdPartyTool(ctx context.Context, req *mcp.CallToolReque
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("internal server error")
}
+ administratorIDsByThirdPartyID, err := r.proboSvc.ThirdParties.MapAdministratorIDsForThirdPartyIDs(ctx, scope, []gid.GID{thirdParty.ID})
+ if err != nil {
+ return nil, types.VetThirdPartyOutput{}, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
+
return nil, types.VetThirdPartyOutput{
- ThirdParty: types.NewThirdParty(thirdParty),
+ ThirdParty: types.NewThirdParty(thirdParty, administratorIDsByThirdPartyID[thirdParty.ID]),
}, nil
}
@@ -6318,7 +6331,17 @@ func (r *Resolver) ListChildThirdPartiesTool(ctx context.Context, req *mcp.CallT
panic(fmt.Errorf("cannot list child third parties: %w", err))
}
- return nil, types.NewListChildThirdPartiesOutput(page), nil
+ thirdPartyIDs := make([]gid.GID, len(page.Data))
+ for i, tp := range page.Data {
+ thirdPartyIDs[i] = tp.ID
+ }
+
+ administratorIDsByThirdPartyID, err := r.proboSvc.ThirdParties.MapAdministratorIDsForThirdPartyIDs(ctx, scope, thirdPartyIDs)
+ if err != nil {
+ return nil, types.ListChildThirdPartiesOutput{}, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
+
+ return nil, types.NewListChildThirdPartiesOutput(page, administratorIDsByThirdPartyID), nil
}
func (r *Resolver) ListRiskAssessmentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRiskAssessmentsInput) (*mcp.CallToolResult, types.ListRiskAssessmentsOutput, error) {
@@ -7658,8 +7681,13 @@ func (r *Resolver) GetThirdPartyTool(ctx context.Context, req *mcp.CallToolReque
return nil, types.GetThirdPartyOutput{}, fmt.Errorf("cannot get third party: %w", err)
}
+ administratorIDsByThirdPartyID, err := r.proboSvc.ThirdParties.MapAdministratorIDsForThirdPartyIDs(ctx, scope, []gid.GID{thirdParty.ID})
+ if err != nil {
+ return nil, types.GetThirdPartyOutput{}, fmt.Errorf("cannot load third party administrators: %w", err)
+ }
+
return nil, types.GetThirdPartyOutput{
- ThirdParty: types.NewThirdParty(thirdParty),
+ ThirdParty: types.NewThirdParty(thirdParty, administratorIDsByThirdPartyID[thirdParty.ID]),
}, nil
}
diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml
index b7fc321a0..1903cfed8 100644
--- a/pkg/server/api/mcp/v1/specification.yaml
+++ b/pkg/server/api/mcp/v1/specification.yaml
@@ -825,16 +825,11 @@ components:
items:
type: string
description: Countries or regions (ISO 3166-1 alpha-2 country codes, EU, or GLOBAL)
- business_owner_id:
- anyOf:
- - $ref: "#/components/schemas/GID"
- - type: "null"
- description: Business owner ID
- security_owner_id:
- anyOf:
- - $ref: "#/components/schemas/GID"
- - type: "null"
- description: Security owner ID
+ administrator_ids:
+ type: array
+ items:
+ $ref: "#/components/schemas/GID"
+ description: Administrator profile IDs
status_page_url:
type:
- string
@@ -1051,12 +1046,11 @@ components:
items:
type: string
description: Countries or regions (ISO 3166-1 alpha-2 country codes, EU, or GLOBAL)
- business_owner_id:
- $ref: "#/components/schemas/GID"
- description: Business owner ID
- security_owner_id:
- $ref: "#/components/schemas/GID"
- description: Security owner ID
+ administrator_ids:
+ type: array
+ items:
+ $ref: "#/components/schemas/GID"
+ description: Administrator profile IDs
status_page_url:
type: string
description: Status page URL
@@ -1152,12 +1146,11 @@ components:
items:
type: string
description: Countries or regions (ISO 3166-1 alpha-2 country codes, EU, or GLOBAL)
- business_owner_id:
- $ref: "#/components/schemas/GID"
- description: Business owner ID
- security_owner_id:
- $ref: "#/components/schemas/GID"
- description: Security owner ID
+ administrator_ids:
+ type: array
+ items:
+ $ref: "#/components/schemas/GID"
+ description: Administrator profile IDs
status_page_url:
type: string
description: Status page URL
diff --git a/pkg/server/api/mcp/v1/types/third_party.go b/pkg/server/api/mcp/v1/types/third_party.go
index 7056f47d2..a82b433a2 100644
--- a/pkg/server/api/mcp/v1/types/third_party.go
+++ b/pkg/server/api/mcp/v1/types/third_party.go
@@ -22,6 +22,7 @@ package types
import (
"go.probo.inc/probo/pkg/coredata"
+ "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
@@ -64,12 +65,16 @@ func NewAddThirdPartyRiskAssessmentOutput(v *coredata.ThirdPartyRiskAssessment)
}
}
-func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
+func NewThirdParty(v *coredata.ThirdParty, administratorIDs []gid.GID) *ThirdParty {
countries := make([]string, len(v.Countries))
for i, c := range v.Countries {
countries[i] = string(c)
}
+ if administratorIDs == nil {
+ administratorIDs = []gid.GID{}
+ }
+
return &ThirdParty{
ID: v.ID,
OrganizationID: v.OrganizationID,
@@ -86,8 +91,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
SubprocessorsListURL: v.SubprocessorsListURL,
Certifications: v.Certifications,
Countries: countries,
- BusinessOwnerID: v.BusinessOwnerID,
- SecurityOwnerID: v.SecurityOwnerID,
+ AdministratorIds: administratorIDs,
StatusPageURL: v.StatusPageURL,
TermsOfServiceURL: v.TermsOfServiceURL,
SecurityPageURL: v.SecurityPageURL,
@@ -98,10 +102,13 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
}
}
-func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListThirdPartiesOutput {
+func NewListThirdPartiesOutput(
+ thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField],
+ administratorIDsByThirdPartyID map[gid.GID][]gid.GID,
+) ListThirdPartiesOutput {
thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data))
for _, v := range thirdPartyPage.Data {
- thirdParties = append(thirdParties, NewThirdParty(v))
+ thirdParties = append(thirdParties, NewThirdParty(v, administratorIDsByThirdPartyID[v.ID]))
}
var nextCursor *page.CursorKey
@@ -117,10 +124,13 @@ func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, c
}
}
-func NewListChildThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListChildThirdPartiesOutput {
+func NewListChildThirdPartiesOutput(
+ thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField],
+ administratorIDsByThirdPartyID map[gid.GID][]gid.GID,
+) ListChildThirdPartiesOutput {
thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data))
for _, v := range thirdPartyPage.Data {
- thirdParties = append(thirdParties, NewThirdParty(v))
+ thirdParties = append(thirdParties, NewThirdParty(v, administratorIDsByThirdPartyID[v.ID]))
}
var nextCursor *page.CursorKey
@@ -136,15 +146,15 @@ func NewListChildThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdPar
}
}
-func NewAddThirdPartyOutput(v *coredata.ThirdParty) AddThirdPartyOutput {
+func NewAddThirdPartyOutput(v *coredata.ThirdParty, administratorIDs []gid.GID) AddThirdPartyOutput {
return AddThirdPartyOutput{
- ThirdParty: NewThirdParty(v),
+ ThirdParty: NewThirdParty(v, administratorIDs),
}
}
-func NewUpdateThirdPartyOutput(v *coredata.ThirdParty) UpdateThirdPartyOutput {
+func NewUpdateThirdPartyOutput(v *coredata.ThirdParty, administratorIDs []gid.GID) UpdateThirdPartyOutput {
return UpdateThirdPartyOutput{
- ThirdParty: NewThirdParty(v),
+ ThirdParty: NewThirdParty(v, administratorIDs),
}
}
diff --git a/pkg/webhook/types/third_party.go b/pkg/webhook/types/third_party.go
index 7bfc96eb8..d6168dc5f 100644
--- a/pkg/webhook/types/third_party.go
+++ b/pkg/webhook/types/third_party.go
@@ -46,13 +46,16 @@ type ThirdParty struct {
HeadquarterAddress *string `json:"headquarterAddress"`
LegalName *string `json:"legalName"`
WebsiteURL *string `json:"websiteUrl"`
- BusinessOwnerID *gid.GID `json:"businessOwnerId"`
- SecurityOwnerID *gid.GID `json:"securityOwnerId"`
+ AdministratorIDs []gid.GID `json:"administratorIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
-func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
+func NewThirdParty(v *coredata.ThirdParty, administratorIDs []gid.GID) *ThirdParty {
+ if administratorIDs == nil {
+ administratorIDs = []gid.GID{}
+ }
+
return &ThirdParty{
ID: v.ID,
Name: v.Name,
@@ -72,8 +75,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
HeadquarterAddress: v.HeadquarterAddress,
LegalName: v.LegalName,
WebsiteURL: v.WebsiteURL,
- BusinessOwnerID: v.BusinessOwnerID,
- SecurityOwnerID: v.SecurityOwnerID,
+ AdministratorIDs: administratorIDs,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}