Add geolocation-based privacy regulation detection to cookie banner

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-06 20:28:24 +04:00
parent 11d70c0d0d
commit d5394317bb
6 changed files with 238 additions and 18 deletions

View File

@@ -0,0 +1,162 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 ""
}
}

View File

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

View File

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

View File

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

View File

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

View File

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