fix(security): guard admin credential deletion against lockout

Admin credential deletion removed password/WebAuthn credentials with no
last-credential check, so an admin could delete a user's only credential and
lock them out. Use the atomic delete_*_if_not_last repo methods; on refusal
re-render the credentials section unchanged with an explanatory alert.

Refs: porchlight-lg7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-05 13:27:00 +02:00
parent 1bb76899a5
commit e54764cda9
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
3 changed files with 40 additions and 5 deletions

View file

@ -328,11 +328,15 @@ async def test_delete_password_credential(client: AsyncClient) -> None:
await _login(client)
target = await _create_target_user(client, username="bob")
# Create a password credential for the target user
# Create a password credential for the target user, plus a second
# credential so the password is not the last one (last-credential guard).
app = client._transport.app # type: ignore[union-attr]
cred_repo = app.state.credential_repo
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=target.userid, password_hash=svc.hash("bobpass")))
await cred_repo.create_webauthn(
WebAuthnCredential(user_id=target.userid, credential_id=b"\xaa\xbb", public_key=b"\x00" * 32)
)
token = await get_csrf_token(client)
response = await client.request(
@ -351,7 +355,8 @@ async def test_delete_webauthn_credential(client: AsyncClient) -> None:
await _login(client)
target = await _create_target_user(client, username="bob")
# Create a webauthn credential for the target user
# Create a webauthn credential for the target user, plus a second
# credential so the key is not the last one (last-credential guard).
app = client._transport.app # type: ignore[union-attr]
cred_repo = app.state.credential_repo
credential_id = b"\x01\x02\x03\x04\x05\x06\x07\x08"
@ -364,6 +369,8 @@ async def test_delete_webauthn_credential(client: AsyncClient) -> None:
device_name="test-key",
)
)
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=target.userid, password_hash=svc.hash("bobpass")))
# URL uses base64url without padding
credential_id_b64 = urlsafe_b64encode(credential_id).decode().rstrip("=")
@ -434,3 +441,25 @@ async def test_reinvite_user_404_for_nonexistent(client: AsyncClient) -> None:
response = await client.post("/admin/users/nonexistent-id/invite", headers={"X-CSRF-Token": token})
assert response.status_code == 404
assert "not found" in response.text.lower()
@pytest.mark.asyncio
async def test_admin_cannot_delete_users_last_credential(client: AsyncClient) -> None:
await _login(client)
target = await _create_target_user(client, userid="lonecred-01", username="lonecred")
app = client._transport.app # type: ignore[union-attr]
cred_repo = app.state.credential_repo
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=target.userid, password_hash=svc.hash("x")))
token = await get_csrf_token(client)
res = await client.request(
"DELETE",
f"/admin/users/{target.userid}/credentials/password",
headers={"X-CSRF-Token": token, "HX-Request": "true"},
)
# The last credential must survive, and the admin is told why.
assert await cred_repo.get_password_by_user(target.userid) is not None
assert "last credential" in res.text.lower()