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:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+119 -13
View File
@@ -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,
+28 -18
View File
@@ -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),
+22 -7
View File
@@ -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),
+9 -5
View File
@@ -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(
+99 -49
View File
@@ -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()