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
+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),