forked from cardosofelipe/fast-next-template
Add tests to improve backend coverage from 85% to 93%: - test_audit.py: 60 tests for AuditLogger (20% -> 99%) - Hash chain integrity, sanitization, retention, handlers - Fixed bug: hash chain modification after event creation - Fixed bug: verification not using correct prev_hash - test_hitl.py: Tests for HITL manager (0% -> 100%) - test_permissions.py: Tests for permissions manager (0% -> 99%) - test_rollback.py: Tests for rollback manager (0% -> 100%) - test_metrics.py: Tests for metrics collector (0% -> 100%) - test_mcp_integration.py: Tests for MCP safety wrapper (0% -> 100%) - test_validation.py: Additional cache and edge case tests (76% -> 100%) - test_scoring.py: Lock cleanup and edge case tests (78% -> 91%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
934 lines
30 KiB
Python
934 lines
30 KiB
Python
"""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
|