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