diff --git a/.env.example b/.env.example index bb0b8e7..89dc881 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ -TASK_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/tasking_manager +JWT_SECRET=your-secret-key OSM_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/osm -JWT_SECRET=your-secret-key \ No newline at end of file +TASK_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/tasking_manager +TDEI_BACKEND_URL=https://portal-api-dev.tdei.us/api/v1/ +TDEI_OIDC_REALM=tdei +TDEI_OIDC_URL=https://account-dev.tdei.us/ diff --git a/api/core/config.py b/api/core/config.py index 58e209a..94edba6 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -7,6 +7,10 @@ class Settings(BaseSettings): TASK_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" OSM_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" + TDEI_BACKEND_URL: str = "https://portal-api-dev.tdei.us/api/v1/" + TDEI_OIDC_URL: str = "https://account-dev.tdei.us/" + TDEI_OIDC_REALM: str = "tdei" + DEBUG: bool = False # used for validation diff --git a/api/core/database.py b/api/core/database.py index a81b999..bff5dbf 100644 --- a/api/core/database.py +++ b/api/core/database.py @@ -1,7 +1,8 @@ # type: ignore -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.orm import declarative_base, sessionmaker +from sqlmodel.ext.asyncio.session import AsyncSession from api.core.config import settings diff --git a/api/core/security.py b/api/core/security.py index 71c12e4..cc16b30 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,18 +1,18 @@ import json -import os from enum import StrEnum -import requests +from uuid import UUID -from api.core.logging import get_logger -import jwt import cachetools +import jwt +import requests from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import UUID +from sqlmodel.ext.asyncio.session import AsyncSession +from api.core.config import settings from api.core.database import get_osm_session, get_task_session +from api.core.logging import get_logger from api.src.workspaces.schemas import WorkspaceUserRoleType # Set up logger for this module @@ -25,6 +25,7 @@ security = HTTPBearer() + class TdeiProjectGroupRole(StrEnum): MEMBER = "member" POINT_OF_CONTACT = "poc" @@ -153,7 +154,7 @@ async def _validate_token_uncached( ) jwks_client = jwt.PyJWKClient( - "https://account-dev.tdei.us/realms/tdei/protocol/openid-connect/certs" + f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}/protocol/openid-connect/certs" ) signing_key = jwks_client.get_signing_key_from_jwt(token) @@ -162,6 +163,8 @@ async def _validate_token_uncached( token, key=signing_key.key, algorithms=["RS256"], + # OIDC server does not currently differentiate tokens by audience + options={"verify_aud": False} ) payload = jwtDecoded.get("payload", {}) @@ -177,7 +180,7 @@ async def _validate_token_uncached( # get user's project groups and roles from TDEI # TODO: fix if user has > 50 PGs authorizationUrl = ( - os.environ.get("TM_TDEI_BACKEND_URL", "https://portal-api-dev.tdei.us/api/v1/") + settings.TDEI_BACKEND_URL + "/project-group-roles/" + user_id + "?page_no=1&page_size=50" @@ -197,7 +200,7 @@ async def _validate_token_uncached( r = UserInfo() r.credentials = token - r.user_uuid = payload.get("sub", "unknown") + r.user_uuid = UUID(payload.get("sub", "unknown")) r.user_name = payload.get("preferred_username", "unknown") # project groups and roles from TDEI KeyCloak @@ -235,7 +238,7 @@ async def _validate_token_uncached( "SELECT workspace_id, role FROM user_workspace_roles \ WHERE user_auth_uid = :auth_uid" ), - {"auth_uid": r.user_uuid}, + {"auth_uid": str(r.user_uuid)}, ) workspaceRoles = list(result.mappings().all()) diff --git a/api/main.py b/api/main.py index 74e0cca..91c7f73 100644 --- a/api/main.py +++ b/api/main.py @@ -5,7 +5,7 @@ import sentry_sdk from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.responses import RedirectResponse, StreamingResponse -from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel.ext.asyncio.session import AsyncSession from starlette.background import BackgroundTask from api.core import config @@ -13,6 +13,7 @@ from api.core.database import get_task_session from api.core.logging import get_logger, setup_logging from api.core.security import UserInfo, validate_token +from api.src.teams.routes import router as teams_router from api.src.workspaces.repository import WorkspaceRepository from api.src.workspaces.routes import router as workspaces_router from api.utils.migrations import run_migrations @@ -41,8 +42,8 @@ ) # Include routers -app.include_router(workspaces_router) - +app.include_router(teams_router, prefix="/api/v1") +app.include_router(workspaces_router, prefix="/api/v1") @app.get("/health") async def health_check(): diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py new file mode 100644 index 0000000..9604282 --- /dev/null +++ b/api/src/teams/repository.py @@ -0,0 +1,122 @@ +from sqlalchemy import delete, exists, select +from sqlalchemy.orm import selectinload +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.src.teams.schemas import ( + WorkspaceTeam, + WorkspaceTeamCreate, + WorkspaceTeamItem, + WorkspaceTeamUpdate, +) +from api.src.workspaces.schemas import User + + +class WorkspaceTeamRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_all(self, workspace_id: int) -> list[WorkspaceTeamItem]: + result = await self.session.exec( + select(WorkspaceTeam) + .options(selectinload(WorkspaceTeam.users)) + .where(WorkspaceTeam.workspace_id == workspace_id) + ) + + return [WorkspaceTeamItem.from_team(x) for x in result.scalars()] + + async def get(self, id: int, load_members: bool = False) -> WorkspaceTeam: + query = select(WorkspaceTeam).where(WorkspaceTeam.id == id) + + if load_members: + query = query.options(selectinload(WorkspaceTeam.users)) + + result = await self.session.exec(query) + team = result.scalar_one_or_none() + + if not team: + raise NotFoundException(f"Team {id} not found") + + return team + + async def get_item(self, id: int) -> WorkspaceTeamItem: + team = await self.get(id, load_members=True) + item = WorkspaceTeamItem.from_team(team) + + return item + + async def assert_team_in_workspace(self, id: int, workspace_id: int): + team_exists = await self.session.scalar( + select( + exists() + .where(WorkspaceTeam.id == id) + .where(WorkspaceTeam.workspace_id == workspace_id) + ) + ) + + if not team_exists: + raise NotFoundException(f"Team {id} not in workspace {workspace_id}") + + async def create(self, workspace_id: int, data: WorkspaceTeamCreate) -> int: + team = WorkspaceTeam() + team.workspace_id = workspace_id + team.name = data.name + + self.session.add(team) + await self.session.commit() + await self.session.refresh(team) + + return team.id + + async def update(self, id: int, data: WorkspaceTeamUpdate): + team = await self.get(id) + team.name = data.name + + self.session.add(team) + await self.session.commit() + + async def delete(self, id: int) -> None: + await self.session.exec(delete(WorkspaceTeam).where(WorkspaceTeam.id == id)) + await self.session.commit() + + async def get_members(self, id: int) -> list[User]: + result = await self.session.exec(select(User).where(User.teams.any(id=id))) + + return result.scalars().all() + + async def add_member(self, id: int, user_id: int): + user_result = await self.session.exec( + select(User).options(selectinload(User.teams)).where(User.id == user_id) + ) + user = user_result.scalar_one_or_none() + + if not user: + raise NotFoundException(f"User {user_id} does not exist") + + team = await self.get(id) + + if team in user.teams: + return + + user.teams.append(team) + self.session.add(user) + await self.session.commit() + + async def remove_member(self, id: int, user_id: int): + user_result = await self.session.exec( + select(User).options(selectinload(User.teams)).where(User.id == user_id) + ) + user = user_result.scalar_one_or_none() + + if not user: + raise NotFoundException(f"User {user_id} does not exist") + + team = await self.get(id) + + if team not in user.teams: + return + + user.teams.remove(team) + self.session.add(user) + await self.session.commit() diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py new file mode 100644 index 0000000..5437ab1 --- /dev/null +++ b/api/src/teams/routes.py @@ -0,0 +1,165 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.teams.repository import WorkspaceTeamRepository +from api.src.teams.schemas import ( + WorkspaceTeamCreate, + WorkspaceTeamItem, + WorkspaceTeamUpdate, +) +from api.src.workspaces.repository import OSMRepository, WorkspaceRepository +from api.src.workspaces.schemas import User + +router = APIRouter(prefix="/workspaces/{workspace_id}/teams", tags=["teams"]) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + repo = WorkspaceRepository(session) + return repo + + +def get_osm_repo( + session: AsyncSession = Depends(get_osm_session), +) -> OSMRepository: + repository = OSMRepository(session) + return repository + + +def get_team_repo( + session: AsyncSession = Depends(get_osm_session), +) -> WorkspaceTeamRepository: + repo = WorkspaceTeamRepository(session) + return repo + + +@router.get("") +async def get_all_teams_for_workspace( + workspace_id: int, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +) -> list[WorkspaceTeamItem]: + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + return await team_repo.get_all(workspace_id) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_team_for_workspace( + workspace_id: int, + team: WorkspaceTeamCreate, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +) -> int: + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + return await team_repo.create(workspace_id, team) + + +@router.get("/{team_id}") +async def get_team_for_workspace( + workspace_id: int, + team_id: int, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +) -> WorkspaceTeamItem: + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + await team_repo.assert_team_in_workspace(team_id, workspace_id) + return await team_repo.get_item(team_id) + + +@router.put("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) +async def update_team_for_workspace( + workspace_id: int, + team_id: int, + team: WorkspaceTeamUpdate, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +): + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + await team_repo.assert_team_in_workspace(team_id, workspace_id) + await team_repo.update(team_id, team) + + +@router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_team_from_workspace( + workspace_id: int, + team_id: int, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +): + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + await team_repo.assert_team_in_workspace(team_id, workspace_id) + await team_repo.delete(team_id) + + +@router.get("/{team_id}/members") +async def get_members_in_workspace_team( + workspace_id: int, + team_id: int, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +) -> list[User]: + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + await team_repo.assert_team_in_workspace(team_id, workspace_id) + return await team_repo.get_members(team_id) + + +@router.post("/{team_id}/members") +async def join_workspace_team( + workspace_id: int, + team_id: int, + workspace_repo=Depends(get_workspace_repo), + osm_repo=Depends(get_osm_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +) -> User: + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + await team_repo.assert_team_in_workspace(team_id, workspace_id) + user = await osm_repo.get_current_user(current_user) + await team_repo.add_member(team_id, user.id) + return user + + +@router.put("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def add_member_to_workspace_team( + workspace_id: int, + team_id: int, + user_id: int, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +): + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + await team_repo.assert_team_in_workspace(team_id, workspace_id) + await team_repo.add_member(team_id, user_id) + + +@router.delete("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_member_from_workspace_team( + workspace_id: int, + team_id: int, + user_id: int, + workspace_repo=Depends(get_workspace_repo), + team_repo=Depends(get_team_repo), + current_user: UserInfo = Depends(validate_token), +): + # Repo guards if workspace doesn't exist or user cannot access: + await workspace_repo.getById(current_user, workspace_id) + await team_repo.assert_team_in_workspace(team_id, workspace_id) + await team_repo.remove_member(team_id, user_id) diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py new file mode 100644 index 0000000..c0def20 --- /dev/null +++ b/api/src/teams/schemas.py @@ -0,0 +1,58 @@ +from typing import Self, TYPE_CHECKING + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from api.src.workspaces.schemas import User + + +class WorkspaceTeamUser(SQLModel, table=True): + """Team to User link table""" + + __tablename__ = "team_user" + + team_id: int | None = Field(default=None, primary_key=True, foreign_key="teams.id") + user_id: int | None = Field(default=None, primary_key=True, foreign_key="users.id") + + +class WorkspaceTeamBase(SQLModel): + """Shared fields for workspace teams""" + + name: str = Field(min_length=1) + + +class WorkspaceTeam(WorkspaceTeamBase, table=True): + """Workspace teams""" + + __tablename__ = "teams" # type: ignore[assignment] + + id: int | None = Field(default=None, primary_key=True) + workspace_id: int = Field(index=True) + + # TODO: map this to actual users + users: list["User"] = Relationship( + back_populates="teams", link_model=WorkspaceTeamUser + ) + + +class WorkspaceTeamItem(WorkspaceTeamBase): + """Existing workspace team DTO""" + + id: int + member_count: int + + @classmethod + def from_team(cls, team: WorkspaceTeam) -> Self: + return cls(id=team.id, name=team.name, member_count=len(team.users)) + + +class WorkspaceTeamCreate(WorkspaceTeamBase): + """New workspace team DTO""" + + pass + + +class WorkspaceTeamUpdate(WorkspaceTeamBase): + """Modify workspace team DTO""" + + pass diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index b457160..080f103 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -3,7 +3,7 @@ from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel.ext.asyncio.session import AsyncSession from api.core.exceptions import AlreadyExistsException, NotFoundException from api.core.security import UserInfo @@ -235,6 +235,14 @@ async def getAllUsers( result = await self.session.execute(query) return list(result.scalars().all()) + async def get_current_user(self, current_user: UserInfo) -> User: + result = await self.session.exec( + select(User).where(User.auth_uid == str(current_user.user_uuid)) + ) + + # Current user should exist--throw if it doesn't: + return result.scalar_one() + async def addUserToWorkspaceWithRole( self, current_user: UserInfo, diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 9741375..86d890d 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -2,7 +2,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session from api.core.logging import get_logger @@ -19,7 +19,7 @@ # Set up logger for this module logger = get_logger(__name__) -router = APIRouter(prefix="/api/v1/workspaces", tags=["workspaces"]) +router = APIRouter(prefix="/workspaces", tags=["workspaces"]) def get_workspace_repository( diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 8203061..2c4e423 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import IntEnum, StrEnum -from typing import Any, Optional +from typing import Any, Optional, TYPE_CHECKING from uuid import UUID from geoalchemy2 import Geometry @@ -8,6 +8,11 @@ from sqlalchemy import Column, SmallInteger, TypeDecorator, Unicode from sqlmodel import Field, Relationship, SQLModel +from api.src.teams.schemas import WorkspaceTeamUser + +if TYPE_CHECKING: + from api.src.teams.schemas import WorkspaceTeam + class IntEnumType(TypeDecorator): """Stores IntEnum as integer, returns as enum.""" @@ -122,7 +127,7 @@ class WorkspaceUserRole(SQLModel, table=True): __tablename__ = "user_workspace_roles" # type: ignore[assignment] # this is the TDEI auth user UUID, from the token - auth_user_uid: UUID = Field(foreign_key="users.auth_uid", primary_key=True) + auth_user_uid: str = Field(foreign_key="users.auth_uid", primary_key=True) workspace_id: int = Field(foreign_key="workspaces.id", primary_key=True) role: WorkspaceUserRoleType = Field( @@ -135,14 +140,18 @@ class User(SQLModel, table=True): __tablename__ = "users" # type: ignore[assignment] - id: UUID = Field(default=None, primary_key=True) + id: int = Field(default=None, primary_key=True) # this is the user ID from the TDEI authentication system - auth_uid: UUID = Field(unique=True, index=True) + auth_uid: str = Field(unique=True, index=True) email: str = Field(unique=True, index=True) display_name: str = Field(nullable=False) + teams: list["WorkspaceTeam"] = Relationship( + back_populates="users", link_model=WorkspaceTeamUser + ) + class Workspace(SQLModel, table=True): """Workspaces"""