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