Users: deleting a user clears their API tokens and detaches audit rows
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

Deleting any user who owned an API token or appeared in the audit log
hit the users FK and 500ed - the import's 'importer' account being the
guaranteed case (its PAT plus every audit row the import wrote).
Tokens are revoked outright; audit history is kept but detached
(userid NULL), so the trail survives the account.
This commit is contained in:
cproudlock
2026-07-17 10:54:47 -04:00
parent bb5308bae0
commit 0cc205d25e
2 changed files with 428 additions and 389 deletions

View File

@@ -212,6 +212,16 @@ def delete_user(userid: int):
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username username = user.username
# Rows that reference the user would otherwise block the delete:
# revoke their API tokens outright, and DETACH their audit history
# (userid -> NULL) - the log rows themselves are kept, entityname and
# details still tell the story.
from shopdb.core.models import ApiToken
ApiToken.query.filter_by(userid=userid).delete(synchronize_session=False)
AuditLog.query.filter_by(userid=userid).update(
{'userid': None}, synchronize_session=False)
db.session.delete(user) db.session.delete(user)
AuditLog.log('deleted', 'User', entityid=userid, entityname=username) AuditLog.log('deleted', 'User', entityid=userid, entityname=username)

View File

@@ -0,0 +1,29 @@
def test_delete_user_with_tokens_and_audit_history(client, auth_headers, app, db):
"""Deleting a user revokes their API tokens and detaches (not deletes)
their audit rows - the importer-user case."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, ApiToken, AuditLog
with app.app_context():
user = User(username='importer2', email='importer2@test.local',
passwordhash=generate_password_hash('x'), isactive=True)
db.session.add(user)
db.session.flush()
db.session.add(ApiToken(userid=user.userid, name='import token',
tokenprefix='deadbeef', tokenhash='x' * 64))
db.session.add(AuditLog(userid=user.userid, action='created',
entitytype='Asset', entityid=1,
entityname='imported thing'))
db.session.commit()
userid = user.userid
response = client.delete(f'/api/users/{userid}', headers=auth_headers)
assert response.status_code == 200, response.get_json()
with app.app_context():
assert db.session.get(User, userid) is None
assert ApiToken.query.filter_by(userid=userid).count() == 0
detached = AuditLog.query.filter_by(entityname='imported thing').one()
assert detached.userid is None