Skip to content

GDPR cookie consent — implementation (ENG-276)

Engineering reference for the cookie-consent mechanism. For the product narrative and decisions (build-vs-buy, per-org model, screenshots) see the product design doc.

Split out of ENG-273 (Zachęta security review, claim #2). ENG-273 removed Mixpanel + the public-IP injection (PR #14325). This work adds the consent mechanism for the remaining non-essential trackers. Shipped in PR #14330.

Under GDPR / ePrivacy, strictly-necessary (operational) cookies do not require consent. Only non-essential analytics/marketing trackers do, they must be off by default until the visitor opts in (prior consent), and the visitor must be able to withdraw consent as easily as they gave it. The implementation honours all three:

  • Trackers render server-side only after the matching category is granted (Services.CookieConsent).
  • The banner shows until a decision is recorded; default = deny.
  • Withdrawal lives in the member portal Settings → Privacy page (Application/Areas/App2/Views/Settings/Privacy.cshtml), a self-contained per-category cookie card. The banner still exports window.rakletOpenCookiePreferences() for a future footer "Cookie preferences" link; the persistent floating "Manage preferences" reopen button was removed in #14350 (redundant with the Settings page).

Tracker classification

Tracker Partial Category Audience today Gated
Google Analytics (gtag) _GoogleAnalytics Analytics everyone
Custom Google Analytics CustomGoogleAnalytics Analytics admin
RakletAnalytics (first-party PageLoad) _Analytics Analytics everyone
Mixpanel _Mixpanel Analytics everyone ✅ (until #14325 deletes it)
Microsoft Clarity (session recording) _Clarity Analytics managers / specific orgs
HubSpot _HubSpot Marketing managers
Twitter pixel _Twitter Marketing admin, prod
Facebook Pixel _FacebookPixel Marketing admin SPA / login
Bing _Bing Marketing admin SPA / login
Intercom (support chat) _Intercom Marketing¹ managers
GTM <noscript> (login) inline Analytics login (JS-off only)
Org-injected CustomJs / CustomHeadJs inline ❓ unknown per-org ❌ deferred (see below)

¹ Intercom is a live-chat/support widget — arguably "functional". Bucketed under Marketing for a conservative default. Re-classify if product decides chat is strictly operational.

  • Cookie raklet_cookie_consent = {"v":1,"analytics":bool,"marketing":bool} (1-year, SameSite=Lax, Secure on https).
  • Banner: Accept all / Reject non-essential / Manage preferences (per-category toggles).
  • Default = deny: no cookie ⇒ no analytics/marketing trackers render.
  • Granting a new category reloads so the now-allowed trackers fire the same session.
  • The reader lives in Services/CookieConsent.cs (namespace Services) so every web project shares one implementation: AnalyticsAllowed(Request) / MarketingAllowed(Request).
  • The banner partial _CookieConsentBanner.cshtml is duplicated per web project (same pattern as the existing _GoogleAnalytics.cshtml).
Surface Cookie scope Why
Member portal host-only (no Domain) per-org: each org is its own data controller; consent must not leak across orgs
Login + admin Domain = CookieDomain (raklet.org / raklet.com) Raklet's own properties; one choice spans login + admin, like www

Login/admin layouts set ViewBag.ConsentCookieDomain (mirroring Startup.Auth.CookieAuthProvider); the portal leaves it unset so the partial defaults to host-only. Known, accepted nuance: a consent given on login/admin (raklet.org-scoped) is readable by org portals on *.raklet.org, so it suppresses their banner. Org-to-org via the portal stays isolated. Strict per-org isolation (separate portal cookie name) was considered and deliberately not taken.

  1. Privacy settings cardApplication/Areas/App2/Views/Settings/Privacy.cshtml, member portal /App/Settings/Privacy: Analytics/Marketing toggles + Save, next to the existing Visibility / Communication consent cards. This is the primary withdrawal path.
  2. window.rakletOpenCookiePreferences() — JS hook still exported by the banner to reopen the preferences panel, available for a future footer "Cookie preferences" link. The persistent floating "Manage preferences" reopen button was removed in #14350 (redundant with the Settings page).

Scope covered (every server-rendered, user-facing layout that loads trackers)

App / layout Scope
Member portal — Application/Views/Shared/_LayoutNg.cshtml per-org
Legacy MVC admin — Application/Views/Shared/_Layout.cshtml per-org
Manager area — Application/Areas/Manager/Views/Shared/_Layout.cshtml per-org
Login area — Application/Areas/Login/Views/Shared/_Layout.cshtml per-org
Admin SPA — Raklet.Admin/Views/Shared/_LayoutSecured.cshtml shared (raklet.com)
Admin SPA — Raklet.Admin/Views/Shared/_LayoutSecuredNg.cshtml shared (raklet.com)
Admin SPA — Raklet.Admin/Views/Shared/_LayoutSecuredWithoutMenu.cshtml shared (raklet.com)
Login/auth — Raklet.Login/Views/Shared/_Layout.cshtml shared (raklet.com)
Backend — Raklet.Backend/Views/Shared/_Layout.cshtml inherits shared raklet.com consent

Plus direct (non-layout) tracker renders gated the same way: the _Intercom include on Raklet.Login/Views/QuickRegister/Index.cshtml and the Intercom includes on the standalone PageNotFound / InternalServerError error pages across Application (root + Login area), Raklet.Admin and Raklet.Login.

The banner renders in all environments (incl. local) so the consent UI can be verified; the trackers it gates stay behind the existing environment != "local" checks. Downstream gtag/mixpanel event calls are already guarded (if (window.gtag) / typeof mixpanel), so gating the loaders does not break the login flow.

Localization

Banner + card strings live in Resources/Strings.resx (CookieConsent* keys). English is in the neutral resx; other locales fall back to English until translated.

Open / deferred (flagged, NOT silently shipped)

  1. Org-injected CustomJs / CustomHeadJs — arbitrary per-tenant script that can carry operational code, not just tracking, so gating it risks breaking tenant functionality. Left ungated pending a product call. Options: (a) leave to tenant responsibility, (b) gate behind analytics consent, (c) add a per-org "this CustomJs is a tracker" flag.
  2. Raklet.Backend _GoogleTagManager — Backend _Layout GA/Mixpanel are now gated, but a separate _GoogleTagManager partial exists; confirm whether any user-facing Backend layout actually loads it and extend gating if so.
  3. Mixpanel — intentionally untouched; ENG-273 / #14325 removes it.
  4. DPA / §12.3 alignment — confirm the classification above matches our DPA commitment.
  5. Translations — translate the CookieConsent* strings beyond English.
  6. Footer "Cookie preferences" link — optional: wire window.rakletOpenCookiePreferences() into the portal/admin footers for an additional withdrawal entry point. Primary withdrawal path is the Settings → Privacy cookie card (the floating reopen button was removed in #14350).
  7. Post-merge verification on Test — trackers don't load on local, so the "GA fires only after consent" behaviour must be confirmed on a non-local environment after merge.