M24: implement privacy governance

This commit is contained in:
NuklearRabbit
2026-08-10 15:56:03 +02:00
parent f0f1be83ae
commit 0935901f11
29 changed files with 920 additions and 11 deletions
+5
View File
@@ -40,6 +40,11 @@ BACKUP_MINIMUM_COPIES=7
BACKUP_SECONDARY_DESTINATION=
MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite
# Privacy governance defaults.
PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS=30
PRIVACY_AUDIT_RETENTION_DAYS=2555
PRIVACY_AUDIT_EXPORT_MAX_ROWS=10000
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
+20
View File
@@ -2554,3 +2554,23 @@ evidence yet."
disposable PostgreSQL instance produced a real dump, checksum verification passed and
`pg_restore --list` accepted the artifact. Exact next action: implement privacy export,
anonymisation safeguards, retention reporting and governance documentation.
## M24 — executable privacy governance (2026-08-10)
- Added a manager-only Privacy workspace with persisted policy metrics, customer dossier
export, bounded audit CSV export and irreversible customer anonymisation. Privacy is
localized in all three supported languages, searchable and hidden from rental staff.
- Anonymisation is row-locked and requires the exact stable customer reference plus a
reason. Reserved/active bookings and bookings inside the configurable minimum retention
window block the action. PII is cleared while stable references and operational history
remain valid; repeated requests are idempotent.
- Every export and anonymisation is audited. The anonymisation audit records state and
justification but deliberately never copies erased PII. Audit CSV ranges are capped at
90 days and a configurable maximum row count.
- Added explicit customer anonymisation state/migration `b913a72e8c14`, a governance
runbook covering inventory, retention, data-subject requests, access review and incident
handling, plus regenerated OpenAPI.
- Evidence: privacy API **6 passed without warnings**; ruff/mypy clean; React review led
to stable callback/effect dependencies and a lazy route chunk; TypeScript, lint and
production build pass. Exact next action: extend RAGcore corpus statistics and health
evidence, then run complete acceptance and deploy all production-readiness milestones.
@@ -0,0 +1,24 @@
"""add explicit customer anonymisation state
Revision ID: b913a72e8c14
Revises: a81d0ce9f662
"""
import sqlalchemy as sa
from alembic import op
revision = "b913a72e8c14"
down_revision = "a81d0ce9f662"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("customers", sa.Column("anonymized_at", sa.DateTime(timezone=True)))
op.create_index("ix_customers_anonymized_at", "customers", ["anonymized_at"])
def downgrade() -> None:
op.drop_index("ix_customers_anonymized_at", table_name="customers")
op.drop_column("customers", "anonymized_at")
+77 -2
View File
@@ -1,23 +1,30 @@
from __future__ import annotations
import csv
import io
import json
import uuid
from collections.abc import Sequence
from datetime import datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
settings = get_settings()
# Only entity types with a stable public reference and (optionally) a real frontend route
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
@@ -37,6 +44,74 @@ _ROUTE_TEMPLATES: dict[str, str] = {
}
@router.get("/export.csv")
def export_audit_csv(
occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> Response:
end = occurred_to or datetime.now(UTC)
start = occurred_from or end - timedelta(days=30)
if end <= start or end - start > timedelta(days=90):
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
events = db.scalars(
select(AuditEvent)
.where(AuditEvent.occurred_at >= start, AuditEvent.occurred_at <= end)
.order_by(AuditEvent.occurred_at)
.limit(settings.privacy_audit_export_max_rows + 1)
).all()
if len(events) > settings.privacy_audit_export_max_rows:
raise HTTPException(status_code=413, detail="Audit export exceeds configured row limit")
output = io.StringIO(newline="")
writer = csv.writer(output)
writer.writerow(
(
"id",
"occurred_at",
"actor_type",
"actor_label",
"action",
"entity_type",
"entity_id",
"correlation_id",
"before",
"after",
"metadata",
)
)
for event in events:
writer.writerow(
(
event.id,
event.occurred_at.isoformat(),
event.actor_type,
event.actor_label,
event.action,
event.entity_type,
event.entity_id or "",
event.correlation_id,
json.dumps(event.before_json, separators=(",", ":"), default=str),
json.dumps(event.after_json, separators=(",", ":"), default=str),
json.dumps(event.metadata_json, separators=(",", ":"), default=str),
)
)
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="audit_exported",
entity_type="audit",
metadata={"from": start.isoformat(), "to": end.isoformat(), "rows": len(events)},
)
db.commit()
return Response(
output.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="mobilityops-audit.csv"'},
)
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
ids_by_type: dict[str, set[uuid.UUID]] = {}
for event in events:
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.models.booking import Booking
from app.models.customer import Customer
from app.schemas import (
CurrentUser,
CustomerAnonymizeRequest,
CustomerAnonymizeResult,
PrivacyRetentionOut,
)
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"])
settings = get_settings()
def _retention_cutoff() -> datetime:
return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days)
def _customer_is_eligible(db: Session, customer_id) -> bool:
blocking = db.scalar(
select(func.count())
.select_from(Booking)
.where(
Booking.customer_id == customer_id,
or_(
Booking.status.in_(("reserved", "active")),
Booking.ends_at > _retention_cutoff(),
),
)
)
return not blocking
@router.get("/retention", response_model=PrivacyRetentionOut)
def retention_status(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> PrivacyRetentionOut:
customers = db.scalars(select(Customer)).all()
return PrivacyRetentionOut(
minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days,
audit_retention_days=settings.privacy_audit_retention_days,
customers_total=len(customers),
customers_anonymized=sum(customer.anonymized_at is not None for customer in customers),
customers_eligible=sum(
customer.anonymized_at is None and _customer_is_eligible(db, customer.id)
for customer in customers
),
)
@router.get("/customers/{public_ref}/export")
def export_customer_data(
public_ref: str,
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> JSONResponse:
customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref))
if customer is None:
raise HTTPException(status_code=404, detail="Customer not found")
bookings = db.scalars(
select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at)
).all()
payload = {
"generated_at": datetime.now(UTC).isoformat(),
"customer": {
"public_ref": customer.public_ref,
"first_name": customer.first_name,
"last_name": customer.last_name,
"email": customer.email,
"phone": customer.phone,
"postal_code": customer.postal_code,
"city": customer.city,
"date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None,
"anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None,
},
"bookings": [
{
"public_ref": booking.public_ref,
"starts_at": booking.starts_at.isoformat(),
"ends_at": booking.ends_at.isoformat(),
"status": booking.status,
}
for booking in bookings
],
}
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="privacy_customer_exported",
entity_type="customer",
entity_id=customer.id,
metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)},
)
db.commit()
return JSONResponse(
payload,
headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'},
)
@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult)
def anonymize_customer(
public_ref: str,
body: CustomerAnonymizeRequest,
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> CustomerAnonymizeResult:
customer = db.scalar(
select(Customer).where(Customer.public_ref == public_ref).with_for_update()
)
if customer is None:
raise HTTPException(status_code=404, detail="Customer not found")
if body.confirmation != public_ref:
raise HTTPException(
status_code=422, detail="Customer reference confirmation does not match"
)
if customer.anonymized_at is not None:
return CustomerAnonymizeResult(
public_ref=public_ref,
anonymized_at=customer.anonymized_at,
status="already_anonymized",
)
if not _customer_is_eligible(db, customer.id):
raise HTTPException(
status_code=409,
detail="Customer has an active/recent booking within the minimum retention period",
)
anonymized_at = datetime.now(UTC)
customer.first_name = "Anoniem"
customer.last_name = public_ref
customer.email = None
customer.phone = None
customer.postal_code = None
customer.city = None
customer.date_of_birth = None
customer.anonymized_at = anonymized_at
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="privacy_customer_anonymized",
entity_type="customer",
entity_id=customer.id,
before={"anonymized": False},
after={"anonymized": True},
metadata={"reason": body.reason, "customer_ref": public_ref},
)
db.commit()
return CustomerAnonymizeResult(
public_ref=public_ref,
anonymized_at=anonymized_at,
status="anonymized",
)
+6
View File
@@ -89,6 +89,12 @@ _SECTIONS: list[dict] = [
"terms": ["audit", "history", "geschiedenis", "historique"],
"role": "operations_manager",
},
{
"id": "privacy",
"link": "/privacy",
"terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"],
"role": "operations_manager",
},
]
+3
View File
@@ -63,6 +63,9 @@ class Settings(BaseSettings):
oidc_default_role: str = "rental_employee"
log_level: str = "INFO"
metrics_bearer_token: str = ""
privacy_minimum_booking_retention_days: int = 30
privacy_audit_retention_days: int = 2555
privacy_audit_export_max_rows: int = 10000
@lru_cache
+2
View File
@@ -21,6 +21,7 @@ from app.api.routers import (
knowledge,
mcp_integrations,
observability,
privacy,
search,
users,
vehicles,
@@ -183,3 +184,4 @@ app.include_router(search.router)
app.include_router(integration_status.router)
app.include_router(users.router)
app.include_router(observability.router)
app.include_router(privacy.router)
+3 -2
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import date
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, String
from sqlalchemy import Date, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -23,3 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id")
)
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+19
View File
@@ -384,6 +384,25 @@ class OidcStatusOut(BaseModel):
provider_name: str | None = None
class PrivacyRetentionOut(BaseModel):
minimum_booking_retention_days: int
audit_retention_days: int
customers_total: int
customers_anonymized: int
customers_eligible: int
class CustomerAnonymizeRequest(BaseModel):
confirmation: str = Field(min_length=1, max_length=20)
reason: str = Field(min_length=8, max_length=500)
class CustomerAnonymizeResult(BaseModel):
public_ref: str
anonymized_at: datetime
status: Literal["anonymized", "already_anonymized"]
class N8nWorkflowEvidence(BaseModel):
name: str
built: bool
+96
View File
@@ -0,0 +1,96 @@
from sqlalchemy import delete, select
from app.core.db import SessionLocal
from app.models.audit import AuditEvent
from app.models.customer import Customer
def test_privacy_retention_uses_persisted_counts(ops_client):
response = ops_client.get("/api/v1/privacy/retention")
assert response.status_code == 200
body = response.json()
assert body["customers_total"] >= 180
assert body["minimum_booking_retention_days"] == 30
assert body["audit_retention_days"] == 2555
def test_privacy_retention_requires_manager(employee_client):
assert employee_client.get("/api/v1/privacy/retention").status_code == 403
def test_customer_export_is_downloadable_and_audited_without_mutation(ops_client):
response = ops_client.get("/api/v1/privacy/customers/CUS-0001/export")
assert response.status_code == 200
assert response.headers["content-disposition"] == 'attachment; filename="CUS-0001-privacy.json"'
assert response.json()["customer"]["public_ref"] == "CUS-0001"
assert isinstance(response.json()["bookings"], list)
events = ops_client.get("/api/v1/audit", params={"action": "privacy_customer_exported"}).json()
assert any(event["metadata"]["customer_ref"] == "CUS-0001" for event in events)
def test_active_customer_cannot_be_anonymized(ops_client):
response = ops_client.post(
"/api/v1/privacy/customers/CUS-0042/anonymize",
json={"confirmation": "CUS-0042", "reason": "Verified erasure request"},
)
assert response.status_code == 409
def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_client):
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-PRIVACY",
first_name="Private",
last_name="Person",
email="private.person@example.test",
phone="+32000000000",
postal_code="1000",
city="Brussels",
date_of_birth=None,
)
db.add(customer)
db.commit()
customer_id = customer.id
try:
mismatch = ops_client.post(
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
json={"confirmation": "CUS-WRONG", "reason": "Verified erasure request"},
)
assert mismatch.status_code == 422
response = ops_client.post(
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
json={"confirmation": "CUS-PRIVACY", "reason": "Verified erasure request"},
)
assert response.status_code == 200
assert response.json()["status"] == "anonymized"
with SessionLocal() as db:
customer = db.scalar(select(Customer).where(Customer.id == customer_id))
assert customer is not None
assert customer.email is None
assert customer.phone is None
assert customer.first_name == "Anoniem"
event = db.scalar(
select(AuditEvent)
.where(AuditEvent.action == "privacy_customer_anonymized")
.order_by(AuditEvent.occurred_at.desc())
)
assert event is not None
serialized = f"{event.before_json}{event.after_json}{event.metadata_json}"
assert "private.person@example.test" not in serialized
finally:
with SessionLocal() as db:
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
db.execute(delete(Customer).where(Customer.id == customer_id))
db.commit()
def test_audit_csv_export_is_bounded_and_audited(ops_client):
response = ops_client.get("/api/v1/audit/export.csv")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")
assert response.text.startswith("id,occurred_at,actor_type")
invalid = ops_client.get(
"/api/v1/audit/export.csv",
params={"occurred_from": "2025-01-01T00:00:00Z", "occurred_to": "2026-01-01T00:00:00Z"},
)
assert invalid.status_code == 422
+3
View File
@@ -54,6 +54,9 @@ services:
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-rental_employee}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
METRICS_BEARER_TOKEN: ${METRICS_BEARER_TOKEN:-}
PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS: ${PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS:-30}
PRIVACY_AUDIT_RETENTION_DAYS: ${PRIVACY_AUDIT_RETENTION_DAYS:-2555}
PRIVACY_AUDIT_EXPORT_MAX_ROWS: ${PRIVACY_AUDIT_EXPORT_MAX_ROWS:-10000}
logging:
driver: json-file
options:
+221
View File
@@ -139,6 +139,43 @@ paths:
additionalProperties: true
type: object
title: Response Demo Reset Api V1 Demo Reset Post
/api/v1/auth/oidc/status:
get:
tags:
- auth
summary: Oidc Status
operationId: oidc_status_api_v1_auth_oidc_status_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/OidcStatusOut'
/api/v1/auth/oidc/login:
get:
tags:
- auth
summary: Oidc Login
operationId: oidc_login_api_v1_auth_oidc_login_get
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
/api/v1/auth/oidc/callback:
get:
tags:
- auth
summary: Oidc Callback
operationId: oidc_callback_api_v1_auth_oidc_callback_get
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
/api/v1/auth/login:
post:
tags:
@@ -762,6 +799,43 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/audit/export.csv:
get:
tags:
- audit
summary: Export Audit Csv
operationId: export_audit_csv_api_v1_audit_export_csv_get
parameters:
- name: occurred_from
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Occurred From
- name: occurred_to
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Occurred To
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/audit:
get:
tags:
@@ -1969,6 +2043,76 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/privacy/retention:
get:
tags:
- privacy
summary: Retention Status
operationId: retention_status_api_v1_privacy_retention_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/PrivacyRetentionOut'
/api/v1/privacy/customers/{public_ref}/export:
get:
tags:
- privacy
summary: Export Customer Data
operationId: export_customer_data_api_v1_privacy_customers__public_ref__export_get
parameters:
- name: public_ref
in: path
required: true
schema:
type: string
title: Public Ref
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/privacy/customers/{public_ref}/anonymize:
post:
tags:
- privacy
summary: Anonymize Customer
operationId: anonymize_customer_api_v1_privacy_customers__public_ref__anonymize_post
parameters:
- name: public_ref
in: path
required: true
schema:
type: string
title: Public Ref
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerAnonymizeRequest'
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerAnonymizeResult'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
ApplyRecommendedStatusRequest:
@@ -2613,6 +2757,44 @@ components:
- display_name
- role
title: CurrentUser
CustomerAnonymizeRequest:
properties:
confirmation:
type: string
maxLength: 20
minLength: 1
title: Confirmation
reason:
type: string
maxLength: 500
minLength: 8
title: Reason
type: object
required:
- confirmation
- reason
title: CustomerAnonymizeRequest
CustomerAnonymizeResult:
properties:
public_ref:
type: string
title: Public Ref
anonymized_at:
type: string
format: date-time
title: Anonymized At
status:
type: string
enum:
- anonymized
- already_anonymized
title: Status
type: object
required:
- public_ref
- anonymized_at
- status
title: CustomerAnonymizeResult
CustomerOptionOut:
properties:
public_ref:
@@ -3554,6 +3736,20 @@ components:
- starts_at
- at_risk
title: NextBookingRisk
OidcStatusOut:
properties:
enabled:
type: boolean
title: Enabled
provider_name:
anyOf:
- type: string
- type: 'null'
title: Provider Name
type: object
required:
- enabled
title: OidcStatusOut
OperationsSummaryOut:
properties:
tenant:
@@ -3583,6 +3779,31 @@ components:
- email
- password
title: PasswordLoginRequest
PrivacyRetentionOut:
properties:
minimum_booking_retention_days:
type: integer
title: Minimum Booking Retention Days
audit_retention_days:
type: integer
title: Audit Retention Days
customers_total:
type: integer
title: Customers Total
customers_anonymized:
type: integer
title: Customers Anonymized
customers_eligible:
type: integer
title: Customers Eligible
type: object
required:
- minimum_booking_retention_days
- audit_retention_days
- customers_total
- customers_anonymized
- customers_eligible
title: PrivacyRetentionOut
ProcedureDocumentOut:
properties:
id:
+2
View File
@@ -3,6 +3,8 @@
For the demo-specific 5-minute/10-minute walkthroughs, reset behaviour, Unraid
redeploy/rollback steps and troubleshooting, see `docs/demo-release/demo-runbook.md`.
This document covers general environment bootstrap and n8n setup.
Privacy retention, data-subject exports, anonymisation and incident governance are
defined in `docs/18-privacy-governance.md`.
## Bootstrap (clean checkout)
+50
View File
@@ -0,0 +1,50 @@
# Privacy and data governance
## Scope and ownership
MobilityOps stores operational users, customers, bookings, vehicle inspections,
maintenance, data-quality issues, workflow state and audit events. The deployed demo
contains synthetic data only. Before introducing real data, the deploying organisation
must document its controller/processor roles, lawful bases, contact point and approved
retention values. Operations Managers are the only role permitted to export or anonymise.
## Retention policy
- Customer PII may be anonymised only when no reserved or active booking exists and the
newest booking is older than `PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS` (default 30).
- Audit events are configured for 2,555 days by default. They are immutable operational
evidence; changing this period requires legal approval and a separate, audited purge
implementation. MobilityOps reports the policy but never silently deletes audit data.
- Database backups default to 30 days with at least seven newest recovery points. A
configured secondary mount must follow the same policy.
- Logs are bounded by Docker rotation. They must not contain request bodies, passwords,
OIDC tokens or customer fields.
## Data-subject request procedure
1. Verify the requester's identity outside MobilityOps and record the case reference.
2. In **Privacy management**, download the customer JSON. The export action is audited.
3. Review active/recent booking blockers and applicable legal retention obligations.
4. Enter a reason and the exact customer reference to anonymise. The operation is
irreversible and row-locked. It clears name, email, phone, address and birth date but
preserves the stable reference and operational bookings so financial/operational
evidence is not corrupted.
5. Verify the resulting audit event. Its before/after payload records only anonymisation
state; erased PII is deliberately not copied into the audit log.
6. Handle verified backups according to their bounded retention; never mutate individual
dump files.
## Audit export and access review
Audit CSV exports are manager-only, limited to a maximum 90-day range and a configurable
row cap. Every export is itself audited. Review manager accounts, external OIDC bindings,
failed sign-ins, privacy exports and anonymisations periodically. Deactivate access at the
canonical MobilityOps user record; this invalidates subsequent operational requests.
## Incident handling
Preserve relevant JSON logs, correlation IDs and audit exports; do not overwrite the
database or backups. Rotate affected secrets, disable OIDC or external integrations when
needed, assess notification obligations and restore only through the guarded runbook.
Record all incident decisions outside the application in the organisation's incident
system.
+7 -1
View File
@@ -25,7 +25,7 @@ test.beforeEach(async ({ page, request }) => {
await expect(page).toHaveURL(/\/dashboard$/);
});
test("all seven nav items navigate correctly", async ({ page }) => {
test("all manager navigation items navigate correctly", async ({ page }) => {
const items: [string, RegExp][] = [
["Overview", /\/dashboard$/],
["Fleet", /\/vehicles$/],
@@ -34,6 +34,8 @@ test("all seven nav items navigate correctly", async ({ page }) => {
["Knowledge", /\/knowledge$/],
["Integrations", /\/automation$/],
["Audit trail", /\/audit$/],
["Users", /\/users$/],
["Privacy", /\/privacy$/],
];
const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" });
for (const [label, urlPattern] of items) {
@@ -378,6 +380,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Privacy" })).toHaveCount(0);
// Direct URL navigation is still blocked server-side and shows the same restricted
// message as a defense-in-depth measure, not just a hidden button.
@@ -394,6 +397,9 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
await page.goto("/audit");
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
await page.goto("/privacy");
await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible();
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
async function resetAndLogin(request: APIRequestContext, page: Page) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
}
test("privacy centre reports policy and produces an audited CSV export", async ({ page, request }) => {
await resetAndLogin(request, page);
await page.goto("/privacy");
await expect(page.getByRole("heading", { name: "Privacybeheer" })).toBeVisible();
await expect(page.getByText("30 dagen")).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "Audit CSV downloaden" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("mobilityops-audit.csv");
});
test("privacy centre refuses anonymisation of a customer with an active booking", async ({ page, request }) => {
await resetAndLogin(request, page);
await page.goto("/privacy");
await page.getByLabel("Klantreferentie").first().fill("CUS-0042");
await page.getByLabel("Gemotiveerde reden").fill("Gevalideerd verzoek van de betrokkene");
await page.getByLabel(/Typ CUS-0042 ter bevestiging/).fill("CUS-0042");
await page.getByRole("button", { name: "Definitief anonimiseren" }).click();
await expect(page.getByText(/kon niet worden uitgevoerd/)).toBeVisible();
});
+2
View File
@@ -20,6 +20,7 @@ const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: mo
const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy })));
function deferredPage(element: ReactNode) {
return <Suspense fallback={<div className="route-loading" role="status"><span className="spinner" /></div>}>{element}</Suspense>;
@@ -51,6 +52,7 @@ export function App() {
<Route path="/knowledge" element={deferredPage(<Knowledge />)} />
<Route path="/audit" element={deferredPage(<Audit />)} />
<Route path="/users" element={deferredPage(<Users />)} />
<Route path="/privacy" element={deferredPage(<Privacy />)} />
<Route path="/about" element={deferredPage(<AboutDemo />)} />
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
</Route>
+14
View File
@@ -90,6 +90,20 @@ export interface UserRecord {
active: boolean;
}
export interface PrivacyRetention {
minimum_booking_retention_days: number;
audit_retention_days: number;
customers_total: number;
customers_anonymized: number;
customers_eligible: number;
}
export interface CustomerAnonymizeResult {
public_ref: string;
anonymized_at: string;
status: "anonymized" | "already_anonymized";
}
export interface Inspection {
public_ref: string;
booking_ref: string;
+1
View File
@@ -70,6 +70,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
],
},
];
+7
View File
@@ -16,6 +16,7 @@ import demoNl from "./locales/nl-BE/demo.json";
import errorsNl from "./locales/nl-BE/errors.json";
import accessibilityNl from "./locales/nl-BE/accessibility.json";
import operationsNl from "./locales/nl-BE/operations.json";
import privacyNl from "./locales/nl-BE/privacy.json";
import commonEn from "./locales/en-GB/common.json";
import authEn from "./locales/en-GB/auth.json";
@@ -32,6 +33,7 @@ import demoEn from "./locales/en-GB/demo.json";
import errorsEn from "./locales/en-GB/errors.json";
import accessibilityEn from "./locales/en-GB/accessibility.json";
import operationsEn from "./locales/en-GB/operations.json";
import privacyEn from "./locales/en-GB/privacy.json";
import commonFr from "./locales/fr-BE/common.json";
import authFr from "./locales/fr-BE/auth.json";
@@ -48,6 +50,7 @@ import demoFr from "./locales/fr-BE/demo.json";
import errorsFr from "./locales/fr-BE/errors.json";
import accessibilityFr from "./locales/fr-BE/accessibility.json";
import operationsFr from "./locales/fr-BE/operations.json";
import privacyFr from "./locales/fr-BE/privacy.json";
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
@@ -70,6 +73,7 @@ export const NAMESPACES = [
"errors",
"accessibility",
"operations",
"privacy",
] as const;
function readStoredLanguage(): SupportedLanguage {
@@ -122,6 +126,7 @@ void i18n
errors: errorsNl,
accessibility: accessibilityNl,
operations: operationsNl,
privacy: privacyNl,
},
"en-GB": {
common: commonEn,
@@ -139,6 +144,7 @@ void i18n
errors: errorsEn,
accessibility: accessibilityEn,
operations: operationsEn,
privacy: privacyEn,
},
"fr-BE": {
common: commonFr,
@@ -156,6 +162,7 @@ void i18n
errors: errorsFr,
accessibility: accessibilityFr,
operations: operationsFr,
privacy: privacyFr,
},
},
});
@@ -12,7 +12,8 @@
"knowledge": "Knowledge",
"integrations": "Integrations",
"audit": "Audit trail",
"users": "Users"
"users": "Users",
"privacy": "Privacy"
},
"primaryNavLabel": "Primary navigation",
"mobileNavLabel": "Mobile navigation",
@@ -42,7 +43,8 @@
"quality": "Quality workbench",
"knowledge": "Procedure assistant",
"integrations": "Automation and integration status",
"audit": "Audit history"
"audit": "Audit history",
"privacy": "Privacy management"
},
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
@@ -0,0 +1,19 @@
{
"eyebrow": "Assure / Governance",
"title": "Privacy management",
"description": "Export personal data, monitor retention and anonymise safely with a complete audit trail.",
"managerOnly": "These governance functions are available to Operations Managers only.",
"loading": "Loading privacy policy…",
"error": "The privacy action could not be completed. Check the reference and retention period.",
"policyTitle": "Retention policy",
"totalCustomers": "Customers",
"eligibleCustomers": "Eligible",
"anonymizedCustomers": "Anonymised",
"bookingRetention": "Minimum booking retention",
"auditRetention": "Audit retention",
"days_one": "{{count}} day",
"days_other": "{{count}} days",
"customerRef": "Customer reference",
"exports": {"title":"Data exports","detail":"Download a customer file as JSON or a bounded audit export as CSV. Every export is logged.","customer":"Download customer file","audit":"Download audit CSV"},
"anonymize": {"title":"Anonymise customer","detail":"Irreversible. Active bookings and bookings newer than {{days}} days block this action.","reason":"Reasoned justification","confirmation":"Type {{ref}} to confirm","submit":"Anonymise permanently","working":"Anonymising…","anonymized":"{{ref}} was anonymised.","already_anonymized":"{{ref}} was already anonymised."}
}
@@ -12,7 +12,8 @@
"knowledge": "Connaissances",
"integrations": "Intégrations",
"audit": "Piste d'audit",
"users": "Utilisateurs"
"users": "Utilisateurs",
"privacy": "Confidentialité"
},
"primaryNavLabel": "Navigation principale",
"mobileNavLabel": "Navigation mobile",
@@ -42,7 +43,8 @@
"quality": "Atelier qualité",
"knowledge": "Assistant de procédures",
"integrations": "Statut d'automatisation et d'intégration",
"audit": "Historique daudit"
"audit": "Historique daudit",
"privacy": "Gestion de la confidentialité"
},
"switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo",
@@ -0,0 +1,19 @@
{
"eyebrow": "Surveiller / Gouvernance",
"title": "Gestion de la confidentialité",
"description": "Exportez les données personnelles, contrôlez la conservation et anonymisez avec une piste daudit complète.",
"managerOnly": "Ces fonctions de gouvernance sont réservées aux responsables des opérations.",
"loading": "Chargement de la politique…",
"error": "Laction na pas pu être exécutée. Vérifiez la référence et la durée de conservation.",
"policyTitle": "Politique de conservation",
"totalCustomers": "Clients",
"eligibleCustomers": "Éligibles",
"anonymizedCustomers": "Anonymisés",
"bookingRetention": "Conservation minimale des réservations",
"auditRetention": "Conservation de laudit",
"days_one": "{{count}} jour",
"days_other": "{{count}} jours",
"customerRef": "Référence client",
"exports": {"title":"Exports de données","detail":"Téléchargez un dossier client JSON ou un export daudit CSV limité. Chaque export est journalisé.","customer":"Télécharger le dossier","audit":"Télécharger laudit CSV"},
"anonymize": {"title":"Anonymiser un client","detail":"Irréversible. Les réservations actives ou de moins de {{days}} jours bloquent laction.","reason":"Motif justifié","confirmation":"Saisissez {{ref}} pour confirmer","submit":"Anonymiser définitivement","working":"Anonymisation…","anonymized":"{{ref}} a été anonymisé.","already_anonymized":"{{ref}} était déjà anonymisé."}
}
@@ -12,7 +12,8 @@
"knowledge": "Kennis",
"integrations": "Integraties",
"audit": "Auditgeschiedenis",
"users": "Gebruikers"
"users": "Gebruikers",
"privacy": "Privacy"
},
"primaryNavLabel": "Hoofdnavigatie",
"mobileNavLabel": "Mobiele navigatie",
@@ -42,7 +43,8 @@
"quality": "Kwaliteitswerkbank",
"knowledge": "Procedureassistent",
"integrations": "Automatiserings- en integratiestatus",
"audit": "Auditgeschiedenis"
"audit": "Auditgeschiedenis",
"privacy": "Privacybeheer"
},
"switchRole": "Wissel van rol",
"switchRoleTitle": "Wissel van demo-rol",
@@ -0,0 +1,19 @@
{
"eyebrow": "Bewaken / Governance",
"title": "Privacybeheer",
"description": "Exporteer persoonsgegevens, bewaak bewaartermijnen en anonimiseer veilig met een volledig auditspoor.",
"managerOnly": "Deze governancefuncties zijn uitsluitend beschikbaar voor Operationsmanagers.",
"loading": "Privacybeleid laden…",
"error": "De privacyactie kon niet worden uitgevoerd. Controleer de referentie en bewaartermijn.",
"policyTitle": "Bewaarbeleid",
"totalCustomers": "Klanten",
"eligibleCustomers": "Anonimiseerbaar",
"anonymizedCustomers": "Geanonimiseerd",
"bookingRetention": "Minimum boekingsretentie",
"auditRetention": "Auditretentie",
"days_one": "{{count}} dag",
"days_other": "{{count}} dagen",
"customerRef": "Klantreferentie",
"exports": {"title":"Gegevensexport","detail":"Download een klantdossier als JSON of een begrensde auditexport als CSV. Elke export wordt gelogd.","customer":"Klantdossier downloaden","audit":"Audit CSV downloaden"},
"anonymize": {"title":"Klant anonimiseren","detail":"Onomkeerbaar. Actieve boekingen en boekingen jonger dan {{days}} dagen blokkeren deze actie.","reason":"Gemotiveerde reden","confirmation":"Typ {{ref}} ter bevestiging","submit":"Definitief anonimiseren","working":"Anonimiseren…","anonymized":"{{ref}} is geanonimiseerd.","already_anonymized":"{{ref}} was al geanonimiseerd."}
}
+82
View File
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { CustomerAnonymizeResult, PrivacyRetention } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function Privacy() {
const { t } = useTranslation("privacy");
const { user } = useAuth();
const [retention, setRetention] = useState<PrivacyRetention | null>(null);
const [customerRef, setCustomerRef] = useState("");
const [confirmation, setConfirmation] = useState("");
const [reason, setReason] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState(false);
const [working, setWorking] = useState(false);
const loadRetention = useCallback(() => {
setError(false);
api.get<PrivacyRetention>("/api/v1/privacy/retention").then(setRetention).catch(() => setError(true));
}, []);
useEffect(loadRetention, [loadRetention]);
async function anonymize(event: FormEvent) {
event.preventDefault();
setWorking(true);
setMessage(null);
setError(false);
try {
const result = await api.post<CustomerAnonymizeResult>(
`/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/anonymize`,
{ confirmation, reason },
);
setMessage(t(`anonymize.${result.status}`, { ref: result.public_ref }));
setConfirmation("");
setReason("");
loadRetention();
} catch {
setError(true);
} finally {
setWorking(false);
}
}
if (user?.role !== "operations_manager") return <div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
</div>;
return <div className="page privacy-page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
{error && <ErrorState message={t("error")} />}
{!retention && !error && <LoadingState label={t("loading")} />}
{retention && <>
<section className="privacy-metrics" aria-label={t("policyTitle")}>
<article><span>{t("totalCustomers")}</span><strong>{retention.customers_total}</strong></article>
<article><span>{t("eligibleCustomers")}</span><strong>{retention.customers_eligible}</strong></article>
<article><span>{t("anonymizedCustomers")}</span><strong>{retention.customers_anonymized}</strong></article>
<article><span>{t("bookingRetention")}</span><strong>{t("days", { count: retention.minimum_booking_retention_days })}</strong></article>
<article><span>{t("auditRetention")}</span><strong>{t("days", { count: retention.audit_retention_days })}</strong></article>
</section>
<div className="privacy-workspaces">
<section className="panel privacy-panel">
<h2>{t("exports.title")}</h2><p>{t("exports.detail")}</p>
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" /></label>
<div className="form-actions-inline">
<a className={`button ${customerRef ? "" : "is-disabled"}`} aria-disabled={!customerRef} href={customerRef ? `/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/export` : undefined}>{t("exports.customer")}</a>
<a className="button" href="/api/v1/audit/export.csv">{t("exports.audit")}</a>
</div>
</section>
<form className="panel privacy-panel" onSubmit={anonymize}>
<h2>{t("anonymize.title")}</h2><p>{t("anonymize.detail", { days: retention.minimum_booking_retention_days })}</p>
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" required /></label>
<label>{t("anonymize.reason")}<textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={8} required /></label>
<label>{t("anonymize.confirmation", { ref: customerRef || "CUS-…" })}<input value={confirmation} onChange={(event) => setConfirmation(event.target.value.toUpperCase())} required /></label>
<button className="button button-danger" disabled={working || confirmation !== customerRef || !customerRef}>{working ? t("anonymize.working") : t("anonymize.submit")}</button>
</form>
</div>
{message && <p className="success" role="status">{message}</p>}
</>}
</div>;
}
+12
View File
@@ -117,6 +117,17 @@ a:hover { color: var(--teal); }
.login-divider::before, .login-divider::after { content: ""; height: 1px; flex: 1; background: var(--line); }
.login-oidc { width: 100%; min-height: 44px; justify-content: center; gap: 8px; background: white; border: 1px solid var(--line-strong); }
.login-oidc svg { width: 17px; }
.privacy-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.privacy-metrics article { min-height: 92px; padding: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.privacy-metrics span { display: block; color: var(--muted); font-size: .68rem; }
.privacy-metrics strong { display: block; margin-top: 10px; font-size: 1.25rem; }
.privacy-workspaces { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.privacy-panel { display: grid; align-content: start; gap: 14px; padding: 20px; }
.privacy-panel h2, .privacy-panel p { margin: 0; }
.privacy-panel label { display: grid; gap: 6px; }
.privacy-panel textarea { min-height: 92px; resize: vertical; }
.form-actions-inline { display: flex; flex-wrap: wrap; gap: 10px; }
.button.is-disabled { pointer-events: none; opacity: .5; }
.demo-badge { position: relative; }
.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; }
.demo-badge-trigger:hover { background: #dfe8ef; }
@@ -513,6 +524,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 960px) {
.privacy-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }.privacy-workspaces { grid-template-columns: 1fr; }
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 68px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 44px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .69rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 68px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
}