Filter banner trackers by linked third party

The trackers list page on the cookie-banner configuration screen needs
to filter rows by the third party that the pattern resolves to. The
tricky part is that "third party" comes from two unrelated tables:
ThirdParty (org-scoped, linked through tracker_patterns.third_party_id)
and CommonThirdParty (global catalog, reached indirectly through
common_tracker_patterns.common_third_party_id). The filter must accept
either flavour of GID and resolve transparently.

Add a single thirdPartyId field to TrackerPatternFilter and dispatch
on the GID's entity-type prefix at the resolver:

  ThirdPartyEntityType       -> WithThirdPartyID
  CommonThirdPartyEntityType -> resolve common_tracker_pattern_id list
                                via the cookiebanner service, then
                                WithCommonTrackerPatternIDs

Any other entity type returns an Invalid error rather than silently
matching everything; an unknown caller-supplied GID is a contract bug.
The empty-but-non-nil ID slice produced when a CommonThirdParty has no
patterns yet correctly yields zero rows because the SQL fragment uses
ANY(...).

To populate the filter combobox, add a new CookieBanner.linkedThirdParties
field returning [TrackerPatternThirdPartyLink!]!, a union of ThirdParty
and CommonThirdParty. The resolver collects DISTINCT third_party_id and
common_tracker_pattern_id from the banner's tracker patterns (no joins,
per coredata convention), then chains the catalog lookup through
CommonTrackerPatterns -> CommonThirdParties. Authorization scopes
follow the existing trackerPattern resolvers: ActionTrackerPatternList
gates the aggregation, ActionThirdPartyGet and ActionCommonThirdPartyGet
each gate their respective fan-out only when that branch has work.

