Files
gt-ai-os-community/apps/tenant-backend/app/services/team_service.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

2362 lines
100 KiB
Python

import json
import uuid
from uuid import UUID
from datetime import datetime
from typing import Dict, List, Optional, Any
from app.core.config import get_settings
from app.core.postgresql_client import get_postgresql_client
from app.core.permissions import get_user_role, is_effective_owner
import logging
logger = logging.getLogger(__name__)
# Import for event logging
EVENT_LOGGING_AVAILABLE = False
try:
from app.services.event_service import EventType
EVENT_LOGGING_AVAILABLE = True
except ImportError:
logger.warning("EventService not available - team events will not be logged")
class TeamService:
"""GT 2.0 Team Collaboration Service with Perfect Tenant Isolation"""
def __init__(self, tenant_domain: str, user_id: str, user_email: str = None):
"""Initialize with tenant and user isolation using PostgreSQL storage"""
self.tenant_domain = tenant_domain
self.user_id = user_id
self.user_email = user_email or user_id # Fallback to user_id if no email provided
self.settings = get_settings()
logger.info(f"Team service initialized for {tenant_domain}/{user_id} (email: {self.user_email})")
async def _get_user_id(self, pg_client, user_identifier: Optional[str] = None) -> str:
"""
Get user UUID from email/username/uuid with tenant isolation.
Follows AgentService pattern for flexible user lookup.
"""
identifier = user_identifier or self.user_email
user_lookup_query = """
SELECT id FROM users
WHERE (LOWER(email) = LOWER($1) OR id::text = $1 OR LOWER(username) = LOWER($1))
AND tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
LIMIT 1
"""
user_id = await pg_client.fetch_scalar(user_lookup_query, identifier, self.tenant_domain)
if not user_id and user_identifier is None:
# Only fallback to self.user_id when looking up current user (no identifier provided)
user_id = await pg_client.fetch_scalar(user_lookup_query, self.user_id, self.tenant_domain)
if not user_id:
raise RuntimeError(f"User not found: {identifier} in tenant {self.tenant_domain}")
logger.info(f"Found user ID: {user_id} for identifier: {identifier}")
return str(user_id)
async def is_team_owner(self, team_id: str, user_id: str) -> bool:
"""Check if user is the team owner"""
pg_client = await get_postgresql_client()
query = """
SELECT owner_id FROM teams
WHERE id = $1::uuid
AND tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
"""
owner_id = await pg_client.fetch_scalar(query, team_id, self.tenant_domain)
return str(owner_id) == str(user_id) if owner_id else False
async def can_manage_team(self, team_id: str, user_id: str) -> bool:
"""
Check if user can manage team (owner, manager, or admin/developer).
Follows GT 2.0 pattern of admin bypass.
"""
pg_client = await get_postgresql_client()
# Check if admin/developer (can manage all teams)
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
if user_role in ["admin", "developer"]:
logger.info(f"User {user_id} has admin/developer role, can manage all teams")
return True
# Check if team owner
if await self.is_team_owner(team_id, user_id):
return True
# Check if user has manager permission
query = """
SELECT team_permission
FROM team_memberships
WHERE team_id = $1 AND user_id = $2 AND status = 'accepted'
"""
membership = await pg_client.fetch_one(query, UUID(team_id), UUID(user_id))
if membership and membership["team_permission"] == "manager":
logger.info(f"User {user_id} has manager permission for team {team_id}")
return True
return False
async def can_share_to_team(self, team_id: str, user_id: str) -> bool:
"""
Check if user can share resources to team.
Requires 'share' or 'manager' team_permission, team ownership, or admin/developer role.
"""
pg_client = await get_postgresql_client()
# Admin/developer bypass
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
if user_role in ["admin", "developer"]:
return True
# Team owner can always share
if await self.is_team_owner(team_id, user_id):
return True
# Check team membership with 'share' or 'manager' permission
query = """
SELECT team_permission FROM team_memberships
WHERE team_id = $1::uuid
AND user_id = $2::uuid
"""
permission = await pg_client.fetch_scalar(query, team_id, user_id)
return permission in ['share', 'manager']
async def create_team(
self,
name: str,
description: str = ""
) -> Dict[str, Any]:
"""Create a new team with the current user as owner"""
try:
pg_client = await get_postgresql_client()
# Validate user exists
user_id = await self._get_user_id(pg_client)
# Generate team ID
team_id = str(uuid.uuid4())
# Create team in PostgreSQL
query = """
INSERT INTO teams (
id, name, description, tenant_id, owner_id, created_at, updated_at
) VALUES (
$1::uuid, $2, $3,
(SELECT id FROM tenants WHERE domain = $4 LIMIT 1),
$5::uuid, NOW(), NOW()
)
RETURNING id, name, description, tenant_id, owner_id, created_at, updated_at
"""
team_data = await pg_client.fetch_one(
query,
team_id, name, description, self.tenant_domain, user_id
)
if not team_data:
raise RuntimeError("Failed to create team")
logger.info(f"Created team {team_id}: {name} for user {user_id}")
return {
"id": str(team_data["id"]),
"name": team_data["name"],
"description": team_data["description"],
"tenant_id": str(team_data["tenant_id"]),
"owner_id": str(team_data["owner_id"]),
"is_owner": True,
"can_manage": True,
"member_count": 0, # No members yet besides owner
"shared_resource_count": 0, # No shared resources yet
"created_at": team_data["created_at"].isoformat() if team_data["created_at"] else None,
"updated_at": team_data["updated_at"].isoformat() if team_data["updated_at"] else None
}
except Exception as e:
logger.error(f"Error creating team: {e}")
raise
async def get_user_teams(self) -> List[Dict[str, Any]]:
"""
Get all teams where user is owner or member.
Returns teams with member counts and permission flags.
"""
try:
pg_client = await get_postgresql_client()
# Get user ID
user_id = await self._get_user_id(pg_client)
# Get user role for admin bypass
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
# Query teams - admins see all teams, regular users see only teams they own or are members of
if is_admin:
query = """
SELECT DISTINCT
t.id, t.name, t.description, t.tenant_id, t.owner_id,
t.created_at, t.updated_at,
u.full_name as owner_name,
u.email as owner_email,
tm_current.team_permission as user_permission,
COUNT(DISTINCT CASE WHEN tm.status = 'accepted' THEN tm.user_id END) as member_count,
COUNT(DISTINCT trs.resource_id) as shared_resource_count
FROM teams t
LEFT JOIN users u ON t.owner_id = u.id
LEFT JOIN team_memberships tm ON t.id = tm.team_id
LEFT JOIN team_memberships tm_current ON t.id = tm_current.team_id
AND tm_current.user_id = $2::uuid
LEFT JOIN team_resource_shares trs ON t.id = trs.team_id
WHERE t.tenant_id = (SELECT id FROM tenants WHERE domain = $1 LIMIT 1)
GROUP BY t.id, t.name, t.description, t.tenant_id, t.owner_id,
t.created_at, t.updated_at, u.full_name, u.email, tm_current.team_permission
ORDER BY t.updated_at DESC
"""
else:
query = """
SELECT DISTINCT
t.id, t.name, t.description, t.tenant_id, t.owner_id,
t.created_at, t.updated_at,
u.full_name as owner_name,
u.email as owner_email,
tm_current.team_permission as user_permission,
COUNT(DISTINCT CASE WHEN tm.status = 'accepted' THEN tm.user_id END) as member_count,
COUNT(DISTINCT trs.resource_id) as shared_resource_count
FROM teams t
LEFT JOIN users u ON t.owner_id = u.id
LEFT JOIN team_memberships tm ON t.id = tm.team_id
LEFT JOIN team_memberships tm_current ON t.id = tm_current.team_id
AND tm_current.user_id = $2::uuid
LEFT JOIN team_resource_shares trs ON t.id = trs.team_id
WHERE t.tenant_id = (SELECT id FROM tenants WHERE domain = $1 LIMIT 1)
AND (t.owner_id = $2::uuid OR EXISTS (
SELECT 1 FROM team_memberships tm2
WHERE tm2.team_id = t.id AND tm2.user_id = $2::uuid
AND tm2.status = 'accepted'
))
GROUP BY t.id, t.name, t.description, t.tenant_id, t.owner_id,
t.created_at, t.updated_at, u.full_name, u.email, tm_current.team_permission
ORDER BY t.updated_at DESC
"""
teams_data = await pg_client.execute_query(query, self.tenant_domain, user_id)
# Format teams with permission flags
teams = []
for team in teams_data:
is_owner = str(team["owner_id"]) == str(user_id)
can_manage = is_admin or is_owner
teams.append({
"id": str(team["id"]),
"name": team["name"],
"description": team["description"],
"tenant_id": str(team["tenant_id"]),
"owner_id": str(team["owner_id"]),
"owner_name": team.get("owner_name", "Unknown"),
"owner_email": team.get("owner_email"),
"is_owner": is_owner,
"can_manage": can_manage,
"user_permission": team.get("user_permission"), # None if owner (not in team_memberships)
"member_count": int(team["member_count"]) if team.get("member_count") else 0,
"shared_resource_count": int(team["shared_resource_count"]) if team.get("shared_resource_count") else 0,
"created_at": team["created_at"].isoformat() if team["created_at"] else None,
"updated_at": team["updated_at"].isoformat() if team["updated_at"] else None
})
logger.info(f"Retrieved {len(teams)} teams for user {user_id}")
return teams
except Exception as e:
logger.error(f"Error getting user teams: {e}")
return []
async def get_team_by_id(self, team_id: str) -> Optional[Dict[str, Any]]:
"""Get a specific team by ID with member details"""
try:
pg_client = await get_postgresql_client()
# Get user ID
user_id = await self._get_user_id(pg_client)
# Query team with owner info
query = """
SELECT
t.id, t.name, t.description, t.tenant_id, t.owner_id,
t.created_at, t.updated_at,
u.full_name as owner_name,
u.email as owner_email
FROM teams t
LEFT JOIN users u ON t.owner_id = u.id
WHERE t.id = $1::uuid
AND t.tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
"""
team_data = await pg_client.fetch_one(query, team_id, self.tenant_domain)
if not team_data:
logger.warning(f"Team {team_id} not found in tenant {self.tenant_domain}")
return None
# Get user role for admin bypass
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
is_owner = str(team_data["owner_id"]) == str(user_id)
can_manage = is_admin or is_owner
# Get member count (only accepted members)
member_count_query = """
SELECT COUNT(*) FROM team_memberships
WHERE team_id = $1::uuid AND status = 'accepted'
"""
member_count = await pg_client.fetch_scalar(member_count_query, team_id) or 0
# Get shared resource count
shared_resource_count_query = """
SELECT COUNT(DISTINCT resource_id) FROM team_resource_shares
WHERE team_id = $1::uuid
"""
shared_resource_count = await pg_client.fetch_scalar(shared_resource_count_query, team_id) or 0
return {
"id": str(team_data["id"]),
"name": team_data["name"],
"description": team_data["description"],
"tenant_id": str(team_data["tenant_id"]),
"owner_id": str(team_data["owner_id"]),
"owner_name": team_data.get("owner_name", "Unknown"),
"owner_email": team_data.get("owner_email"),
"is_owner": is_owner,
"can_manage": can_manage,
"member_count": int(member_count),
"shared_resource_count": int(shared_resource_count),
"created_at": team_data["created_at"].isoformat() if team_data["created_at"] else None,
"updated_at": team_data["updated_at"].isoformat() if team_data["updated_at"] else None
}
except Exception as e:
logger.error(f"Error getting team {team_id}: {e}")
return None
async def update_team(
self,
team_id: str,
updates: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Update team name/description.
Requires team ownership (managers cannot update team details).
Admin/developer roles can also update.
"""
try:
pg_client = await get_postgresql_client()
# Get user ID
user_id = await self._get_user_id(pg_client)
# Check if user is admin/developer (they can update any team)
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
# Check if user is team owner
is_owner = await self.is_team_owner(team_id, user_id)
# Only owners and admins can update team details
if not is_owner and not is_admin:
raise PermissionError("Only team owners can update team details")
# Build UPDATE query dynamically
allowed_fields = ["name", "description"]
update_fields = {k: v for k, v in updates.items() if k in allowed_fields}
if not update_fields:
logger.warning("No valid fields to update")
return await self.get_team_by_id(team_id)
set_clause = ", ".join([f"{k} = ${i+1}" for i, k in enumerate(update_fields.keys())])
values = list(update_fields.values()) + [team_id, self.tenant_domain]
query = f"""
UPDATE teams
SET {set_clause}, updated_at = NOW()
WHERE id = ${len(update_fields)+1}::uuid
AND tenant_id = (SELECT id FROM tenants WHERE domain = ${len(update_fields)+2} LIMIT 1)
RETURNING id
"""
result = await pg_client.fetch_one(query, *values)
if not result:
raise RuntimeError(f"Failed to update team {team_id}")
logger.info(f"Updated team {team_id}: {update_fields}")
# Return updated team
return await self.get_team_by_id(team_id)
except Exception as e:
logger.error(f"Error updating team {team_id}: {e}")
raise
async def delete_team(self, team_id: str) -> bool:
"""
Delete a team and all its memberships (CASCADE).
Requires team ownership or admin/developer role.
"""
try:
pg_client = await get_postgresql_client()
# Get user ID
user_id = await self._get_user_id(pg_client)
# Check permission
if not await self.can_manage_team(team_id, user_id):
raise PermissionError(f"User {user_id} cannot delete team {team_id}")
# Delete team (CASCADE will delete team_memberships)
query = """
DELETE FROM teams
WHERE id = $1::uuid
AND tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
RETURNING id
"""
result = await pg_client.fetch_one(query, team_id, self.tenant_domain)
if not result:
logger.warning(f"Team {team_id} not found or already deleted")
return False
logger.info(f"Deleted team {team_id}")
return True
except Exception as e:
logger.error(f"Error deleting team {team_id}: {e}")
raise
async def add_member(
self,
team_id: str,
user_email: str,
team_permission: str = "read"
) -> Dict[str, Any]:
"""
Add a user to the team with specified permission.
Requires team ownership or admin/developer role.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
current_user_id = await self._get_user_id(pg_client)
# Check permission to manage team
if not await self.can_manage_team(team_id, current_user_id):
raise PermissionError(f"User {current_user_id} cannot manage team {team_id}")
# Get target user ID
target_user_id = await self._get_user_id(pg_client, user_email)
# Check if trying to invite the team owner
team_query = "SELECT owner_id FROM teams WHERE id = $1::uuid"
team_data = await pg_client.fetch_one(team_query, team_id)
if not team_data:
raise ValueError(f"Team {team_id} not found")
if str(team_data["owner_id"]) == str(target_user_id):
raise ValueError("Cannot invite the team owner - they are already a member")
# Validate team_permission
if team_permission not in ["read", "share", "manager"]:
raise ValueError(f"Invalid team_permission: {team_permission}. Must be 'read', 'share', or 'manager'")
# Insert team membership with pending status (invitation)
# Automatically request observability for all invitations
query = """
INSERT INTO team_memberships (
id, team_id, user_id, team_permission, resource_permissions,
status, invited_at, is_observable, observable_consent_status,
created_at, updated_at
) VALUES (
$1::uuid, $2::uuid, $3::uuid, $4, '{}'::jsonb,
'pending', NOW(), true, 'pending',
NOW(), NOW()
)
ON CONFLICT (team_id, user_id) DO UPDATE
SET team_permission = EXCLUDED.team_permission,
status = 'pending',
invited_at = NOW(),
is_observable = true,
observable_consent_status = 'pending',
updated_at = NOW()
RETURNING id, team_id, user_id, team_permission, resource_permissions,
status, invited_at, responded_at, is_observable,
observable_consent_status, created_at, updated_at
"""
member_id = str(uuid.uuid4())
member_data = await pg_client.fetch_one(
query,
member_id, team_id, target_user_id, team_permission
)
if not member_data:
raise RuntimeError(f"Failed to send invitation to {user_email} for team {team_id}")
# Get user details for response
user_query = """
SELECT email, full_name FROM users WHERE id = $1::uuid
"""
user_data = await pg_client.fetch_one(user_query, target_user_id)
logger.info(f"Sent invitation to {user_email} for team {team_id} with permission {team_permission}")
# Log events for invitation creation and observability request
if EVENT_LOGGING_AVAILABLE:
try:
# Get tenant_id for event logging
tenant_query = "SELECT id FROM tenants WHERE domain = $1 LIMIT 1"
tenant_data = await pg_client.fetch_one(tenant_query, self.tenant_domain)
tenant_id = str(tenant_data["id"]) if tenant_data else None
if tenant_id:
# Import dynamically to avoid circular dependency
from app.services.event_service import get_event_service
event_service = await get_event_service()
# Log team invitation created event
await event_service.emit_event(
event_type=EventType.TEAM_INVITATION_CREATED,
user_id=current_user_id,
tenant_id=tenant_id,
data={
"team_id": team_id,
"invited_user_id": target_user_id,
"invited_user_email": user_email,
"team_permission": team_permission,
"invitation_id": str(member_data["id"])
},
metadata={"inviter_id": current_user_id}
)
# Log observability request event
await event_service.emit_event(
event_type=EventType.TEAM_OBSERVABLE_REQUESTED,
user_id=target_user_id, # Target user receives the request
tenant_id=tenant_id,
data={
"team_id": team_id,
"requested_by_user_id": current_user_id,
"invitation_id": str(member_data["id"])
},
metadata={"requested_by": current_user_id}
)
logger.info(f"Logged team events for invitation {member_data['id']}")
except Exception as e:
# Don't fail the invitation if event logging fails
logger.warning(f"Failed to log team events: {e}")
# Parse JSONB resource_permissions to dict
resource_perms = member_data["resource_permissions"]
if isinstance(resource_perms, str):
resource_perms = json.loads(resource_perms)
elif resource_perms is None:
resource_perms = {}
return {
"id": str(member_data["id"]),
"team_id": str(member_data["team_id"]),
"user_id": str(member_data["user_id"]),
"user_email": user_data["email"] if user_data else user_email,
"user_name": user_data["full_name"] if user_data else "Unknown",
"team_permission": member_data["team_permission"],
"resource_permissions": resource_perms,
"is_observable": member_data.get("is_observable", False),
"observable_consent_status": member_data.get("observable_consent_status", "none"),
"status": member_data["status"],
"invited_at": member_data["invited_at"].isoformat() if member_data["invited_at"] else None,
"responded_at": member_data["responded_at"].isoformat() if member_data["responded_at"] else None,
"created_at": member_data["created_at"].isoformat() if member_data["created_at"] else None,
"updated_at": member_data["updated_at"].isoformat() if member_data["updated_at"] else None
}
except Exception as e:
logger.error(f"Error adding member to team {team_id}: {e}")
raise
async def update_member_permission(
self,
team_id: str,
user_id: str,
new_permission: str
) -> Dict[str, Any]:
"""
Update a team member's permission level.
Requires team ownership or admin/developer role.
Note: DB trigger will auto-clear resource_permissions if downgraded from 'share' to 'read'
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
current_user_id = await self._get_user_id(pg_client)
# Check permission to manage team
if not await self.can_manage_team(team_id, current_user_id):
raise PermissionError(f"User {current_user_id} cannot manage team {team_id}")
# Validate new_permission
if new_permission not in ["read", "share", "manager"]:
raise ValueError(f"Invalid permission: {new_permission}. Must be 'read', 'share', or 'manager'")
# Check if current user is team owner
is_owner = await self.is_team_owner(team_id, current_user_id)
# Get target member's current permission
member_query = """
SELECT team_permission FROM team_memberships
WHERE team_id = $1::uuid AND user_id = $2::uuid
"""
member_data = await pg_client.fetch_one(member_query, team_id, user_id)
if not member_data:
raise RuntimeError(f"Member {user_id} not found in team {team_id}")
current_permission = member_data["team_permission"]
# Manager restrictions:
# - Only owners can promote to/from manager
# - Managers cannot change their own permission
# - Managers cannot change other managers' permissions
if not is_owner:
# Prevent managers from promoting to manager
if new_permission == "manager":
raise PermissionError("Only team owners can promote members to manager")
# Prevent managers from demoting other managers
if current_permission == "manager":
raise PermissionError("Only team owners can demote managers")
# Prevent managers from changing their own permission
if str(user_id) == str(current_user_id):
raise PermissionError("Managers cannot change their own permission")
# Update team membership
# Note: DB trigger 'trigger_auto_unshare' will automatically clear resource_permissions
# if downgrading from 'share' to 'read'
query = """
UPDATE team_memberships
SET team_permission = $1, updated_at = NOW()
WHERE team_id = $2::uuid AND user_id = $3::uuid
RETURNING id, team_id, user_id, team_permission, resource_permissions,
status, invited_at, responded_at, is_observable,
observable_consent_status, observable_consent_at,
created_at, updated_at
"""
member_data = await pg_client.fetch_one(query, new_permission, team_id, user_id)
if not member_data:
raise RuntimeError(f"Member {user_id} not found in team {team_id}")
# Get user details
user_query = """
SELECT email, full_name FROM users WHERE id = $1::uuid
"""
user_data = await pg_client.fetch_one(user_query, user_id)
logger.info(f"Updated member {user_id} permission to {new_permission} in team {team_id}")
# Parse JSONB resource_permissions
resource_perms = member_data["resource_permissions"]
if isinstance(resource_perms, str):
resource_perms = json.loads(resource_perms)
elif resource_perms is None:
resource_perms = {}
return {
"id": str(member_data["id"]),
"team_id": str(member_data["team_id"]),
"user_id": str(member_data["user_id"]),
"user_email": user_data["email"] if user_data else "Unknown",
"user_name": user_data["full_name"] if user_data else "Unknown",
"team_permission": member_data["team_permission"],
"resource_permissions": resource_perms,
"is_owner": False, # Members being updated are never owners
"is_observable": member_data.get("is_observable", False),
"observable_consent_status": member_data.get("observable_consent_status", "none"),
"observable_consent_at": member_data["observable_consent_at"].isoformat() if member_data.get("observable_consent_at") else None,
"status": member_data.get("status", "accepted"),
"invited_at": member_data["invited_at"].isoformat() if member_data.get("invited_at") else None,
"responded_at": member_data["responded_at"].isoformat() if member_data.get("responded_at") else None,
"joined_at": member_data["responded_at"].isoformat() if (member_data.get("status") == "accepted" and member_data.get("responded_at")) else None,
"created_at": member_data["created_at"].isoformat() if member_data.get("created_at") else None,
"updated_at": member_data["updated_at"].isoformat() if member_data.get("updated_at") else None
}
except Exception as e:
logger.error(f"Error updating member permission in team {team_id}: {e}")
raise
async def remove_member(self, team_id: str, user_id: str) -> bool:
"""
Remove a user from the team.
Allows self-removal (users can leave teams they're members of).
Team owners cannot remove themselves - they must delete the team or transfer ownership.
Admins can remove any member.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
current_user_id = await self._get_user_id(pg_client)
# Get target user ID (normalize to UUID format for accurate comparison)
target_user_id = await self._get_user_id(pg_client, user_id)
# Check if user is removing themselves (self-removal / leaving team)
is_self_removal = str(current_user_id) == str(target_user_id)
if is_self_removal:
# Check if user is the team owner
team_query = """
SELECT owner_id FROM teams WHERE id = $1::uuid
"""
team_data = await pg_client.fetch_one(team_query, team_id)
if team_data and str(team_data['owner_id']) == str(current_user_id):
raise PermissionError("Team owners cannot leave their own team. Delete the team or transfer ownership first.")
# Allow self-removal for non-owners
logger.info(f"User {current_user_id} is leaving team {team_id}")
else:
# Removing another user - check permission to manage team
if not await self.can_manage_team(team_id, current_user_id):
raise PermissionError(f"User {current_user_id} cannot manage team {team_id}")
# Check if current user is team owner
is_owner = await self.is_team_owner(team_id, current_user_id)
# Get target member's current permission
member_query = """
SELECT team_permission FROM team_memberships
WHERE team_id = $1::uuid AND user_id = $2::uuid
"""
member_data = await pg_client.fetch_one(member_query, team_id, target_user_id)
# Manager restrictions: Only owners can remove other managers
if not is_owner and member_data and member_data["team_permission"] == "manager":
raise PermissionError("Only team owners can remove managers")
# Delete team membership
query = """
DELETE FROM team_memberships
WHERE team_id = $1::uuid AND user_id = $2::uuid
RETURNING id
"""
result = await pg_client.fetch_one(query, team_id, target_user_id)
if not result:
logger.warning(f"Member {target_user_id} not found in team {team_id}")
return False
logger.info(f"Removed member {target_user_id} from team {team_id}")
return True
except Exception as e:
logger.error(f"Error removing member from team {team_id}: {e}")
raise
# ==============================================================================
# INVITATION MANAGEMENT METHODS
# ==============================================================================
async def get_pending_invitations(self) -> List[Dict[str, Any]]:
"""
Get current user's pending team invitations.
Returns invitations with team and owner details.
IMPORTANT: This method returns ONLY invitations for the specific user,
with NO admin bypass. Invitations are personal and should not be visible
to admins or other users.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID - NO admin bypass, use the actual logged-in user
user_id = await self._get_user_id(pg_client)
logger.info(f"Fetching pending invitations for user_id={user_id}, user_email={self.user_email}")
# Query pending invitations - filtered strictly by user_id
query = """
SELECT
tm.id, tm.team_id, tm.team_permission, tm.invited_at,
t.name as team_name, t.description as team_description,
u.full_name as owner_name, u.email as owner_email
FROM team_memberships tm
JOIN teams t ON tm.team_id = t.id
JOIN users u ON t.owner_id = u.id
WHERE tm.user_id = $1::uuid
AND tm.status = 'pending'
ORDER BY tm.invited_at DESC
"""
invitations_data = await pg_client.execute_query(query, user_id)
invitations = []
for inv in invitations_data:
invitations.append({
"id": str(inv["id"]),
"team_id": str(inv["team_id"]),
"team_name": inv["team_name"],
"team_description": inv.get("team_description"),
"owner_name": inv["owner_name"],
"owner_email": inv["owner_email"],
"team_permission": inv["team_permission"],
"invited_at": inv["invited_at"].isoformat() if inv["invited_at"] else None
})
logger.info(f"Retrieved {len(invitations)} pending invitations for user {user_id} (email: {self.user_email})")
return invitations
except Exception as e:
logger.error(f"Error getting pending invitations: {e}")
return []
async def accept_invitation(self, invitation_id: str) -> Dict[str, Any]:
"""
Accept a team invitation.
Updates status to 'accepted' and sets responded_at timestamp.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# DIAGNOSTIC: Check invitation state before attempting update
check_query = """
SELECT id, user_id, status, team_id
FROM team_memberships
WHERE id = $1::uuid
"""
existing = await pg_client.fetch_one(check_query, invitation_id)
logger.info(f"🔍 Accept invitation attempt: invitation_id={invitation_id}, current_user_id={user_id}")
if not existing:
logger.error(f"❌ Invitation {invitation_id} does not exist in database")
raise ValueError(f"Invitation {invitation_id} not found")
logger.info(f"📋 Existing invitation: user_id={existing['user_id']}, status={existing['status']}, team_id={existing['team_id']}")
if str(existing['user_id']) != str(user_id):
logger.error(f"❌ User mismatch: invitation is for {existing['user_id']}, but current user is {user_id}")
raise ValueError(f"Invitation {invitation_id} belongs to a different user")
if existing['status'] != 'pending':
logger.error(f"❌ Status mismatch: invitation status is '{existing['status']}', expected 'pending'")
raise ValueError(f"Invitation {invitation_id} has already been {existing['status']}")
# Update invitation status
query = """
UPDATE team_memberships
SET status = 'accepted', responded_at = NOW(), updated_at = NOW()
WHERE id = $1::uuid
AND user_id = $2::uuid
AND status = 'pending'
RETURNING id, team_id, user_id, team_permission, resource_permissions,
status, invited_at, responded_at, created_at, updated_at,
is_observable, observable_consent_status, observable_consent_at
"""
membership_data = await pg_client.fetch_one(query, invitation_id, user_id)
if not membership_data:
logger.error(f"❌ UPDATE returned no rows despite checks passing - race condition?")
raise ValueError(f"Invitation {invitation_id} could not be accepted - please try again")
# Get user details for response
user_query = """
SELECT email, full_name FROM users WHERE id = $1::uuid
"""
user_data = await pg_client.fetch_one(user_query, user_id)
logger.info(f"User {user_id} accepted invitation {invitation_id}")
# Auto-grant read access to all resources already shared to this team
team_id = membership_data["team_id"]
grant_query = """
SELECT resource_type, resource_id
FROM team_resource_shares
WHERE team_id = $1::uuid
"""
shared_resources = await pg_client.execute_query(grant_query, team_id)
if shared_resources and len(shared_resources) > 0:
logger.info(f"Auto-granting read access to {len(shared_resources)} pre-shared resources for user {user_id}")
# Build resource_permissions dict with read access for all pre-shared resources
auto_permissions = {}
for resource in shared_resources:
resource_key = f"{resource['resource_type']}:{resource['resource_id']}"
auto_permissions[resource_key] = 'read'
# Update the user's resource_permissions JSONB field
update_perms_query = """
UPDATE team_memberships
SET resource_permissions = $1::jsonb, updated_at = NOW()
WHERE id = $2::uuid
"""
import json
await pg_client.execute_query(update_perms_query, json.dumps(auto_permissions), invitation_id)
logger.info(f"✅ Granted read access to {len(auto_permissions)} resources for new team member")
# Parse JSONB resource_permissions to dict (same pattern as add_member)
# Re-fetch to get the updated resource_permissions
refetch_query = """
SELECT resource_permissions FROM team_memberships WHERE id = $1::uuid
"""
refetch_data = await pg_client.fetch_one(refetch_query, invitation_id)
resource_perms = refetch_data["resource_permissions"] if refetch_data else membership_data["resource_permissions"]
if isinstance(resource_perms, str):
import json
resource_perms = json.loads(resource_perms)
elif resource_perms is None:
resource_perms = {}
return {
"id": str(membership_data["id"]),
"team_id": str(membership_data["team_id"]),
"user_id": str(membership_data["user_id"]),
"user_email": user_data["email"] if user_data else "Unknown",
"user_name": user_data["full_name"] if user_data else "Unknown",
"team_permission": membership_data["team_permission"],
"resource_permissions": resource_perms,
"status": membership_data["status"],
"invited_at": membership_data["invited_at"].isoformat() if membership_data["invited_at"] else None,
"responded_at": membership_data["responded_at"].isoformat() if membership_data["responded_at"] else None,
"created_at": membership_data["created_at"].isoformat() if membership_data["created_at"] else None,
"updated_at": membership_data["updated_at"].isoformat() if membership_data["updated_at"] else None,
# Observable fields (required by TeamMember schema)
"is_observable": membership_data.get("is_observable", False),
"observable_consent_status": membership_data.get("observable_consent_status", "none"),
"observable_consent_at": membership_data["observable_consent_at"].isoformat() if membership_data.get("observable_consent_at") else None
}
except Exception as e:
logger.error(f"Error accepting invitation {invitation_id}: {e}")
raise
async def decline_invitation(self, invitation_id: str) -> None:
"""
Decline a team invitation.
Deletes the invitation record.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Delete invitation
query = """
DELETE FROM team_memberships
WHERE id = $1::uuid
AND user_id = $2::uuid
AND status = 'pending'
RETURNING id
"""
result = await pg_client.fetch_one(query, invitation_id, user_id)
if not result:
raise ValueError(f"Invitation {invitation_id} not found or already processed")
logger.info(f"User {user_id} declined invitation {invitation_id}")
except Exception as e:
logger.error(f"Error declining invitation {invitation_id}: {e}")
raise
async def cancel_invitation(self, team_id: str, invitation_id: str) -> None:
"""
Cancel a pending invitation (team owner only).
Deletes the invitation record.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
current_user_id = await self._get_user_id(pg_client)
# Check permission to manage team
if not await self.can_manage_team(team_id, current_user_id):
raise PermissionError(f"User {current_user_id} cannot manage team {team_id}")
# Delete invitation
query = """
DELETE FROM team_memberships
WHERE id = $1::uuid
AND team_id = $2::uuid
AND status = 'pending'
RETURNING id
"""
result = await pg_client.fetch_one(query, invitation_id, team_id)
if not result:
raise ValueError(f"Invitation {invitation_id} not found or already processed")
logger.info(f"Team owner {current_user_id} canceled invitation {invitation_id}")
except Exception as e:
logger.error(f"Error canceling invitation {invitation_id}: {e}")
raise
async def get_team_pending_invitations(self, team_id: str) -> List[Dict[str, Any]]:
"""
Get pending invitations for a team (owner view).
Shows invited users who haven't accepted yet.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
current_user_id = await self._get_user_id(pg_client)
# Check permission to manage team
if not await self.can_manage_team(team_id, current_user_id):
raise PermissionError(f"User {current_user_id} cannot view team {team_id} invitations")
# Query pending invitations
query = """
SELECT
tm.id, tm.team_id, tm.user_id, tm.team_permission, tm.invited_at,
u.email, u.full_name
FROM team_memberships tm
JOIN users u ON tm.user_id = u.id
WHERE tm.team_id = $1::uuid
AND tm.status = 'pending'
ORDER BY tm.invited_at DESC
"""
invitations_data = await pg_client.execute_query(query, team_id)
invitations = []
for inv in invitations_data:
invitations.append({
"id": str(inv["id"]),
"team_id": str(inv["team_id"]),
"user_id": str(inv["user_id"]),
"user_email": inv["email"],
"user_name": inv["full_name"],
"team_permission": inv["team_permission"],
"invited_at": inv["invited_at"].isoformat() if inv["invited_at"] else None
})
logger.info(f"Retrieved {len(invitations)} pending invitations for team {team_id}")
return invitations
except Exception as e:
logger.error(f"Error getting team pending invitations: {e}")
return []
async def get_team_members(self, team_id: str) -> List[Dict[str, Any]]:
"""
Get all members of a team with their permissions.
Only accessible to team members or admins.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if user can view team (member or admin)
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
if not is_admin:
# Check if user is owner or member
check_query = """
SELECT 1 FROM teams t
LEFT JOIN team_memberships tm ON t.id = tm.team_id
WHERE t.id = $1::uuid
AND (t.owner_id = $2::uuid OR tm.user_id = $2::uuid)
LIMIT 1
"""
has_access = await pg_client.fetch_scalar(check_query, team_id, user_id)
if not has_access:
raise PermissionError(f"User {user_id} cannot view team {team_id}")
# Query team members (including pending and accepted)
query = """
SELECT
tm.id, tm.team_id, tm.user_id, tm.team_permission, tm.resource_permissions,
tm.status, tm.invited_at, tm.responded_at, tm.created_at, tm.updated_at,
tm.is_observable, tm.observable_consent_status, tm.observable_consent_at,
u.email, u.full_name,
t.owner_id
FROM team_memberships tm
LEFT JOIN users u ON tm.user_id = u.id
LEFT JOIN teams t ON tm.team_id = t.id
WHERE tm.team_id = $1::uuid
ORDER BY tm.created_at ASC
"""
members_data = await pg_client.execute_query(query, team_id)
# Get team owner information to include them in the members list
owner_query = """
SELECT
t.id as team_id, t.owner_id, t.created_at,
u.email, u.full_name
FROM teams t
JOIN users u ON t.owner_id = u.id
WHERE t.id = $1::uuid
"""
owner_data = await pg_client.fetch_one(owner_query, team_id)
# Format members
members = []
# Add owner as first member
if owner_data:
owner_member = {
"id": str(owner_data["owner_id"]), # Use owner_id as id for consistency
"team_id": str(owner_data["team_id"]),
"user_id": str(owner_data["owner_id"]),
"user_email": owner_data.get("email", "Unknown"),
"user_name": owner_data.get("full_name", "Unknown"),
"team_permission": "share", # Owners have full share permissions
"resource_permissions": {},
"is_owner": True,
"is_observable": False, # Owners don't have observable status
"observable_consent_status": "none",
"observable_consent_at": None,
"status": "accepted",
"invited_at": None,
"responded_at": None,
"joined_at": owner_data["created_at"].isoformat() if owner_data.get("created_at") else None,
"created_at": owner_data["created_at"].isoformat() if owner_data.get("created_at") else None,
"updated_at": owner_data["created_at"].isoformat() if owner_data.get("created_at") else None
}
logger.info(f"Adding owner as member: is_owner={owner_member['is_owner']}, user_id={owner_member['user_id']}")
members.append(owner_member)
# Add regular members (skip owner - already added above)
for member in members_data:
# Skip if this member is the owner (already added)
if str(member["user_id"]) == str(owner_data["owner_id"]):
continue
# Parse JSONB resource_permissions
resource_perms = member["resource_permissions"]
if isinstance(resource_perms, str):
resource_perms = json.loads(resource_perms)
elif resource_perms is None:
resource_perms = {}
# Determine if this user is the team owner
is_owner = str(member["user_id"]) == str(member.get("owner_id"))
# Use responded_at for joined_at if accepted, otherwise created_at
joined_at = None
if member.get("status") == "accepted":
joined_at = member.get("responded_at") or member.get("created_at")
members.append({
"id": str(member["id"]),
"team_id": str(member["team_id"]),
"user_id": str(member["user_id"]),
"user_email": member.get("email", "Unknown"),
"user_name": member.get("full_name", "Unknown"),
"team_permission": member["team_permission"],
"resource_permissions": resource_perms,
"is_owner": is_owner,
"is_observable": member.get("is_observable", False),
"observable_consent_status": member.get("observable_consent_status", "none"),
"observable_consent_at": member["observable_consent_at"].isoformat() if member.get("observable_consent_at") else None,
"status": member.get("status", "accepted"),
"invited_at": member["invited_at"].isoformat() if member.get("invited_at") else None,
"responded_at": member["responded_at"].isoformat() if member.get("responded_at") else None,
"joined_at": joined_at.isoformat() if joined_at else None,
"created_at": member["created_at"].isoformat() if member["created_at"] else None,
"updated_at": member["updated_at"].isoformat() if member["updated_at"] else None
})
logger.info(f"Retrieved {len(members)} members for team {team_id}")
return members
except Exception as e:
logger.error(f"Error getting team members for {team_id}: {e}")
return []
async def share_resource(
self,
team_id: str,
resource_type: str,
resource_id: str,
user_permissions: Dict[str, str]
) -> bool:
"""
Share a resource (agent/dataset) to team with per-user permissions.
Requires team ownership or 'share' permission.
Args:
team_id: Team UUID
resource_type: 'agent' or 'dataset'
resource_id: Resource UUID
user_permissions: Dict mapping user_id -> permission ('read' or 'edit')
e.g., {"user_uuid_1": "read", "user_uuid_2": "edit"}
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if user can share to team
if not await self.can_share_to_team(team_id, user_id):
raise PermissionError(f"User {user_id} cannot share resources to team {team_id}")
# Validate resource_type
if resource_type not in ["agent", "dataset"]:
raise ValueError(f"Invalid resource_type: {resource_type}. Must be 'agent' or 'dataset'")
# Validate all permissions are 'read' or 'edit'
for perm in user_permissions.values():
if perm not in ["read", "edit"]:
raise ValueError(f"Invalid permission: {perm}. Must be 'read' or 'edit'")
# Update resource_permissions JSONB for each user
resource_key = f"{resource_type}:{resource_id}"
for member_user_id, permission in user_permissions.items():
query = """
UPDATE team_memberships
SET resource_permissions = jsonb_set(
COALESCE(resource_permissions, '{}'::jsonb),
$1::text[],
$2::jsonb,
true
),
updated_at = NOW()
WHERE team_id = $3::uuid AND user_id = $4::uuid
RETURNING id
"""
# jsonb_set path as array
path = [resource_key]
result = await pg_client.fetch_one(
query,
path,
json.dumps(permission), # Convert to JSON string
team_id,
member_user_id
)
if not result:
logger.warning(f"Member {member_user_id} not found in team {team_id}, skipping")
logger.info(f"Shared {resource_type}:{resource_id} to team {team_id} with {len(user_permissions)} user permissions")
return True
except Exception as e:
logger.error(f"Error sharing resource to team {team_id}: {e}")
raise
async def unshare_resource(
self,
team_id: str,
resource_type: str,
resource_id: str
) -> bool:
"""
Remove resource sharing from team (removes from all members' resource_permissions).
Requires team ownership or 'share' permission.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if user can share to team
if not await self.can_share_to_team(team_id, user_id):
raise PermissionError(f"User {user_id} cannot unshare resources from team {team_id}")
# Validate resource_type
if resource_type not in ["agent", "dataset"]:
raise ValueError(f"Invalid resource_type: {resource_type}. Must be 'agent' or 'dataset'")
# Remove resource key from all members' resource_permissions JSONB
resource_key = f"{resource_type}:{resource_id}"
query = """
UPDATE team_memberships
SET resource_permissions = resource_permissions - $1::text,
updated_at = NOW()
WHERE team_id = $2::uuid
RETURNING id
"""
await pg_client.execute_query(query, resource_key, team_id)
logger.info(f"Unshared {resource_type}:{resource_id} from team {team_id}")
return True
except Exception as e:
logger.error(f"Error unsharing resource from team {team_id}: {e}")
raise
async def get_shared_resources(
self,
team_id: str,
resource_type: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Get all resources shared to a team.
Returns list of {resource_type, resource_id, user_permissions} dicts.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if user can view team
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
if not is_admin:
check_query = """
SELECT 1 FROM teams t
LEFT JOIN team_memberships tm ON t.id = tm.team_id
WHERE t.id = $1::uuid
AND (t.owner_id = $2::uuid OR tm.user_id = $2::uuid)
LIMIT 1
"""
has_access = await pg_client.fetch_scalar(check_query, team_id, user_id)
if not has_access:
raise PermissionError(f"User {user_id} cannot view team {team_id}")
# Query all resource_permissions from team members
query = """
SELECT user_id, resource_permissions
FROM team_memberships
WHERE team_id = $1::uuid
"""
members_data = await pg_client.execute_query(query, team_id)
# Aggregate all shared resources
shared_resources = {} # {resource_key: {user_id: permission}}
for member in members_data:
resource_perms = member["resource_permissions"]
if isinstance(resource_perms, str):
resource_perms = json.loads(resource_perms)
elif resource_perms is None:
resource_perms = {}
for resource_key, permission in resource_perms.items():
# Parse resource_key like "agent:uuid" or "dataset:uuid"
if ":" not in resource_key:
continue
res_type, res_id = resource_key.split(":", 1)
# Filter by resource_type if specified
if resource_type and res_type != resource_type:
continue
if resource_key not in shared_resources:
shared_resources[resource_key] = {
"resource_type": res_type,
"resource_id": res_id,
"user_permissions": {}
}
shared_resources[resource_key]["user_permissions"][str(member["user_id"])] = permission
result = list(shared_resources.values())
# Fetch resource names and owners for agents and datasets
agent_ids = [r["resource_id"] for r in result if r["resource_type"] == "agent"]
dataset_ids = [r["resource_id"] for r in result if r["resource_type"] == "dataset"]
# Map resource IDs to names and owners
resource_names = {}
resource_owners = {}
# Fetch agent names and owners
if agent_ids:
agent_query = """
SELECT a.id, a.name, u.full_name as owner_name, u.email as owner_email
FROM agents a
LEFT JOIN users u ON a.created_by = u.id
WHERE a.id = ANY($1::uuid[])
"""
agent_rows = await pg_client.execute_query(agent_query, agent_ids)
for row in agent_rows:
resource_key = f"agent:{row['id']}"
resource_names[resource_key] = row['name']
resource_owners[resource_key] = row['owner_name'] or row['owner_email']
# Fetch dataset names and owners
if dataset_ids:
dataset_query = """
SELECT d.id, d.name, u.full_name as owner_name, u.email as owner_email
FROM datasets d
LEFT JOIN users u ON d.created_by = u.id
WHERE d.id = ANY($1::uuid[])
"""
dataset_rows = await pg_client.execute_query(dataset_query, dataset_ids)
for row in dataset_rows:
resource_key = f"dataset:{row['id']}"
resource_names[resource_key] = row['name']
resource_owners[resource_key] = row['owner_name'] or row['owner_email']
# Add names and owners to result
for resource in result:
resource_key = f"{resource['resource_type']}:{resource['resource_id']}"
resource['resource_name'] = resource_names.get(resource_key, 'Unknown')
resource['resource_owner'] = resource_owners.get(resource_key, 'Unknown')
logger.info(f"Retrieved {len(result)} shared resources for team {team_id}")
return result
except Exception as e:
logger.error(f"Error getting shared resources for team {team_id}: {e}")
return []
# =========================================================================
# RESOURCE ACCESS METHODS (Phase 2: Junction Table Integration)
# =========================================================================
async def get_resource_teams(
self,
resource_type: str,
resource_id: str
) -> List[Dict[str, Any]]:
"""
Get all teams this resource is shared with.
Args:
resource_type: 'agent' or 'dataset'
resource_id: UUID of the resource
Returns:
List of teams with sharing metadata
"""
try:
pg_client = await get_postgresql_client()
query = """
SELECT
t.id,
t.name,
t.description,
t.owner_id,
trs.shared_by,
trs.created_at,
COUNT(DISTINCT tm.user_id) as member_count
FROM team_resource_shares trs
JOIN teams t ON t.id = trs.team_id
LEFT JOIN team_memberships tm ON tm.team_id = trs.team_id
WHERE trs.resource_type = $1
AND trs.resource_id = $2::uuid
AND t.tenant_id = (SELECT id FROM tenants WHERE domain = $3 LIMIT 1)
GROUP BY t.id, t.name, t.description, t.owner_id, trs.shared_by, trs.created_at
ORDER BY trs.created_at DESC
"""
teams = await pg_client.execute_query(query, resource_type, resource_id, self.tenant_domain)
logger.info(f"Found {len(teams)} teams for {resource_type}:{resource_id}")
return teams
except Exception as e:
logger.error(f"Error getting teams for resource {resource_type}:{resource_id}: {e}")
return []
async def get_resource_teams_batch(
self,
resource_type: str,
resource_ids: List[str]
) -> Dict[str, List[Dict[str, Any]]]:
"""
Get all teams for multiple resources in a single query.
Fixes N+1 query pattern in agents.py and datasets.py list endpoints.
Args:
resource_type: 'agent' or 'dataset'
resource_ids: List of resource UUIDs
Returns:
Dict mapping resource_id -> list of teams with sharing metadata
"""
if not resource_ids:
return {}
try:
pg_client = await get_postgresql_client()
# Create placeholder string for IN clause: $3, $4, $5, etc.
# $1 = resource_type, $2 = tenant_domain, $3+ = resource_ids
placeholders = ', '.join(f'${i+3}::uuid' for i in range(len(resource_ids)))
query = f"""
SELECT
trs.resource_id::text,
t.id,
t.name,
t.description,
t.owner_id,
trs.shared_by,
trs.created_at,
COUNT(DISTINCT tm.user_id) as member_count
FROM team_resource_shares trs
JOIN teams t ON t.id = trs.team_id
LEFT JOIN team_memberships tm ON tm.team_id = trs.team_id
WHERE trs.resource_type = $1
AND trs.resource_id IN ({placeholders})
AND t.tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
GROUP BY trs.resource_id, t.id, t.name, t.description, t.owner_id, trs.shared_by, trs.created_at
ORDER BY trs.created_at DESC
"""
# Build params: resource_type, tenant_domain, then all resource_ids
params = [resource_type, self.tenant_domain] + list(resource_ids)
rows = await pg_client.execute_query(query, *params)
# Group results by resource_id
result: Dict[str, List[Dict[str, Any]]] = {rid: [] for rid in resource_ids}
for row in rows:
resource_id = row.get('resource_id')
if resource_id in result:
# Build team dict without resource_id
team_data = {
'id': row.get('id'),
'name': row.get('name'),
'description': row.get('description'),
'owner_id': row.get('owner_id'),
'shared_by': row.get('shared_by'),
'created_at': row.get('created_at'),
'member_count': row.get('member_count')
}
result[resource_id].append(team_data)
logger.info(f"Batch fetched teams for {len(resource_ids)} {resource_type}s")
return result
except Exception as e:
logger.error(f"Error batch getting teams for {resource_type}s: {e}")
# Return empty lists for all requested IDs on error
return {rid: [] for rid in resource_ids}
async def get_user_accessible_resources(
self,
user_id: str,
resource_type: str
) -> List[Dict[str, Any]]:
"""
Get all resources accessible to user via team memberships.
Uses the user_accessible_resources view for efficiency.
Args:
user_id: UUID of the user
resource_type: 'agent' or 'dataset'
Returns:
List of accessible resources with permission metadata
"""
try:
pg_client = await get_postgresql_client()
# Check if admin/developer (can access all)
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
if is_admin:
logger.info(f"User {user_id} is admin/developer, has access to all {resource_type}s")
# Return empty list - admin check happens at agent/dataset service level
return []
query = """
SELECT
resource_id,
resource_type,
best_permission,
shared_in_teams,
team_ids,
first_shared_at
FROM user_accessible_resources
WHERE user_id = $1::uuid
AND resource_type = $2
"""
resources = await pg_client.execute_query(query, user_id, resource_type)
logger.info(f"User {user_id} has access to {len(resources)} {resource_type}s via teams")
return resources
except Exception as e:
logger.error(f"Error getting accessible {resource_type}s for user {user_id}: {e}")
return []
async def check_user_resource_permission(
self,
user_id: str,
resource_type: str,
resource_id: str,
required_permission: str = 'read'
) -> bool:
"""
Check if user has required permission on resource via team membership.
Uses the user_resource_access view for fast lookup.
Args:
user_id: UUID of the user
resource_type: 'agent' or 'dataset'
resource_id: UUID of the resource
required_permission: 'read' or 'edit'
Returns:
True if user has required permission
"""
try:
pg_client = await get_postgresql_client()
# Check if admin/developer (can access all)
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
if user_role in ["admin", "developer"]:
logger.info(f"User {user_id} is admin/developer, has full access")
return True
query = """
SELECT permission::text as permission
FROM user_resource_access
WHERE user_id = $1::uuid
AND resource_type = $2
AND resource_id = $3::uuid
LIMIT 1
"""
result = await pg_client.fetch_scalar(query, user_id, resource_type, resource_id)
if not result:
logger.debug(f"User {user_id} has no access to {resource_type}:{resource_id}")
return False
# Remove quotes from JSONB string value
user_permission = result.strip('"')
# Check permission level
if required_permission == 'read':
has_permission = user_permission in ['read', 'edit']
elif required_permission == 'edit':
has_permission = user_permission == 'edit'
else:
has_permission = False
logger.debug(f"User {user_id} permission on {resource_type}:{resource_id}: {user_permission} (required: {required_permission}) = {has_permission}")
return has_permission
except Exception as e:
logger.error(f"Error checking permission for user {user_id} on {resource_type}:{resource_id}: {e}")
return False
async def share_resource_to_teams(
self,
resource_id: str,
resource_type: str,
shared_by: str,
team_shares: List[Dict[str, Any]]
) -> None:
"""
Share a resource to multiple teams with per-user permissions.
Args:
resource_id: UUID of the resource
resource_type: 'agent' or 'dataset'
shared_by: User ID (will be converted to UUID if needed)
team_shares: List of {team_id, user_permissions: {user_id: 'read'|'edit'}}
If user_permissions is empty, auto-populates all team members with 'read' access
Raises:
PermissionError: If user doesn't have 'share' permission on any team
ValueError: If team_shares is invalid
"""
try:
pg_client = await get_postgresql_client()
# Convert shared_by to UUID if it's not already
shared_by_uuid = await self._get_user_id(pg_client, shared_by)
for share in team_shares:
team_id = share.get('team_id')
user_permissions = share.get('user_permissions', {})
if not team_id:
raise ValueError("team_id is required in team_shares")
# Auto-populate read access for all team members if permissions are empty (first-time share)
if not user_permissions or len(user_permissions) == 0:
logger.info(f"Auto-populating read access for all members of team {team_id}")
# Fetch all team members (accepted only)
members_query = """
SELECT user_id FROM team_memberships
WHERE team_id = $1::uuid AND status = 'accepted'
"""
members = await pg_client.execute_query(members_query, team_id)
# Create user_permissions dict with read access for all members
user_permissions = {str(member['user_id']): 'read' for member in members}
share['user_permissions'] = user_permissions # Update the share object
# Allow sharing to teams with no members - permissions will be granted when members join
if len(user_permissions) == 0:
logger.info(f"Team {team_id} has no accepted members yet - resource will be accessible when members join")
else:
logger.info(f"Auto-populated {len(user_permissions)} team members with read access")
# Verify user has share permission on this team
can_share = await self.can_share_to_team(team_id, shared_by_uuid)
if not can_share:
raise PermissionError(f"User {shared_by_uuid} does not have share permission on team {team_id}")
# Insert into team_resource_shares
insert_share_query = """
INSERT INTO team_resource_shares (team_id, resource_type, resource_id, shared_by)
VALUES ($1::uuid, $2, $3::uuid, $4::uuid)
ON CONFLICT (team_id, resource_type, resource_id)
DO UPDATE SET shared_by = EXCLUDED.shared_by
RETURNING id
"""
await pg_client.execute_query(
insert_share_query,
team_id, resource_type, resource_id, shared_by_uuid
)
# Update resource_permissions for each user in team
for user_id, permission in user_permissions.items():
if permission not in ['read', 'edit']:
logger.warning(f"Invalid permission '{permission}' for user {user_id}, skipping")
continue
resource_key = f"{resource_type}:{resource_id}"
update_permission_query = """
UPDATE team_memberships
SET resource_permissions = COALESCE(resource_permissions, '{}'::jsonb) || jsonb_build_object($1::text, $2::text)
WHERE team_id = $3::uuid
AND user_id = $4::uuid
"""
await pg_client.execute_query(
update_permission_query,
resource_key, permission, team_id, user_id
)
logger.info(f"Shared {resource_type}:{resource_id} to team {team_id} with {len(user_permissions)} user permissions")
# Sync agent visibility field when sharing to teams
if resource_type == 'agent':
try:
await pg_client.execute_query(
"UPDATE agents SET visibility = 'team' WHERE id = $1::uuid AND visibility != 'team'",
resource_id
)
logger.info(f"Updated agent {resource_id} visibility to 'team'")
except Exception as vis_error:
logger.warning(f"Failed to update agent visibility: {vis_error}")
# Don't fail the sharing operation if visibility update fails
# Sync dataset visibility and access_group fields when sharing to teams
if resource_type == 'dataset':
try:
await pg_client.execute_query(
"UPDATE datasets SET visibility = 'team', access_group = 'team' WHERE id = $1::uuid AND (visibility != 'team' OR access_group != 'team')",
resource_id
)
logger.info(f"Updated dataset {resource_id} visibility and access_group to 'team'")
except Exception as vis_error:
logger.warning(f"Failed to update dataset visibility: {vis_error}")
# Don't fail the sharing operation if visibility update fails
except Exception as e:
logger.error(f"Error sharing {resource_type}:{resource_id} to teams: {e}")
raise
async def unshare_resource_from_team(
self,
resource_id: str,
resource_type: str,
team_id: str
) -> None:
"""
Remove resource from team (triggers cleanup of member permissions).
Requires team ownership or 'share' permission.
Args:
resource_id: UUID of the resource
resource_type: 'agent' or 'dataset'
team_id: UUID of the team
Note: The cleanup_resource_permissions trigger handles removing
permissions from team members automatically.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if user can share to team (which also means they can unshare)
if not await self.can_share_to_team(team_id, user_id):
raise PermissionError(f"User {user_id} cannot unshare resources from team {team_id}")
query = """
DELETE FROM team_resource_shares
WHERE team_id = $1::uuid
AND resource_type = $2
AND resource_id = $3::uuid
"""
await pg_client.execute_query(query, team_id, resource_type, resource_id)
logger.info(f"Unshared {resource_type}:{resource_id} from team {team_id}")
# Check if this was the last team share for this agent
if resource_type == 'agent':
try:
remaining_shares = await pg_client.fetch_scalar(
"SELECT COUNT(*) FROM team_resource_shares WHERE resource_type = $1 AND resource_id = $2::uuid",
resource_type, resource_id
)
if remaining_shares == 0:
# No more teams have access, reset visibility to individual
await pg_client.execute_query(
"UPDATE agents SET visibility = 'individual' WHERE id = $1::uuid AND visibility = 'team'",
resource_id
)
logger.info(f"Reset agent {resource_id} visibility to 'individual' (no remaining team shares)")
except Exception as vis_error:
logger.warning(f"Failed to reset agent visibility: {vis_error}")
# Don't fail the unsharing operation if visibility update fails
# Check if this was the last team share for this dataset
if resource_type == 'dataset':
try:
remaining_shares = await pg_client.fetch_scalar(
"SELECT COUNT(*) FROM team_resource_shares WHERE resource_type = $1 AND resource_id = $2::uuid",
resource_type, resource_id
)
if remaining_shares == 0:
# No more teams have access, reset visibility and access_group to individual
await pg_client.execute_query(
"UPDATE datasets SET visibility = 'individual', access_group = 'individual' WHERE id = $1::uuid AND (visibility = 'team' OR access_group = 'team')",
resource_id
)
logger.info(f"Reset dataset {resource_id} visibility and access_group to 'individual' (no remaining team shares)")
except Exception as vis_error:
logger.warning(f"Failed to reset dataset visibility: {vis_error}")
# Don't fail the unsharing operation if visibility update fails
except Exception as e:
logger.error(f"Error unsharing {resource_type}:{resource_id} from team {team_id}: {e}")
raise
async def get_team_shared_resource_ids(
self,
team_id: str,
resource_type: Optional[str] = None
) -> Dict[str, List[str]]:
"""
Get all resource IDs shared with a specific team.
Used for team-scoped observability filtering.
Args:
team_id: UUID of the team
resource_type: Optional filter for 'agent' or 'dataset'. If None, returns both.
Returns:
Dict with 'agents' and/or 'datasets' keys containing lists of UUIDs.
Example: {"agents": ["uuid1", "uuid2"], "datasets": ["uuid3"]}
Raises:
RuntimeError: If database query fails
"""
try:
pg_client = await get_postgresql_client()
result = {"agents": [], "datasets": []}
if resource_type is None:
# Fetch both types
query = """
SELECT resource_type, resource_id::text
FROM team_resource_shares
WHERE team_id = $1::uuid
ORDER BY resource_type, created_at DESC
"""
rows = await pg_client.fetch_rows(query, team_id)
for row in rows:
r_type = row['resource_type']
r_id = row['resource_id']
if r_type == 'agent':
result['agents'].append(str(r_id))
elif r_type == 'dataset':
result['datasets'].append(str(r_id))
else:
# Fetch specific type
query = """
SELECT resource_id::text
FROM team_resource_shares
WHERE team_id = $1::uuid
AND resource_type = $2
ORDER BY created_at DESC
"""
rows = await pg_client.fetch_rows(query, team_id, resource_type)
resource_ids = [str(row['resource_id']) for row in rows]
if resource_type == 'agent':
result['agents'] = resource_ids
elif resource_type == 'dataset':
result['datasets'] = resource_ids
logger.debug(f"Team {team_id} shared resources: {result}")
return result
except Exception as e:
logger.error(f"Error fetching team shared resources for team {team_id}: {e}")
raise RuntimeError(f"Failed to fetch team shared resources: {e}")
# ============================================================================
# Observable Member Management
# ============================================================================
async def can_view_observability(self, team_id: str, user_id: str) -> bool:
"""
Check if user can view Observable member activity.
Requires owner or 'manager' team_permission.
NOTE: Admin/developer do NOT get automatic Observable access (use platform observability instead).
"""
pg_client = await get_postgresql_client()
# Team owner can view Observable members
if await self.is_team_owner(team_id, user_id):
return True
# Check for 'manager' permission
query = """
SELECT team_permission FROM team_memberships
WHERE team_id = $1::uuid
AND user_id = $2::uuid
AND status = 'accepted'
"""
permission = await pg_client.fetch_scalar(query, team_id, user_id)
return permission == 'manager'
async def can_manage_members(self, team_id: str, manager_id: str, target_member_id: str) -> bool:
"""
Check if manager can manage a specific team member.
- Owner can manage all members
- Manager can manage non-owner members
- Admin/developer can manage all (system role bypass)
"""
pg_client = await get_postgresql_client()
# Admin/developer bypass
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
if user_role in ["admin", "developer"]:
return True
# Owner can manage all members
if await self.is_team_owner(team_id, manager_id):
return True
# Check if manager has 'manager' permission
manager_perm_query = """
SELECT team_permission FROM team_memberships
WHERE team_id = $1::uuid
AND user_id = $2::uuid
AND status = 'accepted'
"""
manager_permission = await pg_client.fetch_scalar(manager_perm_query, team_id, manager_id)
if manager_permission != 'manager':
return False
# Manager cannot modify the team owner
is_target_owner = await self.is_team_owner(team_id, target_member_id)
return not is_target_owner
async def request_observable_status(self, team_id: str, target_user_id: str) -> Dict[str, Any]:
"""
Request Observable access from a team member.
Can be called by owner or manager.
Sets observable_consent_status='pending', is_observable=true.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if user can view observability (owner or manager)
if not await self.can_view_observability(team_id, user_id):
raise PermissionError(f"User {user_id} does not have permission to request Observable access")
# Check if target user is a member
member_check_query = """
SELECT id, team_permission, is_observable, observable_consent_status
FROM team_memberships
WHERE team_id = $1::uuid
AND user_id = $2::uuid
AND status = 'accepted'
"""
member = await pg_client.fetch_one(member_check_query, team_id, target_user_id)
if not member:
raise ValueError(f"User {target_user_id} is not an accepted member of team {team_id}")
# Check if already Observable
if member["is_observable"] and member["observable_consent_status"] == 'approved':
raise ValueError(f"User {target_user_id} is already Observable")
# Update Observable status to pending
update_query = """
UPDATE team_memberships
SET is_observable = true,
observable_consent_status = 'pending',
updated_at = NOW()
WHERE team_id = $1::uuid
AND user_id = $2::uuid
RETURNING id, is_observable, observable_consent_status
"""
result = await pg_client.fetch_one(update_query, team_id, target_user_id)
if not result:
raise RuntimeError("Failed to request Observable status")
logger.info(f"Observable access requested for user {target_user_id} in team {team_id}")
return {
"team_id": team_id,
"user_id": target_user_id,
"is_observable": result["is_observable"],
"observable_consent_status": result["observable_consent_status"],
"message": "Observable access request sent"
}
except Exception as e:
logger.error(f"Error requesting Observable status: {e}")
raise
async def get_observable_requests(self) -> List[Dict[str, Any]]:
"""
Get pending Observable requests for the current user.
Returns teams where user has pending Observable consent.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
query = """
SELECT
t.id as team_id,
t.name as team_name,
u.full_name as requested_by_name,
u.email as requested_by_email,
tm.updated_at as requested_at
FROM team_memberships tm
JOIN teams t ON tm.team_id = t.id
JOIN users u ON t.owner_id = u.id
WHERE tm.user_id = $1::uuid
AND tm.is_observable = true
AND tm.observable_consent_status = 'pending'
AND t.tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
ORDER BY tm.updated_at DESC
"""
results = await pg_client.execute_query(query, user_id, self.tenant_domain)
requests = []
for row in results:
requests.append({
"team_id": str(row["team_id"]),
"team_name": row["team_name"],
"requested_by_name": row["requested_by_name"],
"requested_by_email": row["requested_by_email"],
"requested_at": row["requested_at"].isoformat() if row["requested_at"] else None
})
logger.info(f"Retrieved {len(requests)} Observable requests for user {user_id}")
return requests
except Exception as e:
logger.error(f"Error getting Observable requests: {e}")
return []
async def approve_observable_consent(self, team_id: str) -> Dict[str, Any]:
"""
Approve Observable status for the current user in a team.
Sets observable_consent_status='approved', observable_consent_at=NOW().
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if there's a pending request
check_query = """
SELECT id, is_observable, observable_consent_status
FROM team_memberships
WHERE team_id = $1::uuid
AND user_id = $2::uuid
AND is_observable = true
AND observable_consent_status = 'pending'
"""
membership = await pg_client.fetch_one(check_query, team_id, user_id)
if not membership:
raise ValueError(f"No pending Observable request found for user {user_id} in team {team_id}")
# Approve Observable status
update_query = """
UPDATE team_memberships
SET observable_consent_status = 'approved',
observable_consent_at = NOW(),
updated_at = NOW()
WHERE team_id = $1::uuid
AND user_id = $2::uuid
RETURNING id, is_observable, observable_consent_status, observable_consent_at
"""
result = await pg_client.fetch_one(update_query, team_id, user_id)
if not result:
raise RuntimeError("Failed to approve Observable status")
logger.info(f"User {user_id} approved Observable status in team {team_id}")
return {
"team_id": team_id,
"user_id": user_id,
"is_observable": result["is_observable"],
"observable_consent_status": result["observable_consent_status"],
"observable_consent_at": result["observable_consent_at"].isoformat() if result["observable_consent_at"] else None,
"message": "Observable status approved"
}
except Exception as e:
logger.error(f"Error approving Observable consent: {e}")
raise
async def revoke_observable_status(self, team_id: str) -> Dict[str, Any]:
"""
Revoke Observable status for the current user in a team.
Sets is_observable=false, observable_consent_status='revoked'.
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check if user is Observable
check_query = """
SELECT id, is_observable, observable_consent_status
FROM team_memberships
WHERE team_id = $1::uuid
AND user_id = $2::uuid
AND is_observable = true
"""
membership = await pg_client.fetch_one(check_query, team_id, user_id)
if not membership:
raise ValueError(f"User {user_id} is not Observable in team {team_id}")
# Revoke Observable status
update_query = """
UPDATE team_memberships
SET is_observable = false,
observable_consent_status = 'revoked',
updated_at = NOW()
WHERE team_id = $1::uuid
AND user_id = $2::uuid
RETURNING id, is_observable, observable_consent_status
"""
result = await pg_client.fetch_one(update_query, team_id, user_id)
if not result:
raise RuntimeError("Failed to revoke Observable status")
logger.info(f"User {user_id} revoked Observable status in team {team_id}")
return {
"team_id": team_id,
"user_id": user_id,
"is_observable": result["is_observable"],
"observable_consent_status": result["observable_consent_status"],
"message": "Observable status revoked"
}
except Exception as e:
logger.error(f"Error revoking Observable status: {e}")
raise
async def get_team_activity(self, team_id: str, days: int = 7) -> Dict[str, Any]:
"""
Get aggregated activity for Observable team members.
Requires can_view_observability permission.
Returns team metrics + per-member breakdowns (Observable only).
"""
try:
pg_client = await get_postgresql_client()
# Get current user ID
user_id = await self._get_user_id(pg_client)
# Check permission
if not await self.can_view_observability(team_id, user_id):
raise PermissionError(f"User {user_id} does not have permission to view team activity")
# Get team info
team = await self.get_team_by_id(team_id)
if not team:
raise ValueError(f"Team {team_id} not found")
# Get Observable members
observable_members_query = """
SELECT user_id
FROM team_memberships
WHERE team_id = $1::uuid
AND is_observable = true
AND observable_consent_status = 'approved'
AND status = 'accepted'
"""
observable_members = await pg_client.execute_query(observable_members_query, team_id)
observable_user_ids = [str(row["user_id"]) for row in observable_members]
if not observable_user_ids:
# No Observable members
return {
"team_id": team_id,
"team_name": team["name"],
"date_range_days": days,
"observable_member_count": 0,
"total_member_count": team.get("member_count", 0),
"team_totals": {
"conversations": 0,
"messages": 0,
"tokens": 0
},
"member_breakdown": [],
"time_series": []
}
# Get activity from v_user_activity_summary for Observable members
activity_query = """
SELECT
user_id::text,
email,
full_name,
total_conversations,
total_messages,
total_tokens,
last_conversation_at
FROM v_user_activity_summary
WHERE user_id::text = ANY($1::text[])
AND tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
ORDER BY total_conversations DESC
"""
activity_data = await pg_client.execute_query(activity_query, observable_user_ids, self.tenant_domain)
# Calculate team totals
team_totals = {
"conversations": 0,
"messages": 0,
"tokens": 0
}
member_breakdown = []
for row in activity_data:
team_totals["conversations"] += row["total_conversations"] or 0
team_totals["messages"] += row["total_messages"] or 0
team_totals["tokens"] += row["total_tokens"] or 0
member_breakdown.append({
"user_id": row["user_id"],
"email": row["email"],
"full_name": row["full_name"],
"conversations": row["total_conversations"] or 0,
"messages": row["total_messages"] or 0,
"tokens": row["total_tokens"] or 0,
"last_activity": row["last_conversation_at"].isoformat() if row["last_conversation_at"] else None
})
# Get time series data (daily for last N days)
time_series_query = """
SELECT
date,
SUM(conversation_count) as conversations,
SUM(total_messages) as messages,
SUM(total_tokens) as tokens
FROM v_daily_usage_stats
WHERE tenant_id = (SELECT id FROM tenants WHERE domain = $1 LIMIT 1)
AND date >= CURRENT_DATE - INTERVAL '%s days'
GROUP BY date
ORDER BY date ASC
""" % days
time_series_data = await pg_client.execute_query(time_series_query, self.tenant_domain)
time_series = []
for row in time_series_data:
time_series.append({
"date": row["date"].isoformat() if row["date"] else None,
"conversations": row["conversations"] or 0,
"messages": row["messages"] or 0,
"tokens": row["tokens"] or 0
})
logger.info(f"Retrieved team activity for {len(observable_user_ids)} Observable members in team {team_id}")
return {
"team_id": team_id,
"team_name": team["name"],
"date_range_days": days,
"observable_member_count": len(observable_user_ids),
"total_member_count": team.get("member_count", 0),
"team_totals": team_totals,
"member_breakdown": member_breakdown,
"time_series": time_series
}
except Exception as e:
logger.error(f"Error getting team activity: {e}")
raise