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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user