- Updated python_coding_microproject.csv to use NVIDIA NIM Kimi K2 - Updated kali_linux_shell_simulator.csv to use NVIDIA NIM Kimi K2 - Made more general-purpose (flexible targets, expanded tools) - Added nemotron-mini-agent.csv for fast local inference via Ollama - Added nemotron-agent.csv for advanced reasoning via Ollama - Added wiki page: Projects for NVIDIA NIMs and Nemotron
2362 lines
100 KiB
Python
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
|