Add DB viewer access logging, account deletion, and forced password change
Logs a distinct db_viewer_accessed event (not just the earlier session_created "requested" event) when an admin's browser actually completes the hand-off into Adminer. Adds a password-confirmed account-deletion box to Settings, relying on the existing ON DELETE CASCADE foreign keys to clean up everything the account owns. Adds an admin-only "require password change" flag that get_current_user enforces server-side (403 on everything except /auth/me, /auth/change-password, /auth/logout) - meant for handing a demo account to someone with a known sample password. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
"""Account deletion (DELETE /auth/me) and admin-forced password change
|
||||
(POST /admin/users/require-password-change -> POST /auth/change-password,
|
||||
enforced by app.auth.dependencies.get_current_user)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import SecurityEventType
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.user_security_event_repository import UserSecurityEventRepository
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def _register_and_login(client: TestClient, password: str = "correct-horse-1") -> dict:
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": password, "display_name": "T"},
|
||||
)
|
||||
tokens = client.post("/api/v1/auth/login", json={"email": email, "password": password}).json()
|
||||
return {"email": email, "headers": {"Authorization": f"Bearer {tokens['access_token']}"}}
|
||||
|
||||
|
||||
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
|
||||
email = f"admin-{uuid.uuid4().hex[:12]}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "Admin"},
|
||||
)
|
||||
user = await UserRepository(db_session).get_by_email(email)
|
||||
user.is_admin = True
|
||||
await db_session.commit()
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
# --- DELETE /auth/me -------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_account_requires_auth(client: TestClient):
|
||||
resp = client.request(
|
||||
"DELETE", "/api/v1/auth/me", json={"password": "whatever"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_delete_account_rejects_wrong_password(client: TestClient):
|
||||
account = _register_and_login(client)
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/v1/auth/me",
|
||||
json={"password": "not-the-right-password"},
|
||||
headers=account["headers"],
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_delete_account_succeeds_with_correct_password_and_logs_out_the_account(
|
||||
client: TestClient,
|
||||
):
|
||||
account = _register_and_login(client)
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/v1/auth/me",
|
||||
json={"password": "correct-horse-1"},
|
||||
headers=account["headers"],
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# The account is gone - the same token no longer resolves to anyone.
|
||||
me_resp = client.get("/api/v1/auth/me", headers=account["headers"])
|
||||
assert me_resp.status_code == 401
|
||||
|
||||
# And a fresh login attempt with the same credentials fails too.
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": account["email"], "password": "correct-horse-1"},
|
||||
)
|
||||
assert login_resp.status_code == 401
|
||||
|
||||
|
||||
async def test_delete_account_cascades_to_owned_data(client: TestClient, db_session: AsyncSession):
|
||||
"""A company created by the account must be gone too (ON DELETE CASCADE),
|
||||
not just the user row."""
|
||||
account = _register_and_login(client)
|
||||
create_resp = client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": f"Co-{uuid.uuid4().hex[:8]}", "official_website": None},
|
||||
headers=account["headers"],
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
company_id = create_resp.json()["id"]
|
||||
|
||||
client.request(
|
||||
"DELETE", "/api/v1/auth/me", json={"password": "correct-horse-1"}, headers=account["headers"]
|
||||
)
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
row = await db_session.execute(
|
||||
text("SELECT 1 FROM companies WHERE id = :id"), {"id": company_id}
|
||||
)
|
||||
assert row.first() is None
|
||||
|
||||
|
||||
# --- POST /admin/users/require-password-change -----------------------------
|
||||
|
||||
|
||||
def test_require_password_change_requires_admin(client: TestClient):
|
||||
account = _register_and_login(client)
|
||||
resp = client.post(
|
||||
"/api/v1/admin/users/require-password-change",
|
||||
json={"email": account["email"]},
|
||||
headers=account["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_require_password_change_rejects_unknown_email(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
headers = await _register_admin_and_login(client, db_session)
|
||||
resp = client.post(
|
||||
"/api/v1/admin/users/require-password-change",
|
||||
json={"email": "[email protected]"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_admin_flags_an_account_and_it_gets_logged_to_the_targets_activity(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
"""Checked directly against the DB, not via GET /auth/security-events -
|
||||
that endpoint isn't in the must-change-password exempt list, so the
|
||||
just-flagged account can't reach it until they change their password
|
||||
(see test_flagged_account_is_blocked_from_other_endpoints)."""
|
||||
admin_headers = await _register_admin_and_login(client, db_session)
|
||||
account = _register_and_login(client)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/admin/users/require-password-change",
|
||||
json={"email": account["email"]},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
user = await UserRepository(db_session).get_by_email(account["email"])
|
||||
assert user.must_change_password is True
|
||||
events = await UserSecurityEventRepository(db_session).list_for_user(user.id)
|
||||
assert any(e.event_type == SecurityEventType.PASSWORD_CHANGE_REQUIRED for e in events)
|
||||
|
||||
|
||||
# --- Enforcement gate: everything except me/change-password/logout 403s ----
|
||||
|
||||
|
||||
async def test_flagged_account_is_blocked_from_other_endpoints(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
admin_headers = await _register_admin_and_login(client, db_session)
|
||||
account = _register_and_login(client)
|
||||
client.post(
|
||||
"/api/v1/admin/users/require-password-change",
|
||||
json={"email": account["email"]},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
blocked = client.get("/api/v1/companies", headers=account["headers"])
|
||||
assert blocked.status_code == 403
|
||||
|
||||
still_ok = client.get("/api/v1/auth/me", headers=account["headers"])
|
||||
assert still_ok.status_code == 200
|
||||
assert still_ok.json()["must_change_password"] is True
|
||||
|
||||
|
||||
async def test_change_password_clears_the_flag_and_unblocks_the_account(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
admin_headers = await _register_admin_and_login(client, db_session)
|
||||
account = _register_and_login(client)
|
||||
client.post(
|
||||
"/api/v1/admin/users/require-password-change",
|
||||
json={"email": account["email"]},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
change_resp = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"},
|
||||
headers=account["headers"],
|
||||
)
|
||||
assert change_resp.status_code == 200
|
||||
new_tokens = change_resp.json()
|
||||
new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"}
|
||||
|
||||
me_resp = client.get("/api/v1/auth/me", headers=new_headers)
|
||||
assert me_resp.status_code == 200
|
||||
assert me_resp.json()["must_change_password"] is False
|
||||
|
||||
unblocked = client.get("/api/v1/companies", headers=new_headers)
|
||||
assert unblocked.status_code == 200
|
||||
|
||||
# The new password actually works on a fresh login.
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": account["email"], "password": "brand-new-horse-2"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
|
||||
def test_change_password_rejects_wrong_current_password(client: TestClient):
|
||||
account = _register_and_login(client)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "totally-wrong", "new_password": "brand-new-horse-2"},
|
||||
headers=account["headers"],
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_change_password_rejects_reusing_the_current_password(client: TestClient):
|
||||
account = _register_and_login(client)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "correct-horse-1", "new_password": "correct-horse-1"},
|
||||
headers=account["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_change_password_revokes_the_old_refresh_token(client: TestClient):
|
||||
email = _unique_email()
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
||||
)
|
||||
tokens = client.post(
|
||||
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
||||
).json()
|
||||
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
stale_refresh_resp = client.post(
|
||||
"/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
|
||||
)
|
||||
assert stale_refresh_resp.status_code == 401
|
||||
@@ -100,12 +100,24 @@ async def test_bootstrap_with_a_valid_token_sets_a_session_cookie_and_redirects(
|
||||
"/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["location"] == "/"
|
||||
assert resp.headers["location"] == "/?pgsql=postgres"
|
||||
assert "db_viewer_session" in resp.headers["set-cookie"]
|
||||
assert "HttpOnly" in resp.headers["set-cookie"]
|
||||
assert "Secure" in resp.headers["set-cookie"]
|
||||
|
||||
|
||||
async def test_bootstrap_success_is_logged_to_the_admins_account_activity(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
headers = await _register_admin_and_login(client, db_session)
|
||||
token = client.post("/api/v1/db-viewer/session", headers=headers).json()["token"]
|
||||
|
||||
client.get("/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False)
|
||||
|
||||
events = client.get("/api/v1/auth/security-events", headers=headers).json()
|
||||
assert any(e["event_type"] == "db_viewer_accessed" for e in events)
|
||||
|
||||
|
||||
def test_bootstrap_rejects_a_garbage_token(client: TestClient):
|
||||
resp = client.get(
|
||||
"/api/v1/db-viewer/bootstrap", params={"token": "not-a-real-token"}, follow_redirects=False
|
||||
|
||||
Reference in New Issue
Block a user