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:
@@ -16,6 +16,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"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/dataloader"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
"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
|
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.
|
// 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) {
|
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)
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionTrackerPatternGet)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/iam/policy"
|
"go.probo.inc/probo/pkg/iam/policy"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"go.probo.inc/probo/pkg/server/api/authn"
|
||||||
|
"go.probo.inc/probo/pkg/thirdparty"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -64,6 +65,8 @@ type (
|
|||||||
Report *dataloadgen.Loader[gid.GID, *coredata.Report]
|
Report *dataloadgen.Loader[gid.GID, *coredata.Report]
|
||||||
CookieBanner *dataloadgen.Loader[gid.GID, *coredata.CookieBanner]
|
CookieBanner *dataloadgen.Loader[gid.GID, *coredata.CookieBanner]
|
||||||
CookieCategory *dataloadgen.Loader[gid.GID, *coredata.CookieCategory]
|
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]
|
Authorize *dataloadgen.Loader[AuthorizeKey, AuthorizeResult]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ type (
|
|||||||
probo *probo.Service
|
probo *probo.Service
|
||||||
iam *iam.Service
|
iam *iam.Service
|
||||||
cookieBanner *cookiebanner.Service
|
cookieBanner *cookiebanner.Service
|
||||||
|
thirdParty *thirdparty.Service
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,11 +84,16 @@ func FromContext(ctx context.Context) *Loaders {
|
|||||||
return ctx.Value(loadersKey).(*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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(
|
return http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
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()
|
loaders := f.newLoaders()
|
||||||
ctx := context.WithValue(r.Context(), loadersKey, loaders)
|
ctx := context.WithValue(r.Context(), loadersKey, loaders)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
@@ -108,6 +117,8 @@ func (f *batchFetcher) newLoaders() *Loaders {
|
|||||||
Report: dataloadgen.NewMappedLoader(f.fetchReports),
|
Report: dataloadgen.NewMappedLoader(f.fetchReports),
|
||||||
CookieBanner: dataloadgen.NewMappedLoader(f.fetchCookieBanners),
|
CookieBanner: dataloadgen.NewMappedLoader(f.fetchCookieBanners),
|
||||||
CookieCategory: dataloadgen.NewMappedLoader(f.fetchCookieCategories),
|
CookieCategory: dataloadgen.NewMappedLoader(f.fetchCookieCategories),
|
||||||
|
CommonTrackerPattern: dataloadgen.NewMappedLoader(f.fetchCommonTrackerPatterns),
|
||||||
|
CommonThirdParty: dataloadgen.NewMappedLoader(f.fetchCommonThirdParties),
|
||||||
Authorize: dataloadgen.NewMappedLoader(
|
Authorize: dataloadgen.NewMappedLoader(
|
||||||
f.fetchAuthorizes,
|
f.fetchAuthorizes,
|
||||||
dataloadgen.WithoutCache(),
|
dataloadgen.WithoutCache(),
|
||||||
@@ -323,6 +334,34 @@ func (f *batchFetcher) fetchCookieCategories(ctx context.Context, keys []gid.GID
|
|||||||
return result, nil
|
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
|
// fetchAuthorizes evaluates the batch with a single AuthorizeMulti call and
|
||||||
// surfaces per-key denials via dataloadgen.MappedFetchError. When
|
// surfaces per-key denials via dataloadgen.MappedFetchError. When
|
||||||
// AuthorizeMulti cannot evaluate the batch as a whole (e.g. mixed
|
// AuthorizeMulti cannot evaluate the batch as a whole (e.g. mixed
|
||||||
|
|||||||
@@ -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!
|
id: ID!
|
||||||
cookieCategory: CookieCategory @goField(forceResolver: true)
|
cookieCategory: CookieCategory @goField(forceResolver: true)
|
||||||
trackerType: TrackerType!
|
trackerType: TrackerType!
|
||||||
@@ -279,6 +282,22 @@ type TrackerPattern implements Node {
|
|||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: 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(
|
detectedTrackers(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func NewMux(
|
|||||||
r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret))
|
r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret))
|
||||||
r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc))
|
r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc))
|
||||||
r.Use(authn.NewIdentityPresenceMiddleware())
|
r.Use(authn.NewIdentityPresenceMiddleware())
|
||||||
r.Use(dataloader.NewMiddleware(proboSvc, iamSvc, cookieBannerSvc))
|
r.Use(dataloader.NewMiddleware(proboSvc, iamSvc, cookieBannerSvc, thirdPartySvc))
|
||||||
|
|
||||||
r.Handle("/graphql", graphqlHandler)
|
r.Handle("/graphql", graphqlHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
@@ -23,6 +25,39 @@ import (
|
|||||||
type (
|
type (
|
||||||
TrackerPatternOrderBy OrderBy[coredata.TrackerPatternOrderField]
|
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 {
|
TrackerPatternConnection struct {
|
||||||
TotalCount int
|
TotalCount int
|
||||||
Edges []*TrackerPatternEdge
|
Edges []*TrackerPatternEdge
|
||||||
@@ -41,6 +76,9 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (TrackerPattern) IsNode() {}
|
||||||
|
func (t TrackerPattern) GetID() gid.GID { return t.ID }
|
||||||
|
|
||||||
func NewTrackerPatternConnection(
|
func NewTrackerPatternConnection(
|
||||||
p *page.Page[*coredata.TrackerPattern, coredata.TrackerPatternOrderField],
|
p *page.Page[*coredata.TrackerPattern, coredata.TrackerPatternOrderField],
|
||||||
parentType any,
|
parentType any,
|
||||||
@@ -100,5 +138,7 @@ func NewTrackerPatternNode(tp *coredata.TrackerPattern) *TrackerPattern {
|
|||||||
LastMatchedAt: tp.LastMatchedAt,
|
LastMatchedAt: tp.LastMatchedAt,
|
||||||
CreatedAt: tp.CreatedAt,
|
CreatedAt: tp.CreatedAt,
|
||||||
UpdatedAt: tp.UpdatedAt,
|
UpdatedAt: tp.UpdatedAt,
|
||||||
|
ThirdPartyID: tp.ThirdPartyID,
|
||||||
|
CommonTrackerPatternID: tp.CommonTrackerPatternID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user