T01: core infrastructure + auth + multi-tenant + RLS

- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens
- Session-based auth (Redis + PostgreSQL audit trail)
- Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config)
- RBAC with roles/permissions + field-level permissions
- CSRF protection via Origin header validation
- Auth rate limiting (Redis counters with TTL)
- CORS with explicit origins (no wildcard)
- Health endpoint (no auth required)
- Notification service + audit log middleware
- 29 tests, 26 ACs, all passing
- Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 3cc0b2e1b4
commit 7a7daf8100
137 changed files with 3866 additions and 10195 deletions
+143 -99
View File
@@ -1,109 +1,153 @@
"""User service: read, update, soft-delete, list."""
"""User management service."""
from __future__ import annotations
from datetime import UTC, datetime
import uuid
from typing import Any
from sqlalchemy import func, select
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.models.user import User, UserRole
from app.schemas.user import UserCreateRequest, UserUpdate
from app.core.auth import hash_password
from app.models.user import User, UserTenant
class UserNotFound(Exception):
"""Raised when a user lookup fails."""
class UserService:
"""Handles user CRUD operations."""
async def list_users(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 25,
search: str | None = None,
) -> dict[str, Any]:
"""List users in a tenant with pagination and search."""
offset = (page - 1) * page_size
q = select(User).where(User.tenant_id == tenant_id)
count_q = select(func.count()).select_from(User).where(User.tenant_id == tenant_id)
if search:
search_filter = or_(
User.name.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
)
q = q.where(search_filter)
count_q = count_q.where(search_filter)
total = (await db.execute(count_q)).scalar() or 0
q = q.offset(offset).limit(page_size).order_by(User.created_at.desc())
result = await db.execute(q)
users = result.scalars().all()
return {
"items": [self._user_to_dict(u) for u in users],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> User | None:
"""Get a single user by ID within tenant scope."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
return result.scalar_one_or_none()
async def create_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
email: str,
name: str,
password: str,
role: str = "viewer",
is_active: bool = True,
) -> User:
"""Create a new user in a tenant."""
user = User(
tenant_id=tenant_id,
email=email,
name=name,
password_hash=hash_password(password),
role=role,
is_active=is_active,
preferences={},
)
db.add(user)
await db.flush()
# Add user-tenant membership
ut = UserTenant(
user_id=user.id,
tenant_id=tenant_id,
is_default=True,
)
db.add(ut)
await db.flush()
return user
async def update_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> User | None:
"""Update a user."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return None
if name is not None:
user.name = name
if role is not None:
user.role = role
if is_active is not None:
user.is_active = is_active
await db.flush()
return user
async def delete_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Delete a user from a tenant."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return False
await db.delete(user)
await db.flush()
return True
def _user_to_dict(self, user: User) -> dict[str, Any]:
"""Convert user to response dict."""
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
class EmailAlreadyTaken(Exception):
"""Raised when attempting to create/update a user with an existing email."""
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
"""Fetch a user by ID (active only, soft-deleted excluded)."""
result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
return result.scalar_one_or_none()
async def get_user_by_email(db: AsyncSession, email: str, org_id: int | None = None) -> User | None:
"""Fetch a user by email, optionally scoped to an org."""
stmt = select(User).where(
User.email == email.lower(),
User.deleted_at.is_(None),
)
if org_id is not None:
stmt = stmt.where(User.org_id == org_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def update_user_profile(
db: AsyncSession, user: User, payload: UserUpdate, *, is_admin: bool = False
) -> User:
"""Apply partial updates to a user.
Non-admin callers cannot change the role. All other fields are optional.
"""
data = payload.model_dump(exclude_unset=True)
if "role" in data and not is_admin:
# Silently drop role change for non-admin callers
data.pop("role")
for field, value in data.items():
if value is not None:
setattr(user, field, value)
await db.commit()
await db.refresh(user)
return user
async def soft_delete_user(db: AsyncSession, user: User) -> User:
"""Soft-delete a user by setting deleted_at to now."""
user.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
return user
async def create_user_as_admin(db: AsyncSession, payload: UserCreateRequest, org_id: int) -> User:
"""Create a new user in the given org (admin-only flow)."""
existing = await get_user_by_email(db, payload.email, org_id=org_id)
if existing is not None:
raise EmailAlreadyTaken(f"A user with email {payload.email!r} already exists in this org.")
user = User(
org_id=org_id,
email=payload.email.lower(),
password_hash=hash_password(payload.password),
name=payload.name,
role=payload.role if payload.role else UserRole.sales_rep,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def list_users(db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50) -> list[User]:
"""List active users in an org, paginated."""
result = await db.execute(
select(User)
.where(User.org_id == org_id, User.deleted_at.is_(None))
.order_by(User.id)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def count_users_in_org(db: AsyncSession, org_id: int) -> int:
"""Count active users in an org."""
result = await db.execute(
select(func.count())
.select_from(User)
.where(User.org_id == org_id, User.deleted_at.is_(None))
)
return int(result.scalar_one())
user_service = UserService()