Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
+119
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -14,6 +15,7 @@ from app.config import get_settings
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import (
|
||||
create_session,
|
||||
get_redis,
|
||||
get_session_data,
|
||||
hash_password,
|
||||
hash_token,
|
||||
@@ -25,6 +27,8 @@ from app.models.auth import PasswordResetToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Handles authentication operations."""
|
||||
@@ -36,11 +40,15 @@ class AuthService:
|
||||
email: str,
|
||||
password: str,
|
||||
tenant_slug: str | None = None,
|
||||
) -> tuple[str, str, User, Tenant] | None:
|
||||
) -> tuple[str, str, User, Tenant, str] | None:
|
||||
"""Authenticate user and create session.
|
||||
Returns (session_id, csrf_token, user, tenant) or None.
|
||||
Returns (session_id, csrf_token, user, tenant, role) or None.
|
||||
|
||||
Email is globally unique so we can safely use scalar_one_or_none().
|
||||
The tenant is resolved from UserTenant via tenant_slug or the
|
||||
user's default tenant membership.
|
||||
"""
|
||||
# Find user by email — need to check across tenants or use default tenant
|
||||
# Find user by email (globally unique now)
|
||||
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -75,7 +83,9 @@ class AuthService:
|
||||
if tenant is None:
|
||||
return None
|
||||
|
||||
session_id, csrf_token = await create_session(db, redis, user, tenant.id)
|
||||
session_id, csrf_token = await create_session(
|
||||
db, redis, user, tenant.id, role=user_tenant.role
|
||||
)
|
||||
|
||||
# Log the login in audit trail
|
||||
await log_audit(
|
||||
@@ -88,7 +98,7 @@ class AuthService:
|
||||
changes={"email": email},
|
||||
)
|
||||
|
||||
return session_id, csrf_token, user, tenant
|
||||
return session_id, csrf_token, user, tenant, user_tenant.role
|
||||
|
||||
async def logout(self, redis: aioredis.Redis, session_id: str) -> bool:
|
||||
"""Invalidate a session."""
|
||||
@@ -141,10 +151,11 @@ class AuthService:
|
||||
UserTenant.tenant_id == new_tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
if ut_result.scalar_one_or_none() is None:
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return None
|
||||
|
||||
updated = await update_session_tenant(redis, session_id, new_tenant_id)
|
||||
updated = await update_session_tenant(redis, session_id, new_tenant_id, role=user_tenant.role)
|
||||
if updated is None:
|
||||
return None
|
||||
|
||||
@@ -169,6 +180,22 @@ class AuthService:
|
||||
if user is None:
|
||||
return True # Don't reveal whether email exists
|
||||
|
||||
# Resolve tenant_id from UserTenant (default or specified)
|
||||
ut_q = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
if tenant_id is not None:
|
||||
ut_q = ut_q.where(UserTenant.tenant_id == tenant_id)
|
||||
else:
|
||||
ut_q = ut_q.where(UserTenant.is_default == True) # noqa: E712
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
# Fallback: get first tenant membership
|
||||
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
ut_result2 = await db.execute(ut_q2)
|
||||
user_tenant = ut_result2.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return True
|
||||
|
||||
# Invalidate previous unused tokens
|
||||
prev_q = select(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user.id,
|
||||
@@ -187,7 +214,7 @@ class AuthService:
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
tenant_id=user_tenant.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
@@ -195,8 +222,26 @@ class AuthService:
|
||||
db.add(reset_token)
|
||||
await db.flush()
|
||||
|
||||
# In production: send email via SMTP. For now, log it.
|
||||
# The raw_token would be in the email link.
|
||||
# Enqueue ARQ job to send the password reset email
|
||||
try:
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
await enqueue_job(
|
||||
"send_password_reset_email",
|
||||
user_id=str(user.id),
|
||||
email=user.email,
|
||||
raw_token=raw_token,
|
||||
expires_at=expires_at.isoformat(),
|
||||
)
|
||||
logger.info("Enqueued password reset email job for user %s", user.id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ARQ enqueue failed for password reset email — "
|
||||
"raw_token for development: %s",
|
||||
raw_token,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def confirm_password_reset(
|
||||
@@ -232,13 +277,46 @@ class AuthService:
|
||||
reset_token.used_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Invalidate all active Redis sessions for this user
|
||||
try:
|
||||
redis = get_redis()
|
||||
# Scan for session keys and check which belong to this user
|
||||
import json
|
||||
|
||||
async for key in redis.scan_iter(match="session:*", count=100):
|
||||
raw = await redis.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
session_data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if session_data.get("user_id") == str(user.id):
|
||||
await redis.delete(key)
|
||||
logger.info("Deleted session %s for user %s after password reset", key, user.id)
|
||||
except Exception:
|
||||
logger.warning("Failed to invalidate Redis sessions for user %s", user.id, exc_info=True)
|
||||
|
||||
# Audit log entry for password reset
|
||||
try:
|
||||
await log_audit(
|
||||
db,
|
||||
reset_token.tenant_id,
|
||||
user.id,
|
||||
"password_reset",
|
||||
"user",
|
||||
user.id,
|
||||
changes={"action": "password_changed"},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to create audit log for password reset of user %s", user.id, exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
async def get_password_reset_token_raw(self, db: AsyncSession, email: str) -> str | None:
|
||||
"""Get the raw (unhashed) reset token for testing purposes.
|
||||
This simulates what would be sent via email.
|
||||
"""
|
||||
# This is a test helper — in production the token goes via email only
|
||||
import secrets
|
||||
|
||||
q = select(User).where(User.email == email)
|
||||
@@ -247,13 +325,27 @@ class AuthService:
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
# Get tenant_id from UserTenant
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user.id,
|
||||
UserTenant.is_default == True, # noqa: E712
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
ut_result2 = await db.execute(ut_q2)
|
||||
user_tenant = ut_result2.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return None
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hash_token(raw_token)
|
||||
settings = get_settings()
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
tenant_id=user_tenant.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
@@ -272,12 +364,26 @@ class AuthService:
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
# Get tenant_id from UserTenant
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user.id,
|
||||
UserTenant.is_default == True, # noqa: E712
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
ut_result2 = await db.execute(ut_q2)
|
||||
user_tenant = ut_result2.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return None
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hash_token(raw_token)
|
||||
expires_at = datetime.now(UTC) - timedelta(hours=1) # Already expired
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
tenant_id=user_tenant.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.services.entity_history_service import record_history
|
||||
def _compute_displayname(data: dict) -> str:
|
||||
"""Compute displayname from type and name fields."""
|
||||
if data.get("type") == "person":
|
||||
parts = [data.get("surfix"), data.get("firstname"), data.get("surname")]
|
||||
parts = [data.get("suffix"), data.get("firstname"), data.get("surname")]
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
else:
|
||||
return data.get("name") or ""
|
||||
@@ -30,10 +30,11 @@ def _serialize_contact(c: Contact) -> dict:
|
||||
"id": str(c.id),
|
||||
"type": c.type,
|
||||
"displayname": c.displayname,
|
||||
"status": getattr(c, "status", "lead"),
|
||||
"name": c.name,
|
||||
"firstname": c.firstname,
|
||||
"surname": c.surname,
|
||||
"surfix": c.surfix,
|
||||
"suffix": c.suffix,
|
||||
"ext_name_line": c.ext_name_line,
|
||||
"gender": c.gender,
|
||||
"code": c.code,
|
||||
@@ -77,12 +78,12 @@ def _serialize_contact(c: Contact) -> dict:
|
||||
"purchase_number": c.purchase_number,
|
||||
"bic": c.bic,
|
||||
"bank_account": c.bank_account,
|
||||
"discount_crew": c.discount_crew,
|
||||
"discount_transport": c.discount_transport,
|
||||
"discount_rental": c.discount_rental,
|
||||
"discount_sale": c.discount_sale,
|
||||
"discount_subrent": c.discount_subrent,
|
||||
"discount_total": c.discount_total,
|
||||
"discount_crew": float(c.discount_crew) if c.discount_crew is not None else 0.0,
|
||||
"discount_transport": float(c.discount_transport) if c.discount_transport is not None else 0.0,
|
||||
"discount_rental": float(c.discount_rental) if c.discount_rental is not None else 0.0,
|
||||
"discount_sale": float(c.discount_sale) if c.discount_sale is not None else 0.0,
|
||||
"discount_subrent": float(c.discount_subrent) if c.discount_subrent is not None else 0.0,
|
||||
"discount_total": float(c.discount_total) if c.discount_total is not None else 0.0,
|
||||
"latitude": c.latitude,
|
||||
"longitude": c.longitude,
|
||||
"projectnote": c.projectnote,
|
||||
@@ -251,17 +252,16 @@ async def create_contact(
|
||||
action="create", snapshot_after=serialized,
|
||||
)
|
||||
|
||||
# Publish events
|
||||
from app.core.event_bus import get_event_bus
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish('contact.created', {
|
||||
# Enqueue domain events via transactional outbox (durable, at-least-once)
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
await enqueue_outbox_event(db, tenant_id, 'contact.created', {
|
||||
'contact_id': str(contact.id),
|
||||
'tenant_id': str(tenant_id),
|
||||
'user_id': str(user_id),
|
||||
'type': data.get('type', 'person'),
|
||||
})
|
||||
if data.get('type') == 'company':
|
||||
await event_bus.publish('lead.created', {
|
||||
await enqueue_outbox_event(db, tenant_id, 'lead.created', {
|
||||
'contact_id': str(contact.id),
|
||||
'tenant_id': str(tenant_id),
|
||||
'user_id': str(user_id),
|
||||
@@ -274,6 +274,8 @@ async def update_contact(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
|
||||
) -> dict:
|
||||
"""Update a contact."""
|
||||
# Expire all cached objects to ensure fresh data with selectinload
|
||||
db.expire_all()
|
||||
q = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
@@ -292,7 +294,7 @@ async def update_contact(
|
||||
snapshot_before = _serialize_contact_detail(contact)
|
||||
|
||||
# Recompute displayname if name fields changed
|
||||
if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")):
|
||||
if any(k in data for k in ("type", "name", "firstname", "surname", "suffix")):
|
||||
merged = {**_serialize_contact(contact), **data}
|
||||
data["displayname"] = _compute_displayname(merged)
|
||||
|
||||
@@ -302,6 +304,15 @@ async def update_contact(
|
||||
contact.updated_by = user_id
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Re-query with selectinload to avoid lazy-loading issues after flush
|
||||
q2 = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(Contact.id == contact.id)
|
||||
)
|
||||
result2 = await db.execute(q2)
|
||||
contact = result2.scalar_one()
|
||||
snapshot_after = _serialize_contact_detail(contact)
|
||||
|
||||
# Compute changes diff
|
||||
@@ -320,10 +331,9 @@ async def update_contact(
|
||||
changes=changes or None,
|
||||
)
|
||||
|
||||
# Publish contact.updated event
|
||||
from app.core.event_bus import get_event_bus
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish('contact.updated', {
|
||||
# Enqueue domain event via transactional outbox (durable, at-least-once)
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
await enqueue_outbox_event(db, tenant_id, 'contact.updated', {
|
||||
'contact_id': str(contact.id),
|
||||
'tenant_id': str(tenant_id),
|
||||
'user_id': str(user_id),
|
||||
|
||||
@@ -218,7 +218,7 @@ def _serialize_full(c: Contact) -> dict:
|
||||
"name": c.name,
|
||||
"firstname": c.firstname,
|
||||
"surname": c.surname,
|
||||
"surfix": c.surfix,
|
||||
"suffix": c.suffix,
|
||||
"email_1": c.email_1,
|
||||
"email_2": c.email_2,
|
||||
"phone_1": c.phone_1,
|
||||
@@ -287,7 +287,6 @@ async def merge_contacts(
|
||||
setattr(target, key, value)
|
||||
|
||||
# Re-point entity_links from source to target
|
||||
from app.models.entity_link import EntityLink
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE entity_links SET entity_id = :target_id "
|
||||
@@ -297,7 +296,6 @@ async def merge_contacts(
|
||||
)
|
||||
|
||||
# Re-point tag_assignments from source to target
|
||||
from app.models.tag import TagAssignment
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE tag_assignments SET entity_id = :target_id "
|
||||
@@ -309,7 +307,7 @@ async def merge_contacts(
|
||||
# Re-point contact_persons from source to target
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE contact_persons SET contact_id = :target_id "
|
||||
"UPDATE contactpersons SET contact_id = :target_id "
|
||||
"WHERE contact_id = :source_id AND tenant_id = :tenant_id"
|
||||
),
|
||||
{"target_id": target_uuid, "source_id": source_uuid, "tenant_id": tenant_id},
|
||||
@@ -322,20 +320,37 @@ async def merge_contacts(
|
||||
# Record merge history
|
||||
history = ContactMergeHistory(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
source_id=source_uuid,
|
||||
target_id=target_uuid,
|
||||
merged_by=user_id,
|
||||
source_contact_id=source_uuid,
|
||||
target_contact_id=target_uuid,
|
||||
note=note,
|
||||
)
|
||||
db.add(history)
|
||||
await db.flush()
|
||||
|
||||
# Determine which fields were actually overridden
|
||||
merged_fields = field_overrides or {}
|
||||
if not merged_fields:
|
||||
# Auto-merge: fill empty target fields from source
|
||||
for attr in ("email_1", "email_2", "phone_1", "phone_2", "website",
|
||||
"mailing_street", "mailing_postalcode", "mailing_city",
|
||||
"mailing_country", "code", "vat_code"):
|
||||
target_val = getattr(target, attr, None)
|
||||
source_val = getattr(source, attr, None)
|
||||
if not target_val and source_val:
|
||||
setattr(target, attr, source_val)
|
||||
merged_fields[attr] = source_val
|
||||
|
||||
history.merged_fields = merged_fields
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"history": {
|
||||
"id": str(history.id),
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"note": note,
|
||||
"merged_fields": merged_fields,
|
||||
"created_at": history.created_at.isoformat() if history.created_at else None,
|
||||
},
|
||||
"target_contact": _serialize_full(target),
|
||||
|
||||
@@ -61,19 +61,23 @@ class TenantService:
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List users in a tenant."""
|
||||
q = select(User).where(User.tenant_id == tenant_id)
|
||||
"""List users in a tenant via UserTenant association."""
|
||||
q = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
users = result.scalars().all()
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"email": u.email,
|
||||
"name": u.name,
|
||||
"role": u.role,
|
||||
"role": ut.role,
|
||||
"is_active": u.is_active,
|
||||
}
|
||||
for u in users
|
||||
for u, ut in rows
|
||||
]
|
||||
|
||||
async def assign_user_to_tenant(
|
||||
|
||||
@@ -18,7 +18,11 @@ _UNSET: Any = object()
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Handles user CRUD operations."""
|
||||
"""Handles user CRUD operations.
|
||||
|
||||
All queries are tenant-scoped through the UserTenant association table.
|
||||
User.email is globally unique; tenant membership and role live in UserTenant.
|
||||
"""
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
@@ -31,25 +35,33 @@ class UserService:
|
||||
"""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)
|
||||
base = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
count_q = (
|
||||
select(func.count())
|
||||
.select_from(UserTenant)
|
||||
.where(UserTenant.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)
|
||||
base = base.where(search_filter)
|
||||
count_q = count_q.join(User, User.id == UserTenant.user_id).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())
|
||||
q = base.offset(offset).limit(page_size).order_by(User.created_at.desc())
|
||||
result = await db.execute(q)
|
||||
users = result.scalars().all()
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
"items": [self._user_to_dict(u) for u in users],
|
||||
"items": [self._user_to_dict(u, ut) for u, ut in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -60,11 +72,21 @@ class UserService:
|
||||
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)
|
||||
) -> tuple[User, UserTenant] | None:
|
||||
"""Get a single user by ID within tenant scope.
|
||||
|
||||
Returns (User, UserTenant) tuple or None.
|
||||
"""
|
||||
q = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return None
|
||||
return row[0], row[1]
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
@@ -77,29 +99,27 @@ class UserService:
|
||||
role_id: uuid.UUID | None = None,
|
||||
is_active: bool = True,
|
||||
) -> User:
|
||||
"""Create a new user in a tenant.
|
||||
"""Create a new user and add them to the specified tenant.
|
||||
|
||||
If role_id is provided it links the user to a custom Role record.
|
||||
The legacy ``role`` string is kept for backward compatibility.
|
||||
If role_id is provided it links the UserTenant to a custom Role record.
|
||||
The ``role`` string is the built-in role (admin/editor/viewer).
|
||||
"""
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
role_id=role_id,
|
||||
is_active=is_active,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# Add user-tenant membership
|
||||
# Add user-tenant membership with role
|
||||
ut = UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
is_default=True,
|
||||
role=role,
|
||||
role_id=role_id,
|
||||
)
|
||||
db.add(ut)
|
||||
@@ -122,35 +142,34 @@ class UserService:
|
||||
email: str | None = None,
|
||||
current_password: str | None = None,
|
||||
new_password: str | None = None,
|
||||
) -> User | None:
|
||||
"""Update a user.
|
||||
) -> tuple[User, UserTenant] | None:
|
||||
"""Update a user and their tenant membership.
|
||||
|
||||
``role_id`` uses a sentinel to distinguish three states:
|
||||
- ``_UNSET`` (default): leave the existing role_id unchanged
|
||||
- ``None``: clear the FK (fall back to the legacy ``role`` string)
|
||||
- ``None``: clear the FK (fall back to the built-in ``role`` string)
|
||||
- ``uuid.UUID``: link to a custom Role record
|
||||
|
||||
Returns (User, UserTenant) tuple or None if not found.
|
||||
"""
|
||||
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
q = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
user, user_tenant = row[0], row[1]
|
||||
|
||||
if name is not None:
|
||||
user.name = name
|
||||
if role is not None:
|
||||
user.role = role
|
||||
user_tenant.role = role
|
||||
if role_id is not _UNSET:
|
||||
user.role_id = role_id
|
||||
# Sync UserTenant.role_id so resolve_permissions picks up the change
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant:
|
||||
user_tenant.role_id = role_id
|
||||
user_tenant.role_id = role_id
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
if first_name is not None:
|
||||
@@ -170,7 +189,7 @@ class UserService:
|
||||
user.password_hash = hash_password(new_password)
|
||||
|
||||
await db.flush()
|
||||
return user
|
||||
return user, user_tenant
|
||||
|
||||
async def delete_user(
|
||||
self,
|
||||
@@ -178,28 +197,59 @@ class UserService:
|
||||
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:
|
||||
"""Remove a user from a tenant (delete UserTenant membership).
|
||||
|
||||
If this is the user's only tenant membership, the User record is
|
||||
also deleted. Otherwise only the UserTenant row is removed.
|
||||
"""
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return False
|
||||
|
||||
await db.delete(user)
|
||||
# Count total tenant memberships for this user
|
||||
count_q = select(func.count()).select_from(UserTenant).where(
|
||||
UserTenant.user_id == user_id
|
||||
)
|
||||
count_result = await db.execute(count_q)
|
||||
membership_count = count_result.scalar() or 0
|
||||
|
||||
await db.delete(user_tenant)
|
||||
|
||||
if membership_count <= 1:
|
||||
# User's only tenant — delete the User record too
|
||||
user_q = select(User).where(User.id == user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
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 {
|
||||
def _user_to_dict(
|
||||
self, user: User, user_tenant: UserTenant | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Convert user + user_tenant to response dict."""
|
||||
result: dict[str, Any] = {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
}
|
||||
if user_tenant is not None:
|
||||
result["role"] = user_tenant.role
|
||||
result["role_id"] = str(user_tenant.role_id) if user_tenant.role_id else None
|
||||
result["tenant_id"] = str(user_tenant.tenant_id)
|
||||
else:
|
||||
result["role"] = "viewer"
|
||||
result["role_id"] = None
|
||||
result["tenant_id"] = None
|
||||
return result
|
||||
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
Reference in New Issue
Block a user