diff --git a/.env.example b/.env.example index 0976e14..f5429c2 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index def749e..a15cf9c 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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. diff --git a/backend/alembic/versions/b913a72e8c14_customer_privacy_state.py b/backend/alembic/versions/b913a72e8c14_customer_privacy_state.py new file mode 100644 index 0000000..3e7bf03 --- /dev/null +++ b/backend/alembic/versions/b913a72e8c14_customer_privacy_state.py @@ -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") diff --git a/backend/app/api/routers/audit.py b/backend/app/api/routers/audit.py index dcdae8d..eeb3050 100644 --- a/backend/app/api/routers/audit.py +++ b/backend/app/api/routers/audit.py @@ -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: diff --git a/backend/app/api/routers/privacy.py b/backend/app/api/routers/privacy.py new file mode 100644 index 0000000..f8ff6bd --- /dev/null +++ b/backend/app/api/routers/privacy.py @@ -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", + ) diff --git a/backend/app/api/routers/search.py b/backend/app/api/routers/search.py index dd1df89..cc71078 100644 --- a/backend/app/api/routers/search.py +++ b/backend/app/api/routers/search.py @@ -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", + }, ] diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 23a4154..c42f189 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index d7af5c7..e880b35 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py index 5d6ea74..d05bb1e 100644 --- a/backend/app/models/customer.py +++ b/backend/app/models/customer.py @@ -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)) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1836413..7d39a91 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/tests/test_privacy.py b/backend/tests/test_privacy.py new file mode 100644 index 0000000..bf89f9f --- /dev/null +++ b/backend/tests/test_privacy.py @@ -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 diff --git a/compose.yaml b/compose.yaml index 4d0e880..14366e4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 9644b85..46892c2 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -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: diff --git a/docs/17-runbook.md b/docs/17-runbook.md index a283d35..cb65405 100644 --- a/docs/17-runbook.md +++ b/docs/17-runbook.md @@ -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) diff --git a/docs/18-privacy-governance.md b/docs/18-privacy-governance.md new file mode 100644 index 0000000..93afd8c --- /dev/null +++ b/docs/18-privacy-governance.md @@ -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. diff --git a/frontend/e2e/interactive-elements.spec.ts b/frontend/e2e/interactive-elements.spec.ts index 1542d21..4eb8b73 100644 --- a/frontend/e2e/interactive-elements.spec.ts +++ b/frontend/e2e/interactive-elements.spec.ts @@ -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); }); diff --git a/frontend/e2e/privacy.spec.ts b/frontend/e2e/privacy.spec.ts new file mode 100644 index 0000000..d2e1b47 --- /dev/null +++ b/frontend/e2e/privacy.spec.ts @@ -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(); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 156f38e..ffa2b4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }>{element}; @@ -51,6 +52,7 @@ export function App() { )} /> )} /> )} /> + )} /> )} /> )} /> diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c8f9751..4180973 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 895b90b..84fca68 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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"] }, ], }, ]; diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts index f06a606..04e6deb 100644 --- a/frontend/src/i18n/config.ts +++ b/frontend/src/i18n/config.ts @@ -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, }, }, }); diff --git a/frontend/src/i18n/locales/en-GB/navigation.json b/frontend/src/i18n/locales/en-GB/navigation.json index f86fbee..1c41554 100644 --- a/frontend/src/i18n/locales/en-GB/navigation.json +++ b/frontend/src/i18n/locales/en-GB/navigation.json @@ -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", diff --git a/frontend/src/i18n/locales/en-GB/privacy.json b/frontend/src/i18n/locales/en-GB/privacy.json new file mode 100644 index 0000000..65d6a04 --- /dev/null +++ b/frontend/src/i18n/locales/en-GB/privacy.json @@ -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."} +} diff --git a/frontend/src/i18n/locales/fr-BE/navigation.json b/frontend/src/i18n/locales/fr-BE/navigation.json index a12704d..732e5da 100644 --- a/frontend/src/i18n/locales/fr-BE/navigation.json +++ b/frontend/src/i18n/locales/fr-BE/navigation.json @@ -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 d’audit" + "audit": "Historique d’audit", + "privacy": "Gestion de la confidentialité" }, "switchRole": "Changer de rôle", "switchRoleTitle": "Changer de rôle de démo", diff --git a/frontend/src/i18n/locales/fr-BE/privacy.json b/frontend/src/i18n/locales/fr-BE/privacy.json new file mode 100644 index 0000000..65776c4 --- /dev/null +++ b/frontend/src/i18n/locales/fr-BE/privacy.json @@ -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 d’audit complète.", + "managerOnly": "Ces fonctions de gouvernance sont réservées aux responsables des opérations.", + "loading": "Chargement de la politique…", + "error": "L’action n’a 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 l’audit", + "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 d’audit CSV limité. Chaque export est journalisé.","customer":"Télécharger le dossier","audit":"Télécharger l’audit CSV"}, + "anonymize": {"title":"Anonymiser un client","detail":"Irréversible. Les réservations actives ou de moins de {{days}} jours bloquent l’action.","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é."} +} diff --git a/frontend/src/i18n/locales/nl-BE/navigation.json b/frontend/src/i18n/locales/nl-BE/navigation.json index 971868b..5c9b37c 100644 --- a/frontend/src/i18n/locales/nl-BE/navigation.json +++ b/frontend/src/i18n/locales/nl-BE/navigation.json @@ -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", diff --git a/frontend/src/i18n/locales/nl-BE/privacy.json b/frontend/src/i18n/locales/nl-BE/privacy.json new file mode 100644 index 0000000..fe3d9b2 --- /dev/null +++ b/frontend/src/i18n/locales/nl-BE/privacy.json @@ -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."} +} diff --git a/frontend/src/pages/Privacy.tsx b/frontend/src/pages/Privacy.tsx new file mode 100644 index 0000000..1b44eaf --- /dev/null +++ b/frontend/src/pages/Privacy.tsx @@ -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(null); + const [customerRef, setCustomerRef] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [reason, setReason] = useState(""); + const [message, setMessage] = useState(null); + const [error, setError] = useState(false); + const [working, setWorking] = useState(false); + + const loadRetention = useCallback(() => { + setError(false); + api.get("/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( + `/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
+ +
; + return
+ + {error && } + {!retention && !error && } + {retention && <> +
+
{t("totalCustomers")}{retention.customers_total}
+
{t("eligibleCustomers")}{retention.customers_eligible}
+
{t("anonymizedCustomers")}{retention.customers_anonymized}
+
{t("bookingRetention")}{t("days", { count: retention.minimum_booking_retention_days })}
+
{t("auditRetention")}{t("days", { count: retention.audit_retention_days })}
+
+
+
+

{t("exports.title")}

{t("exports.detail")}

+ + +
+
+

{t("anonymize.title")}

{t("anonymize.detail", { days: retention.minimum_booking_retention_days })}

+ +