#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 :
Consentement explicite : Chaque abonné doit cocher une case avant inscription (#11 OCR) pour accepter traitement de ses données personnelles (MSISDN, nom, géolocalisation).
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.
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
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 sizeModel SQLAlchemy
# 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 anonymisationEndpoints API
| Méthode | Route | Réponse | Auth |
|---|---|---|---|
| POST | /api/v1/apdp/consent | 201 | Abonné |
| GET | /api/v1/apdp/export/{subscriber_id} | 200 ZIP file | Abonné |
| POST | /api/v1/apdp/forget | 202 {request_id, status} | Abonné |
| GET | /api/v1/apdp/requests | 200 {items: [...], total} | Admin |
| GET | /api/v1/apdp/requests/{request_id} | 200 détail | Admin |
| GET | /api/v1/apdp/stats | 200 {pending, processed, failed} | Admin |
Schémas Pydantic
# 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 24hCommandes Utiles
# 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
ApdpServiceavec 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