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>
This commit is contained in:
451
apps/tenant-backend/app/services/access_controller.py
Normal file
451
apps/tenant-backend/app/services/access_controller.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user