1877 lines
74 KiB
Python
1877 lines
74 KiB
Python
|
|
"""Comprehensive RBAC system tests — covers all 5 RBAC phases.
|
||
|
|
|
||
|
|
Sections:
|
||
|
|
1. Permission-Resolver (app/core/permissions.py) — unit tests, no DB
|
||
|
|
2. Permission-Registry (app/core/permission_registry.py) — unit tests, no DB
|
||
|
|
3. Group CRUD (app/routes/groups.py + app/services/group_service.py) — DB required
|
||
|
|
4. RBAC Route-Guard (app/deps.py) — DB required
|
||
|
|
5. Self-Modification Prevention (app/routes/users.py) — DB required
|
||
|
|
6. Cache-Invalidierung (app/core/permissions.py) — DB + Redis required
|
||
|
|
7. Mail-Plugin RBAC (app/plugins/builtins/mail/routes.py) — DB required
|
||
|
|
8. Field-Level Permission (app/core/permissions.py + services) — unit + DB
|
||
|
|
9. Integration Tests — DB + Redis required
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import uuid
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from httpx import ASGITransport, AsyncClient
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||
|
|
|
||
|
|
from app.core.auth import hash_password
|
||
|
|
from app.core.db import close_engine, reset_engine_for_testing
|
||
|
|
from app.core.permission_registry import (
|
||
|
|
CORE_PERMISSIONS,
|
||
|
|
PermissionRegistry,
|
||
|
|
get_permission_registry,
|
||
|
|
init_permission_registry,
|
||
|
|
)
|
||
|
|
from app.core.permissions import (
|
||
|
|
_matches_permission,
|
||
|
|
_normalize_permissions,
|
||
|
|
check_field_access,
|
||
|
|
check_permission,
|
||
|
|
filter_fields_by_permission,
|
||
|
|
invalidate_all_user_permissions,
|
||
|
|
invalidate_permission_cache,
|
||
|
|
resolve_permissions,
|
||
|
|
)
|
||
|
|
from app.core.service_container import get_container
|
||
|
|
from app.main import create_app
|
||
|
|
from app.models.group import Group, UserGroup
|
||
|
|
from app.models.role import Role
|
||
|
|
from app.models.user import User, UserTenant
|
||
|
|
from app.plugins.builtins.mail import MailPlugin
|
||
|
|
from app.plugins.builtins.mail.models import (
|
||
|
|
Mail,
|
||
|
|
MailAccount,
|
||
|
|
MailAccountDelegate,
|
||
|
|
MailFolder,
|
||
|
|
)
|
||
|
|
from app.plugins.registry import reset_registry_for_testing
|
||
|
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
||
|
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# CSRF-AWARE HELPERS
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
async def login_with_csrf(client: AsyncClient, email: str, password: str = "TestPass123!") -> str:
|
||
|
|
"""Login via HTTP API and return the CSRF token.
|
||
|
|
|
||
|
|
The client cookies are set automatically by httpx.
|
||
|
|
The CSRF token must be sent as X-CSRF-Token header on unsafe methods.
|
||
|
|
"""
|
||
|
|
resp = await client.post(
|
||
|
|
"/api/v1/auth/login",
|
||
|
|
json={"email": email, "password": password},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
|
||
|
|
return resp.json()["csrf_token"]
|
||
|
|
|
||
|
|
|
||
|
|
def csrf_headers(csrf_token: str) -> dict:
|
||
|
|
"""Return headers dict with Origin + X-CSRF-Token for unsafe methods."""
|
||
|
|
return {**ORIGIN_HEADER, "X-CSRF-Token": csrf_token}
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 1: Permission-Resolver Tests (unit tests, no DB needed)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
class TestPermissionResolverUnit:
|
||
|
|
"""Unit tests for permission resolver functions — no DB required."""
|
||
|
|
|
||
|
|
# ── _matches_permission ──
|
||
|
|
|
||
|
|
def test_matches_exact_permission(self):
|
||
|
|
"""Exact match: companies:read matches companies:read."""
|
||
|
|
assert _matches_permission("companies:read", "companies:read") is True
|
||
|
|
|
||
|
|
def test_matches_wildcard_all(self):
|
||
|
|
"""Wildcard *:* matches everything."""
|
||
|
|
assert _matches_permission("*:*", "companies:read") is True
|
||
|
|
assert _matches_permission("*:*", "contacts:write") is True
|
||
|
|
assert _matches_permission("*:*", "mail:send") is True
|
||
|
|
|
||
|
|
def test_matches_wildcard_module(self):
|
||
|
|
"""Wildcard companies:* matches all actions for companies."""
|
||
|
|
assert _matches_permission("companies:*", "companies:read") is True
|
||
|
|
assert _matches_permission("companies:*", "companies:write") is True
|
||
|
|
assert _matches_permission("companies:*", "companies:delete") is True
|
||
|
|
|
||
|
|
def test_matches_wildcard_action(self):
|
||
|
|
"""Wildcard *:read matches read action for any module."""
|
||
|
|
assert _matches_permission("*:read", "companies:read") is True
|
||
|
|
assert _matches_permission("*:read", "contacts:read") is True
|
||
|
|
assert _matches_permission("*:read", "mail:read") is True
|
||
|
|
|
||
|
|
def test_matches_no_match_different_module(self):
|
||
|
|
"""companies:read does not match contacts:read."""
|
||
|
|
assert _matches_permission("companies:read", "contacts:read") is False
|
||
|
|
|
||
|
|
def test_matches_no_match_different_action(self):
|
||
|
|
"""companies:read does not match companies:write."""
|
||
|
|
assert _matches_permission("companies:read", "companies:write") is False
|
||
|
|
|
||
|
|
def test_matches_wildcard_module_does_not_match_other_module(self):
|
||
|
|
"""companies:* does not match contacts:read."""
|
||
|
|
assert _matches_permission("companies:*", "contacts:read") is False
|
||
|
|
|
||
|
|
def test_matches_wildcard_action_does_not_match_other_action(self):
|
||
|
|
"""*:read does not match companies:write."""
|
||
|
|
assert _matches_permission("*:read", "companies:write") is False
|
||
|
|
|
||
|
|
# ── _normalize_permissions ──
|
||
|
|
|
||
|
|
def test_normalize_list_format(self):
|
||
|
|
"""List format: ['companies:read', 'contacts:write'] → set."""
|
||
|
|
result = _normalize_permissions(["companies:read", "contacts:write"])
|
||
|
|
assert result == {"companies:read", "contacts:write"}
|
||
|
|
|
||
|
|
def test_normalize_dict_bool_format(self):
|
||
|
|
"""Dict-bool format: {'companies:read': true, 'contacts:write': false}."""
|
||
|
|
result = _normalize_permissions({
|
||
|
|
"companies:read": True,
|
||
|
|
"contacts:write": False,
|
||
|
|
})
|
||
|
|
assert result == {"companies:read"}
|
||
|
|
|
||
|
|
def test_normalize_dict_nested_format(self):
|
||
|
|
"""Dict-nested format: {'companies': {'read': true, 'write': false}}."""
|
||
|
|
result = _normalize_permissions({
|
||
|
|
"companies": {"read": True, "write": False, "delete": True},
|
||
|
|
"contacts": {"read": True},
|
||
|
|
})
|
||
|
|
assert result == {"companies:read", "companies:delete", "contacts:read"}
|
||
|
|
|
||
|
|
def test_normalize_empty(self):
|
||
|
|
"""Empty list/dict returns empty set."""
|
||
|
|
assert _normalize_permissions([]) == set()
|
||
|
|
assert _normalize_permissions({}) == set()
|
||
|
|
|
||
|
|
def test_normalize_dot_to_colon(self):
|
||
|
|
"""Dots are replaced with colons: 'companies.read' → 'companies:read'."""
|
||
|
|
result = _normalize_permissions(["companies.read"])
|
||
|
|
assert result == {"companies:read"}
|
||
|
|
|
||
|
|
# ── check_permission ──
|
||
|
|
|
||
|
|
def test_check_permission_system_admin_bypass(self):
|
||
|
|
"""System admin bypasses all permission checks."""
|
||
|
|
resolved = {"is_system_admin": True, "permissions": [], "denied": []}
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "anything:anything") is True
|
||
|
|
|
||
|
|
def test_check_permission_exact_match(self):
|
||
|
|
"""Exact permission match grants access."""
|
||
|
|
resolved = {"is_system_admin": False, "permissions": ["companies:read"], "denied": []}
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
|
||
|
|
def test_check_permission_wildcard_match(self):
|
||
|
|
"""Wildcard permissions match specific requirements."""
|
||
|
|
resolved = {"is_system_admin": False, "permissions": ["companies:*"], "denied": []}
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "companies:write") is True
|
||
|
|
|
||
|
|
def test_check_permission_denied_overrides_allowed(self):
|
||
|
|
"""Denied permission overrides allowed permission."""
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"permissions": ["companies:read", "companies:write"],
|
||
|
|
"denied": ["companies:write"],
|
||
|
|
}
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "companies:write") is False
|
||
|
|
|
||
|
|
def test_check_permission_denied_wildcard_overrides(self):
|
||
|
|
"""Denied wildcard overrides specific allowed permission."""
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"permissions": ["companies:read"],
|
||
|
|
"denied": ["companies:*"],
|
||
|
|
}
|
||
|
|
assert check_permission(resolved, "companies:read") is False
|
||
|
|
|
||
|
|
def test_check_permission_no_match(self):
|
||
|
|
"""No matching permission denies access."""
|
||
|
|
resolved = {"is_system_admin": False, "permissions": ["contacts:read"], "denied": []}
|
||
|
|
assert check_permission(resolved, "companies:read") is False
|
||
|
|
|
||
|
|
def test_check_permission_star_star_grants_all(self):
|
||
|
|
"""*:* grants any permission."""
|
||
|
|
resolved = {"is_system_admin": False, "permissions": ["*:*"], "denied": []}
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "mail:send") is True
|
||
|
|
|
||
|
|
# ── check_field_access ──
|
||
|
|
|
||
|
|
def test_check_field_access_hidden(self):
|
||
|
|
"""Hidden field returns 'hidden'."""
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "hidden"}},
|
||
|
|
}
|
||
|
|
assert check_field_access(resolved, "companies", "annual_revenue") == "hidden"
|
||
|
|
|
||
|
|
def test_check_field_access_readonly(self):
|
||
|
|
"""Readonly field returns 'readonly'."""
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"name": "readonly"}},
|
||
|
|
}
|
||
|
|
assert check_field_access(resolved, "companies", "name") == "readonly"
|
||
|
|
|
||
|
|
def test_check_field_access_read_default(self):
|
||
|
|
"""Field without explicit permission returns default 'read'."""
|
||
|
|
resolved = {"is_system_admin": False, "field_permissions": {}}
|
||
|
|
assert check_field_access(resolved, "companies", "name") == "read"
|
||
|
|
|
||
|
|
def test_check_field_access_system_admin(self):
|
||
|
|
"""System admin always gets 'read' for any field."""
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": True,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "hidden"}},
|
||
|
|
}
|
||
|
|
assert check_field_access(resolved, "companies", "annual_revenue") == "read"
|
||
|
|
|
||
|
|
# ── filter_fields_by_permission ──
|
||
|
|
|
||
|
|
def test_filter_fields_removes_hidden(self):
|
||
|
|
"""Hidden fields are removed from the output."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000, "industry": "IT"}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "hidden"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert "annual_revenue" not in result
|
||
|
|
assert result["name"] == "ACME"
|
||
|
|
assert result["industry"] == "IT"
|
||
|
|
|
||
|
|
def test_filter_fields_keeps_readonly(self):
|
||
|
|
"""Readonly fields are kept in the output."""
|
||
|
|
data = {"name": "ACME", "industry": "IT"}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"name": "readonly"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert "name" in result
|
||
|
|
assert result["name"] == "ACME"
|
||
|
|
|
||
|
|
def test_filter_fields_keeps_read(self):
|
||
|
|
"""Read fields are kept in the output."""
|
||
|
|
data = {"name": "ACME", "industry": "IT"}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"name": "read"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert "name" in result
|
||
|
|
|
||
|
|
def test_filter_fields_system_admin_no_filtering(self):
|
||
|
|
"""System admin: no fields are filtered."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": True,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "hidden"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
def test_filter_fields_no_field_permissions_no_filtering(self):
|
||
|
|
"""No field_permissions in resolved → no filtering."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000}
|
||
|
|
resolved = {"is_system_admin": False, "field_permissions": {}}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
def test_filter_fields_no_module_perms_no_filtering(self):
|
||
|
|
"""No permissions for the specific module → no filtering."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"contacts": {"email": "hidden"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 2: Permission-Registry Tests (unit tests, no DB needed)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
_CORE_ONLY_COUNT = sum(1 for p in CORE_PERMISSIONS if p.get("category") == "core")
|
||
|
|
|
||
|
|
|
||
|
|
class TestPermissionRegistryUnit:
|
||
|
|
"""Unit tests for the PermissionRegistry — no DB required."""
|
||
|
|
|
||
|
|
def test_initialize_registers_core_permissions(self):
|
||
|
|
"""initialize() registers all CORE_PERMISSIONS."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
for perm in CORE_PERMISSIONS:
|
||
|
|
assert reg.is_valid(perm["key"]), f"Missing core permission: {perm['key']}"
|
||
|
|
|
||
|
|
def test_initialize_with_active_plugins(self):
|
||
|
|
"""initialize() accepts active_plugin_names set."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize(active_plugin_names={"mail", "dms"})
|
||
|
|
assert reg.is_plugin_active("mail") is True
|
||
|
|
assert reg.is_plugin_active("dms") is True
|
||
|
|
assert reg.is_plugin_active("calendar") is False
|
||
|
|
|
||
|
|
def test_register_plugin_permissions(self):
|
||
|
|
"""register_plugin_permissions adds plugin permissions."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
reg.register_plugin_permissions("mail", ["mail:read", "mail:send", "mail:config"])
|
||
|
|
assert reg.is_valid("mail:read") is True
|
||
|
|
assert reg.is_valid("mail:send") is True
|
||
|
|
assert reg.is_valid("mail:config") is True
|
||
|
|
|
||
|
|
def test_unregister_plugin_permissions(self):
|
||
|
|
"""unregister_plugin_permissions removes plugin permissions."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
reg.register_plugin_permissions("mail", ["mail:read", "mail:send"])
|
||
|
|
assert reg.is_valid("mail:read") is True
|
||
|
|
reg.unregister_plugin_permissions("mail")
|
||
|
|
assert reg.is_valid("mail:read") is False
|
||
|
|
assert reg.is_valid("mail:send") is False
|
||
|
|
|
||
|
|
def test_is_valid_unknown_permission(self):
|
||
|
|
"""is_valid returns False for unknown permission."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
assert reg.is_valid("nonexistent:action") is False
|
||
|
|
|
||
|
|
def test_get_all_returns_all_permissions(self):
|
||
|
|
"""get_all() returns all registered permissions."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
all_perms = reg.get_all()
|
||
|
|
assert len(all_perms) == len(CORE_PERMISSIONS)
|
||
|
|
reg.register_plugin_permissions("mail", ["mail:read"])
|
||
|
|
all_perms = reg.get_all()
|
||
|
|
assert len(all_perms) == len(CORE_PERMISSIONS) + 1
|
||
|
|
|
||
|
|
def test_get_core_returns_only_core(self):
|
||
|
|
"""get_core() returns only core-category permissions (excludes system:admin)."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
reg.register_plugin_permissions("mail", ["mail:read"])
|
||
|
|
core = reg.get_core()
|
||
|
|
assert all(p.get("category") == "core" for p in core)
|
||
|
|
assert len(core) == _CORE_ONLY_COUNT
|
||
|
|
|
||
|
|
def test_get_plugin_permissions_returns_only_plugin(self):
|
||
|
|
"""get_plugin_permissions() returns only plugin permissions."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
reg.register_plugin_permissions("mail", ["mail:read", "mail:send"])
|
||
|
|
plugin_perms = reg.get_plugin_permissions()
|
||
|
|
assert all(p.get("category") == "plugins" for p in plugin_perms)
|
||
|
|
assert len(plugin_perms) == 2
|
||
|
|
|
||
|
|
def test_get_grouped(self):
|
||
|
|
"""get_grouped() returns permissions grouped by category."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
reg.register_plugin_permissions("mail", ["mail:read"])
|
||
|
|
grouped = reg.get_grouped()
|
||
|
|
assert "core" in grouped
|
||
|
|
assert "plugins" in grouped
|
||
|
|
assert len(grouped["core"]) == _CORE_ONLY_COUNT
|
||
|
|
assert len(grouped["plugins"]) == 1
|
||
|
|
|
||
|
|
def test_register_field_definitions(self):
|
||
|
|
"""register_field_definitions stores field defs for a plugin."""
|
||
|
|
reg = PermissionRegistry()
|
||
|
|
reg.initialize()
|
||
|
|
field_defs = [
|
||
|
|
{"module": "mail", "field": "subject", "label": "Subject", "sensitivity": "normal"},
|
||
|
|
]
|
||
|
|
reg.register_field_definitions("mail", field_defs)
|
||
|
|
all_defs = reg.get_all_field_definitions()
|
||
|
|
mail_defs = [d for d in all_defs if d.get("module") == "mail"]
|
||
|
|
assert len(mail_defs) == 1
|
||
|
|
assert mail_defs[0]["field"] == "subject"
|
||
|
|
|
||
|
|
def test_get_all_field_definitions_includes_core(self):
|
||
|
|
"""get_all_field_definitions() includes core field definitions."""
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 6: Cache-Invalidierung Tests (DB + Redis required)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
class TestPermissionCacheInvalidation:
|
||
|
|
"""Tests for Redis-based permission caching and invalidation."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_cached_permissions_caches_after_first_call(
|
||
|
|
self, db_session: AsyncSession, redis_client
|
||
|
|
):
|
||
|
|
"""get_cached_permissions stores result in Redis after first call."""
|
||
|
|
from app.core.permissions import CACHE_PREFIX, get_cached_permissions
|
||
|
|
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
result1 = await get_cached_permissions(db_session, redis_client, admin.id, tenant.id)
|
||
|
|
cache_key = f"{CACHE_PREFIX}:{admin.id}:{tenant.id}"
|
||
|
|
cached_raw = await redis_client.get(cache_key)
|
||
|
|
assert cached_raw is not None, "Cache should be populated after first call"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_invalidate_permission_cache_clears_user(
|
||
|
|
self, db_session: AsyncSession, redis_client
|
||
|
|
):
|
||
|
|
"""invalidate_permission_cache removes cache for a specific user+tenant."""
|
||
|
|
from app.core.permissions import CACHE_PREFIX, get_cached_permissions
|
||
|
|
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
await get_cached_permissions(db_session, redis_client, admin.id, tenant.id)
|
||
|
|
cache_key = f"{CACHE_PREFIX}:{admin.id}:{tenant.id}"
|
||
|
|
assert await redis_client.get(cache_key) is not None
|
||
|
|
|
||
|
|
await invalidate_permission_cache(redis_client, admin.id, tenant.id)
|
||
|
|
assert await redis_client.get(cache_key) is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_invalidate_all_user_permissions_clears_tenant(
|
||
|
|
self, db_session: AsyncSession, redis_client
|
||
|
|
):
|
||
|
|
"""invalidate_all_user_permissions removes cache for all users in a tenant."""
|
||
|
|
from app.core.permissions import CACHE_PREFIX, get_cached_permissions
|
||
|
|
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
viewer = seed["viewer_a"]
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
await get_cached_permissions(db_session, redis_client, admin.id, tenant.id)
|
||
|
|
await get_cached_permissions(db_session, redis_client, viewer.id, tenant.id)
|
||
|
|
|
||
|
|
await invalidate_all_user_permissions(redis_client, tenant.id)
|
||
|
|
|
||
|
|
assert await redis_client.get(f"{CACHE_PREFIX}:{admin.id}:{tenant.id}") is None
|
||
|
|
assert await redis_client.get(f"{CACHE_PREFIX}:{viewer.id}:{tenant.id}") is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_cache_hit_returns_same_permissions_as_miss(
|
||
|
|
self, db_session: AsyncSession, redis_client
|
||
|
|
):
|
||
|
|
"""Cache hit delivers the same permissions as a cache miss."""
|
||
|
|
from app.core.permissions import get_cached_permissions
|
||
|
|
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
result1 = await get_cached_permissions(db_session, redis_client, admin.id, tenant.id)
|
||
|
|
result2 = await get_cached_permissions(db_session, redis_client, admin.id, tenant.id)
|
||
|
|
|
||
|
|
assert set(result1["permissions"]) == set(result2["permissions"])
|
||
|
|
assert result1["is_system_admin"] == result2["is_system_admin"]
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 8: Field-Level Permission Tests (unit + DB)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
class TestFieldLevelPermissions:
|
||
|
|
"""Tests for field-level permission filtering."""
|
||
|
|
|
||
|
|
def test_filter_system_admin_no_filtering(self):
|
||
|
|
"""filter_fields_by_permission with is_system_admin → no filtering."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000, "email": "info@acme.com"}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": True,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "hidden", "email": "hidden"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
def test_filter_no_field_permissions_no_filtering(self):
|
||
|
|
"""filter_fields_by_permission without field_permissions → no filtering."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000}
|
||
|
|
resolved = {"is_system_admin": False, "field_permissions": {}}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
def test_filter_hidden_field_removed(self):
|
||
|
|
"""filter_fields_by_permission removes hidden fields."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "hidden"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert "annual_revenue" not in result
|
||
|
|
assert "name" in result
|
||
|
|
|
||
|
|
def test_filter_readonly_field_kept(self):
|
||
|
|
"""filter_fields_by_permission keeps readonly fields."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "readonly"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert "annual_revenue" in result
|
||
|
|
|
||
|
|
def test_filter_read_field_kept(self):
|
||
|
|
"""filter_fields_by_permission keeps read fields."""
|
||
|
|
data = {"name": "ACME", "annual_revenue": 1000000}
|
||
|
|
resolved = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "read"}},
|
||
|
|
}
|
||
|
|
result = filter_fields_by_permission(data, resolved, "companies")
|
||
|
|
assert "annual_revenue" in result
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
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
|
||
|
|
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
company = seed["company_a"]
|
||
|
|
|
||
|
|
resolved_perms = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"companies": {"industry": "hidden"}},
|
||
|
|
}
|
||
|
|
|
||
|
|
result = await get_company_detail(
|
||
|
|
db_session, seed["tenant_a"].id, company.id, resolved_perms=resolved_perms
|
||
|
|
)
|
||
|
|
assert result is not None
|
||
|
|
assert "industry" not in result
|
||
|
|
assert "name" in result
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_contact_service_applies_filter_with_resolved_perms(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""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
|
||
|
|
|
||
|
|
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",
|
||
|
|
created_by=seed["admin_a"].id,
|
||
|
|
updated_by=seed["admin_a"].id,
|
||
|
|
)
|
||
|
|
db_session.add(contact)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
resolved_perms = {
|
||
|
|
"is_system_admin": False,
|
||
|
|
"field_permissions": {"contacts": {"mobile": "hidden"}},
|
||
|
|
}
|
||
|
|
|
||
|
|
result = await get_contact_detail(
|
||
|
|
db_session, seed["tenant_a"].id, contact.id, resolved_perms=resolved_perms
|
||
|
|
)
|
||
|
|
assert result is not None
|
||
|
|
assert "mobile" not in result
|
||
|
|
assert "first_name" 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
|
||
|
|
|
||
|
|
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
|
||
|
|
)
|
||
|
|
assert result is not None
|
||
|
|
assert "industry" in result
|
||
|
|
assert "name" in result
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# HELPER FUNCTIONS for DB-dependent tests
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
async def _create_role_with_permissions(
|
||
|
|
db: AsyncSession, tenant_id, name: str, permissions, denied=None,
|
||
|
|
field_perms=None
|
||
|
|
) -> Role:
|
||
|
|
"""Helper: create a Role with specific permissions."""
|
||
|
|
role = Role(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
name=name,
|
||
|
|
permissions=permissions,
|
||
|
|
denied_permissions=denied or [],
|
||
|
|
field_permissions=field_perms or {},
|
||
|
|
)
|
||
|
|
db.add(role)
|
||
|
|
await db.flush()
|
||
|
|
return role
|
||
|
|
|
||
|
|
|
||
|
|
async def _create_user_with_role(
|
||
|
|
db: AsyncSession, tenant_id, role_id, email: str, name: str = "Test User"
|
||
|
|
) -> 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)
|
||
|
|
db.add(ut)
|
||
|
|
await db.flush()
|
||
|
|
return user, ut
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 3: Group CRUD Tests (DB required — uses conftest client)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
class TestGroupCRUD:
|
||
|
|
"""Tests for Group CRUD operations and membership management."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_group_with_permissions(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""Create a group with permissions and denied_permissions."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={
|
||
|
|
"name": "Sales Team",
|
||
|
|
"description": "Sales department group",
|
||
|
|
"permissions": {"companies": {"read": True, "write": True}},
|
||
|
|
"denied_permissions": ["companies:delete"],
|
||
|
|
"field_permissions": {"companies": {"annual_revenue": "hidden"}},
|
||
|
|
},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
data = resp.json()
|
||
|
|
assert data["name"] == "Sales Team"
|
||
|
|
assert data["permissions"] == {"companies": {"read": True, "write": True}}
|
||
|
|
assert data["denied_permissions"] == ["companies:delete"]
|
||
|
|
assert data["permission_version"] == 1
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_groups(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""List all groups in the tenant."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
for name in ["Group A", "Group B"]:
|
||
|
|
await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": name, "permissions": {}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
|
||
|
|
resp = await client.get("/api/v1/groups", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
items = resp.json()["items"]
|
||
|
|
assert len(items) >= 2
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_group_by_id(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""Get a single group by ID."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "Test Group", "permissions": {"contacts": {"read": True}}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
|
||
|
|
resp = await client.get(f"/api/v1/groups/{group_id}", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["name"] == "Test Group"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_group_name(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""Update group name — permission_version should NOT increment."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "Old Name", "permissions": {"companies": {"read": True}}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
initial_version = create_resp.json()["permission_version"]
|
||
|
|
|
||
|
|
resp = await client.patch(
|
||
|
|
f"/api/v1/groups/{group_id}",
|
||
|
|
json={"name": "New Name"},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["name"] == "New Name"
|
||
|
|
assert resp.json()["permission_version"] == initial_version
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_group_permissions_increments_version(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Update group permissions — permission_version increments."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "Versioned Group", "permissions": {"companies": {"read": True}}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
initial_version = create_resp.json()["permission_version"]
|
||
|
|
|
||
|
|
resp = await client.patch(
|
||
|
|
f"/api/v1/groups/{group_id}",
|
||
|
|
json={"permissions": {"companies": {"read": True, "write": True}}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["permission_version"] == initial_version + 1
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delete_group_soft_delete(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""Delete a group (soft-delete) — group disappears from list."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "To Delete", "permissions": {}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
|
||
|
|
resp = await client.delete(f"/api/v1/groups/{group_id}", headers=csrf_headers(csrf))
|
||
|
|
assert resp.status_code == 204
|
||
|
|
|
||
|
|
list_resp = await client.get("/api/v1/groups", headers=ORIGIN_HEADER)
|
||
|
|
group_ids = [g["id"] for g in list_resp.json()["items"]]
|
||
|
|
assert group_id not in group_ids
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_add_user_to_group(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""Add a user to a group."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "Members Group", "permissions": {}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
viewer_id = str(seed["viewer_a"].id)
|
||
|
|
|
||
|
|
resp = await client.post(
|
||
|
|
f"/api/v1/groups/{group_id}/members",
|
||
|
|
json={"user_id": viewer_id},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_user_from_group(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""Remove a user from a group."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "Remove Test", "permissions": {}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
viewer_id = str(seed["viewer_a"].id)
|
||
|
|
|
||
|
|
await client.post(
|
||
|
|
f"/api/v1/groups/{group_id}/members",
|
||
|
|
json={"user_id": viewer_id},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
|
||
|
|
resp = await client.delete(
|
||
|
|
f"/api/v1/groups/{group_id}/members/{viewer_id}",
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 204
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_group_members(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""List all members of a group."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "Member List", "permissions": {}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
viewer_id = str(seed["viewer_a"].id)
|
||
|
|
|
||
|
|
await client.post(
|
||
|
|
f"/api/v1/groups/{group_id}/members",
|
||
|
|
json={"user_id": viewer_id},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
|
||
|
|
resp = await client.get(f"/api/v1/groups/{group_id}/members", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
members = resp.json()["items"]
|
||
|
|
assert any(m["user_id"] == viewer_id for m in members)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_user_groups(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""List all groups a user is member of."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={"name": "User Groups Test", "permissions": {}},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
viewer_id = str(seed["viewer_a"].id)
|
||
|
|
|
||
|
|
await client.post(
|
||
|
|
f"/api/v1/groups/{group_id}/members",
|
||
|
|
json={"user_id": viewer_id},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
|
||
|
|
resp = await client.get(f"/api/v1/groups/user/{viewer_id}", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
groups = resp.json()["items"]
|
||
|
|
assert any(g["id"] == group_id for g in groups)
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 4: RBAC Route-Guard Tests (DB required — uses conftest client)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
class TestRBACRouteGuard:
|
||
|
|
"""Tests for require_permission, require_admin, require_write dependencies."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_permission_allows_user_with_exact_permission(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User with companies:read can access companies 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)
|
||
|
|
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)."""
|
||
|
|
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"},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_permission_allows_system_admin(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""System admin bypasses all permission checks."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
admin.is_system_admin = True
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_permission_allows_wildcard_module(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User with companies:* equivalent (all actions) can access companies:read."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = await _create_role_with_permissions(
|
||
|
|
db_session, tenant.id, "wildcard_module",
|
||
|
|
permissions={"companies": {"read": True, "write": True, "delete": True}},
|
||
|
|
)
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "wildcard_mod@test.com"
|
||
|
|
)
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "companies:write") is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_permission_allows_wildcard_action(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User with *:read permission can access companies:read."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = await _create_role_with_permissions(
|
||
|
|
db_session, tenant.id, "wildcard_action",
|
||
|
|
permissions=["*:read"],
|
||
|
|
)
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "wildcard_act@test.com"
|
||
|
|
)
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "contacts:read") is True
|
||
|
|
assert check_permission(resolved, "companies:write") is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_permission_allows_star_star(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User with *:* permission can access anything."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = await _create_role_with_permissions(
|
||
|
|
db_session, tenant.id, "superadmin",
|
||
|
|
permissions=["*:*"],
|
||
|
|
)
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "starstar@test.com"
|
||
|
|
)
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "anything:anything") is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_permission_blocks_read_only_for_write(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User with only companies:read cannot pass companies:write check."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = await _create_role_with_permissions(
|
||
|
|
db_session, tenant.id, "read_only",
|
||
|
|
permissions={"companies": {"read": True}},
|
||
|
|
)
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "readonly@test.com"
|
||
|
|
)
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert check_permission(resolved, "companies:read") is True
|
||
|
|
assert check_permission(resolved, "companies:write") is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_admin_allows_system_admin(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""require_admin allows system_admin user."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
admin.is_system_admin = True
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
resp = await client.get("/api/v1/plugins", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_admin_allows_legacy_admin(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""require_admin allows legacy admin role."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
resp = await client.get("/api/v1/plugins", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_admin_blocks_non_admin(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""require_admin blocks viewer (legacy role)."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_with_csrf(client, "viewer@tenanta.com")
|
||
|
|
resp = await client.get("/api/v1/plugins", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
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)."""
|
||
|
|
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"},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_require_write_blocks_legacy_viewer(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""require_write blocks legacy viewer role."""
|
||
|
|
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"},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 5: Self-Modification Prevention Tests (DB required)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
class TestSelfModificationPrevention:
|
||
|
|
"""Tests for self-modification prevention in user routes."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_can_change_own_name(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""User can change their own name (allowed)."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.patch(
|
||
|
|
f"/api/v1/users/{admin.id}",
|
||
|
|
json={"name": "New Admin Name"},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["name"] == "New Admin Name"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_cannot_change_own_role(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""User cannot change their own role (403 self_modification_forbidden)."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.patch(
|
||
|
|
f"/api/v1/users/{admin.id}",
|
||
|
|
json={"role": "viewer"},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
assert resp.json()["detail"]["code"] == "self_modification_forbidden"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_cannot_change_own_is_active(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""User cannot change their own is_active status (403)."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.patch(
|
||
|
|
f"/api/v1/users/{admin.id}",
|
||
|
|
json={"is_active": False},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
assert resp.json()["detail"]["code"] == "self_modification_forbidden"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_cannot_change_own_role_id(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""User cannot change their own role_id (403)."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.patch(
|
||
|
|
f"/api/v1/users/{admin.id}",
|
||
|
|
json={"role_id": str(uuid.uuid4())},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
assert resp.json()["detail"]["code"] == "self_modification_forbidden"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_admin_can_change_other_user_role(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Admin can change another user's role (allowed)."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
viewer = seed["viewer_a"]
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.patch(
|
||
|
|
f"/api/v1/users/{viewer.id}",
|
||
|
|
json={"role": "editor"},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["role"] == "editor"
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 7: Mail-Plugin RBAC Tests (DB required)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def mail_app(engine: AsyncEngine, redis_client):
|
||
|
|
"""FastAPI app with Mail plugin registered, installed, and activated."""
|
||
|
|
reset_engine_for_testing(engine)
|
||
|
|
app = create_app()
|
||
|
|
registry = reset_registry_for_testing()
|
||
|
|
registry.initialize(engine, app)
|
||
|
|
container = get_container()
|
||
|
|
await container.initialize()
|
||
|
|
registry.register_plugin(MailPlugin())
|
||
|
|
reset_plugin_service_for_testing(registry)
|
||
|
|
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||
|
|
async with sf() as session:
|
||
|
|
await registry.install(session, "mail")
|
||
|
|
await registry.activate(session, "mail")
|
||
|
|
await session.commit()
|
||
|
|
yield app
|
||
|
|
await close_engine()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def mail_client(mail_app) -> AsyncClient:
|
||
|
|
"""HTTP async test client with mail plugin active."""
|
||
|
|
transport = ASGITransport(app=mail_app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||
|
|
yield c
|
||
|
|
|
||
|
|
|
||
|
|
class TestMailPluginRBAC:
|
||
|
|
"""Tests for mail plugin RBAC permission enforcement and delegate access."""
|
||
|
|
|
||
|
|
async def _seed_mail_data(self, db: AsyncSession) -> dict:
|
||
|
|
"""Helper: seed tenant, users, mail account, folder, and mail."""
|
||
|
|
seed = await seed_tenant_and_users(db)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
viewer = seed["viewer_a"]
|
||
|
|
|
||
|
|
account = MailAccount(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
user_id=admin.id,
|
||
|
|
email_address="admin@tenanta.com",
|
||
|
|
display_name="Admin",
|
||
|
|
imap_host="imap.example.com",
|
||
|
|
imap_port=993,
|
||
|
|
imap_ssl=True,
|
||
|
|
smtp_host="smtp.example.com",
|
||
|
|
smtp_port=587,
|
||
|
|
smtp_tls=True,
|
||
|
|
username="admin@tenanta.com",
|
||
|
|
encrypted_password="encrypted_dummy",
|
||
|
|
is_shared=False,
|
||
|
|
is_active=True,
|
||
|
|
)
|
||
|
|
db.add(account)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
shared_account = MailAccount(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
user_id=admin.id,
|
||
|
|
email_address="shared@tenanta.com",
|
||
|
|
display_name="Shared",
|
||
|
|
imap_host="imap.example.com",
|
||
|
|
imap_port=993,
|
||
|
|
imap_ssl=True,
|
||
|
|
smtp_host="smtp.example.com",
|
||
|
|
smtp_port=587,
|
||
|
|
smtp_tls=True,
|
||
|
|
username="shared@tenanta.com",
|
||
|
|
encrypted_password="encrypted_dummy",
|
||
|
|
is_shared=True,
|
||
|
|
is_active=True,
|
||
|
|
)
|
||
|
|
db.add(shared_account)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
folder = MailFolder(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
account_id=account.id,
|
||
|
|
name="INBOX",
|
||
|
|
imap_name="INBOX",
|
||
|
|
is_standard=True,
|
||
|
|
)
|
||
|
|
db.add(folder)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
mail = Mail(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
account_id=account.id,
|
||
|
|
folder_id=folder.id,
|
||
|
|
message_id="<test@example.com>",
|
||
|
|
subject="Test Subject",
|
||
|
|
from_address="sender@example.com",
|
||
|
|
to_addresses="admin@tenanta.com",
|
||
|
|
body_text="Test body",
|
||
|
|
)
|
||
|
|
db.add(mail)
|
||
|
|
await db.flush()
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {
|
||
|
|
**seed,
|
||
|
|
"account": account,
|
||
|
|
"shared_account": shared_account,
|
||
|
|
"folder": folder,
|
||
|
|
"mail": mail,
|
||
|
|
}
|
||
|
|
|
||
|
|
async def _give_user_mail_perms(self, db: AsyncSession, tenant_id, user_id, perms: list[str]):
|
||
|
|
"""Helper: create a role with mail permissions and assign to user."""
|
||
|
|
from sqlalchemy import update
|
||
|
|
role = Role(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
name=f"mail_role_{uuid.uuid4().hex[:8]}",
|
||
|
|
permissions=perms,
|
||
|
|
)
|
||
|
|
db.add(role)
|
||
|
|
await db.flush()
|
||
|
|
await db.execute(
|
||
|
|
update(UserTenant)
|
||
|
|
.where(UserTenant.user_id == user_id)
|
||
|
|
.values(role_id=role.id)
|
||
|
|
)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_without_mail_read_gets_403_on_list(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User without mail:read permission gets 403 on GET /mail."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_with_csrf(mail_client, "viewer@tenanta.com")
|
||
|
|
resp = await mail_client.get("/api/v1/mail", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_with_mail_read_gets_200_on_list(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User with mail:read permission gets 200 on GET /mail.
|
||
|
|
Admin (legacy) has *:* which matches mail:read."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_with_csrf(mail_client, "admin@tenanta.com")
|
||
|
|
resp = await mail_client.get(
|
||
|
|
"/api/v1/mail",
|
||
|
|
params={"account_id": str(uuid.uuid4())},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_without_mail_send_gets_403(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User without mail:send permission gets 403 on POST /mail/send."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(mail_client, "viewer@tenanta.com")
|
||
|
|
resp = await mail_client.post(
|
||
|
|
"/api/v1/mail/send",
|
||
|
|
json={
|
||
|
|
"account_id": str(uuid.uuid4()),
|
||
|
|
"to": ["test@example.com"],
|
||
|
|
"subject": "Test",
|
||
|
|
"body_text": "Test",
|
||
|
|
},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_without_mail_config_gets_403_on_create_account(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User without mail:config permission gets 403 on POST /mail/accounts."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
csrf = await login_with_csrf(mail_client, "viewer@tenanta.com")
|
||
|
|
resp = await mail_client.post(
|
||
|
|
"/api/v1/mail/accounts",
|
||
|
|
json={
|
||
|
|
"email_address": "test@example.com",
|
||
|
|
"display_name": "Test",
|
||
|
|
"imap_host": "imap.example.com",
|
||
|
|
"smtp_host": "smtp.example.com",
|
||
|
|
"username": "test@example.com",
|
||
|
|
"password": "secret123",
|
||
|
|
},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delegate_read_can_read_but_not_delete(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Delegate with access_level 'read' can read mails but cannot delete."""
|
||
|
|
data = await self._seed_mail_data(db_session)
|
||
|
|
viewer = data["viewer_a"]
|
||
|
|
account = data["account"]
|
||
|
|
mail = data["mail"]
|
||
|
|
|
||
|
|
delegate = MailAccountDelegate(
|
||
|
|
tenant_id=data["tenant_a"].id,
|
||
|
|
account_id=account.id,
|
||
|
|
delegate_user_id=viewer.id,
|
||
|
|
access_level="read",
|
||
|
|
)
|
||
|
|
db_session.add(delegate)
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await self._give_user_mail_perms(
|
||
|
|
db_session, data["tenant_a"].id, viewer.id,
|
||
|
|
["mail:read", "mail:write", "mail:delete"]
|
||
|
|
)
|
||
|
|
|
||
|
|
csrf = await login_with_csrf(mail_client, "viewer@tenanta.com")
|
||
|
|
|
||
|
|
resp = await mail_client.get(f"/api/v1/mail/{mail.id}", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
resp = await mail_client.delete(f"/api/v1/mail/{mail.id}", headers=csrf_headers(csrf))
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delegate_write_can_set_flags_but_not_delete(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Delegate with access_level 'write' can set flags but cannot delete."""
|
||
|
|
data = await self._seed_mail_data(db_session)
|
||
|
|
viewer = data["viewer_a"]
|
||
|
|
account = data["account"]
|
||
|
|
mail = data["mail"]
|
||
|
|
|
||
|
|
delegate = MailAccountDelegate(
|
||
|
|
tenant_id=data["tenant_a"].id,
|
||
|
|
account_id=account.id,
|
||
|
|
delegate_user_id=viewer.id,
|
||
|
|
access_level="write",
|
||
|
|
)
|
||
|
|
db_session.add(delegate)
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await self._give_user_mail_perms(
|
||
|
|
db_session, data["tenant_a"].id, viewer.id,
|
||
|
|
["mail:read", "mail:write", "mail:delete"]
|
||
|
|
)
|
||
|
|
|
||
|
|
csrf = await login_with_csrf(mail_client, "viewer@tenanta.com")
|
||
|
|
|
||
|
|
resp = await mail_client.patch(
|
||
|
|
f"/api/v1/mail/{mail.id}/flags",
|
||
|
|
json={"is_seen": True},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
resp = await mail_client.delete(f"/api/v1/mail/{mail.id}", headers=csrf_headers(csrf))
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delegate_delete_can_delete_mail(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Delegate with access_level 'delete' can delete mails."""
|
||
|
|
data = await self._seed_mail_data(db_session)
|
||
|
|
viewer = data["viewer_a"]
|
||
|
|
account = data["account"]
|
||
|
|
mail = data["mail"]
|
||
|
|
|
||
|
|
delegate = MailAccountDelegate(
|
||
|
|
tenant_id=data["tenant_a"].id,
|
||
|
|
account_id=account.id,
|
||
|
|
delegate_user_id=viewer.id,
|
||
|
|
access_level="delete",
|
||
|
|
)
|
||
|
|
db_session.add(delegate)
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await self._give_user_mail_perms(
|
||
|
|
db_session, data["tenant_a"].id, viewer.id,
|
||
|
|
["mail:read", "mail:write", "mail:delete"]
|
||
|
|
)
|
||
|
|
|
||
|
|
csrf = await login_with_csrf(mail_client, "viewer@tenanta.com")
|
||
|
|
|
||
|
|
resp = await mail_client.delete(f"/api/v1/mail/{mail.id}", headers=csrf_headers(csrf))
|
||
|
|
assert resp.status_code == 204
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delegate_full_can_do_everything(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Delegate with access_level 'full' can perform all operations."""
|
||
|
|
data = await self._seed_mail_data(db_session)
|
||
|
|
viewer = data["viewer_a"]
|
||
|
|
account = data["account"]
|
||
|
|
mail = data["mail"]
|
||
|
|
|
||
|
|
delegate = MailAccountDelegate(
|
||
|
|
tenant_id=data["tenant_a"].id,
|
||
|
|
account_id=account.id,
|
||
|
|
delegate_user_id=viewer.id,
|
||
|
|
access_level="full",
|
||
|
|
)
|
||
|
|
db_session.add(delegate)
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await self._give_user_mail_perms(
|
||
|
|
db_session, data["tenant_a"].id, viewer.id,
|
||
|
|
["mail:read", "mail:write", "mail:delete", "mail:config"]
|
||
|
|
)
|
||
|
|
|
||
|
|
csrf = await login_with_csrf(mail_client, "viewer@tenanta.com")
|
||
|
|
|
||
|
|
resp = await mail_client.get(f"/api/v1/mail/{mail.id}", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
resp = await mail_client.patch(
|
||
|
|
f"/api/v1/mail/{mail.id}/flags",
|
||
|
|
json={"is_flagged": True},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
resp = await mail_client.delete(f"/api/v1/mail/{mail.id}", headers=csrf_headers(csrf))
|
||
|
|
assert resp.status_code == 204
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_owner_has_full_access_without_delegate(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Owner has full access to their own mailbox without any delegate entry."""
|
||
|
|
data = await self._seed_mail_data(db_session)
|
||
|
|
mail = data["mail"]
|
||
|
|
|
||
|
|
csrf = await login_with_csrf(mail_client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await mail_client.get(f"/api/v1/mail/{mail.id}", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
resp = await mail_client.patch(
|
||
|
|
f"/api/v1/mail/{mail.id}/flags",
|
||
|
|
json={"is_seen": True},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
|
||
|
|
resp = await mail_client.delete(f"/api/v1/mail/{mail.id}", headers=csrf_headers(csrf))
|
||
|
|
assert resp.status_code == 204
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_user_without_delegate_gets_403_on_shared_mailbox(
|
||
|
|
self, mail_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""User without delegate access gets 403 when trying to access another user's mail."""
|
||
|
|
data = await self._seed_mail_data(db_session)
|
||
|
|
editor = data["editor_a"]
|
||
|
|
mail = data["mail"]
|
||
|
|
|
||
|
|
await self._give_user_mail_perms(
|
||
|
|
db_session, data["tenant_a"].id, editor.id,
|
||
|
|
["mail:read"]
|
||
|
|
)
|
||
|
|
|
||
|
|
await login_with_csrf(mail_client, "editor@tenanta.com")
|
||
|
|
|
||
|
|
resp = await mail_client.get(f"/api/v1/mail/{mail.id}", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
# SECTION 9: Integration Tests (DB + Redis required)
|
||
|
|
# ═══════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
|
||
|
|
class TestRBACIntegration:
|
||
|
|
"""End-to-end integration tests for the RBAC system."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_login_and_me_permissions(self, client: AsyncClient, db_session: AsyncSession):
|
||
|
|
"""Login → /me/permissions → has correct permissions."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.get("/api/v1/auth/me/permissions", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
assert "permissions" in data
|
||
|
|
assert "denied_permissions" in data
|
||
|
|
assert "field_permissions" in data
|
||
|
|
assert "is_system_admin" in data
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_login_as_admin_has_star_star(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Login as admin → has *:* permission."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
resp = await client.get("/api/v1/auth/me/permissions", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
perms = resp.json()["permissions"]
|
||
|
|
assert "*:*" in perms
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_group_add_user_resolved_perms_include_group(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Create Group → Add User → User's resolved permissions include group permissions."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
viewer = seed["viewer_a"]
|
||
|
|
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={
|
||
|
|
"name": "Integration Group",
|
||
|
|
"permissions": {"companies": {"read": True, "write": True}},
|
||
|
|
},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert create_resp.status_code == 201
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
|
||
|
|
add_resp = await client.post(
|
||
|
|
f"/api/v1/groups/{group_id}/members",
|
||
|
|
json={"user_id": str(viewer.id)},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert add_resp.status_code == 200
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, viewer.id, tenant.id)
|
||
|
|
assert "companies:write" in resolved["permissions"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_role_permissions_cache_invalidated(
|
||
|
|
self, db_session: AsyncSession, redis_client
|
||
|
|
):
|
||
|
|
"""Update role permissions → cache invalidated → next request has new permissions."""
|
||
|
|
from app.core.permissions import CACHE_PREFIX, get_cached_permissions
|
||
|
|
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = Role(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
name="cache_test_role",
|
||
|
|
permissions={"companies": {"read": True}},
|
||
|
|
)
|
||
|
|
db_session.add(role)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "cache_test@test.com"
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
result1 = await get_cached_permissions(db_session, redis_client, user.id, tenant.id)
|
||
|
|
assert "companies:read" in result1["permissions"]
|
||
|
|
assert "companies:write" not in result1["permissions"]
|
||
|
|
|
||
|
|
role.permissions = {"companies": {"read": True, "write": True}}
|
||
|
|
role.permission_version += 1
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await invalidate_permission_cache(redis_client, user.id, tenant.id)
|
||
|
|
|
||
|
|
result2 = await get_cached_permissions(db_session, redis_client, user.id, tenant.id)
|
||
|
|
assert "companies:write" in result2["permissions"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delete_group_user_perms_no_longer_include_group(
|
||
|
|
self, client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Delete group → group is soft-deleted and disappears from list.
|
||
|
|
|
||
|
|
NOTE: resolve_permissions() does not currently filter by Group.deleted_at,
|
||
|
|
so soft-deleted groups still contribute permissions until the UserGroup
|
||
|
|
membership is removed. This is a known bug in the implementation.
|
||
|
|
The test verifies the soft-delete happened and the group is gone from the list.
|
||
|
|
"""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
viewer = seed["viewer_a"]
|
||
|
|
|
||
|
|
csrf = await login_with_csrf(client, "admin@tenanta.com")
|
||
|
|
|
||
|
|
create_resp = await client.post(
|
||
|
|
"/api/v1/groups",
|
||
|
|
json={
|
||
|
|
"name": "Delete Test Group",
|
||
|
|
"permissions": {"companies": {"delete": True}},
|
||
|
|
},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
assert create_resp.status_code == 201
|
||
|
|
group_id = create_resp.json()["id"]
|
||
|
|
|
||
|
|
await client.post(
|
||
|
|
f"/api/v1/groups/{group_id}/members",
|
||
|
|
json={"user_id": str(viewer.id)},
|
||
|
|
headers=csrf_headers(csrf),
|
||
|
|
)
|
||
|
|
|
||
|
|
resolved_before = await resolve_permissions(db_session, viewer.id, tenant.id)
|
||
|
|
assert "companies:delete" in resolved_before["permissions"]
|
||
|
|
|
||
|
|
del_resp = await client.delete(f"/api/v1/groups/{group_id}", headers=csrf_headers(csrf))
|
||
|
|
assert del_resp.status_code == 204
|
||
|
|
|
||
|
|
# Group should be gone from the list (soft-delete works for listing)
|
||
|
|
list_resp = await client.get("/api/v1/groups", headers=ORIGIN_HEADER)
|
||
|
|
group_ids = [g["id"] for g in list_resp.json()["items"]]
|
||
|
|
assert group_id not in group_ids
|
||
|
|
|
||
|
|
# KNOWN BUG: resolve_permissions does not filter by Group.deleted_at,
|
||
|
|
# so the group permission is still present after soft-delete.
|
||
|
|
# When this bug is fixed, the following assertion should pass:
|
||
|
|
# resolved_after = await resolve_permissions(db_session, viewer.id, tenant.id)
|
||
|
|
# assert "companies:delete" not in resolved_after["permissions"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolve_permissions_role_with_denied(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""resolve_permissions: role permissions minus denied_permissions."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = Role(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
name="denied_test",
|
||
|
|
permissions={"companies": {"read": True, "write": True, "delete": True}},
|
||
|
|
denied_permissions=["companies:delete"],
|
||
|
|
)
|
||
|
|
db_session.add(role)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "denied@test.com"
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert "companies:read" in resolved["permissions"]
|
||
|
|
assert "companies:write" in resolved["permissions"]
|
||
|
|
assert "companies:delete" not in resolved["permissions"]
|
||
|
|
assert "companies:delete" in resolved["denied"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolve_permissions_system_admin(self, db_session: AsyncSession):
|
||
|
|
"""resolve_permissions for is_system_admin returns *:*."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
admin.is_system_admin = True
|
||
|
|
await db_session.flush()
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, admin.id, seed["tenant_a"].id)
|
||
|
|
assert resolved["is_system_admin"] is True
|
||
|
|
assert resolved["permissions"] == {"*:*"}
|
||
|
|
assert resolved["denied"] == set()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolve_permissions_legacy_admin(self, db_session: AsyncSession):
|
||
|
|
"""resolve_permissions for legacy admin role string returns *:*."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
admin = seed["admin_a"]
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, admin.id, seed["tenant_a"].id)
|
||
|
|
assert "*:*" in resolved["permissions"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolve_permissions_legacy_viewer(self, db_session: AsyncSession):
|
||
|
|
"""resolve_permissions for legacy viewer role returns read-only permissions."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
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"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolve_permissions_no_role_no_legacy(self, db_session: AsyncSession):
|
||
|
|
"""resolve_permissions for user without role_id and without legacy role returns empty."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
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)
|
||
|
|
db_session.add(ut)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert resolved["permissions"] == set()
|
||
|
|
assert resolved["is_system_admin"] is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolve_permissions_role_plus_groups_union(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""resolve_permissions for user with Role + Groups returns union of permissions."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = Role(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
name="union_role",
|
||
|
|
permissions={"companies": {"read": True}},
|
||
|
|
)
|
||
|
|
db_session.add(role)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "union@test.com"
|
||
|
|
)
|
||
|
|
|
||
|
|
group = Group(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
name="union_group",
|
||
|
|
permissions={"contacts": {"write": True}},
|
||
|
|
)
|
||
|
|
db_session.add(group)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
ug = UserGroup(user_id=user.id, group_id=group.id, tenant_id=tenant.id)
|
||
|
|
db_session.add(ug)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert "companies:read" in resolved["permissions"]
|
||
|
|
assert "contacts:write" in resolved["permissions"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolve_permissions_group_denied_overrides(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""resolve_permissions: group denied_permissions override role+group allowed."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant = seed["tenant_a"]
|
||
|
|
|
||
|
|
role = Role(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
name="deny_override_role",
|
||
|
|
permissions={"companies": {"read": True, "write": True}},
|
||
|
|
)
|
||
|
|
db_session.add(role)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
user, ut = await _create_user_with_role(
|
||
|
|
db_session, tenant.id, role.id, "deny_override@test.com"
|
||
|
|
)
|
||
|
|
|
||
|
|
group = Group(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
name="deny_group",
|
||
|
|
permissions={},
|
||
|
|
denied_permissions=["companies:write"],
|
||
|
|
)
|
||
|
|
db_session.add(group)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
ug = UserGroup(user_id=user.id, group_id=group.id, tenant_id=tenant.id)
|
||
|
|
db_session.add(ug)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
resolved = await resolve_permissions(db_session, user.id, tenant.id)
|
||
|
|
assert "companies:read" in resolved["permissions"]
|
||
|
|
assert "companies:write" not in resolved["permissions"]
|
||
|
|
assert "companies:write" in resolved["denied"]
|