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.
Legal basis¶
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 exportswindow.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.
Consent model¶
- Cookie
raklet_cookie_consent={"v":1,"analytics":bool,"marketing":bool}(1-year,SameSite=Lax,Secureon 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(namespaceServices) so every web project shares one implementation:AnalyticsAllowed(Request)/MarketingAllowed(Request). - The banner partial
_CookieConsentBanner.cshtmlis duplicated per web project (same pattern as the existing_GoogleAnalytics.cshtml).
Cookie scope — per-organisation vs shared¶
| 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.
Withdrawal paths (consent withdrawable everywhere)¶
- Privacy settings card —
Application/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. 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)¶
- 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. Raklet.Backend_GoogleTagManager— Backend_LayoutGA/Mixpanel are now gated, but a separate_GoogleTagManagerpartial exists; confirm whether any user-facing Backend layout actually loads it and extend gating if so.- Mixpanel — intentionally untouched; ENG-273 / #14325 removes it.
- DPA / §12.3 alignment — confirm the classification above matches our DPA commitment.
- Translations — translate the
CookieConsent*strings beyond English. - 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). - 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.