Initial commit - production deployment
This commit is contained in:
588
services/tenant/app/repositories/tenant_member_repository.py
Normal file
588
services/tenant/app/repositories/tenant_member_repository.py
Normal file
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Tenant Member Repository
|
||||
Repository for tenant membership operations
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, and_
|
||||
from datetime import datetime, timedelta
|
||||
import structlog
|
||||
import json
|
||||
|
||||
from .base import TenantBaseRepository
|
||||
from app.models.tenants import TenantMember
|
||||
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
|
||||
from shared.config.base import is_internal_service
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class TenantMemberRepository(TenantBaseRepository):
|
||||
"""Repository for tenant member operations"""
|
||||
|
||||
def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 300):
|
||||
# Member data changes more frequently, shorter cache time (5 minutes)
|
||||
super().__init__(model_class, session, cache_ttl)
|
||||
|
||||
async def create_membership(self, membership_data: Dict[str, Any]) -> TenantMember:
|
||||
"""Create a new tenant membership with validation"""
|
||||
try:
|
||||
# Validate membership data
|
||||
validation_result = self._validate_tenant_data(
|
||||
membership_data,
|
||||
["tenant_id", "user_id", "role"]
|
||||
)
|
||||
|
||||
if not validation_result["is_valid"]:
|
||||
raise ValidationError(f"Invalid membership data: {validation_result['errors']}")
|
||||
|
||||
# Check for existing membership
|
||||
existing_membership = await self.get_membership(
|
||||
membership_data["tenant_id"],
|
||||
membership_data["user_id"]
|
||||
)
|
||||
|
||||
if existing_membership and existing_membership.is_active:
|
||||
raise DuplicateRecordError(f"User is already an active member of this tenant")
|
||||
|
||||
# Set default values
|
||||
if "is_active" not in membership_data:
|
||||
membership_data["is_active"] = True
|
||||
if "joined_at" not in membership_data:
|
||||
membership_data["joined_at"] = datetime.utcnow()
|
||||
|
||||
# Set permissions based on role
|
||||
if "permissions" not in membership_data:
|
||||
membership_data["permissions"] = self._get_default_permissions(
|
||||
membership_data["role"]
|
||||
)
|
||||
|
||||
# If reactivating existing membership
|
||||
if existing_membership and not existing_membership.is_active:
|
||||
# Update existing membership
|
||||
update_data = {
|
||||
key: value for key, value in membership_data.items()
|
||||
if key not in ["tenant_id", "user_id"]
|
||||
}
|
||||
membership = await self.update(existing_membership.id, update_data)
|
||||
else:
|
||||
# Create new membership
|
||||
membership = await self.create(membership_data)
|
||||
|
||||
logger.info("Tenant membership created",
|
||||
membership_id=membership.id,
|
||||
tenant_id=membership.tenant_id,
|
||||
user_id=membership.user_id,
|
||||
role=membership.role)
|
||||
|
||||
return membership
|
||||
|
||||
except (ValidationError, DuplicateRecordError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to create membership",
|
||||
tenant_id=membership_data.get("tenant_id"),
|
||||
user_id=membership_data.get("user_id"),
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to create membership: {str(e)}")
|
||||
|
||||
async def get_membership(self, tenant_id: str, user_id: str) -> Optional[TenantMember]:
|
||||
"""Get specific membership by tenant and user"""
|
||||
try:
|
||||
# Validate that user_id is a proper UUID format for actual users
|
||||
# Service names like 'inventory-service' should be handled differently
|
||||
import uuid
|
||||
try:
|
||||
uuid.UUID(user_id)
|
||||
is_valid_uuid = True
|
||||
except ValueError:
|
||||
is_valid_uuid = False
|
||||
|
||||
# For internal service access, return None to indicate no user membership
|
||||
# Service access should be handled at the API layer
|
||||
if not is_valid_uuid:
|
||||
if is_internal_service(user_id):
|
||||
# This is a known internal service request, return None
|
||||
# Service access is granted at the API endpoint level
|
||||
logger.debug("Internal service detected in membership lookup",
|
||||
service=user_id,
|
||||
tenant_id=tenant_id)
|
||||
return None
|
||||
elif user_id == "unknown-service":
|
||||
# Special handling for 'unknown-service' which commonly occurs in demo sessions
|
||||
# This happens when service identification fails during demo operations
|
||||
logger.warning("Demo session service identification issue",
|
||||
service=user_id,
|
||||
tenant_id=tenant_id,
|
||||
message="Service not properly identified - likely demo session context")
|
||||
return None
|
||||
else:
|
||||
# This is an unknown service
|
||||
# Return None to prevent database errors, but log a warning
|
||||
logger.warning("Unknown service detected in membership lookup",
|
||||
service=user_id,
|
||||
tenant_id=tenant_id,
|
||||
message="Service not in internal services registry")
|
||||
return None
|
||||
|
||||
memberships = await self.get_multi(
|
||||
filters={
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id
|
||||
},
|
||||
limit=1,
|
||||
order_by="created_at",
|
||||
order_desc=True
|
||||
)
|
||||
return memberships[0] if memberships else None
|
||||
except Exception as e:
|
||||
logger.error("Failed to get membership",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to get membership: {str(e)}")
|
||||
|
||||
async def get_tenant_members(
|
||||
self,
|
||||
tenant_id: str,
|
||||
active_only: bool = True,
|
||||
role: str = None,
|
||||
include_user_info: bool = False
|
||||
) -> List[TenantMember]:
|
||||
"""Get all members of a tenant with optional user info enrichment"""
|
||||
try:
|
||||
filters = {"tenant_id": tenant_id}
|
||||
|
||||
if active_only:
|
||||
filters["is_active"] = True
|
||||
|
||||
if role:
|
||||
filters["role"] = role
|
||||
|
||||
members = await self.get_multi(
|
||||
filters=filters,
|
||||
order_by="joined_at",
|
||||
order_desc=False
|
||||
)
|
||||
|
||||
# If include_user_info is True, enrich with user data from auth service
|
||||
if include_user_info and members:
|
||||
members = await self._enrich_members_with_user_info(members)
|
||||
|
||||
return members
|
||||
except Exception as e:
|
||||
logger.error("Failed to get tenant members",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to get members: {str(e)}")
|
||||
|
||||
async def _enrich_members_with_user_info(self, members: List[TenantMember]) -> List[TenantMember]:
|
||||
"""Enrich member objects with user information from auth service using batch endpoint"""
|
||||
try:
|
||||
import httpx
|
||||
import os
|
||||
|
||||
if not members:
|
||||
return members
|
||||
|
||||
# Get unique user IDs
|
||||
user_ids = list(set([str(member.user_id) for member in members]))
|
||||
|
||||
if not user_ids:
|
||||
return members
|
||||
|
||||
# Fetch user data from auth service using batch endpoint
|
||||
# Using internal service communication
|
||||
auth_service_url = os.getenv('AUTH_SERVICE_URL', 'http://auth-service:8000')
|
||||
|
||||
user_data_map = {}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
# Use batch endpoint for efficiency
|
||||
response = await client.post(
|
||||
f"{auth_service_url}/api/v1/auth/users/batch",
|
||||
json={"user_ids": user_ids},
|
||||
timeout=10.0,
|
||||
headers={"x-internal-service": "tenant-service"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
batch_result = response.json()
|
||||
user_data_map = batch_result.get("users", {})
|
||||
logger.info(
|
||||
"Batch user fetch successful",
|
||||
requested_count=len(user_ids),
|
||||
found_count=batch_result.get("found_count", 0)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Batch user fetch failed, falling back to individual calls",
|
||||
status_code=response.status_code
|
||||
)
|
||||
# Fallback to individual calls if batch fails
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{auth_service_url}/api/v1/auth/users/{user_id}",
|
||||
timeout=5.0,
|
||||
headers={"x-internal-service": "tenant-service"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
user_data = response.json()
|
||||
user_data_map[user_id] = user_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch user data for {user_id}", error=str(e))
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Batch user fetch failed, falling back to individual calls", error=str(e))
|
||||
# Fallback to individual calls
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{auth_service_url}/api/v1/auth/users/{user_id}",
|
||||
timeout=5.0,
|
||||
headers={"x-internal-service": "tenant-service"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
user_data = response.json()
|
||||
user_data_map[user_id] = user_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch user data for {user_id}", error=str(e))
|
||||
continue
|
||||
|
||||
# Enrich members with user data
|
||||
for member in members:
|
||||
user_id_str = str(member.user_id)
|
||||
if user_id_str in user_data_map and user_data_map[user_id_str] is not None:
|
||||
user_data = user_data_map[user_id_str]
|
||||
# Add user fields as attributes to the member object
|
||||
member.user_email = user_data.get("email")
|
||||
member.user_full_name = user_data.get("full_name")
|
||||
member.user = user_data # Store full user object for compatibility
|
||||
else:
|
||||
# Set defaults for missing users
|
||||
member.user_email = None
|
||||
member.user_full_name = "Unknown User"
|
||||
member.user = None
|
||||
|
||||
return members
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to enrich members with user info", error=str(e))
|
||||
# Return members without enrichment if it fails
|
||||
return members
|
||||
|
||||
async def get_user_memberships(
|
||||
self,
|
||||
user_id: str,
|
||||
active_only: bool = True
|
||||
) -> List[TenantMember]:
|
||||
"""Get all tenants a user is a member of"""
|
||||
try:
|
||||
filters = {"user_id": user_id}
|
||||
|
||||
if active_only:
|
||||
filters["is_active"] = True
|
||||
|
||||
return await self.get_multi(
|
||||
filters=filters,
|
||||
order_by="joined_at",
|
||||
order_desc=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to get user memberships",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to get memberships: {str(e)}")
|
||||
|
||||
async def verify_user_access(
|
||||
self,
|
||||
user_id: str,
|
||||
tenant_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Verify if user has access to tenant and return access details"""
|
||||
try:
|
||||
membership = await self.get_membership(tenant_id, user_id)
|
||||
|
||||
if not membership or not membership.is_active:
|
||||
return {
|
||||
"has_access": False,
|
||||
"role": "none",
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
# Parse permissions
|
||||
permissions = []
|
||||
if membership.permissions:
|
||||
try:
|
||||
permissions = json.loads(membership.permissions)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid permissions JSON for membership",
|
||||
membership_id=membership.id)
|
||||
permissions = self._get_default_permissions(membership.role)
|
||||
|
||||
return {
|
||||
"has_access": True,
|
||||
"role": membership.role,
|
||||
"permissions": permissions,
|
||||
"membership_id": str(membership.id),
|
||||
"joined_at": membership.joined_at.isoformat() if membership.joined_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to verify user access",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
return {
|
||||
"has_access": False,
|
||||
"role": "none",
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
async def update_member_role(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
new_role: str,
|
||||
updated_by: str = None
|
||||
) -> Optional[TenantMember]:
|
||||
"""Update member role and permissions"""
|
||||
try:
|
||||
valid_roles = ["owner", "admin", "member", "viewer"]
|
||||
if new_role not in valid_roles:
|
||||
raise ValidationError(f"Invalid role. Must be one of: {valid_roles}")
|
||||
|
||||
membership = await self.get_membership(tenant_id, user_id)
|
||||
if not membership:
|
||||
raise ValidationError("Membership not found")
|
||||
|
||||
# Get new permissions based on role
|
||||
new_permissions = self._get_default_permissions(new_role)
|
||||
|
||||
updated_membership = await self.update(membership.id, {
|
||||
"role": new_role,
|
||||
"permissions": json.dumps(new_permissions)
|
||||
})
|
||||
|
||||
logger.info("Member role updated",
|
||||
membership_id=membership.id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
old_role=membership.role,
|
||||
new_role=new_role,
|
||||
updated_by=updated_by)
|
||||
|
||||
return updated_membership
|
||||
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to update member role",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
new_role=new_role,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to update role: {str(e)}")
|
||||
|
||||
async def deactivate_membership(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
deactivated_by: str = None
|
||||
) -> Optional[TenantMember]:
|
||||
"""Deactivate a membership (remove user from tenant)"""
|
||||
try:
|
||||
membership = await self.get_membership(tenant_id, user_id)
|
||||
if not membership:
|
||||
raise ValidationError("Membership not found")
|
||||
|
||||
# Don't allow deactivating the owner
|
||||
if membership.role == "owner":
|
||||
raise ValidationError("Cannot deactivate the owner membership")
|
||||
|
||||
updated_membership = await self.update(membership.id, {
|
||||
"is_active": False
|
||||
})
|
||||
|
||||
logger.info("Membership deactivated",
|
||||
membership_id=membership.id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
deactivated_by=deactivated_by)
|
||||
|
||||
return updated_membership
|
||||
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to deactivate membership",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to deactivate membership: {str(e)}")
|
||||
|
||||
async def reactivate_membership(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
reactivated_by: str = None
|
||||
) -> Optional[TenantMember]:
|
||||
"""Reactivate a deactivated membership"""
|
||||
try:
|
||||
membership = await self.get_membership(tenant_id, user_id)
|
||||
if not membership:
|
||||
raise ValidationError("Membership not found")
|
||||
|
||||
updated_membership = await self.update(membership.id, {
|
||||
"is_active": True,
|
||||
"joined_at": datetime.utcnow() # Update join date
|
||||
})
|
||||
|
||||
logger.info("Membership reactivated",
|
||||
membership_id=membership.id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
reactivated_by=reactivated_by)
|
||||
|
||||
return updated_membership
|
||||
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to reactivate membership",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to reactivate membership: {str(e)}")
|
||||
|
||||
async def get_membership_statistics(self, tenant_id: str) -> Dict[str, Any]:
|
||||
"""Get membership statistics for a tenant"""
|
||||
try:
|
||||
# Get counts by role
|
||||
role_query = text("""
|
||||
SELECT role, COUNT(*) as count
|
||||
FROM tenant_members
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
GROUP BY role
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(role_query, {"tenant_id": tenant_id})
|
||||
members_by_role = {row.role: row.count for row in result.fetchall()}
|
||||
|
||||
# Get basic counts
|
||||
total_members = await self.count(filters={"tenant_id": tenant_id})
|
||||
active_members = await self.count(filters={
|
||||
"tenant_id": tenant_id,
|
||||
"is_active": True
|
||||
})
|
||||
|
||||
# Get recent activity (members joined in last 30 days)
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
recent_joins = len(await self.get_multi(
|
||||
filters={
|
||||
"tenant_id": tenant_id,
|
||||
"is_active": True
|
||||
},
|
||||
limit=1000 # High limit to get accurate count
|
||||
))
|
||||
|
||||
# Filter for recent joins (manual filtering since we can't use date range in filters easily)
|
||||
recent_members = 0
|
||||
all_active_members = await self.get_tenant_members(tenant_id, active_only=True)
|
||||
for member in all_active_members:
|
||||
if member.joined_at and member.joined_at >= thirty_days_ago:
|
||||
recent_members += 1
|
||||
|
||||
return {
|
||||
"total_members": total_members,
|
||||
"active_members": active_members,
|
||||
"inactive_members": total_members - active_members,
|
||||
"members_by_role": members_by_role,
|
||||
"recent_joins_30d": recent_members
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get membership statistics",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
return {
|
||||
"total_members": 0,
|
||||
"active_members": 0,
|
||||
"inactive_members": 0,
|
||||
"members_by_role": {},
|
||||
"recent_joins_30d": 0
|
||||
}
|
||||
|
||||
def _get_default_permissions(self, role: str) -> str:
|
||||
"""Get default permissions JSON string for a role"""
|
||||
permission_map = {
|
||||
"owner": ["read", "write", "admin", "delete"],
|
||||
"admin": ["read", "write", "admin"],
|
||||
"member": ["read", "write"],
|
||||
"viewer": ["read"]
|
||||
}
|
||||
|
||||
permissions = permission_map.get(role, ["read"])
|
||||
return json.dumps(permissions)
|
||||
|
||||
async def bulk_update_permissions(
|
||||
self,
|
||||
tenant_id: str,
|
||||
role_permissions: Dict[str, List[str]]
|
||||
) -> int:
|
||||
"""Bulk update permissions for all members of specific roles"""
|
||||
try:
|
||||
updated_count = 0
|
||||
|
||||
for role, permissions in role_permissions.items():
|
||||
members = await self.get_tenant_members(
|
||||
tenant_id, active_only=True, role=role
|
||||
)
|
||||
|
||||
for member in members:
|
||||
await self.update(member.id, {
|
||||
"permissions": json.dumps(permissions)
|
||||
})
|
||||
updated_count += 1
|
||||
|
||||
logger.info("Bulk updated member permissions",
|
||||
tenant_id=tenant_id,
|
||||
updated_count=updated_count,
|
||||
roles=list(role_permissions.keys()))
|
||||
|
||||
return updated_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to bulk update permissions",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Bulk permission update failed: {str(e)}")
|
||||
|
||||
async def cleanup_inactive_memberships(self, days_old: int = 180) -> int:
|
||||
"""Clean up old inactive memberships"""
|
||||
try:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
|
||||
|
||||
query_text = """
|
||||
DELETE FROM tenant_members
|
||||
WHERE is_active = false
|
||||
AND created_at < :cutoff_date
|
||||
"""
|
||||
|
||||
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
|
||||
deleted_count = result.rowcount
|
||||
|
||||
logger.info("Cleaned up inactive memberships",
|
||||
deleted_count=deleted_count,
|
||||
days_old=days_old)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to cleanup inactive memberships",
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Cleanup failed: {str(e)}")
|
||||
Reference in New Issue
Block a user