The org-scoped fan-out uses dataloadgen.LoadAll so it batches in a
single round-trip; per-key NotFound errors are dropped (a deleted
third party doesn't fail the whole list), other errors bubble up.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-28 08:49:25 +02:00
parent d93ff7ba25
commit 66ffcf3411
4 changed files with 143 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"
@@ -210,6 +211,25 @@ func (r *cookieBannerResolver) TrackerPatterns(ctx context.Context, obj *types.C
if filter != nil {
coredataFilter = coredata.NewTrackerPatternFilter(nil, filter.CookieCategoryID, nil)
coredataFilter = coredataFilter.WithQuery(filter.Query).WithSource(filter.Source).WithTrackerType(filter.TrackerType)
if filter.ThirdPartyID != nil {
switch filter.ThirdPartyID.EntityType() {
case coredata.ThirdPartyEntityType:
coredataFilter = coredataFilter.WithThirdPartyID(filter.ThirdPartyID)
case coredata.CommonThirdPartyEntityType:
ids, err := r.cookieBanner.LoadCommonTrackerPatternIDsByCommonThirdPartyID(ctx, *filter.ThirdPartyID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot resolve common third party tracker patterns", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if ids == nil {
ids = []gid.GID{}
}
coredataFilter = coredataFilter.WithCommonTrackerPatternIDs(ids)
default:
return nil, gqlutils.Invalidf(ctx, "thirdPartyId must reference a ThirdParty or CommonThirdParty")
}
}
}
patterns, err := r.cookieBanner.ListTrackerPatternsForBanner(ctx, scope, obj.ID, cursor, coredataFilter)
@@ -223,6 +243,101 @@ func (r *cookieBannerResolver) TrackerPatterns(ctx context.Context, obj *types.C
return types.NewTrackerPatternConnectionWithFilter(p, r, obj.ID, filter), nil
}
// LinkedThirdParties is the resolver for the linkedThirdParties field.
//
// Aggregates the deduped union of third parties linked to the banner's
// tracker patterns: the org-scoped ThirdParty values reached through
// the direct foreign key, plus the global CommonThirdParty values
// reached indirectly through CommonTrackerPattern. The two sources are
// independent, so a tracker pattern that has both ThirdPartyID and
// CommonTrackerPatternID contributes the org-scoped link only — the
// commonThirdParty resolver follows the same priority and we want the
// banner-level filter to mirror it.
func (r *cookieBannerResolver) LinkedThirdParties(ctx context.Context, obj *types.CookieBanner) ([]types.TrackerPatternThirdPartyLink, error) {
if _, err := r.authorize(ctx, obj.ID, probo.ActionTrackerPatternList); err != nil {
return nil, err
}
scope := coredata.NewScopeFromObjectID(obj.ID)
thirdPartyIDs, err := r.cookieBanner.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list banner third party links", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
commonPatternIDs, err := r.cookieBanner.LoadDistinctCommonTrackerPatternIDsByCookieBannerID(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list banner common tracker pattern links", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
out := make([]types.TrackerPatternThirdPartyLink, 0, len(thirdPartyIDs)+len(commonPatternIDs))
loaders := dataloader.FromContext(ctx)
if len(thirdPartyIDs) > 0 {
if _, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet); err != nil {
return nil, err
}
tps, loadErr := loaders.ThirdParty.LoadAll(ctx, thirdPartyIDs)
var loadErrs dataloadgen.ErrorSlice
if loadErr != nil && !errors.As(loadErr, &loadErrs) {
r.logger.ErrorCtx(ctx, "cannot get third parties", log.Error(loadErr))
return nil, gqlutils.Internal(ctx)
}
for i, tp := range tps {
if loadErrs != nil && loadErrs[i] != nil {
if errors.Is(loadErrs[i], coredata.ErrResourceNotFound) || errors.Is(loadErrs[i], dataloadgen.ErrNotFound) {
continue
}
r.logger.ErrorCtx(ctx, "cannot get third party", log.Error(loadErrs[i]))
return nil, gqlutils.Internal(ctx)
}
out = append(out, types.NewThirdParty(tp))
}
}
if len(commonPatternIDs) > 0 {
identity := authn.IdentityFromContext(ctx)
if _, err := r.authorize(ctx, identity.ID, probo.ActionCommonThirdPartyGet); err != nil {
return nil, err
}
patterns, err := r.cookieBanner.GetCommonTrackerPatternsByIDs(ctx, commonPatternIDs...)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get common tracker patterns", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
seen := make(map[gid.GID]struct{}, len(patterns))
commonThirdPartyIDs := make([]gid.GID, 0, len(patterns))
for _, p := range patterns {
if p.CommonThirdPartyID == nil {
continue
}
if _, ok := seen[*p.CommonThirdPartyID]; ok {
continue
}
seen[*p.CommonThirdPartyID] = struct{}{}
commonThirdPartyIDs = append(commonThirdPartyIDs, *p.CommonThirdPartyID)
}
if len(commonThirdPartyIDs) > 0 {
parties, err := r.thirdParty.GetCommonThirdPartiesByIDs(ctx, commonThirdPartyIDs...)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get common third parties", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
for _, p := range parties {
out = append(out, types.NewCommonThirdParty(p))
}
}
}
return out, nil
}
// UncategorisedTrackerResources is the resolver for the uncategorisedTrackerResources field.
func (r *cookieBannerResolver) UncategorisedTrackerResources(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TrackerResourceOrderBy, filter *types.TrackerResourceFilter) (*types.TrackerResourceConnection, error) {
if _, err := r.authorize(ctx, obj.ID, probo.ActionTrackerResourceList); err != nil {

View File

@@ -167,6 +167,15 @@ type CookieBanner implements Node {
filter: TrackerPatternFilter
): TrackerPatternConnection @goField(forceResolver: true)
"""
The deduped union of third parties referenced by the banner's
tracker patterns, both org-scoped (ThirdParty) and global
(CommonThirdParty). Powers the third-party filter combobox on the
trackers list page.
"""
linkedThirdParties: [TrackerPatternThirdPartyLink!]!
@goField(forceResolver: true)
uncategorisedTrackerResources(
first: Int
after: CursorKey
@@ -318,6 +327,14 @@ type TrackerPatternConnection
pageInfo: PageInfo!
}
"""
A third party associated with a banner's tracker patterns. Either the
tenant-managed ThirdParty (when a pattern was promoted to the org
catalog) or the global CommonThirdParty linked through a common
tracker pattern.
"""
union TrackerPatternThirdPartyLink = ThirdParty | CommonThirdParty
type TrackerPatternEdge {
cursor: CursorKey!
node: TrackerPattern!
@@ -339,6 +356,14 @@ input TrackerPatternFilter
source: CookieSource
trackerType: TrackerType
cookieCategoryId: ID
"""
Filter to tracker patterns linked to a single third party. Accepts
either an org-scoped ThirdParty GID (matched against third_party_id)
or a global CommonThirdParty GID (matched against the indirect link
through common_tracker_patterns). The dispatch happens at the
resolver based on the GID entity-type prefix.
"""
thirdPartyId: ID
}
type DetectedTracker implements Node {

View File

@@ -37,6 +37,8 @@ type CommonThirdParty struct {
LogoFileID *gid.GID `json:"logoFileId,omitempty"`
}
func (CommonThirdParty) IsTrackerPatternThirdPartyLink() {}
func NewCommonThirdParty(c *coredata.CommonThirdParty) *CommonThirdParty {
return &CommonThirdParty{
ID: c.ID,

View File

@@ -73,6 +73,7 @@ type (
Source *coredata.CookieSource
TrackerType *coredata.TrackerType
CookieCategoryID *gid.GID
ThirdPartyID *gid.GID
}
)