Skip to content

#46 — Module APDP Bénin

PLANIFIÉ

Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/api/v1/endpoints/apdp.pyDépendances: #1 rgz-api, #4 rgz-db


Description

Conformité APDP (Autorité de Protection des Données Personnelles) Bénin. Implémente trois fonctionnalités obligatoires :

  1. Consentement explicite : Chaque abonné doit cocher une case avant inscription (#11 OCR) pour accepter traitement de ses données personnelles (MSISDN, nom, géolocalisation).

  2. Droit à l'oubli (90j) : L'abonné peut demander suppression de ses données. Le système anonymise automatiquement : MSISDN → "ANONYMIZED_{uuid}", full_name → "ANONYMIZED_{uuid}", id_document_hash → "ANONYMIZED_{uuid}". Les données restent dans DB pour conformité ARCEP (12 mois) mais sont inaccessibles pour analyse business.

  3. Export de données : L'abonné peut télécharger un ZIP avec toutes ses données (inscriptions, paiements, sessions, consentements) en JSON/CSV format.

Le module expose des endpoints HTTPS pour abonnés et un endpoint admin pour tracking des demandes de suppression. Une Celery task (rgz.apdp.purge_expired) s'exécute quotidiennement 05:00 UTC pour anonymiser les demandes >90j.

Architecture Interne

1. Inscription avec consentement (#11):
   └─> Formulaire OCR:
       ├─> Champs: MSISDN, full_name, id_document, photo
       ├─> Checkbox: "Je consens au traitement de mes données personnelles
       │            conformément à la APDP Bénin"
       └─> POST /api/v1/apdp/consent
           ├─> Vérifier checkbox = true (sinon retour 400)
           ├─> INSERT apdp_consents(subscriber_id, consent_given_at, ip_address, user_agent)
           ├─> INSERT subscribers(..., consent_given_at=NOW())
           └─> LOG: immutable_logs "subscription_with_consent"

2. Extraction des données (export):
   └─> GET /api/v1/apdp/export/{subscriber_id}
       ├─> Vérifier ownership: current_user.subscriber_id == path  [SEC-01]
       ├─> Requête DB:
       │   ├─ SELECT * FROM subscribers WHERE id = ?
       │   ├─ SELECT * FROM subscriber_devices WHERE subscriber_id = ?
       │   ├─ SELECT * FROM apdp_consents WHERE subscriber_id = ?
       │   ├─ SELECT * FROM radius_sessions WHERE subscriber_id = ?
       │   ├─ SELECT * FROM payments WHERE subscriber_id = ?
       │   └─ SELECT * FROM invoices WHERE subscriber_id = ?
       ├─> Formatage JSON/CSV
       ├─> Création ZIP: export_{subscriber_id}_{timestamp}.zip
       │   ├─ subscriber.json
       │   ├─ devices.json
       │   ├─ consentements.json
       │   ├─ sessions.json
       │   ├─ payments.json
       │   └─ invoices.json
       ├─> Download HTTP 200 + Content-Type: application/zip
       └─> LOG: immutable_logs "data_export"

3. Demande d'anonymisation (droit à l'oubli):
   └─> POST /api/v1/apdp/forget
       ├─> Vérifier owner [SEC-01]
       ├─> Vérifier pas déjà en cours: SELECT WHERE status = PENDING
       ├─> INSERT apdp_forget_requests(
       │       subscriber_id,
       │       requested_at=NOW(),
       │       status=PENDING,
       │       reason='user_request' ou 'contract_end'
       │   )
       ├─> Réponse: 202 (demande acceptée, traitement asynchrone)
       └─> LOG: immutable_logs "anonymization_requested"

4. Traitement anonymisation (daily 05:00 UTC, Celery task #46):
   └─> rgz.apdp.purge_expired (daily queue=rgz.compliance):
       ├─> SELECT FROM apdp_forget_requests WHERE status = PENDING AND requested_at < (NOW - 90 DAYS)
       ├─> Pour chaque demande:
       │   ├─ anon_uuid = uuid4()
       │   ├─ UPDATE subscribers SET
       │   │       msisdn = 'ANONYMIZED_{anon_uuid}',
       │   │       msisdn_e164 = 'ANONYMIZED_{anon_uuid}',
       │   │       full_name = 'ANONYMIZED_{anon_uuid}',
       │   │       id_document_hash = 'ANONYMIZED_{anon_uuid}',
       │   │       anonymized_at = NOW()
       │   │   WHERE id = subscriber_id;
       │   ├─ UPDATE subscriber_devices SET mac_address = 'ANONYMIZED_{anon_uuid}' (keep pour ARCEP)
       │   ├─ DELETE FROM apdp_consents WHERE subscriber_id = ?
       │   ├─ UPDATE apdp_forget_requests SET status = PROCESSED, anonymized_at = NOW()
       │   └─ LOG: immutable_logs "anonymization_completed"
       │       (note: ne pas logguer les vraies données, seulement anonymization_id)
       └─> Si erreur: status = FAILED, error_message, retry next day

5. Gestion des demandes (admin):
   └─> GET /api/v1/apdp/requests?status=PENDING&from=&to=
       ├─> SELECT * FROM apdp_forget_requests
       │   WHERE status IN (PENDING, PROCESSED, FAILED)
       │   AND requested_at BETWEEN ? AND ?
       └─> JSON: {items: [{subscriber_id (anonymized), requested_at, status, anonymized_at}], total}

   └─> GET /api/v1/apdp/requests/{request_id}
       ├─> Vérifier admin auth
       ├─> SELECT détail + logs de traitement
       └─> JSON

6. Intégration inscriptions:
   └─> #11 OCR portal:
       ├─> Après remplissage formulaire + photo
       ├─> Afficher consent checkbox AVANT validation
       ├─> POST /api/v1/apdp/consent (avec subscriber_id créé provisoirement)
       └─> Si 201: poursuivre, sinon erreur "Consentement obligatoire"

Configuration

Variables d'environnement

env
APDP_ENABLED=true                     # Activation module
APDP_FORGET_DELAY_DAYS=90             # Délai avant anonymisation
APDP_CONSENT_MANDATORY=true           # Bloquer inscription si pas consent
APDP_PURGE_TASK_SCHEDULE=05:00        # daily 05:00 UTC
APDP_ANONYMIZE_PREFIX=ANONYMIZED      # Prefix pour données anonymisées
APDP_EXPORT_ENABLE=true               # Allow data export
APDP_EXPORT_MAX_SIZE_MB=50            # Max ZIP size

Model SQLAlchemy

python
# app/models/compliance.py

class ApdpConsent(Base):
    __tablename__ = "apdp_consents"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
    subscriber_id = Column(UUID(as_uuid=True), ForeignKey("subscribers.id"),
                          nullable=False, unique=True)
    consent_type = Column(String(50), nullable=False,
        CheckConstraint("consent_type IN ('personal_data', 'location', 'payment_history', 'analytics')"))
    consent_given = Column(Boolean, nullable=False, default=False)
    consent_given_at = Column(DateTime, nullable=True)
    ip_address = Column(String(45), nullable=True)  # IPv4 ou IPv6
    user_agent = Column(String(255), nullable=True)
    created_at = Column(DateTime, default=utcnow, nullable=False)
    updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

class ApdpForgetRequest(Base):
    __tablename__ = "apdp_forget_requests"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
    subscriber_id = Column(UUID(as_uuid=True), ForeignKey("subscribers.id"), nullable=False)
    status = Column(String(20), nullable=False, default='PENDING',
        CheckConstraint("status IN ('PENDING', 'PROCESSED', 'FAILED')"))
    requested_at = Column(DateTime, default=utcnow, nullable=False)
    reason = Column(String(50), nullable=False,
        CheckConstraint("reason IN ('user_request', 'contract_end', 'other')"))
    anonymized_at = Column(DateTime, nullable=True)
    error_message = Column(Text, nullable=True)
    retry_count = Column(Integer, default=0, nullable=False)
    created_at = Column(DateTime, default=utcnow, nullable=False)
    updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

# Étendre Subscriber model
class Subscriber(Base):
    __tablename__ = "subscribers"
    # ... autres colonnes ...
    consent_given_at = Column(DateTime, nullable=True)
    anonymized_at = Column(DateTime, nullable=True)
    apdp_anonymous_id = Column(UUID, nullable=True)  # Reference après anonymisation

Endpoints API

MéthodeRouteRéponseAuth
POST/api/v1/apdp/consent201Abonné
GET/api/v1/apdp/export/{subscriber_id}200 ZIP fileAbonné
POST/api/v1/apdp/forget202 {request_id, status}Abonné
GET/api/v1/apdp/requests200 {items: [...], total}Admin
GET/api/v1/apdp/requests/{request_id}200 détailAdmin
GET/api/v1/apdp/stats200 {pending, processed, failed}Admin

Schémas Pydantic

python
# app/schemas/compliance.py

class ApdpConsentRequest(BaseModel):
    subscriber_id: UUID
    consent_type: Literal["personal_data", "location", "payment_history", "analytics"]
    consent_given: bool

class ApdpConsentResponse(BaseModel):
    id: UUID
    subscriber_id: UUID
    consent_given_at: datetime
    status: str = "accepted"

class ApdpForgetRequest(BaseModel):
    reason: Literal["user_request", "contract_end", "other"]
    confirmation: bool  # Must be true

class ApdpForgetResponse(BaseModel):
    request_id: UUID
    status: str = "PENDING"
    message: str = "Votre demande a été enregistrée. Traitement en cours."

class ApdpExportResponse(BaseModel):
    subscriber_id: UUID
    export_filename: str
    download_url: str
    expiry_at: datetime  # Download link valid for 24h

Commandes Utiles

bash
# Créer consentement (inscription)
curl -X POST http://localhost:8000/api/v1/apdp/consent \
  -H "Content-Type: application/json" \
  -d '{
    "subscriber_id": "550e8400-e29b-41d4-a716-446655440000",
    "consent_type": "personal_data",
    "consent_given": true
  }'

# Télécharger ses données
curl -X GET http://localhost:8000/api/v1/apdp/export/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer $TOKEN" \
  --output export_data.zip

# Demander suppression (droit à l'oubli)
curl -X POST http://localhost:8000/api/v1/apdp/forget \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "reason": "user_request",
    "confirmation": true
  }'

