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/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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user