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:
2026-08-05 23:41:21 -04:00
co-authored by Claude Sonnet 5
parent 3d6fe56991
commit 4ee38b6241
23 changed files with 1056 additions and 7 deletions
+19 -1
View File
@@ -15,13 +15,14 @@ from app.core.security import get_client_ip
from app.db.session import get_db
from app.models.user import User
from app.repositories.unban_request_repository import UnbanRequestRepository
from app.schemas.auth import RequirePasswordChangeRequest
from app.schemas.unban import (
BanIpRequest,
IpBanResponse,
UnbanRequestPayload,
UnbanRequestResponse,
)
from app.services import unban_service
from app.services import auth_service, unban_service
router = APIRouter(tags=["admin"])
@@ -89,3 +90,20 @@ async def reject_unban_request(
_admin: User = Depends(require_admin),
) -> None:
await unban_service.reject_unban_request(db, request_id)
@router.post("/admin/users/require-password-change", status_code=status.HTTP_204_NO_CONTENT)
async def require_password_change(
request: Request,
payload: RequirePasswordChangeRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> None:
"""Flags an account (by email) so its next request is blocked
everywhere except /auth/me, /auth/change-password, and /auth/logout
until they set a new password - see app.auth.dependencies.get_current_user
and app.services.auth_service.require_password_change. Meant for handing
a demo account to someone with a known sample password."""
client_ip = get_client_ip(request, settings)
await auth_service.require_password_change(db, client_ip, payload.email)
+23
View File
@@ -14,7 +14,9 @@ from app.core.security import get_client_ip, is_localhost
from app.db.session import get_db
from app.models.user import LOCAL_DEV_USER_ID, User
from app.schemas.auth import (
ChangePasswordRequest,
ConfirmPasswordResetRequest,
DeleteAccountRequest,
LoginRequest,
LogoutRequest,
RefreshRequest,
@@ -166,3 +168,24 @@ async def me(user: User = Depends(get_current_user)) -> MeResponse:
effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt"
base = UserResponse.model_validate(user).model_dump()
return MeResponse(**base, auth_mode=effective_auth_mode)
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account(
payload: DeleteAccountRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
await auth_service.delete_account(db, user, payload.password)
@router.post("/change-password", response_model=TokenResponse)
async def change_password(
request: Request,
payload: ChangePasswordRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> TokenResponse:
client_ip = get_client_ip(request, settings)
return await auth_service.change_password(db, settings, client_ip, user, payload)
+19 -1
View File
@@ -68,8 +68,10 @@ async def create_db_viewer_session(
@router.get("/bootstrap")
async def bootstrap_db_viewer_session(
request: Request,
token: str = Query(...),
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> RedirectResponse:
"""Reached only via Nginx's db.<domain> /_auth location - never called
directly by the frontend. No `require_admin` dependency: the bootstrap
@@ -82,8 +84,24 @@ async def bootstrap_db_viewer_session(
status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired token"
) from exc
# Logged here, not at /session mint time, because this is the point the
# admin's browser actually completes the hand-off into Adminer - minting
# a token only proves they clicked the button, not that they got in.
await UserSecurityEventRepository(db).create(
user_id=decoded.user_id,
event_type=SecurityEventType.DB_VIEWER_ACCESSED,
ip_address=get_client_ip(request, settings),
)
await db.commit()
session_token = create_db_viewer_session_token(decoded.user_id, settings)
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
# Adminer's own driver dropdown defaults to MySQL, not Postgres - a bare
# "/" redirect leaves it selecting MySQL against a host that only speaks
# Postgres, producing a "Connection refused" error that has nothing to
# do with this app's own auth. `?pgsql=postgres` pre-selects the right
# driver and server (the internal Compose service name, same in dev and
# prod) - only the Postgres password itself is left for the admin to type.
response = RedirectResponse(url="/?pgsql=postgres", status_code=status.HTTP_302_FOUND)
response.set_cookie(
SESSION_COOKIE_NAME,
session_token,
+14
View File
@@ -29,6 +29,15 @@ from app.models.user import User
from app.repositories.user_repository import UserRepository
from app.services.auth_service import get_or_create_local_user
# Everything an account with must_change_password=True can still reach -
# just enough to discover the flag (/me), fix it (/change-password), and
# bail out (/logout, which doesn't even route through get_current_user but
# is listed for clarity). Every other endpoint 403s until they change it -
# see app.services.auth_service.require_password_change.
_PASSWORD_CHANGE_EXEMPT_PATHS = frozenset(
{"/api/v1/auth/me", "/api/v1/auth/change-password", "/api/v1/auth/logout"}
)
async def get_current_user(
request: Request,
@@ -58,6 +67,11 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired access token",
)
if user.must_change_password and request.url.path not in _PASSWORD_CHANGE_EXEMPT_PATHS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password change required",
)
return user
+3
View File
@@ -154,6 +154,9 @@ class SecurityEventType(StrEnum):
SERVER_SECRET_UPDATED = "server_secret_updated"
API_KEY_UPDATED = "api_key_updated"
DB_VIEWER_SESSION_CREATED = "db_viewer_session_created"
DB_VIEWER_ACCESSED = "db_viewer_accessed"
PASSWORD_CHANGED = "password_changed"
PASSWORD_CHANGE_REQUIRED = "password_change_required"
class ApiKeyProvider(StrEnum):
+6
View File
@@ -42,6 +42,12 @@ class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Admin-forced reset (e.g. before handing a demo account to someone) -
# see app.auth.dependencies.get_current_user, which blocks every
# endpoint except /auth/me, /auth/change-password, and /auth/logout
# while this is true.
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
@@ -23,6 +23,13 @@ class UserRepository:
result = await self.db.execute(select(User.email).where(User.is_admin.is_(True)))
return list(result.scalars().all())
async def delete(self, user: User) -> None:
"""Cascades (ON DELETE CASCADE, see migrations) to every row the
user owns - companies and everything under them, refresh tokens,
security events, etc. Irreversible."""
await self.db.delete(user)
await self.db.flush()
async def create(
self,
*,
+18
View File
@@ -73,6 +73,24 @@ class ConfirmPasswordResetRequest(BaseModel):
return _validate_password_strength(value)
class DeleteAccountRequest(BaseModel):
password: str = Field(min_length=1, max_length=128)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=10, max_length=128)
@field_validator("new_password")
@classmethod
def _password_strength(cls, value: str) -> str:
return _validate_password_strength(value)
class RequirePasswordChangeRequest(BaseModel):
email: EmailStr
class SecurityEventResponse(BaseModel):
event_type: str
ip_address: str
+1
View File
@@ -14,6 +14,7 @@ class UserResponse(BaseModel):
timezone: str
is_active: bool
is_admin: bool
must_change_password: bool
class MeResponse(UserResponse):
+80 -1
View File
@@ -13,7 +13,13 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.errors import AuthenticationError, ConflictError, ThrottledError, ValidationAppError
from app.core.errors import (
AuthenticationError,
ConflictError,
NotFoundError,
ThrottledError,
ValidationAppError,
)
from app.core.security import (
InvalidTokenError,
TokenType,
@@ -37,6 +43,7 @@ from app.repositories.user_known_ip_repository import UserKnownIpRepository
from app.repositories.user_repository import UserRepository
from app.repositories.user_security_event_repository import UserSecurityEventRepository
from app.schemas.auth import (
ChangePasswordRequest,
ConfirmPasswordResetRequest,
LoginRequest,
RegisterRequest,
@@ -426,3 +433,75 @@ async def list_security_events(db: AsyncSession, user_id: uuid.UUID) -> list[Use
"""The calling user's own security activity - the user-facing
counterpart to the admin-only app-wide log feed (core/logging.py)."""
return await UserSecurityEventRepository(db).list_for_user(user_id)
async def delete_account(db: AsyncSession, user: User, password: str) -> None:
"""Permanently deletes the account and, via ON DELETE CASCADE foreign
keys (see migrations), everything it owns - companies and everything
under them, refresh tokens, security events, API keys, etc. There is no
soft-delete or recovery path. Rejected for accounts with no
password_hash (the fixed AUTH_MODE=local user) - there's nothing to
verify the caller's identity against."""
if user.password_hash is None:
raise ValidationAppError("Account deletion isn't available for this account.")
if not verify_password(password, user.password_hash):
raise AuthenticationError("Incorrect password")
await UserRepository(db).delete(user)
await db.commit()
async def change_password(
db: AsyncSession,
settings: Settings,
client_ip: str,
user: User,
payload: ChangePasswordRequest,
) -> TokenResponse:
"""Authenticated in-app password change - distinct from
confirm_password_reset (which is the emailed-code flow for someone who
can't log in at all). Also the only way to clear must_change_password,
the admin-forced-reset flag (see require_password_change below)."""
if user.password_hash is None:
raise ValidationAppError("This account doesn't use password sign-in.")
if not verify_password(payload.current_password, user.password_hash):
raise AuthenticationError("Incorrect current password")
history_repo = PasswordHistoryRepository(db)
previous_hashes = await history_repo.list_hashes_for_user(user.id)
previous_hashes.append(user.password_hash)
if any(verify_password(payload.new_password, h) for h in previous_hashes):
raise ValidationAppError("You've used this password before. Choose a different one.")
await history_repo.add(user_id=user.id, password_hash=user.password_hash)
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
# Rotate every session, including the one making this request - the
# fresh token pair returned below replaces it immediately, so the
# caller keeps working without a forced re-login.
await RefreshTokenRepository(db).revoke_all_for_user(user.id)
await UserSecurityEventRepository(db).create(
user_id=user.id, event_type=SecurityEventType.PASSWORD_CHANGED, ip_address=client_ip
)
return await _issue_token_pair(db, settings, user)
async def require_password_change(db: AsyncSession, client_ip: str, email: str) -> User:
"""Admin action: flags an account so its next request is blocked
everywhere except /auth/me, /auth/change-password, and /auth/logout
(enforced in app.auth.dependencies.get_current_user) until they set a
new password. Meant for handing a demo account to someone with a known
sample password."""
user = await UserRepository(db).get_by_email(email)
if user is None:
raise NotFoundError("No account with that email")
if user.password_hash is None:
raise ValidationAppError("This account doesn't use password sign-in.")
user.must_change_password = True
await UserSecurityEventRepository(db).create(
user_id=user.id,
event_type=SecurityEventType.PASSWORD_CHANGE_REQUIRED,
ip_address=client_ip,
)
await db.commit()
return user