Add multi-tenancy with Teams feature

This commit is contained in:
Mondo Diaz
2026-01-28 12:50:58 -06:00
parent a5796f5437
commit 576791d19e
33 changed files with 5493 additions and 115 deletions

View File

@@ -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",