Skip to main content
Petanque Life

Privacy & Consent

F02.05 20 features Shipped

At a glance

Privacy & Consent operationalises GDPR across the entire platform: data export, 30-day grace deletion with cascading anonymisation, granular consent records, parental consent for minors, per-user privacy settings on profiles, append-only data-access audit, cookie consent for both authenticated and anonymous visitors, support-access consent that gates sys-operator impersonation, and a self-service support portal that surfaces every consent decision and audit event back to the user.

How it works

Privacy is enforced through a small set of dedicated models and a single user-facing surface. ConsentRecord captures explicit opt-ins by type — marketing, photo_publication, data_sharing, analytics — and every change is timestamped and audited. ParentalConsent extends the model for minors: when a profile's age falls below the tenant's threshold, consent endpoints refuse to record an opt-in until a verified guardian has approved, with verification recorded against a guardian auth identity.

PrivacySettings is embedded in PlayerProfile and controls what other users see — profile visibility, results, ranking, club affiliation, date of birth — with hidden profiles returning 404 to non-owners and public search respecting the same visibility. CookieConsentRecord covers four canonical categories (essential, functional, analytics, marketing) and supports both authenticated users and anonymous browsers via dual subject identification. DataRetentionPolicy is configurable per tenant, telling background jobs when to archive, anonymise or delete categories of data.

GDPR data subject rights are exposed under /me: GET /me/data-export streams a JSON archive; POST /me/data-deletion-request begins the 30-day grace window before anonymisation cascades through results, rankings and audit references. DataAccessAuditLog is an append-only record of every read of personal data; users can pull their own access history under GET /data-access-audit/me (Art. 15). Support-access consent is the user's veto on operator impersonation: the support portal at /me/support shows pending grant requests linked to a SysSupportTicket, and the user must approve before any sys-operator can act on their account.

An SSE stream pushes new requests to an in-app banner, the activity-summary endpoint aggregates exactly how many reads/writes the operator performed, and notification preferences let the user pick email/push/in-app channels (break-glass transitions bypass opt-outs by compliance rule). The self-view activity timeline (GET /me/activity-timeline) surfaces login, billing, notification, support-grant, incident and legal events in one stream, with operator names masked according to per-tenant impersonation_actor_visibility.

Key capabilities

  • GDPR data export and 30-day grace deletion with cascading anonymisation
  • ConsentRecord, ParentalConsent (minors) and CookieConsentRecord across four categories
  • PrivacySettings embedded in PlayerProfile with field-level visibility control
  • DataAccessAuditLog append-only with self-view (Art. 15) endpoint
  • Support-access consent gating sys-operator impersonation, with SSE notifier
  • User support portal aggregating consent decisions, tickets and history
  • Self-view activity timeline and per-channel notification preferences with break-glass override

In practice

A user in Norway opens /me/support after spotting a banner about a pending support request. The portal shows a Norwegian sys-operator asking to view her account to investigate a billing complaint she filed earlier; the request links to her ticket. She approves the grant for one hour.

While the operator works, an SSE-driven activity counter shows reads incrementing in real time. Later she opens GET /me/support/access-grants/{id}/activity-summary and confirms the operator made 12 reads and one write — exactly the refund she requested. The next day she revokes the (already expired) grant for tidiness, downloads her cookie-consent record from /me/cookie-consent/me, and pulls her full data archive via /me/data-export to verify everything is in order.

Features in this subsystem

