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,