Files
gt-ai-os-community/apps/tenant-backend/app/services/access_controller.py
HackWeasel b9dfb86260 GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts:

- Fix stack trace exposure in error responses
- Add SSRF protection with DNS resolution checking
- Implement proper URL hostname validation (replaces substring matching)
- Add centralized path sanitization to prevent path traversal
- Fix ReDoS vulnerability in email validation regex
- Improve HTML sanitization in validation utilities
- Fix capability wildcard matching in auth utilities
- Update glob dependency to address CVE
- Add CodeQL suppression comments for verified false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 17:04:45 -05:00

451 lines
17 KiB
Python

"""
Access Controller Service for GT 2.0
Manages resource access control with capability-based security.
Ensures perfect tenant isolation and proper permission cascading.
"""
import os
import stat
from typing import List, Optional, Dict, Any, Tuple
from datetime import datetime
import logging
from pathlib import Path
from app.models.access_group import (
AccessGroup, TenantStructure, User, Resource,
ResourceCreate, ResourceUpdate, ResourceResponse
)
from app.core.security import verify_capability_token
from app.core.database import get_db_session
logger = logging.getLogger(__name__)
class AccessController:
"""
Centralized access control service
Manages permissions for all resources with tenant isolation
"""
def __init__(self, tenant_domain: str):
self.tenant_domain = tenant_domain
self.base_path = Path(f"/data/{tenant_domain}")
self._ensure_tenant_directory()
def _ensure_tenant_directory(self):
"""
Ensure tenant directory exists with proper permissions
OS User: gt-{tenant_domain}-{pod_id}
Permissions: 700 (owner only)
"""
if not self.base_path.exists():
self.base_path.mkdir(parents=True, exist_ok=True)
# Set strict permissions - owner only
os.chmod(self.base_path, stat.S_IRWXU) # 700
logger.info(f"Created tenant directory: {self.base_path} with 700 permissions")
async def check_permission(
self,
user_id: str,
resource: Resource,
action: str = "read"
) -> Tuple[bool, Optional[str]]:
"""
Check if user has permission for action on resource
Args:
user_id: User requesting access
resource: Resource being accessed
action: read, write, delete, share
Returns:
Tuple of (allowed, reason)
"""
# Verify tenant isolation
if resource.tenant_domain != self.tenant_domain:
logger.warning(f"Cross-tenant access attempt: {user_id} -> {resource.id}")
return False, "Cross-tenant access denied"
# Owner has all permissions
if resource.owner_id == user_id:
return True, "Owner access granted"
# Check action-specific permissions
if action == "read":
return self._check_read_permission(user_id, resource)
elif action == "write":
return self._check_write_permission(user_id, resource)
elif action == "delete":
return False, "Only owner can delete"
elif action == "share":
return False, "Only owner can share"
else:
return False, f"Unknown action: {action}"
def _check_read_permission(self, user_id: str, resource: Resource) -> Tuple[bool, str]:
"""Check read permission based on access group"""
if resource.access_group == AccessGroup.ORGANIZATION:
return True, "Organization-wide read access"
elif resource.access_group == AccessGroup.TEAM:
if user_id in resource.team_members:
return True, "Team member read access"
return False, "Not a team member"
else: # INDIVIDUAL
return False, "Private resource"
def _check_write_permission(self, user_id: str, resource: Resource) -> Tuple[bool, str]:
"""Check write permission - only owner can write"""
return False, "Only owner can modify"
async def create_resource(
self,
user_id: str,
resource_data: ResourceCreate,
capability_token: str
) -> Resource:
"""
Create a new resource with proper access control
Args:
user_id: User creating the resource
resource_data: Resource creation data
capability_token: JWT capability token
Returns:
Created resource
"""
# Verify capability token
token_data = verify_capability_token(capability_token)
if not token_data or token_data.get("tenant_id") != self.tenant_domain:
raise PermissionError("Invalid capability token")
# Create resource
resource = Resource(
id=self._generate_resource_id(),
name=resource_data.name,
resource_type=resource_data.resource_type,
owner_id=user_id,
tenant_domain=self.tenant_domain,
access_group=resource_data.access_group,
team_members=resource_data.team_members or [],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
metadata=resource_data.metadata or {},
file_path=None
)
# Create file-based storage if needed
if self._requires_file_storage(resource.resource_type):
resource.file_path = await self._create_resource_file(resource)
# Audit log
logger.info(f"Resource created: {resource.id} by {user_id} in {self.tenant_domain}")
return resource
async def update_resource_access(
self,
user_id: str,
resource_id: str,
new_access_group: AccessGroup,
team_members: Optional[List[str]] = None
) -> Resource:
"""
Update resource access group
Args:
user_id: User requesting update
resource_id: Resource to update
new_access_group: New access level
team_members: Team members if team access
Returns:
Updated resource
"""
# Load resource
resource = await self._load_resource(resource_id)
# Check permission
allowed, reason = await self.check_permission(user_id, resource, "share")
if not allowed:
raise PermissionError(f"Access denied: {reason}")
# Update access
old_group = resource.access_group
resource.update_access_group(new_access_group, team_members)
# Update file permissions if needed
if resource.file_path:
await self._update_file_permissions(resource)
# Audit log
logger.info(
f"Access updated: {resource_id} from {old_group} to {new_access_group} "
f"by {user_id}"
)
return resource
async def list_accessible_resources(
self,
user_id: str,
resource_type: Optional[str] = None
) -> List[Resource]:
"""
List all resources accessible to user
Args:
user_id: User requesting list
resource_type: Filter by type
Returns:
List of accessible resources
"""
accessible = []
# Get all resources in tenant
all_resources = await self._list_tenant_resources(resource_type)
for resource in all_resources:
allowed, _ = await self.check_permission(user_id, resource, "read")
if allowed:
accessible.append(resource)
return accessible
async def get_resource_stats(self, user_id: str) -> Dict[str, Any]:
"""
Get resource statistics for user
Args:
user_id: User to get stats for
Returns:
Statistics dictionary
"""
all_resources = await self._list_tenant_resources()
owned = [r for r in all_resources if r.owner_id == user_id]
accessible = await self.list_accessible_resources(user_id)
stats = {
"owned_count": len(owned),
"accessible_count": len(accessible),
"by_type": {},
"by_access_group": {
AccessGroup.INDIVIDUAL: 0,
AccessGroup.TEAM: 0,
AccessGroup.ORGANIZATION: 0
}
}
for resource in owned:
# Count by type
if resource.resource_type not in stats["by_type"]:
stats["by_type"][resource.resource_type] = 0
stats["by_type"][resource.resource_type] += 1
# Count by access group
stats["by_access_group"][resource.access_group] += 1
return stats
def _generate_resource_id(self) -> str:
"""Generate unique resource ID"""
import uuid
return str(uuid.uuid4())
def _requires_file_storage(self, resource_type: str) -> bool:
"""Check if resource type requires file storage"""
file_based_types = [
"agent", "dataset", "document", "workflow",
"notebook", "model", "configuration"
]
return resource_type in file_based_types
async def _create_resource_file(self, resource: Resource) -> str:
"""
Create file for resource with proper permissions
Args:
resource: Resource to create file for
Returns:
File path
"""
# Determine path based on resource type
type_dir = self.base_path / resource.resource_type / resource.id
type_dir.mkdir(parents=True, exist_ok=True)
# Create main file
file_path = type_dir / "data.json"
file_path.touch()
# Set strict permissions - 700 for directory, 600 for file
os.chmod(type_dir, stat.S_IRWXU) # 700
os.chmod(file_path, stat.S_IRUSR | stat.S_IWUSR) # 600
logger.info(f"Created resource file: {file_path} with secure permissions")
return str(file_path)
async def _update_file_permissions(self, resource: Resource):
"""Update file permissions (always 700/600 for security)"""
if not resource.file_path or not Path(resource.file_path).exists():
return
# Permissions don't change based on access group
# All files remain 700/600 for OS-level security
# Access control is handled at application level
pass
async def _load_resource(self, resource_id: str) -> Resource:
"""Load resource from storage"""
try:
# Search for resource in all resource type directories
for resource_type_dir in self.base_path.iterdir():
if not resource_type_dir.is_dir():
continue
resource_file = resource_type_dir / "data.json"
if resource_file.exists():
try:
import json
with open(resource_file, 'r') as f:
resources_data = json.load(f)
if not isinstance(resources_data, list):
resources_data = [resources_data]
for resource_data in resources_data:
if resource_data.get('id') == resource_id:
return Resource(
id=resource_data['id'],
name=resource_data['name'],
resource_type=resource_data['resource_type'],
owner_id=resource_data['owner_id'],
tenant_domain=resource_data['tenant_domain'],
access_group=AccessGroup(resource_data['access_group']),
team_members=resource_data.get('team_members', []),
created_at=datetime.fromisoformat(resource_data['created_at']),
updated_at=datetime.fromisoformat(resource_data['updated_at']),
metadata=resource_data.get('metadata', {}),
file_path=resource_data.get('file_path')
)
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.warning(f"Failed to parse resource file {resource_file}: {e}")
continue
raise ValueError(f"Resource {resource_id} not found")
except Exception as e:
logger.error(f"Failed to load resource {resource_id}: {e}")
raise
async def _list_tenant_resources(
self,
resource_type: Optional[str] = None
) -> List[Resource]:
"""List all resources in tenant"""
try:
import json
resources = []
# If specific resource type requested, search only that directory
search_dirs = [self.base_path / resource_type] if resource_type else list(self.base_path.iterdir())
for resource_type_dir in search_dirs:
if not resource_type_dir.exists() or not resource_type_dir.is_dir():
continue
resource_file = resource_type_dir / "data.json"
if resource_file.exists():
try:
with open(resource_file, 'r') as f:
resources_data = json.load(f)
if not isinstance(resources_data, list):
resources_data = [resources_data]
for resource_data in resources_data:
try:
resource = Resource(
id=resource_data['id'],
name=resource_data['name'],
resource_type=resource_data['resource_type'],
owner_id=resource_data['owner_id'],
tenant_domain=resource_data['tenant_domain'],
access_group=AccessGroup(resource_data['access_group']),
team_members=resource_data.get('team_members', []),
created_at=datetime.fromisoformat(resource_data['created_at']),
updated_at=datetime.fromisoformat(resource_data['updated_at']),
metadata=resource_data.get('metadata', {}),
file_path=resource_data.get('file_path')
)
resources.append(resource)
except (KeyError, ValueError) as e:
logger.warning(f"Failed to parse resource data: {e}")
continue
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to read resource file {resource_file}: {e}")
continue
return resources
except Exception as e:
logger.error(f"Failed to list tenant resources: {e}")
raise
class AccessControlMiddleware:
"""
Middleware for enforcing access control on API requests
"""
def __init__(self, tenant_domain: str):
self.controller = AccessController(tenant_domain)
async def verify_request(
self,
user_id: str,
resource_id: str,
action: str,
capability_token: str
) -> bool:
"""
Verify request has proper permissions
Args:
user_id: User making request
resource_id: Resource being accessed
action: Action being performed
capability_token: JWT capability token
Returns:
True if allowed, raises PermissionError if not
"""
# Verify capability token
token_data = verify_capability_token(capability_token)
if not token_data:
raise PermissionError("Invalid capability token")
# Verify tenant match
if token_data.get("tenant_id") != self.controller.tenant_domain:
raise PermissionError("Tenant mismatch in capability token")
# Load resource and check permission
resource = await self.controller._load_resource(resource_id)
allowed, reason = await self.controller.check_permission(
user_id, resource, action
)
if not allowed:
logger.warning(
f"Access denied: {user_id} -> {resource_id} ({action}): {reason}"
)
raise PermissionError(f"Access denied: {reason}")
return True