From d5394317bbb0c1e55220cac89ce59ee675577893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 6 May 2026 20:28:24 +0400 Subject: [PATCH] Add geolocation-based privacy regulation detection to cookie banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the visitor's IP to a country code via the geoloc service and map it to the applicable privacy regulation (GDPR, UK GDPR, FADP, CCPA, PIPEDA, LGPD, LFPDPPP, POPIA, PDPA, PIPL, PIPA, APPI, DPDP, PDPL). The regulation and its implied consent mode (OPT_IN / OPT_OUT) are injected into the GET /config response so the SDK can adapt its behavior. Also makes geoloc.Service self-contained: LookupCountry and IsPopulated now manage their own DB connections instead of requiring callers to pass a pg.Querier. Signed-off-by: Émile Ré --- pkg/cookiebanner/regulation.go | 162 ++++++++++++++++++++++ pkg/geoloc/service.go | 36 ++++- pkg/probod/probod.go | 17 +-- pkg/server/api/api.go | 3 + pkg/server/api/cookiebanner/v1/handler.go | 35 ++++- pkg/server/server.go | 3 + 6 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 pkg/cookiebanner/regulation.go diff --git a/pkg/cookiebanner/regulation.go b/pkg/cookiebanner/regulation.go new file mode 100644 index 000000000..94eb4096d --- /dev/null +++ b/pkg/cookiebanner/regulation.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cookiebanner + +import "go.probo.inc/probo/pkg/coredata" + +type Regulation string + +const ( + RegulationNone Regulation = "" + RegulationGDPR Regulation = "GDPR" + RegulationUKGDPR Regulation = "UK_GDPR" + RegulationFADP Regulation = "FADP" + RegulationCCPA Regulation = "CCPA" + RegulationPIPEDA Regulation = "PIPEDA" + RegulationLGPD Regulation = "LGPD" + RegulationLFPDPPP Regulation = "LFPDPPP" + RegulationPOPIA Regulation = "POPIA" + RegulationPDPA Regulation = "PDPA" + RegulationPIPL Regulation = "PIPL" + RegulationPIPA Regulation = "PIPA" + RegulationAPPI Regulation = "APPI" + RegulationDPDP Regulation = "DPDP" + RegulationPDPL Regulation = "PDPL" +) + +const ( + ConsentModeOptIn = "OPT_IN" + ConsentModeOptOut = "OPT_OUT" +) + +// RegulationForCountry maps a country code to the applicable privacy +// regulation. For countries with no known cookie-consent regulation it +// returns RegulationNone. +// +// US states (CCPA/CPRA, CPA, VCDPA, UCPA) and Canadian provinces +// (PIPEDA, Law 25) are collapsed to the country level because IP +// geolocation only resolves to a country code. +func RegulationForCountry(cc coredata.CountryCode) Regulation { + switch cc { + // EU 27 member states + case + coredata.CountryCodeAT, // Austria + coredata.CountryCodeBE, // Belgium + coredata.CountryCodeBG, // Bulgaria + coredata.CountryCodeHR, // Croatia + coredata.CountryCodeCY, // Cyprus + coredata.CountryCodeCZ, // Czechia + coredata.CountryCodeDK, // Denmark + coredata.CountryCodeEE, // Estonia + coredata.CountryCodeFI, // Finland + coredata.CountryCodeFR, // France + coredata.CountryCodeDE, // Germany + coredata.CountryCodeGR, // Greece + coredata.CountryCodeHU, // Hungary + coredata.CountryCodeIE, // Ireland + coredata.CountryCodeIT, // Italy + coredata.CountryCodeLV, // Latvia + coredata.CountryCodeLT, // Lithuania + coredata.CountryCodeLU, // Luxembourg + coredata.CountryCodeMT, // Malta + coredata.CountryCodeNL, // Netherlands + coredata.CountryCodePL, // Poland + coredata.CountryCodePT, // Portugal + coredata.CountryCodeRO, // Romania + coredata.CountryCodeSK, // Slovakia + coredata.CountryCodeSI, // Slovenia + coredata.CountryCodeES, // Spain + coredata.CountryCodeSE, // Sweden + // EEA (non-EU) + coredata.CountryCodeIS, // Iceland + coredata.CountryCodeLI, // Liechtenstein + coredata.CountryCodeNO: // Norway + return RegulationGDPR + + case coredata.CountryCodeGB: + return RegulationUKGDPR + + case coredata.CountryCodeCH: + return RegulationFADP + + case coredata.CountryCodeUS: + return RegulationCCPA + + case coredata.CountryCodeCA: + return RegulationPIPEDA + + case coredata.CountryCodeBR: + return RegulationLGPD + + case coredata.CountryCodeMX: + return RegulationLFPDPPP + + case coredata.CountryCodeZA: + return RegulationPOPIA + + case coredata.CountryCodeTH: + return RegulationPDPA + + case coredata.CountryCodeCN: + return RegulationPIPL + + case coredata.CountryCodeKR: + return RegulationPIPA + + case coredata.CountryCodeJP: + return RegulationAPPI + + case coredata.CountryCodeIN: + return RegulationDPDP + + case coredata.CountryCodeSA: + return RegulationPDPL + + default: + return RegulationNone + } +} + +// ConsentModeForRegulation returns the consent model implied by a +// regulation. OPT_IN means non-necessary cookies must be blocked until +// the visitor gives explicit consent; OPT_OUT means cookies may fire +// immediately but the visitor must be offered a way to opt out. +// +// When the regulation is unknown or RegulationNone, it returns an empty +// string so the caller can fall back to the banner's configured default. +func ConsentModeForRegulation(r Regulation) string { + switch r { + case RegulationGDPR, + RegulationUKGDPR, + RegulationFADP, + RegulationPOPIA, + RegulationPDPA, + RegulationPIPL, + RegulationPIPA, + RegulationDPDP, + RegulationPDPL: + return ConsentModeOptIn + + case RegulationCCPA, + RegulationPIPEDA, + RegulationLGPD, + RegulationLFPDPPP, + RegulationAPPI: + return ConsentModeOptOut + + default: + return "" + } +} diff --git a/pkg/geoloc/service.go b/pkg/geoloc/service.go index f8e36412d..191cee279 100644 --- a/pkg/geoloc/service.go +++ b/pkg/geoloc/service.go @@ -89,17 +89,45 @@ func (s *Service) ImportFromDir(ctx context.Context, dataDir string) error { ) } -func (s *Service) LookupCountry(ctx context.Context, conn pg.Querier, ip string) (coredata.CountryCode, error) { +func (s *Service) LookupCountry(ctx context.Context, ip string) (coredata.CountryCode, error) { parsed := net.ParseIP(ip) if parsed == nil { return "", fmt.Errorf("cannot parse IP address: %q", ip) } - return coredata.LookupCountryByIP(ctx, conn, ip) + var cc coredata.CountryCode + + err := s.pgClient.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var lookupErr error + cc, lookupErr = coredata.LookupCountryByIP(ctx, conn, ip) + return lookupErr + }, + ) + if err != nil { + return "", err + } + + return cc, nil } -func (s *Service) IsPopulated(ctx context.Context, conn pg.Querier) (bool, error) { - return coredata.IsIPCountryBlocksPopulated(ctx, conn) +func (s *Service) IsPopulated(ctx context.Context) (bool, error) { + var populated bool + + err := s.pgClient.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var lookupErr error + populated, lookupErr = coredata.IsIPCountryBlocksPopulated(ctx, conn) + return lookupErr + }, + ) + if err != nil { + return false, err + } + + return populated, nil } func parseCIDRFile(path string) ([]string, error) { diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 5b8d08b96..d8bcf63b2 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -262,21 +262,11 @@ func (impl *Implm) Run( } geolocService := geoloc.NewService(pgClient) - err = pgClient.WithConn( - ctx, - func(ctx context.Context, conn pg.Querier) error { - populated, err := geolocService.IsPopulated(ctx, conn) - if err != nil { - return err - } - if !populated { - l.Warn("IP geolocation table is empty; run geoloc-import to populate it") - } - return nil - }, - ) + populated, err := geolocService.IsPopulated(ctx) if err != nil { l.ErrorCtx(ctx, "cannot check geoloc table", log.Error(err)) + } else if !populated { + l.Warn("IP geolocation table is empty; run geoloc-import to populate it") } hp, err := passwdhash.NewProfile(pepper, uint32(impl.cfg.Auth.Password.Iterations)) @@ -538,6 +528,7 @@ func (impl *Implm) Run( AccessReview: accessReviewService, Mailman: mailmanService, CookieBanner: cookieBannerService, + Geoloc: geolocService, Slack: slackService, ConnectorRegistry: defaultConnectorRegistry, BaseURL: baseURL, diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index eab92b025..e5b127259 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -31,6 +31,7 @@ import ( "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/file" + "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -59,6 +60,7 @@ type ( Slack *slack.Service Mailman *mailman.Service CookieBanner *cookiebanner.Service + Geoloc *geoloc.Service Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry @@ -192,6 +194,7 @@ func NewServer(cfg Config) (*Server, error) { cookieBannerHandler: cookiebanner_v1.NewMux( cfg.Logger.Named("cookiebanner.v1"), cfg.CookieBanner, + cfg.Geoloc, ), filesHandler: files_v1.NewMux( cfg.Logger.Named("files.v1"), diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 2659e9388..0e1fbc64b 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -27,6 +27,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/server/api/clientip" "go.probo.inc/probo/pkg/server/jsonutil" @@ -35,15 +36,18 @@ import ( type Handler struct { logger *log.Logger cookieBannerSvc *cookiebanner.Service + geolocSvc *geoloc.Service } func NewMux( logger *log.Logger, cookieBannerSvc *cookiebanner.Service, + geolocSvc *geoloc.Service, ) *chi.Mux { h := &Handler{ logger: logger, cookieBannerSvc: cookieBannerSvc, + geolocSvc: geolocSvc, } r := chi.NewMux() @@ -59,6 +63,11 @@ func NewMux( return r } +type configResponse struct { + *cookiebanner.BannerConfig + Regulation cookiebanner.Regulation `json:"regulation"` +} + func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { @@ -83,7 +92,31 @@ func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { return } - httpserver.RenderJSON(w, http.StatusOK, config) + regulation := h.resolveRegulation(r) + if cm := cookiebanner.ConsentModeForRegulation(regulation); cm != "" { + config.ConsentMode = cm + } + + httpserver.RenderJSON(w, http.StatusOK, configResponse{ + BannerConfig: config, + Regulation: regulation, + }) +} + +func (h *Handler) resolveRegulation(r *http.Request) cookiebanner.Regulation { + ip := clientip.Extract(r) + + cc, err := h.geolocSvc.LookupCountry(r.Context(), ip) + if err != nil { + h.logger.ErrorCtx(r.Context(), "cannot resolve country for IP", log.Error(err)) + return cookiebanner.RegulationNone + } + + if cc == "" { + return cookiebanner.RegulationNone + } + + return cookiebanner.RegulationForCountry(cc) } func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/server/server.go b/pkg/server/server.go index 51423b38a..b3ee1ed8f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -30,6 +30,7 @@ import ( "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/file" + "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam/oauth2server" "go.probo.inc/probo/pkg/mailman" @@ -58,6 +59,7 @@ type Config struct { Slack *slack.Service Mailman *mailman.Service CookieBanner *cookiebanner.Service + Geoloc *geoloc.Service Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry @@ -92,6 +94,7 @@ func NewServer(cfg Config) (*Server, error) { Slack: cfg.Slack, Mailman: cfg.Mailman, CookieBanner: cfg.CookieBanner, + Geoloc: cfg.Geoloc, Cookie: cfg.Cookie, TokenSecret: cfg.TokenSecret, ConnectorRegistry: cfg.ConnectorRegistry,