GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
758
apps/tenant-backend/app/api/v1/teams.py
Normal file
758
apps/tenant-backend/app/api/v1/teams.py
Normal file
@@ -0,0 +1,758 @@
|
||||
"""
|
||||
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))
|
||||
Reference in New Issue
Block a user