fix(auth): B-2.1 — FK erasure policy, totp_secret_encrypted, validates, docs

- ConsentLog.user_id: nullable=True + ondelete='SET NULL' for Loi 25 art. 28.1
  right-to-erasure (audit row survives user deletion, user_id nulled out).
  Matches existing pattern in auth_log.py / access_log.py.
- Add ConsentLog.@validates('consent_type') to reject typos at ORM level
  (silent typos in audit data are very hard to detect later).
- Rename User.totp_secret -> totp_secret_encrypted (size 64->255 for Fernet
  envelope). Self-documenting contract: never assign plaintext to this column.
- init_db.py: drop NOT NULL from totp_enabled migration string for consistency
  with every other Boolean column in the file (model-side nullable=False is
  sufficient).
- Docs: User class docstring updated to reflect MFA/billing/ordre context;
  webauthn_credentials shape documented; version column policy documented.
- Tests: cleaner IntegrityError catch; add survives_user_deletion test
  (right-to-erasure); add rejects_invalid_consent_type test (validator).
This commit is contained in:
Allison
2026-04-27 21:57:32 -04:00
parent 48d2abfa74
commit 8792ffb8a4
4 changed files with 117 additions and 21 deletions

View File

@@ -286,10 +286,10 @@ def initialize_database(app):
app.logger.info("Added transcription_initial_prompt column to user table")
# === B-2.1: MFA / WebAuthn / Stripe / Loi 25 user fields ===
if add_column_if_not_exists(engine, 'user', 'totp_secret', 'VARCHAR(64)'):
app.logger.info("Added totp_secret column to user table")
if add_column_if_not_exists(engine, 'user', 'totp_enabled', 'BOOLEAN DEFAULT 0 NOT NULL'):
app.logger.info("Added totp_enabled column to user table")
if add_column_if_not_exists(engine, 'user', 'totp_secret_encrypted', 'VARCHAR(255)'):
app.logger.info("Added 'totp_secret_encrypted' column to user")
if add_column_if_not_exists(engine, 'user', 'totp_enabled', 'BOOLEAN DEFAULT 0'):
app.logger.info("Added 'totp_enabled' column to user")
if add_column_if_not_exists(engine, 'user', 'webauthn_credentials', 'JSON'):
app.logger.info("Added webauthn_credentials column to user table")
if add_column_if_not_exists(engine, 'user', 'ordre_pro', 'VARCHAR(50)'):

View File

@@ -6,6 +6,8 @@ explicit and tracé) and art. 3.5 (audit trail).
"""
from datetime import datetime
from sqlalchemy.orm import validates
from src.database import db
@@ -17,12 +19,25 @@ class ConsentLog(db.Model):
"""
__tablename__ = 'consent_log'
ALLOWED_CONSENT_TYPES = ('cgu', 'confidentialite', 'marketing', 'analytics')
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
# nullable + ondelete=SET NULL preserves audit trail (LPRPSP art. 3.5) while
# supporting right-to-erasure (LPRPSP art. 28.1): on user deletion, the row
# survives with user_id=NULL — proof that consent existed without identifying
# the data subject. Pattern matches src/models/auth_log.py and access_log.py.
user_id = db.Column(
db.Integer,
db.ForeignKey('user.id', ondelete='SET NULL'),
nullable=True,
index=True,
)
# 'cgu', 'confidentialite', 'marketing', 'analytics'
consent_type = db.Column(db.String(50), nullable=False)
# Version of the document accepted (e.g. '1.0', '2026-04-27')
# Version of the legal text accepted. Convention: ISO date 'YYYY-MM-DD' of
# the document revision (e.g. '2026-04-27'). B-2.9 will define the canonical
# version constants in src/legal/__init__.py — DO NOT hardcode dates here.
version = db.Column(db.String(20), nullable=False)
granted = db.Column(db.Boolean, nullable=False)
@@ -36,6 +51,15 @@ class ConsentLog(db.Model):
# Backref creates User.consent_logs
user = db.relationship('User', backref='consent_logs')
@validates('consent_type')
def _validate_consent_type(self, key, value):
if value not in self.ALLOWED_CONSENT_TYPES:
raise ValueError(
f"Invalid consent_type {value!r}. "
f"Must be one of: {self.ALLOWED_CONSENT_TYPES}"
)
return value
def __repr__(self):
action = 'granted' if self.granted else 'revoked'
return f"<ConsentLog user={self.user_id} type={self.consent_type} v={self.version} {action}>"

View File

@@ -13,7 +13,13 @@ from src.database import db
class User(db.Model, UserMixin):
"""User model for authentication and profile management."""
"""User model authentication, profile, MFA enrollment, and subscription state.
Post-B-2.1 columns include MFA (totp_secret_encrypted, totp_enabled,
webauthn_credentials), Stripe billing (stripe_customer_id, subscription_status),
and ordre professionnel context (ordre_pro, cabinet) used at signup (B-2.2).
Consent audit trail in src/models/consent.py via User.consent_logs backref.
"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False)
@@ -65,11 +71,14 @@ class User(db.Model, UserMixin):
transcription_initial_prompt = db.Column(db.Text, nullable=True)
# === B-2.1: MFA / WebAuthn / Stripe / Loi 25 fields (Phase 2 backend) ===
# TOTP MFA (B-2.5) — chiffré au repos via SECRET_KEY (handled in service layer)
totp_secret = db.Column(db.String(64), nullable=True)
# B-2.5 service layer encrypts the base32 secret with SECRET_KEY before storing.
# The encrypted blob (Fernet token) is what lives in this column. NEVER assign a
# raw base32 secret to this attribute — use the service-layer setter.
totp_secret_encrypted = db.Column(db.String(255), nullable=True)
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
# WebAuthn / Passkey credentials (B-2.6) — list of credential dicts
# WebAuthn / Passkey credentials (B-2.6) — list of credential dicts:
# [{'id': str, 'public_key': str, 'sign_count': int, 'transports': list[str]}]
webauthn_credentials = db.Column(db.JSON, nullable=True)
# Loi 25 + ordre professionnel context (used at signup B-2.2)