Files
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

759 lines
24 KiB
Python

"""
Teams API endpoints for GT 2.0 Tenant Backend
Provides team collaboration management with two-tier permissions:
- Tier 1 (Team-level): 'read' or 'share' set by team owner
- Tier 2 (Resource-level): 'read' or 'edit' set by resource sharer
Follows GT 2.0 principles:
- Perfect tenant isolation
- Admin bypass for tenant admins
- Fail-fast error handling
- PostgreSQL-first storage
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any, List
import logging
from app.core.security import get_current_user
from app.services.team_service import TeamService
from app.api.auth import get_tenant_user_uuid_by_email
from app.models.collaboration_team import (
TeamCreate,
TeamUpdate,
Team,
TeamListResponse,
TeamResponse,
TeamWithMembers,
TeamWithMembersResponse,
AddMemberRequest,
UpdateMemberPermissionRequest,
MemberListResponse,
MemberResponse,
ShareResourceRequest,
SharedResourcesResponse,
SharedResource,
TeamInvitation,
InvitationListResponse,
ObservableRequest,
ObservableRequestListResponse,
TeamActivityMetrics,
TeamActivityResponse,
ErrorResponse
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/teams", tags=["teams"])
async def get_team_service_for_user(current_user: Dict[str, Any]) -> TeamService:
"""Helper function to create TeamService with proper tenant UUID mapping"""
user_email = current_user.get('email')
if not user_email:
raise HTTPException(status_code=401, detail="User email not found in token")
tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email)
if not tenant_user_uuid:
raise HTTPException(status_code=404, detail=f"User {user_email} not found in tenant system")
return TeamService(
tenant_domain=current_user.get('tenant_domain', 'test-company'),
user_id=tenant_user_uuid,
user_email=user_email
)
# ============================================================================
# TEAM CRUD ENDPOINTS
# ============================================================================
@router.get("", response_model=TeamListResponse)
async def list_teams(
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
List all teams where the current user is owner or member.
Returns teams with member counts and permission flags.
Permission flags:
- is_owner: User created this team
- can_manage: User can manage team (owner or admin/developer)
"""
logger.info(f"Listing teams for user {current_user['sub']}")
try:
service = await get_team_service_for_user(current_user)
teams = await service.get_user_teams()
return TeamListResponse(
data=teams,
total=len(teams)
)
except Exception as e:
logger.error(f"Error listing teams: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("", response_model=TeamResponse, status_code=201)
async def create_team(
team_data: TeamCreate,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Create a new team with the current user as owner.
The creator is automatically the team owner with full management permissions.
"""
logger.info(f"Creating team '{team_data.name}' for user {current_user['sub']}")
try:
service = await get_team_service_for_user(current_user)
team = await service.create_team(
name=team_data.name,
description=team_data.description or ""
)
return TeamResponse(data=team)
except Exception as e:
logger.error(f"Error creating team: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==============================================================================
# TEAM INVITATION ENDPOINTS (must come before /{team_id} routes)
# ==============================================================================
@router.get("/invitations", response_model=InvitationListResponse)
async def list_my_invitations(
current_user: Dict = Depends(get_current_user)
):
"""
Get current user's pending team invitations.
Returns list of invitations with team details and inviter information.
"""
try:
service = await get_team_service_for_user(current_user)
invitations = await service.get_pending_invitations()
return InvitationListResponse(
data=invitations,
total=len(invitations)
)
except Exception as e:
logger.error(f"Error listing invitations: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/invitations/{invitation_id}/accept", response_model=MemberResponse)
async def accept_team_invitation(
invitation_id: str,
current_user: Dict = Depends(get_current_user)
):
"""
Accept a team invitation.
Updates the invitation status to 'accepted' and grants team membership.
"""
try:
service = await get_team_service_for_user(current_user)
member = await service.accept_invitation(invitation_id)
return MemberResponse(data=member)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error accepting invitation: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/invitations/{invitation_id}/decline", status_code=204)
async def decline_team_invitation(
invitation_id: str,
current_user: Dict = Depends(get_current_user)
):
"""
Decline a team invitation.
Removes the invitation from the system.
"""
try:
service = await get_team_service_for_user(current_user)
await service.decline_invitation(invitation_id)
return None
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error declining invitation: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/observable-requests", response_model=ObservableRequestListResponse)
async def get_observable_requests(
current_user: Dict = Depends(get_current_user)
):
"""
Get pending Observable requests for the current user.
Returns list of teams requesting Observable access.
"""
try:
service = await get_team_service_for_user(current_user)
requests = await service.get_observable_requests()
return ObservableRequestListResponse(
data=[ObservableRequest(**req) for req in requests],
total=len(requests)
)
except Exception as e:
logger.error(f"Error getting Observable requests: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==============================================================================
# TEAM CRUD ENDPOINTS (dynamic routes with {team_id})
# ==============================================================================
@router.get("/{team_id}", response_model=TeamResponse)
async def get_team(
team_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Get team details by ID.
Only accessible to team members or tenant admins.
"""
logger.info(f"Getting team {team_id} for user {current_user['sub']}")
try:
service = await get_team_service_for_user(current_user)
team = await service.get_team_by_id(team_id)
if not team:
raise HTTPException(status_code=404, detail=f"Team {team_id} not found")
return TeamResponse(data=team)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting team: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{team_id}", response_model=TeamResponse)
async def update_team(
team_id: str,
updates: TeamUpdate,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Update team name/description.
Requires: Team ownership or admin/developer role
"""
logger.info(f"Updating team {team_id} for user {current_user['sub']}")
try:
service = await get_team_service_for_user(current_user)
# Convert Pydantic model to dict, excluding None values
update_dict = updates.model_dump(exclude_none=True)
team = await service.update_team(team_id, update_dict)
if not team:
raise HTTPException(status_code=404, detail=f"Team {team_id} not found")
return TeamResponse(data=team)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating team: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{team_id}", status_code=204)
async def delete_team(
team_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Delete a team and all its memberships (CASCADE).
Requires: Team ownership or admin/developer role
"""
logger.info(f"Deleting team {team_id} for user {current_user['sub']}")
try:
service = await get_team_service_for_user(current_user)
success = await service.delete_team(team_id)
if not success:
raise HTTPException(status_code=404, detail=f"Team {team_id} not found")
return None # 204 No Content
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting team: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# TEAM MEMBER ENDPOINTS
# ============================================================================
@router.get("/{team_id}/members", response_model=MemberListResponse)
async def list_team_members(
team_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
List all members of a team with their permissions.
Only accessible to team members or tenant admins.
Returns:
- user_id, user_email, user_name
- team_permission: 'read' or 'share'
- resource_permissions: JSONB dict of resource-level permissions
"""
logger.info(f"Listing members for team {team_id}")
try:
service = await get_team_service_for_user(current_user)
members = await service.get_team_members(team_id)
return MemberListResponse(
data=members,
total=len(members)
)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except Exception as e:
logger.error(f"Error listing team members: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{team_id}/members", response_model=MemberResponse, status_code=201)
async def add_team_member(
team_id: str,
member_data: AddMemberRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Add a user to the team with specified permission.
Requires: Team ownership or admin/developer role
Team Permissions:
- 'read': Can access resources shared to this team
- 'share': Can access resources AND share own resources to this team
Note: Observability access is automatically requested when inviting users.
The invited user can approve or decline the observability request separately.
"""
logger.info(f"Adding member {member_data.user_email} to team {team_id}")
try:
service = await get_team_service_for_user(current_user)
member = await service.add_member(
team_id=team_id,
user_email=member_data.user_email,
team_permission=member_data.team_permission
)
return MemberResponse(data=member)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error adding team member: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{team_id}/members/{user_id}", response_model=MemberResponse)
async def update_member_permission(
team_id: str,
user_id: str,
permission_data: UpdateMemberPermissionRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Update a team member's permission level.
Requires: Team ownership or admin/developer role
Note: DB trigger auto-clears resource_permissions when downgraded from 'share' to 'read'
"""
logger.info(f"PUT /teams/{team_id}/members/{user_id} - Permission update request")
logger.info(f"Request body: {permission_data.model_dump()}")
logger.info(f"Current user: {current_user.get('email')}")
try:
service = await get_team_service_for_user(current_user)
member = await service.update_member_permission(
team_id=team_id,
user_id=user_id,
new_permission=permission_data.team_permission
)
return MemberResponse(data=member)
except PermissionError as e:
logger.error(f"PermissionError updating member permission: {str(e)}")
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
logger.error(f"ValueError updating member permission: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
logger.error(f"RuntimeError updating member permission: {str(e)}")
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error updating member permission: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{team_id}/members/{user_id}", status_code=204)
async def remove_team_member(
team_id: str,
user_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Remove a user from the team.
Requires: Team ownership or admin/developer role
"""
logger.info(f"Removing member {user_id} from team {team_id}")
try:
service = await get_team_service_for_user(current_user)
success = await service.remove_member(team_id, user_id)
if not success:
raise HTTPException(status_code=404, detail=f"Member {user_id} not found in team {team_id}")
return None # 204 No Content
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error removing team member: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# RESOURCE SHARING ENDPOINTS
# ============================================================================
@router.post("/{team_id}/share", status_code=201)
async def share_resource_to_team(
team_id: str,
share_data: ShareResourceRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Share a resource (agent/dataset) to team with per-user permissions.
Requires: Team ownership or 'share' team permission
Request body:
{
"resource_type": "agent" | "dataset",
"resource_id": "uuid",
"user_permissions": {
"user_uuid_1": "read",
"user_uuid_2": "edit"
}
}
Resource Permissions:
- 'read': View-only access to resource
- 'edit': Full edit access to resource
"""
logger.info(f"Sharing {share_data.resource_type}:{share_data.resource_id} to team {team_id}")
try:
service = await get_team_service_for_user(current_user)
# Use new junction table method (Phase 2)
await service.share_resource_to_teams(
resource_id=share_data.resource_id,
resource_type=share_data.resource_type,
shared_by=current_user["user_id"],
team_shares=[{
"team_id": team_id,
"user_permissions": share_data.user_permissions
}]
)
return {"message": "Resource shared successfully", "success": True}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error sharing resource: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{team_id}/share/{resource_type}/{resource_id}", status_code=204)
async def unshare_resource_from_team(
team_id: str,
resource_type: str,
resource_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Remove resource sharing from team (removes from all members' resource_permissions).
Requires: Team ownership or 'share' team permission
"""
logger.info(f"Unsharing {resource_type}:{resource_id} from team {team_id}")
try:
service = await get_team_service_for_user(current_user)
# Use new junction table method (Phase 2)
await service.unshare_resource_from_team(
resource_id=resource_id,
resource_type=resource_type,
team_id=team_id
)
return None # 204 No Content
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error unsharing resource: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{team_id}/resources", response_model=SharedResourcesResponse)
async def list_shared_resources(
team_id: str,
resource_type: str = Query(None, description="Filter by resource type: 'agent' or 'dataset'"),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
List all resources shared to a team.
Only accessible to team members or tenant admins.
Returns list of:
{
"resource_type": "agent" | "dataset",
"resource_id": "uuid",
"user_permissions": {"user_id": "read|edit", ...}
}
"""
logger.info(f"Listing shared resources for team {team_id}")
try:
service = await get_team_service_for_user(current_user)
resources = await service.get_shared_resources(
team_id=team_id,
resource_type=resource_type
)
return SharedResourcesResponse(
data=resources,
total=len(resources)
)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except Exception as e:
logger.error(f"Error listing shared resources: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{team_id}/invitations", response_model=InvitationListResponse)
async def list_team_invitations(
team_id: str,
current_user: Dict = Depends(get_current_user)
):
"""
Get pending invitations for a team (owner view).
Shows all users who have been invited but haven't accepted yet.
Requires team ownership or admin role.
"""
try:
service = await get_team_service_for_user(current_user)
invitations = await service.get_team_pending_invitations(team_id)
return InvitationListResponse(
data=invitations,
total=len(invitations)
)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except Exception as e:
logger.error(f"Error listing team invitations: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{team_id}/invitations/{invitation_id}", status_code=204)
async def cancel_team_invitation(
team_id: str,
invitation_id: str,
current_user: Dict = Depends(get_current_user)
):
"""
Cancel a pending invitation (owner only).
Removes the invitation before the user accepts it.
Requires team ownership or admin role.
"""
try:
service = await get_team_service_for_user(current_user)
await service.cancel_invitation(team_id, invitation_id)
return None
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error canceling invitation: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Observable Member Management Endpoints
# ============================================================================
@router.post("/{team_id}/members/{user_id}/request-observable", status_code=200)
async def request_observable_access(
team_id: str,
user_id: str,
current_user: Dict = Depends(get_current_user)
):
"""
Request Observable access from a team member.
Sets Observable status to pending for the target user.
Requires owner or manager permission.
"""
try:
service = await get_team_service_for_user(current_user)
result = await service.request_observable_status(team_id, user_id)
return {"success": True, "data": result}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error requesting Observable access: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{team_id}/observable/approve", status_code=200)
async def approve_observable_request(
team_id: str,
current_user: Dict = Depends(get_current_user)
):
"""
Approve Observable status for current user in a team.
User explicitly consents to team managers viewing their activity.
"""
try:
service = await get_team_service_for_user(current_user)
result = await service.approve_observable_consent(team_id)
return {"success": True, "data": result}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error approving Observable request: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{team_id}/observable", status_code=200)
async def revoke_observable_status(
team_id: str,
current_user: Dict = Depends(get_current_user)
):
"""
Revoke Observable status for current user in a team.
Immediately removes manager access to user's activity data.
"""
try:
service = await get_team_service_for_user(current_user)
result = await service.revoke_observable_status(team_id)
return {"success": True, "data": result}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error revoking Observable status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{team_id}/activity", response_model=TeamActivityResponse)
async def get_team_activity(
team_id: str,
days: int = Query(7, ge=1, le=365, description="Number of days to include in metrics"),
current_user: Dict = Depends(get_current_user)
):
"""
Get team activity metrics for Observable members.
Returns aggregated activity data for team members who have approved Observable status.
Requires owner or manager permission.
Args:
team_id: Team UUID
days: Number of days to include (1-365, default 7)
"""
try:
service = await get_team_service_for_user(current_user)
activity = await service.get_team_activity(team_id, days)
return TeamActivityResponse(
data=TeamActivityMetrics(**activity)
)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error getting team activity: {e}")
raise HTTPException(status_code=500, detail=str(e))