20
ID Status Features
F02.05.01 Shipped GDPR data subject rights — GET /me/data-export, POST /me/data-deletion-request with anonymisation (PL-208, PL-F0205a). 🔁 Go-backend 525ece23.m-4 (D-USERS m-4 t-1): rena GET /v1/me/data-export — portabelt aggregat av den data D-USERS äger (identitet med maskerad e-post, profiler i alla tenants + privacy, foto-metadata, check_in_record utan token, samtycke=unavailable tills D-GDPR finns); ren /me-ägar-väg, inga secrets (api/internal/http/dusers_gdpr.go, specs/api/endpoints/me-data-export.md). POST /v1/me/data-deletion-request + /v1/me/consents graderas till m-4b (gated på D-GDPR:s erasure-request_type resp. samtyckessubstrat, §5d.1). app-UI 525ece23.m-4 t-2: Inställningar → Integritet & data → "Ladda ner min data" konsumerar GET /v1/me/data-export (app/app/integritet-och-data.tsx, specs/app/views/integritet-och-data.md); samtycke/radering listas som "kommande", wiras ej. 🔁 D-GDPR m-2 (742fe3db.m-2 t-2 + t-3): den FORMELLA DSAR-exporten (Art. 15/20) ligger BREDVID D-USERS snabb-JSON och ersätter den inte — POST /v1/me/data-export (202 ny · 200 idempotent replay) → F1-7-jobbet gdpr_data_export → signerat zip-arkiv (manifest + HMAC-signatur + README + tio kategorier som .json OCH .csv) bakom en engångstoken som går ut efter 7 dygn; fail-hard (ett fel i någon aggregator ⇒ failed, inget arkiv, ingen token — aldrig en delleverans). app-UI t-3: sektionen FULL EXPORT med skärm-kontraktets tre tillstånd (kö · klart med "länken går ut om N dagar" + *Ladda ner signerat arkiv* · fel-kortet som säger att vi inte levererar en ofullständig fil). 🔁 D-GDPR m-3 (742fe3db.m-3): radering (Art. 17) är byggd — migration 00128 (data_deletion_request, data_retention_policy, erasure_legal_hold), det normativa RetentionGuard/OnErasure-kontraktet (api/internal/retention, specs/api/endpoints/retention-guard.md) och sju domänmoduler i api/erasure_registry.go. Rutter: POST /v1/me/data-deletion-request (self, 409 deletion_request_in_progress som databasinvariant), GET /v1/me/data-deletion-requests, POST /v1/me/data-deletion-request/{id}/cancel (409 deletion_request_not_cancellable) samt förbundets läsvyer GET /v1/data-deletion-requests[/{id}[/proof]] (capability gdpr:erasure.manage). Beslutet ägs av F1-6 (approve/reject/withdraw på de generiska rutterna) — aldrig auto-beviljat, och SelfDecisionGuard gör att den sökande aldrig kan besluta sin egen radering; m-3 bygger medvetet ingen egen /process-FSM. 30 dygns ångerfrist räknad från begäran; jobben gdpr_data_erasure + gdpr_erasure_maintenance kör anonymiseringen i EN transaktion och skriver ett signerat deletion_proof vars fullständighet prövas mot täckningsmanifestet (annars proof_incomplete:<tabell>). app-UI t-3: sektionen Radering i Integritet & data (persistent "Raderas om N dagar"-banner + *Avbryt raderingen*, formulär med förbundsval/skäl/bekräftelse, listorna DETTA RADERAS / DETTA BEHÅLLS). admin-UI t-3: GDPR-konsolens flik Raderingar ([raderad] för slutförda, bevis-nedladdning, "Öppna ärendet →" till F1-6). Bevisat mot körande stack av tools/gate/dgdpr-m3-app-e2e.mjs + tools/gate/dgdpr-m3-admin-e2e.mjs. ✅ PL-F0205a
F02.05.02 Shipped Data retention policies — configurable per tenant via DataRetentionPolicy model (PL-208, PL-F0205a) ✅ PL-F0205a
F02.05.03 Shipped Consent management — ConsentRecord model with marketing, photo_publication, data_sharing, analytics types (PL-208, PL-F0205a) ✅ PL-F0205a
F02.05.04 Shipped Minor/youth data protection — ParentalConsent model with guardian verification, blocks consent for minors without parental approval (PL-208, PL-F0205a) ✅ PL-F0205a | 🔁 Go-backend 742fe3db.m-1 (D-GDPR m-1 t-1): art. 8-grinden läser tenantens gdpr_tenant_config.consent_age (GDPR art. 8(1): 13–16 per land — SE 13, FR 15, DE/NL 16 — default 16 = EU-takvärdet), aldrig hårdkodat 18; den avtalsrättsliga 18-årsmyndigheten (medlemskap/licens) ägs av D-MEMBER och blandas aldrig in. Ett barn under tröskeln kräver parental_consent med status='verified' OCH scopet i scope_keys — pending räcker aldrig (403 parental_consent_required). Grinden går inte att självbetjäna: en POST /v1/me/parental-consent från den minderåriga själv som breddar scope_keys på en redan verifierad rad (eller byter målsman/metod) återställer raden till pending och nollar verified_at — inget nytt scope öppnas förrän en consent:manage-aktör verifierat på nytt (parental_consent.reopened i audit). Verifieringsvägen: POST /v1/parental-consents/{id}/verify (capability consent:manage); konfigytan GET/PUT /v1/gdpr/config (capability gdpr:manage, 422 consent_age_out_of_range utanför 13–16). Se specs/api/endpoints/gdpr-config.md. app-UI 742fe3db.m-1 t-2: guardian-grinden i Integritet & data — ett 403 parental_consent_required möts av ett varsamt, klarspråkligt kort (aldrig en rå felkod) som skickar målsmans-godkännandet som pending och säger uttryckligen att det gäller först när förbundet verifierat det (app/components/Samtycken.tsx, specs/app/views/integritet-och-data.md). admin-UI 742fe3db.m-1 t-2: /gdpr fliken Samtyckesålder — 13–16 med optimistisk låsning, referenstabell per nation och callouten "Två trösklar — blandas aldrig" (specs/admin/views/gdpr.md).
F02.05.05 Shipped Privacy settings per user — PrivacySettings embedded in PlayerProfile (profile visibility, results, ranking, club, DOB) — GET/PUT /player-profiles/{id}/privacy-settings subresource, owner-only update, hidden profiles return 404 to non-owners, public search respects visibility ✅ PL-F0205b | 🔁 Go-backend: player_profile.privacy (jsonb: profile_public/results_public/ranking_public/club_public/dob_public) ägs av D-USERS (GET/PUT /v1/player-profiles/{id}/privacy-settings, ägar-yta, dold profil ⇒ 404); D-GDPR m-2 t-1 läser blocket in i aggregatet GET /v1/me/privacy-overview men äger det inte.
F02.05.06 Shipped Audit log of all data access — DataAccessAuditLog append-only model, record_access() best-effort service, GET /data-access-audit admin listing, GET /data-access-audit/me GDPR Art. 15 right-of-access, audit rows on profile reads/lists/exports ✅ PL-F0205b | 🔁 Go-backend 742fe3db.m-2 (D-GDPR m-2 t-1): ren tabell data_access_audit (migration 00127) — append-only via DB-trigger (UPDATE/DELETE RAISE:ar) och hash-kedjad per tenant (chain_seq + chain_hash = sha256(prev ‖ row_digest), serialiserad med pg_advisory_xact_lock, unique (tenant_id, chain_seq)); target_identity_id bär MEDVETET ingen FK så raden överlever art. 17-raderingen i m-3. Instrumenteringen är ett deklarativt registry (rutt → action/target_type/syfte): profilläsningar av någon annan än ägaren (player/official/coach/administrator), GET /v1/memberships (en rad per registrerad på sidan, i EN sats) och player-search (en rad per träff, aldrig söksträngen i reason); ägarens egen läsning skriver noll rader. Fail-closed (NFR-8): en läsning som inte kunde loggas levereras inte — problem+json utan ett fält ur profilen (bevisas med sömmen PL_GDPR_ACCESS_LOG_FAIL=1, inert utanför local/test). Den registrerades ytor: GET /v1/me/privacy/access (art. 15, keyset-paginerad, aktörsprojektion med namn/rolletikett + masked-krok för D-SYS/D-SAFE-källor som inte fejkas), GET /v1/me/privacy-overview (ETT anrop för hela skärmen) och POST /v1/me/privacy/flag-access (data_access_flag open/high + gdpr.access_flag.created; främmande och okänd id ⇒ samma 422 access_flag_invalid_reference). Privacy-officer-vyn GET /v1/data-access-audit (+ /flags) bakom nya capabilityn gdpr:access_audit.read bär ip_address/user_agent/route/chain_seq — aldrig innehållet i det som lästes. Kedjan verifieras med api gdpr-verify-access-chain <tenant_id> (exit 0 hel / 1 bruten, pekar ut exakt chain_seq). UI 742fe3db.m-2 t-3: app visar åtkomstraderna tidsordnat i aktivitetsloggen (merge av /v1/me/activity-timeline + /v1/me/privacy/access) med ikon och text och en *Flagga*-åtgärd som kvitterar varsamt utan att lova utfall; admin/gdpr har fliken Åtkomstlogg (tabell med aktör/åtgärd/rutt/IP/chain_seq, filter-chips, datumintervall, "endast flaggade", keyset-paginering och flagg-panelen) bakom gdpr:access_audit.read — en roll utan capabilityn möts av ett ärligt behörighetsbesked, aldrig av en tom tabell. Bevisat mot körande stack av tools/gate/dgdpr-m2-app-e2e.mjs + tools/gate/dgdpr-m2-admin-e2e.mjs. Se specs/api/endpoints/privacy-settings.md + docs/product/app/privacy-dashboard.md + specs/admin/views/gdpr.md.
F02.05.07 Shipped Cookie consent and tracking preferences — CookieConsentRecord model with 4 canonical categories, GET /cookie-consent/categories, POST /cookie-consent upsert, GET /cookie-consent/me, GET /cookie-consent/anonymous/{id}, PATCH /cookie-consent/{id}, POST /cookie-consent/{id}/withdraw, dual subject identification authenticated+anonymous, GET /me/privacy-overview combined snapshot ✅ PL-F0205b
F02.05.08 Shipped Support-access consent — user-side portal at /me/support/access-grants for consent over sys-operator impersonation. Approve / deny / revoke grants that reference a SysSupportTicket; every transition is audited. Full contract in docs/engineering/security/support-access-consent.md; operator-side in F21.02a. ✅ PL-T141
F02.05.09 Shipped User support portal — /me/support landing aggregate in both app/ (mobile-first) and admin/ (desktop-first) with three columns (pending requests, open tickets, recent history). Surfaces consent decisions inline without navigating away. ✅ PL-T143
F02.05.10 Shipped Access-history drill-in — GET /me/support/access-grants/{id}/activity-summary aggregates sys-audit rows by impersonation_id so the target user can see exactly how many reads / writes the operator performed and when. ✅ PL-T143
F02.05.11 Shipped Real-time pending-request notifier — SSE stream GET /me/support/access-grants/stream pushes new grant requests to the in-app banner; 2-second polling loop in the service, heartbeat every 15 s, 10-minute client-reconnect cap. ✅ PL-T143
F02.05.12 Shipped User-scoped support tickets — GET/POST /me/support/tickets, GET /me/support/tickets/{id}, POST …/reply, POST …/close. Reporter-only; operator-internal notes hidden from the thread; reuses the T130 intake_ticket service. ✅ PL-T143
F02.05.13 Shipped Per-channel notification preferences — GET/PUT /me/support/notifications/preferences toggles email / push / in-app. Break-glass transitions bypass opt-outs by compliance rule. ✅ PL-T143
F02.05.14 Shipped User activity log (self-view) — GET /me/activity-timeline?user_email=…&kinds=… returns the user-facing subset of the sys-timeline (PL-T145). Login, billing, notification, support-grant, incident and legal events flow through unchanged; sys_action rows are redacted to "Support agent viewed your account" and operator identities may be masked per tenant impersonation_actor_visibility. Surfaces: app/app/settings/activity-log.tsx (mobile) + admin/app/(dashboard)/privacy/activity-log.tsx (federation admins). Read-only; scoped automatically to the caller by email. 🔁 Go-backend 525ece23.m-4 (D-USERS m-4 t-1): rena GET /v1/me/activity-timeline — självvänd projektion av audit_log (F1-5, ingen ny tabell), självet som aktör∨subjekt i aktiv tenant (ur claim, aldrig body), användarvänd action-allowlist (auth./login./session./consent./parental_consent./profile./checkin./support./impersonation.), keyset/ListEnvelope, maskering bevarad verbatim, stödåtkomst-grants med aktör+tid (api/internal/http/dusers_gdpr.go, specs/api/endpoints/me-activity-timeline.md). app-UI 525ece23.m-4 t-2: Inställningar → Integritet & data visar aktivitetsloggen (ikon+text per typ, "Visa fler"-paginering, stödåtkomst med vem+när) via GET /v1/me/activity-timeline (app/app/integritet-och-data.tsx, specs/app/views/integritet-och-data.md). ✅ PL-T145
F02.05.15 Shipped Granular consent scope versioning — ConsentScopeDefinition per (tenant_id, scope_key, version) with monotonic version-bump and auto-superseding. Categories: marketing / analytics / operational / subprocessor / photo / research. Existing user consents stay attributable to the exact text the subject saw — no auto-revoke on republish. Endpoints: GET/POST /dpo/consent-scopes, GET /dpo/consent-scopes/{key}/versions, POST /dpo/consent-scopes/{key}/retire. Audit-log category gdpr_consent_versioning. ✅ PL-T221 | 🔁 Go-backend 742fe3db.m-1 (D-GDPR m-1 t-1): rena tabeller consent_scope_definition/consent_record/parental_consent/gdpr_tenant_config (migration 00126), atomisk publish v(n+1) + superseded_by_version i SAMMA transaktion, ingen auto-revoke (beslutet mot v(n) består och flaggas stale för re-consent), idempotent retire, hård invariant "tvingande grund (legal_obligation/public_interest) måste vara revocable=false" (422). Rutter: GET/POST /v1/dpo/consent-scopes, POST /v1/dpo/consent-scopes/{scope_key}/retire (capability consent:manage) och den registrerades yta GET /v1/me/consents, POST /v1/me/consents/{scope_key}, POST /v1/me/consents/{scope_key}/revoke, POST /v1/me/parental-consent (self). En audit_log-rad per mutation i samma tx (consent.granted/consent.revoked/consent.scope.published/consent.scope.retired). Se specs/api/endpoints/consent.md. app-UI 742fe3db.m-1 t-2: Samtycken-sektionen i Integritet & data — kort per aktuellt ändamål (kategori, syftestext ur purpose_text_i18n, rättslig-grund-chip, status-pill i ord), re-consent-stepper när beslutet är stale (steg 1 visar den NYA texten, steg 2 tar beslutet) och låstext utan knappar för revocable=false; förbundsväljare eftersom samtycken är per tenant (app/lib/samtycken.tsx). admin-UI 742fe3db.m-1 t-2: /gdpr fliken Samtyckes-scopes — kategorifilter, versionshistorik, publicera-ny-version med invarianten "tvingande grund ⇒ ej återkallbar" speglad i formuläret, retire. Bevisat mot körande stack av tools/gate/dgdpr-m1-app-e2e.mjs + tools/gate/dgdpr-m1-admin-e2e.mjs.
F02.05.16 Shipped DPO-on-behalf DSAR — DpoAccessRequest model with status FSM (draft → submitted → in_review → fulfilled / rejected / cancelled). DPO files data_export / rectification / erasure / restriction / portability with mandatory legal_ground. Inline append-only audit_trail mirrored to global AuditLog (category=gdpr_dpo). Endpoints: GET/POST /dpo/access-requests, GET /dpo/access-requests/{id}, POST .../submit, POST .../cancel. Cancel blocked once fulfilment_started_at is set. ✅ PL-T221
F02.05.17 Shipped DSAR-job proxy for DPO portal — GET /dpo/data-export-jobs lists GdprPortabilityExport jobs initiated through the DPO console; GET /dpo/data-export-jobs/{id} returns status + signed download URL + expires_at. Reuses F16-DSAR pipeline — PL-T221 only initiates and links. ✅ PL-T221
F02.05.18 Shipped Cross-tenant DPO overview — POST /dpo/cross-tenant-overview for international DPOs (CEP/FIPJP) holding gdpr:dpo:read in 1–20 tenants. Returns per-tenant {tenant_id, open_requests, active_scopes}. In-memory parallel fan-out — no automatic cross-tenant DB join. Operator's own tenant always allowed; system-scope tokens get full body.tenants. ✅ PL-T221
F02.05.19 Shipped DPO capability gates — new capabilities gdpr:dpo:read, gdpr:dpo:write. Capability-deny logged to AuditLog with action=capability.deny so federation security teams can chase unauthorised attempts. System-scope tokens implicitly hold all capabilities. ✅ PL-T221
F02.05.20 Shipped DPO admin console — /(dashboard)/dpo (overview), /dpo/consent-scopes (publish + version history + retire), /dpo/access-requests (kanban with 6 columns + detail with shared AuditTrail component), /dpo/cross-tenant (per-tenant tile with setActiveTenant deep-link). Hidden for affiliate / venue / individual tenant types. ✅ PL-T221