# Voir demandes en attente (admin)
curl -X GET http://localhost:8000/api/v1/apdp/requests?status=PENDING \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Lancer task anonymisation manuellement
curl -X POST http://localhost:8000/api/v1/admin/tasks/apdp-purge \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Requête DB: voir consentements
psql -h localhost -U rgz_admin -d rgz_db -c \
  "SELECT subscriber_id, consent_given_at FROM apdp_consents LIMIT 10;"

# Requête DB: voir demandes anonymisation
psql -h localhost -U rgz_admin -d rgz_db -c \
  "SELECT subscriber_id, status, requested_at FROM apdp_forget_requests ORDER BY requested_at DESC LIMIT 10;"

Implémentation TODO

  • [ ] Classe ApdpService avec méthodes consent/export/forget
  • [ ] Endpoint POST /api/v1/apdp/consent avec vérification mandatory
  • [ ] Endpoint GET /api/v1/apdp/export/{subscriber_id} générant ZIP
  • [ ] Endpoint POST /api/v1/apdp/forget avec validation 90j
  • [ ] Celery task: rgz.apdp.purge_expired (daily 05:00)
  • [ ] Fonction anonymisation: replace MSISDN/name/hash par "ANONYMIZED_{uuid}"
  • [ ] Endpoint admin GET /api/v1/apdp/requests pour tracking
  • [ ] Integration #11 OCR : appeler consent endpoint
  • [ ] Validation APDP Bénin: checkbox obligatoire
  • [ ] Tests: consent flow, data export, anonymisation 90j, no data leakage
  • [ ] Documentation APDP pour abonnés (droits, comment exporter, comment supprimer)
  • [ ] Intégration #45 logs immuables : logguer consentement + anonymisation
  • [ ] Intégration #48 audit trail : qui a demandé l'export, quand

Dernière mise à jour: 2026-02-21

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone