Surface third party links on TrackerPattern in GraphQL

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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-27 21:02:53 +02:00
parent cdd7eb1171
commit d93ff7ba25
5 changed files with 213 additions and 42 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

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