From d93ff7ba2557ffa0f4e3d249c606e71aff63bbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 27 May 2026 21:02:53 +0200 Subject: [PATCH] Surface third party links on TrackerPattern in GraphQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tracker pattern carries either a direct org-scoped third_party_id or an indirect link via common_tracker_pattern_id, but the console API never surfaced either. Expose two optional resolver-driven fields on the GraphQL TrackerPattern node: thirdParty: ThirdParty commonThirdParty: CommonThirdParty The org-scoped ThirdParty takes priority. When ThirdPartyID is set the commonThirdParty resolver short-circuits to nil, so the chained common_tracker_pattern -> common_third_party lookup is only paid for when a pattern has not been promoted to a tenant-managed third party. To make the resolver pattern viable across paginated banner trackers listings, the model now uses @goModel and a custom struct that carries the foreign-key handles (ThirdPartyID, CommonTrackerPatternID) without exposing them in the schema. NewTrackerPatternNode populates them from coredata. Two new request-scoped dataloaders (CommonTrackerPattern, CommonThirdParty) batch the chained lookup, mirroring the existing ThirdParty / CookieCategory loaders. The console mux now wires the third-party service through dataloader.NewMiddleware so the second loader has its backing service. Authorization follows existing precedent: ActionThirdPartyGet for the org-scoped lookup, ActionCommonThirdPartyGet (granted by the identity-scoped CommonThirdPartyCatalogPolicy) for the catalog lookup. ErrResourceNotFound and dataloadgen.ErrNotFound are mapped to a null field rather than an error. Signed-off-by: Émile Ré --- .../api/console/v1/cookie_banner_resolvers.go | 73 ++++++++++++++ .../api/console/v1/dataloader/dataloader.go | 97 +++++++++++++------ .../console/v1/graphql/cookie_banner.graphql | 21 +++- pkg/server/api/console/v1/resolver.go | 2 +- .../api/console/v1/types/tracker_pattern.go | 62 +++++++++--- 5 files changed, 213 insertions(+), 42 deletions(-) diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index d7cc59614..220b79c56 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -16,6 +16,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/console/v1/dataloader" "go.probo.inc/probo/pkg/server/api/console/v1/schema" "go.probo.inc/probo/pkg/server/api/console/v1/types" @@ -1273,6 +1274,78 @@ func (r *trackerPatternResolver) DetectedCount(ctx context.Context, obj *types.T return count, nil } +// ThirdParty is the resolver for the thirdParty field. +func (r *trackerPatternResolver) ThirdParty(ctx context.Context, obj *types.TrackerPattern) (*types.ThirdParty, error) { + if obj.ThirdPartyID == nil { + return nil, nil + } + + if _, err := r.authorize(ctx, *obj.ThirdPartyID, probo.ActionThirdPartyGet); err != nil { + return nil, err + } + + loaders := dataloader.FromContext(ctx) + + tp, err := loaders.ThirdParty.Load(ctx, *obj.ThirdPartyID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, nil + } + + r.logger.ErrorCtx(ctx, "cannot get tracker pattern third party", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return types.NewThirdParty(tp), nil +} + +// CommonThirdParty is the resolver for the commonThirdParty field. +// +// The org-scoped thirdParty takes priority: when ThirdPartyID is set we +// short-circuit to nil so the chained common-tracker-pattern lookup is +// never paid for. +func (r *trackerPatternResolver) CommonThirdParty(ctx context.Context, obj *types.TrackerPattern) (*types.CommonThirdParty, error) { + if obj.ThirdPartyID != nil || obj.CommonTrackerPatternID == nil { + return nil, nil + } + + identity := authn.IdentityFromContext(ctx) + if _, err := r.authorize(ctx, identity.ID, probo.ActionCommonThirdPartyGet); err != nil { + return nil, err + } + + loaders := dataloader.FromContext(ctx) + + pattern, err := loaders.CommonTrackerPattern.Load(ctx, *obj.CommonTrackerPatternID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, nil + } + + r.logger.ErrorCtx(ctx, "cannot get common tracker pattern", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + if pattern.CommonThirdPartyID == nil { + return nil, nil + } + + party, err := loaders.CommonThirdParty.Load(ctx, *pattern.CommonThirdPartyID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, nil + } + + r.logger.ErrorCtx(ctx, "cannot get common third party", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return types.NewCommonThirdParty(party), nil +} + // DetectedTrackers is the resolver for the detectedTrackers field. func (r *trackerPatternResolver) DetectedTrackers(ctx context.Context, obj *types.TrackerPattern, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DetectedTrackerOrderBy) (*types.DetectedTrackerConnection, error) { scope, err := r.authorize(ctx, obj.ID, probo.ActionTrackerPatternGet) diff --git a/pkg/server/api/console/v1/dataloader/dataloader.go b/pkg/server/api/console/v1/dataloader/dataloader.go index 9ac3cf77f..be4b22d7f 100644 --- a/pkg/server/api/console/v1/dataloader/dataloader.go +++ b/pkg/server/api/console/v1/dataloader/dataloader.go @@ -29,6 +29,7 @@ import ( "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/thirdparty" ) type ( @@ -51,26 +52,29 @@ 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] - Report *dataloadgen.Loader[gid.GID, *coredata.Report] - CookieBanner *dataloadgen.Loader[gid.GID, *coredata.CookieBanner] - CookieCategory *dataloadgen.Loader[gid.GID, *coredata.CookieCategory] - 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] + Report *dataloadgen.Loader[gid.GID, *coredata.Report] + 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] } batchFetcher struct { probo *probo.Service iam *iam.Service cookieBanner *cookiebanner.Service + thirdParty *thirdparty.Service } ) @@ -80,11 +84,16 @@ func FromContext(ctx context.Context) *Loaders { return ctx.Value(loadersKey).(*Loaders) } -func NewMiddleware(proboSvc *probo.Service, iamSvc *iam.Service, cookieBannerSvc *cookiebanner.Service) func(http.Handler) http.Handler { +func NewMiddleware(proboSvc *probo.Service, iamSvc *iam.Service, cookieBannerSvc *cookiebanner.Service, thirdPartySvc *thirdparty.Service) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - f := &batchFetcher{probo: proboSvc, iam: iamSvc, cookieBanner: cookieBannerSvc} + f := &batchFetcher{ + probo: proboSvc, + iam: iamSvc, + cookieBanner: cookieBannerSvc, + thirdParty: thirdPartySvc, + } loaders := f.newLoaders() ctx := context.WithValue(r.Context(), loadersKey, loaders) next.ServeHTTP(w, r.WithContext(ctx)) @@ -95,19 +104,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), - Report: dataloadgen.NewMappedLoader(f.fetchReports), - CookieBanner: dataloadgen.NewMappedLoader(f.fetchCookieBanners), - CookieCategory: dataloadgen.NewMappedLoader(f.fetchCookieCategories), + 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), + Report: dataloadgen.NewMappedLoader(f.fetchReports), + CookieBanner: dataloadgen.NewMappedLoader(f.fetchCookieBanners), + CookieCategory: dataloadgen.NewMappedLoader(f.fetchCookieCategories), + CommonTrackerPattern: dataloadgen.NewMappedLoader(f.fetchCommonTrackerPatterns), + CommonThirdParty: dataloadgen.NewMappedLoader(f.fetchCommonThirdParties), Authorize: dataloadgen.NewMappedLoader( f.fetchAuthorizes, dataloadgen.WithoutCache(), @@ -323,6 +334,34 @@ func (f *batchFetcher) fetchCookieCategories(ctx context.Context, keys []gid.GID return result, nil } +func (f *batchFetcher) fetchCommonTrackerPatterns(ctx context.Context, keys []gid.GID) (map[gid.GID]*coredata.CommonTrackerPattern, error) { + patterns, err := f.cookieBanner.GetCommonTrackerPatternsByIDs(ctx, keys...) + if err != nil { + return nil, fmt.Errorf("cannot batch load common tracker patterns: %w", err) + } + + result := make(map[gid.GID]*coredata.CommonTrackerPattern, len(patterns)) + for _, v := range patterns { + result[v.ID] = v + } + + return result, nil +} + +func (f *batchFetcher) fetchCommonThirdParties(ctx context.Context, keys []gid.GID) (map[gid.GID]*coredata.CommonThirdParty, error) { + parties, err := f.thirdParty.GetCommonThirdPartiesByIDs(ctx, keys...) + if err != nil { + return nil, fmt.Errorf("cannot batch load common third parties: %w", err) + } + + result := make(map[gid.GID]*coredata.CommonThirdParty, len(parties)) + for _, v := range parties { + result[v.ID] = v + } + + 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/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 9eab52b06..cce17b609 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -263,7 +263,10 @@ enum TrackerPatternOrderField ) } -type TrackerPattern implements Node { +type TrackerPattern implements Node + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrackerPattern" + ) { id: ID! cookieCategory: CookieCategory @goField(forceResolver: true) trackerType: TrackerType! @@ -279,6 +282,22 @@ type TrackerPattern implements Node { createdAt: Datetime! updatedAt: Datetime! + """ + The org-scoped third party this pattern is mapped to, if any. Set + when the mapping worker promoted the pattern to a tenant-managed + ThirdParty record. When this field is non-null, commonThirdParty is + always null. + """ + thirdParty: ThirdParty @goField(forceResolver: true) + + """ + The global third party this pattern is mapped to via the common + tracker-pattern catalog. Null when the pattern has its own + org-scoped thirdParty, when it has not been mapped, or when the + matched common pattern has no common third party. + """ + commonThirdParty: CommonThirdParty @goField(forceResolver: true) + detectedTrackers( first: Int after: CursorKey diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index cf8e9255a..943ee556d 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -107,7 +107,7 @@ func NewMux( r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret)) r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc)) r.Use(authn.NewIdentityPresenceMiddleware()) - r.Use(dataloader.NewMiddleware(proboSvc, iamSvc, cookieBannerSvc)) + r.Use(dataloader.NewMiddleware(proboSvc, iamSvc, cookieBannerSvc, thirdPartySvc)) r.Handle("/graphql", graphqlHandler) diff --git a/pkg/server/api/console/v1/types/tracker_pattern.go b/pkg/server/api/console/v1/types/tracker_pattern.go index 0c8e4baf0..bc6ee0652 100644 --- a/pkg/server/api/console/v1/types/tracker_pattern.go +++ b/pkg/server/api/console/v1/types/tracker_pattern.go @@ -15,6 +15,8 @@ package types import ( + "time" + "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" @@ -23,6 +25,39 @@ import ( type ( TrackerPatternOrderBy OrderBy[coredata.TrackerPatternOrderField] + // TrackerPattern is the Go model bound to the GraphQL TrackerPattern + // type via @goModel. The first block contains the fields gqlgen + // fulfills directly from the model; resolver-only fields + // (cookieCategory, detectedTrackers, thirdParty, commonThirdParty, + // detectedCount, permission) are populated by the resolver. + // + // ThirdPartyID and CommonTrackerPatternID are not exposed in + // GraphQL — they are foreign-key handles the resolver uses to load + // the linked third party (org-scoped or via the common catalog) + // without re-querying coredata. + TrackerPattern struct { + ID gid.GID `json:"id"` + TrackerType coredata.TrackerType `json:"trackerType"` + Pattern string `json:"pattern"` + MatchType coredata.TrackerPatternMatchType `json:"matchType"` + DisplayName string `json:"displayName"` + MaxAgeSeconds *int `json:"maxAgeSeconds,omitempty"` + Description string `json:"description"` + Source *coredata.CookieSource `json:"source,omitempty"` + Excluded bool `json:"excluded"` + LastMatchedAt *time.Time `json:"lastMatchedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + CookieCategory *CookieCategory `json:"cookieCategory,omitempty"` + DetectedTrackers *DetectedTrackerConnection `json:"detectedTrackers,omitempty"` + DetectedCount int `json:"detectedCount"` + Permission bool `json:"permission"` + + ThirdPartyID *gid.GID `json:"-"` + CommonTrackerPatternID *gid.GID `json:"-"` + } + TrackerPatternConnection struct { TotalCount int Edges []*TrackerPatternEdge @@ -41,6 +76,9 @@ type ( } ) +func (TrackerPattern) IsNode() {} +func (t TrackerPattern) GetID() gid.GID { return t.ID } + func NewTrackerPatternConnection( p *page.Page[*coredata.TrackerPattern, coredata.TrackerPatternOrderField], parentType any, @@ -89,16 +127,18 @@ func NewTrackerPatternNode(tp *coredata.TrackerPattern) *TrackerPattern { ID: tp.CookieBannerID, }, }, - TrackerType: tp.TrackerType, - Pattern: tp.Pattern, - MatchType: tp.MatchType, - DisplayName: tp.DisplayName, - MaxAgeSeconds: tp.MaxAgeSeconds, - Description: tp.Description, - Source: tp.Source, - Excluded: tp.Excluded, - LastMatchedAt: tp.LastMatchedAt, - CreatedAt: tp.CreatedAt, - UpdatedAt: tp.UpdatedAt, + TrackerType: tp.TrackerType, + Pattern: tp.Pattern, + MatchType: tp.MatchType, + DisplayName: tp.DisplayName, + MaxAgeSeconds: tp.MaxAgeSeconds, + Description: tp.Description, + Source: tp.Source, + Excluded: tp.Excluded, + LastMatchedAt: tp.LastMatchedAt, + CreatedAt: tp.CreatedAt, + UpdatedAt: tp.UpdatedAt, + ThirdPartyID: tp.ThirdPartyID, + CommonTrackerPatternID: tp.CommonTrackerPatternID, } }