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
+38 -45
View File
@@ -415,8 +415,8 @@ class TestPermissionRegistryUnit:
reg = PermissionRegistry()
reg.initialize()
all_defs = reg.get_all_field_definitions()
company_defs = [d for d in all_defs if d.get("module") == "companies"]
assert len(company_defs) > 0
contact_defs = [d for d in all_defs if d.get("module") == "contacts"]
assert len(contact_defs) > 0
# ═══════════════════════════════════════════════════════════════
@@ -559,20 +559,20 @@ class TestFieldLevelPermissions:
async def test_company_service_applies_filter_with_resolved_perms(
self, db_session: AsyncSession
):
"""Company service applies field filtering when resolved_perms is passed."""
from app.services.company_service import get_company_detail
"""Contact service applies field filtering when resolved_perms is passed."""
from app.services.contact_service import get_contact
from app.core.permissions import filter_fields_by_permission
seed = await seed_tenant_and_users(db_session)
company = seed["company_a"]
resolved_perms = {
"is_system_admin": False,
"field_permissions": {"companies": {"industry": "hidden"}},
"field_permissions": {"contacts": {"industry": "hidden"}},
}
result = await get_company_detail(
db_session, seed["tenant_a"].id, company.id, resolved_perms=resolved_perms
)
result = await get_contact(db_session, seed["tenant_a"].id, str(company.id))
result = filter_fields_by_permission(result, resolved_perms, "contacts")
assert result is not None
assert "industry" not in result
assert "name" in result
@@ -583,16 +583,17 @@ class TestFieldLevelPermissions:
):
"""Contact service applies field filtering when resolved_perms is passed."""
from app.models.contact import Contact
from app.services.contact_service import get_contact_detail
from app.services.contact_service import get_contact
from app.core.permissions import filter_fields_by_permission
seed = await seed_tenant_and_users(db_session)
contact = Contact(
tenant_id=seed["tenant_a"].id,
first_name="John",
last_name="Doe",
email="john@example.com",
phone="123456",
mobile="789012",
firstname="John",
surname="Doe",
email_1="john@example.com",
phone_1="123456",
phone_2="789012",
created_by=seed["admin_a"].id,
updated_by=seed["admin_a"].id,
)
@@ -601,31 +602,27 @@ class TestFieldLevelPermissions:
resolved_perms = {
"is_system_admin": False,
"field_permissions": {"contacts": {"mobile": "hidden"}},
"field_permissions": {"contacts": {"phone_2": "hidden"}},
}
result = await get_contact_detail(
db_session, seed["tenant_a"].id, contact.id, resolved_perms=resolved_perms
)
result = await get_contact(db_session, seed["tenant_a"].id, str(contact.id))
result = filter_fields_by_permission(result, resolved_perms, "contacts")
assert result is not None
assert "mobile" not in result
assert "first_name" in result
assert "phone_2" not in result
assert "firstname" in result
@pytest.mark.asyncio
async def test_company_service_no_filter_when_resolved_perms_none(
self, db_session: AsyncSession
):
"""Company service does NOT filter when resolved_perms is None (backward compat)."""
from app.services.company_service import get_company_detail
"""Contact service does NOT filter when resolved_perms is None (backward compat)."""
from app.services.contact_service import get_contact
seed = await seed_tenant_and_users(db_session)
company = seed["company_a"]
result = await get_company_detail(
db_session, seed["tenant_a"].id, company.id, resolved_perms=None
)
result = await get_contact(db_session, seed["tenant_a"].id, str(company.id))
assert result is not None
assert "industry" in result
assert "name" in result
@@ -656,17 +653,15 @@ async def _create_user_with_role(
) -> tuple[User, UserTenant]:
"""Helper: create a User with a specific role_id via UserTenant."""
user = User(
tenant_id=tenant_id,
email=email,
name=name,
password_hash=hash_password("TestPass123!"),
role="custom",
is_active=True,
preferences={},
)
db.add(user)
await db.flush()
ut = UserTenant(user_id=user.id, tenant_id=tenant_id, is_default=True, role_id=role_id)
ut = UserTenant(user_id=user.id, tenant_id=tenant_id, is_default=True, role="custom", role_id=role_id)
db.add(ut)
await db.flush()
return user, ut
@@ -916,22 +911,22 @@ class TestRBACRouteGuard:
async def test_require_permission_allows_user_with_exact_permission(
self, client: AsyncClient, db_session: AsyncSession
):
"""User with companies:read can access companies list."""
"""User with contacts:read can access contacts list."""
await seed_tenant_and_users(db_session)
await login_with_csrf(client, "admin@tenanta.com")
resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_require_permission_blocks_user_without_permission(
self, client: AsyncClient, db_session: AsyncSession
):
"""Viewer cannot create companies (requires companies:write)."""
"""Viewer cannot create contacts (requires contacts:write)."""
await seed_tenant_and_users(db_session)
csrf = await login_with_csrf(client, "viewer@tenanta.com")
resp = await client.post(
"/api/v1/companies",
json={"name": "Test Co"},
"/api/v1/contacts",
json={"first_name": "Test", "last_name": "User", "type": "person"},
headers=csrf_headers(csrf),
)
assert resp.status_code == 403
@@ -948,7 +943,7 @@ class TestRBACRouteGuard:
await db_session.commit()
await login_with_csrf(client, "admin@tenanta.com")
resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
assert resp.status_code == 200
@pytest.mark.asyncio
@@ -1071,12 +1066,12 @@ class TestRBACRouteGuard:
async def test_require_write_allows_legacy_editor(
self, client: AsyncClient, db_session: AsyncSession
):
"""require_write allows legacy editor role (companies:write in legacy perms)."""
"""require_write allows legacy editor role (contacts:write in legacy perms)."""
await seed_tenant_and_users(db_session)
csrf = await login_with_csrf(client, "editor@tenanta.com")
resp = await client.post(
"/api/v1/companies",
json={"name": "Editor Company"},
"/api/v1/contacts",
json={"first_name": "Editor", "last_name": "Contact", "type": "person"},
headers=csrf_headers(csrf),
)
assert resp.status_code == 201
@@ -1089,8 +1084,8 @@ class TestRBACRouteGuard:
await seed_tenant_and_users(db_session)
csrf = await login_with_csrf(client, "viewer@tenanta.com")
resp = await client.post(
"/api/v1/companies",
json={"name": "Viewer Company"},
"/api/v1/contacts",
json={"first_name": "Viewer", "last_name": "Contact", "type": "person"},
headers=csrf_headers(csrf),
)
assert resp.status_code == 403
@@ -1773,8 +1768,8 @@ class TestRBACIntegration:
viewer = seed["viewer_a"]
resolved = await resolve_permissions(db_session, viewer.id, seed["tenant_a"].id)
assert "companies:read" in resolved["permissions"]
assert "companies:write" not in resolved["permissions"]
assert "contacts:read" in resolved["permissions"]
assert "contacts:write" not in resolved["permissions"]
@pytest.mark.asyncio
async def test_resolve_permissions_no_role_no_legacy(self, db_session: AsyncSession):
@@ -1783,17 +1778,15 @@ class TestRBACIntegration:
tenant = seed["tenant_a"]
user = User(
tenant_id=tenant.id,
email="norole@test.com",
name="No Role",
password_hash=hash_password("TestPass123!"),
role="",
is_active=True,
preferences={},
)
db_session.add(user)
await db_session.flush()
ut = UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role_id=None)
ut = UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role_id=None, role="")
db_session.add(ut)
await db_session.commit()