From 771dfd45ba528fcee5ac63325777f6d66c182a02 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:07:22 +0200 Subject: [PATCH] docs(compliance): record the deletion trigger for client-side storage (#1246) Answers the ISO 27001 A.8.10 finding from the compliance swarm on #1242, which read the storage inventory as saying the two localStorage keys persist forever with no deletion mechanism. Half of that was our omission: the inventory never said what logout does. ph_conv_ IS deleted on logout. posthog.reset() resets the conversations manager, which removes that single known key, and resetAnalyticsIdentity() already runs in both logout handlers before signOut(). That is what stops a shared device carrying one user's support-ticket session into the next user's. Recorded in lib/analytics/reset.ts too, because it now makes that call load-bearing for a cross-user concern rather than mere tidiness. Verified by reading the SDK, not by executing a logout, and the docs say so. seenSurvey_ genuinely has no deletion trigger and cannot have one: no PostHog bundle enumerates localStorage (zero occurrences of localStorage.key( or Object.keys(localStorage) across module.js, surveys.js and conversations.js), so nothing can discover the keys to remove them. Stated as the accepted retention position rather than left silent: the value is "true" under an opaque survey id with no personal data, and clearing it on logout would re-prompt every survey to the next person on the device and produce false survey responses. Adds the review date the A.5.9 remediation asked for, and reframes the review trigger as a pre-launch step, since Support was caught post-hoc and left the privacy page inaccurate in the interval. Co-authored-by: Claude Opus 5 (1M context) --- .compliance/Data_Classification_Handling.md | 42 +++++++++++++++++---- .compliance/ropa.yaml | 7 ++++ DECISIONS.md | 1 + lib/analytics/reset.ts | 16 ++++++-- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/.compliance/Data_Classification_Handling.md b/.compliance/Data_Classification_Handling.md index 704ce6cb..665691b7 100644 --- a/.compliance/Data_Classification_Handling.md +++ b/.compliance/Data_Classification_Handling.md @@ -100,17 +100,45 @@ or accounting content, and neither is readable across origins. They are the basis for treating this storage as strictly necessary rather than requiring consent, so a change in their content changes that assessment. -Controls: +Retention and deletion (ISO 27001 A.8.10). The two keys have different +lifecycles, and the difference is a property of the PostHog SDK, not a choice: + +- `ph_conv_` — **deleted on logout.** `resetAnalyticsIdentity()` + (`lib/analytics/reset.ts`) runs in both logout handlers before + `supabase.auth.signOut()`; `posthog.reset()` calls the conversations + manager's own reset, which removes this key. This is what stops a shared + device carrying one person's ticket session into the next person's session. + Verified by inspecting the SDK, not by executing a logout: confirm manually + when convenient. +- `seenSurvey_` — **no deletion trigger; persists until the user + clears site data.** No PostHog bundle enumerates `localStorage` (verified: + zero occurrences of `localStorage.key(` or `Object.keys(localStorage)` in + `module.js`, `surveys.js` and `conversations.js`), so the SDK cannot + discover these keys to remove them, and neither `posthog.reset()` nor our + own purge can either without a substring sweep. + + Accepted, and this is the retention position rather than an oversight: the + value is the string `"true"` under an opaque survey id, holds no personal + data, and is bounded in count by the number of surveys ever shown. Deleting + it on logout would re-prompt every survey to the next person on the device, + which is worse for the user and produces false survey responses. + +Other controls: - `lib/analytics/purge-legacy-storage.ts` removes storage from the retired Recapt processor (`recapt` / `glimt` substrings) on every boot. It - deliberately does NOT touch `seenSurvey_*` or `ph_conv_*`: clearing those - would re-prompt surveys and orphan the user's own ticket. + deliberately does NOT touch `seenSurvey_*` or `ph_conv_*`. - No cookies are set by the application or by PostHog under this configuration. Review trigger: **enabling any new PostHog product may silently add device storage**, because the products write directly to `localStorage` rather than -through the SDK's persistence setting. After enabling one, inspect -`localStorage` on production and update this section, `.compliance/ropa.yaml` -and `app/(public)/privacy/page.tsx` together. The Support product was caught -this way; the assumption that `persistence: 'memory'` was sufficient was wrong. +through the SDK's persistence setting. Treat enabling one as a change to this +section: before launch, inspect `localStorage` on production and update this +section, `.compliance/ropa.yaml` and `app/(public)/privacy/page.tsx` together, +including each new key's deletion trigger. The Support product was caught +post-hoc rather than pre-launch; the assumption that `persistence: 'memory'` +was sufficient was wrong, and it left the privacy page inaccurate in the +interval. + +Last reviewed: 2026-07-27 (PostHog Support enablement). Next review: on the +next PostHog product enablement, or 2027-01-27, whichever comes first. diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index a7fa2a83..6fef2aff 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -597,6 +597,13 @@ processing_activities: # innan användaren faktiskt kontaktar supporten. Verifierat i prod. - no_analytics_data_or_cookies_on_device - only_functional_ui_state_persisted # seenSurvey_*, ph_conv_* (uuid, ingen PII) + # Radering (A.8.10), se .compliance/Data_Classification_Handling.md: + # ph_conv_* raderas vid utloggning (resetAnalyticsIdentity -> + # posthog.reset() -> conversations-managerns egen reset). seenSurvey_* + # har ingen raderingstrigger: ingen PostHog-bundle itererar över + # localStorage, så SDK:n kan inte hitta nycklarna. Accepterat: värdet är + # "true" under ett ogenomskinligt survey-id, utan personuppgifter. + - ph_conv_cleared_on_logout # hindrar delad enhet från att ärva ärendesession - same_origin_reverse_proxy # /rl-rewrite; ingen tredjepartsvärd i CSP - sandbox_companies_never_identified - disabled_entirely_when_self_hosted diff --git a/DECISIONS.md b/DECISIONS.md index 6488261b..60ef7ab0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -619,3 +619,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-27] Dropbox web links point at /home/Apps unless DROPBOX_APP_FOLDER_NAME is set: app-folder scoped calls cannot discover where the app folder sits in the user's account, and a link into the wrong folder reads as a lost backup, so the app-name deep link is opt-in rather than guessed. [2026-07-27] performSync keeps `provider` optional, defaulting to Google Drive: it preserves the pre-Dropbox call shape (and its test suite) as the documented legacy path while all three production call sites pass a provider explicitly. [2026-07-27] Privacy page corrected after verifying prod: PostHog's conversations module writes ph_conv_ = {"widgetSessionId": uuid} to localStorage at PAGE LOAD, not when support is contacted, bypassing persistence: 'memory' exactly like surveys' seenSurvey_ flags. The page previously claimed enkäter were the only local storage, which was wrong the moment Support was enabled. Both values are functional UI state without personuppgifter (do-not-ask-again, and find-my-own-ticket), so the no-consent-banner position holds, but "inga kakor eller annan lagring" did not and is now stated accurately. Worth remembering that enabling any new PostHog product can silently add device storage: check localStorage in prod after each one rather than trusting the persistence setting. +[2026-07-27] Documented the two client-side storage keys as having DIFFERENT lifecycles, after the compliance swarm flagged ISO 27001 A.8.10 (no deletion mechanism) on the storage inventory. ph_conv_ IS deleted on logout: posthog.reset() calls the conversations manager's own reset, which removes that single known key, and resetAnalyticsIdentity() already runs in both logout handlers. That is what stops a shared device carrying one user's support-ticket session into the next user's session. seenSurvey_ genuinely has no deletion trigger, and cannot have one from the SDK: no PostHog bundle enumerates localStorage (zero occurrences of localStorage.key( or Object.keys(localStorage) across module.js, surveys.js, conversations.js), so nothing can discover the keys to remove them. Accepted as the retention position rather than an oversight: the value is "true" under an opaque survey id with no personal data, and clearing it on logout would re-prompt every survey to the next person on the device and produce false survey responses. diff --git a/lib/analytics/reset.ts b/lib/analytics/reset.ts index a3aa77c8..07f4a783 100644 --- a/lib/analytics/reset.ts +++ b/lib/analytics/reset.ts @@ -12,9 +12,19 @@ import { isAnalyticsEnabled } from './enabled' * Replaces clearRecaptIdentity() from lib/recapt.ts. That helper also had to * sweep localStorage by key prefix, because Recapt cached the uid there. We * run with `persistence: 'memory'`, so there is no cached identity to wipe: - * reset() is sufficient. (Surveys' own `seenSurvey_*` flags are deliberately - * left alone: they carry no identity, only "this browser already saw this - * survey", and clearing them would re-prompt the next person on the device.) + * reset() is sufficient. + * + * This call is load-bearing for shared devices, not just tidiness. + * `posthog.reset()` also resets the conversations manager, which removes the + * `ph_conv_` key holding the support-ticket session id. Without it the + * next person at the same browser would inherit the previous user's ticket + * session. Do not "optimise" this away when analytics is otherwise quiet. + * + * What it does NOT clear: `seenSurvey_`. No PostHog bundle enumerates + * localStorage, so the SDK cannot find those keys to delete them. That is + * accepted, and desirable: clearing them would re-prompt every survey to the + * next person on the device. See the client-side storage inventory in + * .compliance/Data_Classification_Handling.md. */ export function resetAnalyticsIdentity(): void { if (typeof window === 'undefined') return