Add multi-tenancy with Teams feature
This commit is contained in:
@@ -16,7 +16,7 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse, PlainTextResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, and_, func, text
|
||||
from sqlalchemy import or_, and_, func, text, case
|
||||
from typing import List, Optional, Literal
|
||||
import math
|
||||
import io
|
||||
@@ -48,6 +48,8 @@ from .models import (
|
||||
AccessPermission,
|
||||
PackageVersion,
|
||||
ArtifactDependency,
|
||||
Team,
|
||||
TeamMembership,
|
||||
)
|
||||
from .schemas import (
|
||||
ProjectCreate,
|
||||
@@ -127,6 +129,13 @@ from .schemas import (
|
||||
DependencyResolutionResponse,
|
||||
CircularDependencyError as CircularDependencyErrorSchema,
|
||||
DependencyConflictError as DependencyConflictErrorSchema,
|
||||
TeamCreate,
|
||||
TeamUpdate,
|
||||
TeamResponse,
|
||||
TeamDetailResponse,
|
||||
TeamMemberCreate,
|
||||
TeamMemberUpdate,
|
||||
TeamMemberResponse,
|
||||
)
|
||||
from .metadata import extract_metadata
|
||||
from .dependencies import (
|
||||
@@ -558,6 +567,9 @@ from .auth import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
check_project_access,
|
||||
AuthorizationService,
|
||||
TeamAuthorizationService,
|
||||
check_team_access,
|
||||
get_team_authorization_service,
|
||||
)
|
||||
from .rate_limit import limiter, LOGIN_RATE_LIMIT
|
||||
|
||||
@@ -1081,6 +1093,43 @@ def oidc_callback(
|
||||
return response
|
||||
|
||||
|
||||
# --- User Search Routes (for autocomplete) ---
|
||||
|
||||
|
||||
@router.get("/api/v1/users/search")
|
||||
def search_users(
|
||||
q: str = Query(..., min_length=1, description="Search query for username"),
|
||||
limit: int = Query(default=10, ge=1, le=50, description="Maximum results"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Search for users by username prefix.
|
||||
Returns basic user info for autocomplete (no email for privacy).
|
||||
Any authenticated user can search.
|
||||
"""
|
||||
search_pattern = f"{q.lower()}%"
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(
|
||||
func.lower(User.username).like(search_pattern),
|
||||
User.is_active == True,
|
||||
)
|
||||
.order_by(User.username)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"username": u.username,
|
||||
"is_admin": u.is_admin,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
# --- Admin User Management Routes ---
|
||||
|
||||
|
||||
@@ -1438,15 +1487,46 @@ def list_projects(
|
||||
)
|
||||
|
||||
# Base query - filter by access
|
||||
query = db.query(Project).filter(
|
||||
or_(Project.is_public == True, Project.created_by == user_id)
|
||||
)
|
||||
# Users can see projects that are:
|
||||
# 1. Public
|
||||
# 2. Created by them
|
||||
# 3. Belong to a team they're a member of
|
||||
if current_user:
|
||||
# Get team IDs where user is a member
|
||||
user_team_ids = db.query(TeamMembership.team_id).filter(
|
||||
TeamMembership.user_id == current_user.id
|
||||
).subquery()
|
||||
|
||||
query = db.query(Project).filter(
|
||||
or_(
|
||||
Project.is_public == True,
|
||||
Project.created_by == user_id,
|
||||
Project.team_id.in_(user_team_ids)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Anonymous users only see public projects
|
||||
query = db.query(Project).filter(Project.is_public == True)
|
||||
|
||||
# Apply visibility filter
|
||||
if visibility == "public":
|
||||
query = query.filter(Project.is_public == True)
|
||||
elif visibility == "private":
|
||||
query = query.filter(Project.is_public == False, Project.created_by == user_id)
|
||||
if current_user:
|
||||
# Get team IDs where user is a member (for private filter)
|
||||
user_team_ids_for_private = db.query(TeamMembership.team_id).filter(
|
||||
TeamMembership.user_id == current_user.id
|
||||
).subquery()
|
||||
query = query.filter(
|
||||
Project.is_public == False,
|
||||
or_(
|
||||
Project.created_by == user_id,
|
||||
Project.team_id.in_(user_team_ids_for_private)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Anonymous users can't see private projects
|
||||
query = query.filter(False)
|
||||
|
||||
# Apply search filter (case-insensitive on name and description)
|
||||
if search:
|
||||
@@ -1543,11 +1623,33 @@ def create_project(
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Project already exists")
|
||||
|
||||
# If team_id is provided, verify user has admin access to the team
|
||||
team = None
|
||||
if project.team_id:
|
||||
team = db.query(Team).filter(Team.id == project.team_id).first()
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
|
||||
# Check if user has admin role in team
|
||||
if current_user:
|
||||
team_auth = TeamAuthorizationService(db)
|
||||
if not team_auth.can_create_project(str(team.id), current_user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Requires admin role in team to create projects",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required to create projects in a team",
|
||||
)
|
||||
|
||||
db_project = Project(
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
is_public=project.is_public,
|
||||
created_by=user_id,
|
||||
team_id=project.team_id,
|
||||
)
|
||||
db.add(db_project)
|
||||
|
||||
@@ -1558,12 +1660,28 @@ def create_project(
|
||||
resource=f"project/{project.name}",
|
||||
user_id=user_id,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
details={"is_public": project.is_public},
|
||||
details={
|
||||
"is_public": project.is_public,
|
||||
"team_id": str(project.team_id) if project.team_id else None,
|
||||
},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
return db_project
|
||||
|
||||
# Build response with team info
|
||||
return ProjectResponse(
|
||||
id=db_project.id,
|
||||
name=db_project.name,
|
||||
description=db_project.description,
|
||||
is_public=db_project.is_public,
|
||||
created_at=db_project.created_at,
|
||||
updated_at=db_project.updated_at,
|
||||
created_by=db_project.created_by,
|
||||
team_id=team.id if team else None,
|
||||
team_slug=team.slug if team else None,
|
||||
team_name=team.name if team else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse)
|
||||
@@ -1574,7 +1692,20 @@ def get_project(
|
||||
):
|
||||
"""Get a single project by name. Requires read access for private projects."""
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
return project
|
||||
|
||||
# Build response with team info
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
is_public=project.is_public,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
created_by=project.created_by,
|
||||
team_id=project.team.id if project.team else None,
|
||||
team_slug=project.team.slug if project.team else None,
|
||||
team_name=project.team.name if project.team else None,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/v1/projects/{project_name}", response_model=ProjectResponse)
|
||||
@@ -1701,14 +1832,63 @@ def list_project_permissions(
|
||||
):
|
||||
"""
|
||||
List all access permissions for a project.
|
||||
Includes both explicit permissions and team-based access.
|
||||
Requires admin access to the project.
|
||||
"""
|
||||
project = check_project_access(db, project_name, current_user, "admin")
|
||||
|
||||
auth_service = AuthorizationService(db)
|
||||
permissions = auth_service.list_project_permissions(str(project.id))
|
||||
explicit_permissions = auth_service.list_project_permissions(str(project.id))
|
||||
|
||||
return permissions
|
||||
# Convert to response format with source field
|
||||
result = []
|
||||
for perm in explicit_permissions:
|
||||
result.append(AccessPermissionResponse(
|
||||
id=perm.id,
|
||||
project_id=perm.project_id,
|
||||
user_id=perm.user_id,
|
||||
level=perm.level,
|
||||
created_at=perm.created_at,
|
||||
expires_at=perm.expires_at,
|
||||
source="explicit",
|
||||
))
|
||||
|
||||
# Add team-based access if project belongs to a team
|
||||
if project.team_id:
|
||||
team = db.query(Team).filter(Team.id == project.team_id).first()
|
||||
if team:
|
||||
memberships = (
|
||||
db.query(TeamMembership)
|
||||
.join(User, TeamMembership.user_id == User.id)
|
||||
.filter(TeamMembership.team_id == project.team_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Track users who already have explicit permissions
|
||||
explicit_users = {p.user_id for p in result}
|
||||
|
||||
for membership in memberships:
|
||||
user = db.query(User).filter(User.id == membership.user_id).first()
|
||||
if user and user.username not in explicit_users:
|
||||
# Map team role to project access level
|
||||
if membership.role in ("owner", "admin"):
|
||||
level = "admin"
|
||||
else:
|
||||
level = "read"
|
||||
|
||||
result.append(AccessPermissionResponse(
|
||||
id=membership.id, # Use membership ID
|
||||
project_id=project.id,
|
||||
user_id=user.username,
|
||||
level=level,
|
||||
created_at=membership.created_at,
|
||||
expires_at=None,
|
||||
source="team",
|
||||
team_slug=team.slug,
|
||||
team_role=membership.role,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -1842,6 +2022,653 @@ def get_my_project_access(
|
||||
}
|
||||
|
||||
|
||||
# Team routes
|
||||
@router.get("/api/v1/teams", response_model=PaginatedResponse[TeamDetailResponse])
|
||||
def list_teams(
|
||||
page: int = Query(default=1, ge=1, description="Page number"),
|
||||
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
|
||||
search: Optional[str] = Query(default=None, description="Search by name or slug"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all teams the current user belongs to."""
|
||||
# Base query - teams user is a member of
|
||||
query = (
|
||||
db.query(Team)
|
||||
.join(TeamMembership)
|
||||
.filter(TeamMembership.user_id == current_user.id)
|
||||
)
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
query = query.filter(
|
||||
or_(
|
||||
func.lower(Team.name).contains(search_lower),
|
||||
func.lower(Team.slug).contains(search_lower),
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply sorting and pagination
|
||||
query = query.order_by(Team.name)
|
||||
offset = (page - 1) * limit
|
||||
teams = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Calculate total pages
|
||||
total_pages = math.ceil(total / limit) if total > 0 else 1
|
||||
|
||||
# Build response with member counts and user roles
|
||||
items = []
|
||||
for team in teams:
|
||||
member_count = db.query(TeamMembership).filter(TeamMembership.team_id == team.id).count()
|
||||
project_count = db.query(Project).filter(Project.team_id == team.id).count()
|
||||
|
||||
# Get user's role in this team
|
||||
membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
items.append(
|
||||
TeamDetailResponse(
|
||||
id=team.id,
|
||||
name=team.name,
|
||||
slug=team.slug,
|
||||
description=team.description,
|
||||
created_at=team.created_at,
|
||||
updated_at=team.updated_at,
|
||||
member_count=member_count,
|
||||
project_count=project_count,
|
||||
user_role=membership.role if membership else None,
|
||||
)
|
||||
)
|
||||
|
||||
return PaginatedResponse(
|
||||
items=items,
|
||||
pagination=PaginationMeta(
|
||||
page=page,
|
||||
limit=limit,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
has_more=page < total_pages,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/teams", response_model=TeamDetailResponse, status_code=201)
|
||||
def create_team(
|
||||
team_data: TeamCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new team. The creator becomes the owner."""
|
||||
# Check if slug already exists
|
||||
existing = db.query(Team).filter(Team.slug == team_data.slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Team slug already exists")
|
||||
|
||||
# Create the team
|
||||
team = Team(
|
||||
name=team_data.name,
|
||||
slug=team_data.slug,
|
||||
description=team_data.description,
|
||||
created_by=current_user.username,
|
||||
)
|
||||
db.add(team)
|
||||
db.flush() # Get the team ID
|
||||
|
||||
# Add creator as owner
|
||||
membership = TeamMembership(
|
||||
team_id=team.id,
|
||||
user_id=current_user.id,
|
||||
role="owner",
|
||||
invited_by=current_user.username,
|
||||
)
|
||||
db.add(membership)
|
||||
|
||||
# Audit log
|
||||
_log_audit(
|
||||
db=db,
|
||||
action="team.create",
|
||||
resource=f"team/{team.slug}",
|
||||
user_id=current_user.username,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
details={"team_name": team.name},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(team)
|
||||
|
||||
return TeamDetailResponse(
|
||||
id=team.id,
|
||||
name=team.name,
|
||||
slug=team.slug,
|
||||
description=team.description,
|
||||
created_at=team.created_at,
|
||||
updated_at=team.updated_at,
|
||||
member_count=1,
|
||||
project_count=0,
|
||||
user_role="owner",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/teams/{slug}", response_model=TeamDetailResponse)
|
||||
def get_team(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get team details. Requires team membership."""
|
||||
team = check_team_access(db, slug, current_user, "member")
|
||||
|
||||
member_count = db.query(TeamMembership).filter(TeamMembership.team_id == team.id).count()
|
||||
project_count = db.query(Project).filter(Project.team_id == team.id).count()
|
||||
|
||||
# Get user's role
|
||||
membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
user_role = membership.role if membership else ("admin" if current_user.is_admin else None)
|
||||
|
||||
return TeamDetailResponse(
|
||||
id=team.id,
|
||||
name=team.name,
|
||||
slug=team.slug,
|
||||
description=team.description,
|
||||
created_at=team.created_at,
|
||||
updated_at=team.updated_at,
|
||||
member_count=member_count,
|
||||
project_count=project_count,
|
||||
user_role=user_role,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/v1/teams/{slug}", response_model=TeamDetailResponse)
|
||||
def update_team(
|
||||
slug: str,
|
||||
team_update: TeamUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update team details. Requires admin role."""
|
||||
team = check_team_access(db, slug, current_user, "admin")
|
||||
|
||||
# Track changes for audit
|
||||
changes = {}
|
||||
if team_update.name is not None and team_update.name != team.name:
|
||||
changes["name"] = {"old": team.name, "new": team_update.name}
|
||||
team.name = team_update.name
|
||||
if team_update.description is not None and team_update.description != team.description:
|
||||
changes["description"] = {"old": team.description, "new": team_update.description}
|
||||
team.description = team_update.description
|
||||
|
||||
if changes:
|
||||
_log_audit(
|
||||
db=db,
|
||||
action="team.update",
|
||||
resource=f"team/{slug}",
|
||||
user_id=current_user.username,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
details=changes,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(team)
|
||||
|
||||
member_count = db.query(TeamMembership).filter(TeamMembership.team_id == team.id).count()
|
||||
project_count = db.query(Project).filter(Project.team_id == team.id).count()
|
||||
|
||||
membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
user_role = membership.role if membership else ("admin" if current_user.is_admin else None)
|
||||
|
||||
return TeamDetailResponse(
|
||||
id=team.id,
|
||||
name=team.name,
|
||||
slug=team.slug,
|
||||
description=team.description,
|
||||
created_at=team.created_at,
|
||||
updated_at=team.updated_at,
|
||||
member_count=member_count,
|
||||
project_count=project_count,
|
||||
user_role=user_role,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/v1/teams/{slug}", status_code=204)
|
||||
def delete_team(
|
||||
slug: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a team. Requires owner role."""
|
||||
team = check_team_access(db, slug, current_user, "owner")
|
||||
|
||||
# Check if team has any projects
|
||||
project_count = db.query(Project).filter(Project.team_id == team.id).count()
|
||||
if project_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot delete team with {project_count} project(s). Move or delete projects first.",
|
||||
)
|
||||
|
||||
# Audit log
|
||||
_log_audit(
|
||||
db=db,
|
||||
action="team.delete",
|
||||
resource=f"team/{slug}",
|
||||
user_id=current_user.username,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
details={"team_name": team.name},
|
||||
)
|
||||
|
||||
db.delete(team)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# Team membership routes
|
||||
@router.get("/api/v1/teams/{slug}/members", response_model=List[TeamMemberResponse])
|
||||
def list_team_members(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all members of a team. Requires team membership.
|
||||
|
||||
Email addresses are only visible to team admins/owners.
|
||||
"""
|
||||
team = check_team_access(db, slug, current_user, "member")
|
||||
|
||||
# Check if current user is admin/owner to determine email visibility
|
||||
current_membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
can_see_emails = (
|
||||
current_user.is_admin or
|
||||
(current_membership and current_membership.role in ("owner", "admin"))
|
||||
)
|
||||
|
||||
memberships = (
|
||||
db.query(TeamMembership)
|
||||
.join(User)
|
||||
.filter(TeamMembership.team_id == team.id)
|
||||
.order_by(
|
||||
# Sort by role (owner first, then admin, then member)
|
||||
case(
|
||||
(TeamMembership.role == "owner", 0),
|
||||
(TeamMembership.role == "admin", 1),
|
||||
else_=2,
|
||||
),
|
||||
User.username,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
TeamMemberResponse(
|
||||
id=m.id,
|
||||
user_id=m.user_id,
|
||||
username=m.user.username,
|
||||
email=m.user.email if can_see_emails else None,
|
||||
role=m.role,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in memberships
|
||||
]
|
||||
|
||||
|
||||
@router.post("/api/v1/teams/{slug}/members", response_model=TeamMemberResponse, status_code=201)
|
||||
def add_team_member(
|
||||
slug: str,
|
||||
member_data: TeamMemberCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add a member to a team. Requires admin role."""
|
||||
team = check_team_access(db, slug, current_user, "admin")
|
||||
|
||||
# Find the user by username
|
||||
user = db.query(User).filter(User.username == member_data.username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail=f"User '{member_data.username}' not found")
|
||||
|
||||
# Check if already a member
|
||||
existing = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User is already a member of this team")
|
||||
|
||||
# Only owners can add other owners
|
||||
if member_data.role == "owner":
|
||||
current_membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not current_membership or current_membership.role != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only owners can add other owners")
|
||||
|
||||
membership = TeamMembership(
|
||||
team_id=team.id,
|
||||
user_id=user.id,
|
||||
role=member_data.role,
|
||||
invited_by=current_user.username,
|
||||
)
|
||||
db.add(membership)
|
||||
|
||||
_log_audit(
|
||||
db=db,
|
||||
action="team.member.add",
|
||||
resource=f"team/{slug}/members/{member_data.username}",
|
||||
user_id=current_user.username,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
details={"role": member_data.role},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
|
||||
return TeamMemberResponse(
|
||||
id=membership.id,
|
||||
user_id=membership.user_id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=membership.role,
|
||||
created_at=membership.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/v1/teams/{slug}/members/{username}", response_model=TeamMemberResponse)
|
||||
def update_team_member(
|
||||
slug: str,
|
||||
username: str,
|
||||
member_update: TeamMemberUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a member's role. Requires admin role."""
|
||||
team = check_team_access(db, slug, current_user, "admin")
|
||||
|
||||
# Find the user
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
|
||||
# Find the membership
|
||||
membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' is not a member of this team")
|
||||
|
||||
# Prevent self-role modification
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own role")
|
||||
|
||||
# Get current user's membership to check permissions
|
||||
current_membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
current_role = current_membership.role if current_membership else None
|
||||
|
||||
# Prevent demoting the last owner
|
||||
if membership.role == "owner" and member_update.role != "owner":
|
||||
owner_count = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.role == "owner",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if owner_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote the last owner")
|
||||
|
||||
# Only team owners can modify other owners or promote to owner (system admins cannot)
|
||||
if membership.role == "owner" or member_update.role == "owner":
|
||||
if current_role != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only team owners can modify owner roles")
|
||||
|
||||
old_role = membership.role
|
||||
membership.role = member_update.role
|
||||
|
||||
_log_audit(
|
||||
db=db,
|
||||
action="team.member.update",
|
||||
resource=f"team/{slug}/members/{username}",
|
||||
user_id=current_user.username,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
details={"old_role": old_role, "new_role": member_update.role},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
|
||||
return TeamMemberResponse(
|
||||
id=membership.id,
|
||||
user_id=membership.user_id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=membership.role,
|
||||
created_at=membership.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/v1/teams/{slug}/members/{username}", status_code=204)
|
||||
def remove_team_member(
|
||||
slug: str,
|
||||
username: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a member from a team. Requires admin role."""
|
||||
team = check_team_access(db, slug, current_user, "admin")
|
||||
|
||||
# Find the user
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
|
||||
# Find the membership
|
||||
membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' is not a member of this team")
|
||||
|
||||
# Prevent self-removal (use a "leave team" action instead if needed)
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot remove yourself. Transfer ownership first if you are an owner.")
|
||||
|
||||
# Prevent removing the last owner
|
||||
if membership.role == "owner":
|
||||
owner_count = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.role == "owner",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if owner_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot remove the last owner")
|
||||
|
||||
# Only team owners can remove other owners (system admins cannot)
|
||||
current_membership = (
|
||||
db.query(TeamMembership)
|
||||
.filter(
|
||||
TeamMembership.team_id == team.id,
|
||||
TeamMembership.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not current_membership or current_membership.role != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only team owners can remove other owners")
|
||||
|
||||
_log_audit(
|
||||
db=db,
|
||||
action="team.member.remove",
|
||||
resource=f"team/{slug}/members/{username}",
|
||||
user_id=current_user.username,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
details={"role": membership.role},
|
||||
)
|
||||
|
||||
db.delete(membership)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# Team projects route
|
||||
@router.get("/api/v1/teams/{slug}/projects", response_model=PaginatedResponse[ProjectResponse])
|
||||
def list_team_projects(
|
||||
slug: str,
|
||||
page: int = Query(default=1, ge=1, description="Page number"),
|
||||
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
|
||||
search: Optional[str] = Query(default=None, description="Search by name or description"),
|
||||
visibility: Optional[str] = Query(default=None, description="Filter by visibility (public, private)"),
|
||||
sort: str = Query(default="name", description="Sort field (name, created_at, updated_at)"),
|
||||
order: str = Query(default="asc", description="Sort order (asc, desc)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all projects in a team. Requires team membership."""
|
||||
team = check_team_access(db, slug, current_user, "member")
|
||||
|
||||
# Validate sort field
|
||||
valid_sort_fields = {
|
||||
"name": Project.name,
|
||||
"created_at": Project.created_at,
|
||||
"updated_at": Project.updated_at,
|
||||
}
|
||||
if sort not in valid_sort_fields:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}",
|
||||
)
|
||||
|
||||
if order not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'")
|
||||
|
||||
# Base query - projects in this team
|
||||
query = db.query(Project).filter(Project.team_id == team.id)
|
||||
|
||||
# Apply visibility filter
|
||||
if visibility == "public":
|
||||
query = query.filter(Project.is_public == True)
|
||||
elif visibility == "private":
|
||||
query = query.filter(Project.is_public == False)
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
query = query.filter(
|
||||
or_(
|
||||
func.lower(Project.name).contains(search_lower),
|
||||
func.lower(Project.description).contains(search_lower),
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply sorting
|
||||
sort_column = valid_sort_fields[sort]
|
||||
if order == "desc":
|
||||
query = query.order_by(sort_column.desc())
|
||||
else:
|
||||
query = query.order_by(sort_column.asc())
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * limit
|
||||
projects = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Calculate total pages
|
||||
total_pages = math.ceil(total / limit) if total > 0 else 1
|
||||
|
||||
# Build response with team info
|
||||
items = []
|
||||
for p in projects:
|
||||
items.append(
|
||||
ProjectResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
is_public=p.is_public,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
created_by=p.created_by,
|
||||
team_id=team.id,
|
||||
team_slug=team.slug,
|
||||
team_name=team.name,
|
||||
)
|
||||
)
|
||||
|
||||
return PaginatedResponse(
|
||||
items=items,
|
||||
pagination=PaginationMeta(
|
||||
page=page,
|
||||
limit=limit,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
has_more=page < total_pages,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Package routes
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/packages",
|
||||
|
||||
Reference in New Issue
Block a user