"""Tests for Permission Manager. Tests cover: - PermissionGrant: creation, expiry, matching, hierarchy - PermissionManager: grant, revoke, check, require, list, defaults - Edge cases: wildcards, expiration, default deny/allow """ from datetime import datetime, timedelta import pytest import pytest_asyncio from app.services.safety.exceptions import PermissionDeniedError from app.services.safety.models import ( ActionMetadata, ActionRequest, ActionType, PermissionLevel, ResourceType, ) from app.services.safety.permissions.manager import PermissionGrant, PermissionManager # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def action_metadata() -> ActionMetadata: """Create standard action metadata for tests.""" return ActionMetadata( agent_id="test-agent", project_id="test-project", session_id="test-session", ) @pytest_asyncio.fixture async def permission_manager() -> PermissionManager: """Create a PermissionManager for testing.""" return PermissionManager(default_deny=True) @pytest_asyncio.fixture async def permissive_manager() -> PermissionManager: """Create a PermissionManager with default_deny=False.""" return PermissionManager(default_deny=False) # ============================================================================ # PermissionGrant Tests # ============================================================================ class TestPermissionGrant: """Tests for the PermissionGrant class.""" def test_grant_creation(self) -> None: """Test basic grant creation.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, granted_by="admin", reason="Read access to data directory", ) assert grant.id is not None assert grant.agent_id == "agent-1" assert grant.resource_pattern == "/data/*" assert grant.resource_type == ResourceType.FILE assert grant.level == PermissionLevel.READ assert grant.granted_by == "admin" assert grant.reason == "Read access to data directory" assert grant.expires_at is None assert grant.created_at is not None def test_grant_with_expiration(self) -> None: """Test grant with expiration time.""" future = datetime.utcnow() + timedelta(hours=1) grant = PermissionGrant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.API, level=PermissionLevel.EXECUTE, expires_at=future, ) assert grant.expires_at == future assert grant.is_expired() is False def test_is_expired_no_expiration(self) -> None: """Test is_expired with no expiration set.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) assert grant.is_expired() is False def test_is_expired_future(self) -> None: """Test is_expired with future expiration.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, expires_at=datetime.utcnow() + timedelta(hours=1), ) assert grant.is_expired() is False def test_is_expired_past(self) -> None: """Test is_expired with past expiration.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, expires_at=datetime.utcnow() - timedelta(hours=1), ) assert grant.is_expired() is True def test_matches_exact(self) -> None: """Test matching with exact pattern.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="/data/file.txt", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) assert grant.matches("/data/file.txt", ResourceType.FILE) is True assert grant.matches("/data/other.txt", ResourceType.FILE) is False def test_matches_wildcard(self) -> None: """Test matching with wildcard pattern.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) assert grant.matches("/data/file.txt", ResourceType.FILE) is True # fnmatch's * matches everything including / assert grant.matches("/data/subdir/file.txt", ResourceType.FILE) is True assert grant.matches("/other/file.txt", ResourceType.FILE) is False def test_matches_recursive_wildcard(self) -> None: """Test matching with recursive pattern.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="/data/**", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) # fnmatch treats ** similar to * - both match everything including / assert grant.matches("/data/file.txt", ResourceType.FILE) is True assert grant.matches("/data/subdir/file.txt", ResourceType.FILE) is True def test_matches_wrong_resource_type(self) -> None: """Test matching fails with wrong resource type.""" grant = PermissionGrant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) # Same pattern but different resource type assert grant.matches("/data/table", ResourceType.DATABASE) is False def test_allows_hierarchy(self) -> None: """Test permission level hierarchy.""" admin_grant = PermissionGrant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.ADMIN, ) # ADMIN allows all levels assert admin_grant.allows(PermissionLevel.NONE) is True assert admin_grant.allows(PermissionLevel.READ) is True assert admin_grant.allows(PermissionLevel.WRITE) is True assert admin_grant.allows(PermissionLevel.EXECUTE) is True assert admin_grant.allows(PermissionLevel.DELETE) is True assert admin_grant.allows(PermissionLevel.ADMIN) is True def test_allows_read_only(self) -> None: """Test READ grant only allows READ and NONE.""" read_grant = PermissionGrant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) assert read_grant.allows(PermissionLevel.NONE) is True assert read_grant.allows(PermissionLevel.READ) is True assert read_grant.allows(PermissionLevel.WRITE) is False assert read_grant.allows(PermissionLevel.EXECUTE) is False assert read_grant.allows(PermissionLevel.DELETE) is False assert read_grant.allows(PermissionLevel.ADMIN) is False def test_allows_write_includes_read(self) -> None: """Test WRITE grant includes READ.""" write_grant = PermissionGrant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.WRITE, ) assert write_grant.allows(PermissionLevel.READ) is True assert write_grant.allows(PermissionLevel.WRITE) is True assert write_grant.allows(PermissionLevel.EXECUTE) is False # ============================================================================ # PermissionManager Tests # ============================================================================ class TestPermissionManager: """Tests for the PermissionManager class.""" @pytest.mark.asyncio async def test_grant_creates_permission( self, permission_manager: PermissionManager, ) -> None: """Test granting a permission.""" grant = await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, granted_by="admin", reason="Read access", ) assert grant.id is not None assert grant.agent_id == "agent-1" assert grant.resource_pattern == "/data/*" @pytest.mark.asyncio async def test_grant_with_duration( self, permission_manager: PermissionManager, ) -> None: """Test granting a temporary permission.""" grant = await permission_manager.grant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.API, level=PermissionLevel.EXECUTE, duration_seconds=3600, # 1 hour ) assert grant.expires_at is not None assert grant.is_expired() is False @pytest.mark.asyncio async def test_revoke_by_id( self, permission_manager: PermissionManager, ) -> None: """Test revoking a grant by ID.""" grant = await permission_manager.grant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) success = await permission_manager.revoke(grant.id) assert success is True # Verify grant is removed grants = await permission_manager.list_grants(agent_id="agent-1") assert len(grants) == 0 @pytest.mark.asyncio async def test_revoke_nonexistent( self, permission_manager: PermissionManager, ) -> None: """Test revoking a non-existent grant.""" success = await permission_manager.revoke("nonexistent-id") assert success is False @pytest.mark.asyncio async def test_revoke_all_for_agent( self, permission_manager: PermissionManager, ) -> None: """Test revoking all permissions for an agent.""" # Grant multiple permissions await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) await permission_manager.grant( agent_id="agent-1", resource_pattern="/api/*", resource_type=ResourceType.API, level=PermissionLevel.EXECUTE, ) await permission_manager.grant( agent_id="agent-2", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) revoked = await permission_manager.revoke_all("agent-1") assert revoked == 2 # Verify agent-1 grants are gone grants = await permission_manager.list_grants(agent_id="agent-1") assert len(grants) == 0 # Verify agent-2 grant remains grants = await permission_manager.list_grants(agent_id="agent-2") assert len(grants) == 1 @pytest.mark.asyncio async def test_revoke_all_no_grants( self, permission_manager: PermissionManager, ) -> None: """Test revoking all when no grants exist.""" revoked = await permission_manager.revoke_all("nonexistent-agent") assert revoked == 0 @pytest.mark.asyncio async def test_check_granted( self, permission_manager: PermissionManager, ) -> None: """Test checking a granted permission.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) allowed = await permission_manager.check( agent_id="agent-1", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) assert allowed is True @pytest.mark.asyncio async def test_check_denied_default_deny( self, permission_manager: PermissionManager, ) -> None: """Test checking denied with default_deny=True.""" # No grants, should be denied allowed = await permission_manager.check( agent_id="agent-1", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) assert allowed is False @pytest.mark.asyncio async def test_check_uses_default_permissions( self, permissive_manager: PermissionManager, ) -> None: """Test that default permissions apply when default_deny=False.""" # No explicit grants, but FILE default is READ allowed = await permissive_manager.check( agent_id="agent-1", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) assert allowed is True # But WRITE should fail allowed = await permissive_manager.check( agent_id="agent-1", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.WRITE, ) assert allowed is False @pytest.mark.asyncio async def test_check_shell_denied_by_default( self, permissive_manager: PermissionManager, ) -> None: """Test SHELL is denied by default (NONE level).""" allowed = await permissive_manager.check( agent_id="agent-1", resource="rm -rf /", resource_type=ResourceType.SHELL, required_level=PermissionLevel.EXECUTE, ) assert allowed is False @pytest.mark.asyncio async def test_check_expired_grant_ignored( self, permission_manager: PermissionManager, ) -> None: """Test that expired grants are ignored in checks.""" # Create an already-expired grant grant = await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, duration_seconds=1, # Very short ) # Manually expire it grant.expires_at = datetime.utcnow() - timedelta(seconds=10) allowed = await permission_manager.check( agent_id="agent-1", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) assert allowed is False @pytest.mark.asyncio async def test_check_insufficient_level( self, permission_manager: PermissionManager, ) -> None: """Test check fails when grant level is insufficient.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) # Try to get WRITE access with only READ grant allowed = await permission_manager.check( agent_id="agent-1", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.WRITE, ) assert allowed is False @pytest.mark.asyncio async def test_check_action_file_read( self, permission_manager: PermissionManager, action_metadata: ActionMetadata, ) -> None: """Test check_action for file read.""" await permission_manager.grant( agent_id="test-agent", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) action = ActionRequest( action_type=ActionType.FILE_READ, resource="/data/file.txt", metadata=action_metadata, ) allowed = await permission_manager.check_action(action) assert allowed is True @pytest.mark.asyncio async def test_check_action_file_write( self, permission_manager: PermissionManager, action_metadata: ActionMetadata, ) -> None: """Test check_action for file write.""" await permission_manager.grant( agent_id="test-agent", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.WRITE, ) action = ActionRequest( action_type=ActionType.FILE_WRITE, resource="/data/file.txt", metadata=action_metadata, ) allowed = await permission_manager.check_action(action) assert allowed is True @pytest.mark.asyncio async def test_check_action_uses_tool_name_as_resource( self, permission_manager: PermissionManager, action_metadata: ActionMetadata, ) -> None: """Test check_action uses tool_name when resource is None.""" await permission_manager.grant( agent_id="test-agent", resource_pattern="search_*", resource_type=ResourceType.CUSTOM, level=PermissionLevel.EXECUTE, ) action = ActionRequest( action_type=ActionType.TOOL_CALL, tool_name="search_documents", resource=None, metadata=action_metadata, ) allowed = await permission_manager.check_action(action) assert allowed is True @pytest.mark.asyncio async def test_require_permission_granted( self, permission_manager: PermissionManager, ) -> None: """Test require_permission doesn't raise when granted.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) # Should not raise await permission_manager.require_permission( agent_id="agent-1", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) @pytest.mark.asyncio async def test_require_permission_denied( self, permission_manager: PermissionManager, ) -> None: """Test require_permission raises when denied.""" with pytest.raises(PermissionDeniedError) as exc_info: await permission_manager.require_permission( agent_id="agent-1", resource="/secret/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) assert "/secret/file.txt" in str(exc_info.value) assert exc_info.value.agent_id == "agent-1" assert exc_info.value.required_permission == "read" @pytest.mark.asyncio async def test_list_grants_all( self, permission_manager: PermissionManager, ) -> None: """Test listing all grants.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) await permission_manager.grant( agent_id="agent-2", resource_pattern="/api/*", resource_type=ResourceType.API, level=PermissionLevel.EXECUTE, ) grants = await permission_manager.list_grants() assert len(grants) == 2 @pytest.mark.asyncio async def test_list_grants_by_agent( self, permission_manager: PermissionManager, ) -> None: """Test listing grants filtered by agent.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) await permission_manager.grant( agent_id="agent-2", resource_pattern="/api/*", resource_type=ResourceType.API, level=PermissionLevel.EXECUTE, ) grants = await permission_manager.list_grants(agent_id="agent-1") assert len(grants) == 1 assert grants[0].agent_id == "agent-1" @pytest.mark.asyncio async def test_list_grants_by_resource_type( self, permission_manager: PermissionManager, ) -> None: """Test listing grants filtered by resource type.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) await permission_manager.grant( agent_id="agent-1", resource_pattern="/api/*", resource_type=ResourceType.API, level=PermissionLevel.EXECUTE, ) grants = await permission_manager.list_grants(resource_type=ResourceType.FILE) assert len(grants) == 1 assert grants[0].resource_type == ResourceType.FILE @pytest.mark.asyncio async def test_list_grants_excludes_expired( self, permission_manager: PermissionManager, ) -> None: """Test that list_grants excludes expired grants.""" # Create expired grant grant = await permission_manager.grant( agent_id="agent-1", resource_pattern="/old/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, duration_seconds=1, ) grant.expires_at = datetime.utcnow() - timedelta(seconds=10) # Create valid grant await permission_manager.grant( agent_id="agent-1", resource_pattern="/new/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) grants = await permission_manager.list_grants() assert len(grants) == 1 assert grants[0].resource_pattern == "/new/*" def test_set_default_permission( self, ) -> None: """Test setting default permission level.""" manager = PermissionManager(default_deny=False) # Default for SHELL is NONE assert manager._default_permissions[ResourceType.SHELL] == PermissionLevel.NONE # Change it manager.set_default_permission(ResourceType.SHELL, PermissionLevel.EXECUTE) assert ( manager._default_permissions[ResourceType.SHELL] == PermissionLevel.EXECUTE ) @pytest.mark.asyncio async def test_set_default_permission_affects_checks( self, permissive_manager: PermissionManager, ) -> None: """Test that changing default permissions affects checks.""" # Initially SHELL is NONE allowed = await permissive_manager.check( agent_id="agent-1", resource="ls", resource_type=ResourceType.SHELL, required_level=PermissionLevel.EXECUTE, ) assert allowed is False # Change default permissive_manager.set_default_permission( ResourceType.SHELL, PermissionLevel.EXECUTE ) # Now should be allowed allowed = await permissive_manager.check( agent_id="agent-1", resource="ls", resource_type=ResourceType.SHELL, required_level=PermissionLevel.EXECUTE, ) assert allowed is True # ============================================================================ # Edge Cases # ============================================================================ class TestPermissionEdgeCases: """Edge cases that could reveal hidden bugs.""" @pytest.mark.asyncio async def test_multiple_matching_grants( self, permission_manager: PermissionManager, ) -> None: """Test when multiple grants match - first sufficient one wins.""" # Grant READ on all files await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) # Also grant WRITE on specific path await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/writable/*", resource_type=ResourceType.FILE, level=PermissionLevel.WRITE, ) # Write on writable path should work allowed = await permission_manager.check( agent_id="agent-1", resource="/data/writable/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.WRITE, ) assert allowed is True @pytest.mark.asyncio async def test_wildcard_all_pattern( self, permission_manager: PermissionManager, ) -> None: """Test * pattern matches everything.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="*", resource_type=ResourceType.FILE, level=PermissionLevel.ADMIN, ) allowed = await permission_manager.check( agent_id="agent-1", resource="/any/path/anywhere/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.DELETE, ) # fnmatch's * matches everything including / assert allowed is True @pytest.mark.asyncio async def test_question_mark_wildcard( self, permission_manager: PermissionManager, ) -> None: """Test ? wildcard matches single character.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="file?.txt", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) assert ( await permission_manager.check( agent_id="agent-1", resource="file1.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) is True ) assert ( await permission_manager.check( agent_id="agent-1", resource="file10.txt", # Two characters, won't match resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) is False ) @pytest.mark.asyncio async def test_concurrent_grant_revoke( self, permission_manager: PermissionManager, ) -> None: """Test concurrent grant and revoke operations.""" async def grant_many(): grants = [] for i in range(10): g = await permission_manager.grant( agent_id="agent-1", resource_pattern=f"/path{i}/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) grants.append(g) return grants async def revoke_many(grants): for g in grants: await permission_manager.revoke(g.id) grants = await grant_many() await revoke_many(grants) # All should be revoked remaining = await permission_manager.list_grants(agent_id="agent-1") assert len(remaining) == 0 @pytest.mark.asyncio async def test_check_action_with_no_resource_or_tool( self, permission_manager: PermissionManager, action_metadata: ActionMetadata, ) -> None: """Test check_action when both resource and tool_name are None.""" await permission_manager.grant( agent_id="test-agent", resource_pattern="*", resource_type=ResourceType.LLM, level=PermissionLevel.EXECUTE, ) action = ActionRequest( action_type=ActionType.LLM_CALL, resource=None, tool_name=None, metadata=action_metadata, ) # Should use "*" as fallback allowed = await permission_manager.check_action(action) assert allowed is True @pytest.mark.asyncio async def test_cleanup_expired_called_on_check( self, permission_manager: PermissionManager, ) -> None: """Test that expired grants are cleaned up during check.""" # Create expired grant grant = await permission_manager.grant( agent_id="agent-1", resource_pattern="/old/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, duration_seconds=1, ) grant.expires_at = datetime.utcnow() - timedelta(seconds=10) # Create valid grant await permission_manager.grant( agent_id="agent-1", resource_pattern="/new/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) # Run a check - this should trigger cleanup await permission_manager.check( agent_id="agent-1", resource="/new/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) # Now verify expired grant was cleaned up async with permission_manager._lock: assert len(permission_manager._grants) == 1 assert permission_manager._grants[0].resource_pattern == "/new/*" @pytest.mark.asyncio async def test_check_wrong_agent_id( self, permission_manager: PermissionManager, ) -> None: """Test check fails for different agent.""" await permission_manager.grant( agent_id="agent-1", resource_pattern="/data/*", resource_type=ResourceType.FILE, level=PermissionLevel.READ, ) # Different agent should not have access allowed = await permission_manager.check( agent_id="agent-2", resource="/data/file.txt", resource_type=ResourceType.FILE, required_level=PermissionLevel.READ, ) assert allowed is False