diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go index c773b8dfb..82b7cb1ed 100644 --- a/pkg/coredata/tracker_pattern.go +++ b/pkg/coredata/tracker_pattern.go @@ -1143,6 +1143,55 @@ WHERE return nil } +// LinkThirdPartyByCommonThirdPartyID points the organization's unlinked +// tracker patterns at an org ThirdParty when their catalog row resolves +// to the given common third party. It is the backfill the explicit +// import action runs so patterns that previously surfaced the catalog +// (CommonThirdParty) entry now surface the managed org ThirdParty. Only +// patterns with no third_party_id are touched, so it is idempotent and +// never overrides an existing link. The common_tracker_patterns +// subquery only narrows the WHERE clause, keeping the resolution in the +// database. +func (tps *TrackerPatterns) LinkThirdPartyByCommonThirdPartyID( + ctx context.Context, + tx pg.Tx, + scope Scoper, + organizationID gid.GID, + commonThirdPartyID gid.GID, + thirdPartyID gid.GID, +) error { + q := ` +UPDATE tracker_patterns +SET + third_party_id = @third_party_id, + updated_at = NOW() +WHERE + %s + AND organization_id = @organization_id + AND third_party_id IS NULL + AND common_tracker_pattern_id IN ( + SELECT id FROM common_tracker_patterns + WHERE common_third_party_id = @common_third_party_id + ) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "organization_id": organizationID, + "common_third_party_id": commonThirdPartyID, + "third_party_id": thirdPartyID, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot link tracker patterns to third party: %w", err) + } + + return nil +} + func (tp *TrackerPattern) LoadNextForMappingForUpdateSkipLocked( ctx context.Context, tx pg.Tx, diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go index af221042b..712812283 100644 --- a/pkg/probo/third_party_service.go +++ b/pkg/probo/third_party_service.go @@ -16,6 +16,7 @@ package probo import ( "context" + "errors" "fmt" "time" @@ -88,8 +89,22 @@ type ( BusinessImpact coredata.BusinessImpact Notes *string } + + ImportThirdPartyFromCommonRequest struct { + OrganizationID gid.GID + CommonThirdPartyID gid.GID + } ) +func (r *ImportThirdPartyFromCommonRequest) Validate() error { + v := validator.New() + + v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) + v.Check(r.CommonThirdPartyID, "common_third_party_id", validator.Required(), validator.GID(coredata.CommonThirdPartyEntityType)) + + return v.Error() +} + func (cvr *CreateThirdPartyRequest) Validate() error { v := validator.New() @@ -669,6 +684,131 @@ func (s ThirdPartyService) Create( return thirdParty, nil } +// ImportFromCommon creates an org ThirdParty seeded from the global +// CommonThirdParty catalog entry, or returns the existing one when the +// organization already imported it (idempotent on the +// (organization_id, common_third_party_id) pair). It then backfills +// tracker_patterns.third_party_id for the organization's unlinked +// patterns whose catalog row resolves to the same common third party, so +// the trackers UI and tracker-policy document surface the managed vendor +// instead of the catalog entry. The backfill runs on both the create and +// reuse paths because new patterns may have been detected since a prior +// import; it only touches patterns with no existing link. Returns the +// org ThirdParty and whether it was newly created. +func (s ThirdPartyService) ImportFromCommon( + ctx context.Context, scope coredata.Scoper, + req ImportThirdPartyFromCommonRequest, +) (*coredata.ThirdParty, bool, error) { + if err := req.Validate(); err != nil { + return nil, false, err + } + + thirdParty := &coredata.ThirdParty{} + created := false + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, req.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + existing := &coredata.ThirdParty{} + + err := existing.LoadByOrganizationIDAndCommonThirdPartyID( + ctx, + conn, + scope, + organization.ID, + req.CommonThirdPartyID, + ) + + switch { + case err == nil: + *thirdParty = *existing + case errors.Is(err, coredata.ErrResourceNotFound): + commonParty := &coredata.CommonThirdParty{} + if err := commonParty.LoadByID(ctx, conn, req.CommonThirdPartyID); err != nil { + return fmt.Errorf("cannot load common third party: %w", err) + } + + now := time.Now() + commonID := commonParty.ID + + certifications := commonParty.Certifications + if certifications == nil { + certifications = []string{} + } + + *thirdParty = coredata.ThirdParty{ + ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType), + OrganizationID: organization.ID, + CommonThirdPartyID: &commonID, + Name: commonParty.Name, + Category: commonParty.Category, + HeadquarterAddress: commonParty.HeadquarterAddress, + LegalName: commonParty.LegalName, + WebsiteURL: commonParty.WebsiteURL, + PrivacyPolicyURL: commonParty.PrivacyPolicyURL, + ServiceLevelAgreementURL: commonParty.ServiceLevelAgreementURL, + DataProcessingAgreementURL: commonParty.DataProcessingAgreementURL, + BusinessAssociateAgreementURL: commonParty.BusinessAssociateAgreementURL, + SubprocessorsListURL: commonParty.SubprocessorsListURL, + Certifications: certifications, + Countries: coredata.CountryCodes{}, + StatusPageURL: commonParty.StatusPageURL, + TermsOfServiceURL: commonParty.TermsOfServiceURL, + SecurityPageURL: commonParty.SecurityPageURL, + TrustPageURL: commonParty.TrustPageURL, + ShowOnTrustCenter: false, + FirstLevel: true, + CreatedAt: now, + UpdatedAt: now, + } + + if err := thirdParty.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert third party: %w", err) + } + + created = true + + if err := webhook.InsertData( + ctx, + conn, + scope, + organization.ID, + coredata.WebhookEventTypeThirdPartyCreated, + webhooktypes.NewThirdParty(thirdParty), + ); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) + } + default: + return fmt.Errorf("cannot load third party by common id: %w", err) + } + + var patterns coredata.TrackerPatterns + if err := patterns.LinkThirdPartyByCommonThirdPartyID( + ctx, + conn, + scope, + organization.ID, + req.CommonThirdPartyID, + thirdParty.ID, + ); err != nil { + return fmt.Errorf("cannot link tracker patterns to imported third party: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, false, err + } + + return thirdParty, created, nil +} + func (s ThirdPartyService) CountForAssetID( ctx context.Context, scope coredata.Scoper, assetID gid.GID,