fix(arch): changement de role sans jeter la session, et en une transaction

ARCH-008. `role` est le discriminateur polymorphe, et SQLAlchemy fixe la
classe d une instance au chargement. edit_user ecrivait donc la colonne par
un UPDATE de niveau instruction puis relisait la ligne -- c est correct.
La suite ne l etait pas.

  db.session.commit()
  db.session.remove()   # discard stale session entirely
  user = User.query.get(user_id_local)

Deux defauts dans ces trois lignes.

1. remove() jette la session entiere. Tout ce que la requete tenait encore
   se retrouvait detache, current_user compris ; le moindre acces a un
   attribut ensuite levait DetachedInstanceError. La route ne survivait
   qu en ayant recopie le nom et l id de l acteur dans des variables
   locales avant -- un contournement, pas la correction. Un expunge de la
   seule instance perimee suffit.

2. Le commit intermediaire coupait l edition en deux. Le role etait acquis
   avant que le reste du formulaire soit applique : une erreur ensuite
   laissait un compte promu et le reste perdu, sans qu aucune interface ne
   le signale. Sans ce commit, l UPDATE reste dans la transaction, la
   relecture le voit, et l ensemble part en un seul commit.

Les evenements d audit passent apres le commit. account.role_changed etait
journalise avant l UPDATE : le journal affirmait un changement que la
transaction pouvait encore annuler.

tests/test_role_change.py, 6 tests. Un seul echoue sur le code d avant --
celui de l atomicite ; les cinq autres fixent le comportement qui marchait
deja, pour que la suite de la vague D ne le casse pas.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-08 14:51:41 -04:00
co-authored by Claude Opus 5
parent 92d72e4d48
commit d85da32ef5
2 changed files with 156 additions and 21 deletions
+37 -21
View File
@@ -134,9 +134,6 @@ def edit_user(user_id):
user = User.query.get_or_404(user_id)
if request.method == 'POST':
# Captured up front, on purpose. A role change below calls
# db.session.remove(), which detaches current_user from the session:
# any later attribute access on it raises DetachedInstanceError.
actor_name, actor_id = current_user.username, current_user.id
def _rerender():
@@ -171,7 +168,10 @@ def edit_user(user_id):
flash(_('Email already in use by another account.'), 'danger')
return _rerender()
if user.role != role:
role_changed = user.role != role
previous_role = user.role
if role_changed:
# Two ways to lock everyone out of administration, neither of
# which any interface can undo afterwards.
if user.id == actor_id:
@@ -190,23 +190,30 @@ def edit_user(user_id):
'another account before changing this one.'), 'danger')
return _rerender()
# Change role via raw SQL to avoid polymorphic identity corruption.
# Must discard the entire session because the polymorphic discriminator
# change invalidates the identity map for this instance and anything
# that references it via relationships.
if user.role != role:
log_auth_event('account.role_changed',
actor=actor_name, actor_id=actor_id,
target=user.username, target_id=user.id,
previous_role=user.role, new_role=role)
user_id_local = user.id
if role_changed:
# The role column is the polymorphic discriminator, and SQLAlchemy
# decides an instance's class when it loads it. Assigning to it
# through the ORM leaves a Player object in the identity map for a
# row that now says 'coach', so every later isinstance() check —
# which is how this application does authorisation — answers with
# the old role. Hence the statement-level UPDATE.
#
# The instance then has to be re-read. This used to call
# db.session.remove(), which throws away the whole session:
# everything the request still held was detached, current_user
# included, and the next attribute access on any of them raised
# DetachedInstanceError. Expunging the one stale instance is
# enough, and it leaves the transaction open — so the role change
# and the rest of the edit now commit together instead of the
# role landing on its own and the remaining fields failing after
# it (ARCH-008).
user_pk = user.id
db.session.execute(
db.text("UPDATE users SET role = :role WHERE id = :id"),
{"role": role, "id": user_id_local}
db.text('UPDATE users SET role = :role WHERE id = :id'),
{'role': role, 'id': user_pk},
)
db.session.commit()
db.session.remove() # discard stale session entirely
user = User.query.get(user_id_local) # fresh session, correct class
db.session.expunge(user)
user = db.session.get(User, user_pk)
user.full_name = full_name
user.email = email
@@ -224,11 +231,20 @@ def edit_user(user_id):
password = validated.get('password')
if password:
user.password_hash = hash_password(password)
db.session.commit()
# Logged after the commit, not before: the audit trail should record
# what happened, and until this point nothing had.
if role_changed:
log_auth_event('account.role_changed',
actor=actor_name, actor_id=actor_id,
target=user.username, target_id=user.id,
previous_role=previous_role, new_role=role)
if password:
log_auth_event('account.password_reset_by_admin',
actor=actor_name, actor_id=actor_id,
target=user.username, target_id=user.id)
db.session.commit()
log_auth_event('account.updated',
actor=actor_name, actor_id=actor_id,
target=user.username, target_id=user.id, active=is_active)