From 408a7c31df7d0b00761a5d1b5f08de7f2dccc4a8 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 30 Dec 2025 12:22:49 -0800 Subject: [PATCH 1/8] Switch JWKS client to singleton for cert/key caching --- api/core/jwt.py | 33 +++++++++++++++++++++++++++++++++ api/core/security.py | 42 ++++++++++++++++++++---------------------- 2 files changed, 53 insertions(+), 22 deletions(-) create mode 100644 api/core/jwt.py diff --git a/api/core/jwt.py b/api/core/jwt.py new file mode 100644 index 0000000..1f2c268 --- /dev/null +++ b/api/core/jwt.py @@ -0,0 +1,33 @@ +import jwt + +from api.core.config import settings + +# Singleton JWKS client reused to take advantage of internal cert/key caching: +_jwks_client: jwt.PyJWKClient | None = None + + +def _get_jwks_client() -> jwt.PyJWKClient: + global _jwks_client + + if _jwks_client is None: + _jwks_client = jwt.PyJWKClient( + f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}" + f"/protocol/openid-connect/certs" + ) + + return _jwks_client + + +def validate_and_decode_token(token: str) -> dict: + # TODO: use an async client like pyjwt-key-fetcher + signing_key = _get_jwks_client().get_signing_key_from_jwt(token) + + decoded = jwt.decode_complete( + token, + key=signing_key.key, + algorithms=["RS256"], + # OIDC server does not currently differentiate tokens by audience + options={"verify_aud": False}, + ) + + return decoded.get("payload", {}) diff --git a/api/core/security.py b/api/core/security.py index 7531677..b8d11e6 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -3,7 +3,6 @@ from uuid import UUID import cachetools -import jwt import requests from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -12,6 +11,7 @@ from api.core.config import settings from api.core.database import get_osm_session, get_task_session +from api.core.jwt import validate_and_decode_token from api.core.logging import get_logger from api.src.workspaces.schemas import WorkspaceUserRoleType @@ -131,17 +131,34 @@ async def validate_token( # Check cache first if token in _token_cache: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = validate_and_decode_token(token) + except Exception: + raise credentials_exception + user_id: str | None = payload.get("sub") + if user_id is None: + raise credentials_exception logger.info("Token validation cache hit") return _token_cache[token] # Cache miss - perform full validation - user_info = await _validate_token_uncached(token, osm_db_session, task_db_session) _token_cache[token] = user_info + user_info = await _validate_token_uncached( + token, user_id, payload, osm_db_session, task_db_session + ) return user_info async def _validate_token_uncached( token: str, + user_id: str, + payload: dict, osm_db_session: AsyncSession, task_db_session: AsyncSession, ) -> UserInfo: @@ -153,25 +170,6 @@ async def _validate_token_uncached( headers={"WWW-Authenticate": "Bearer"}, ) - jwks_client = jwt.PyJWKClient( - f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}/protocol/openid-connect/certs" - ) - - signing_key = jwks_client.get_signing_key_from_jwt(token) - - jwtDecoded = jwt.decode_complete( - token, - key=signing_key.key, - algorithms=["RS256"], - # OIDC server does not currently differentiate tokens by audience - options={"verify_aud": False} - ) - payload = jwtDecoded.get("payload", {}) - - user_id: str | None = payload.get("sub") - if user_id is None: - raise credentials_exception - headers = { "Authorization": "Bearer " + token, "Content-Type": "application/json", @@ -200,7 +198,7 @@ async def _validate_token_uncached( r = UserInfo() r.credentials = token - r.user_uuid = UUID(payload.get("sub", "unknown")) + r.user_uuid = UUID(user_id) r.user_name = payload.get("preferred_username", "unknown") # project groups and roles from TDEI KeyCloak From 3a284ef3695599d40fff2e79a5d65acdc80d18f1 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 30 Dec 2025 14:31:19 -0800 Subject: [PATCH 2/8] Fix ValueError raised in project group verification --- api/core/security.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/core/security.py b/api/core/security.py index b8d11e6..f31f614 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -84,7 +84,9 @@ def isWorkspaceLead(self, workspaceId: int) -> bool: for pg in self.projectGroups: if TdeiProjectGroupRole.POINT_OF_CONTACT in pg.tdeiRoles: - if workspaceId in self.accessibleWorkspaceIds[pg.project_group_id]: + if workspaceId in self.accessibleWorkspaceIds.get( + pg.project_group_id, [] + ): return True return False From 83becf1272cb22466ac663532041b32d1bcb3ec0 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 31 Dec 2025 18:03:11 -0800 Subject: [PATCH 3/8] Fix the blocking requests for a user's project groups --- api/core/security.py | 58 +++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index f31f614..2520cc5 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,9 +1,8 @@ -import json from enum import StrEnum from uuid import UUID import cachetools -import requests +import httpx from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text @@ -177,42 +176,39 @@ async def _validate_token_uncached( "Content-Type": "application/json", } - # get user's project groups and roles from TDEI - # TODO: fix if user has > 50 PGs - authorizationUrl = ( - settings.TDEI_BACKEND_URL - + "/project-group-roles/" - + user_id - + "?page_no=1&page_size=50" - ) - - response = requests.get(authorizationUrl, headers=headers) - - # token is not valid or server unavailable - if response.status_code != 200: - raise credentials_exception - - try: - content = response.text - j = json.loads(content) - except json.JSONDecodeError: - raise credentials_exception - r = UserInfo() r.credentials = token r.user_uuid = UUID(user_id) r.user_name = payload.get("preferred_username", "unknown") - # project groups and roles from TDEI KeyCloak + # get user's project groups and roles from TDEI + pg_base_url = f"{settings.TDEI_BACKEND_URL}/project-group-roles/{user_id}" pgs = [] - for i in j: - pgs.append( - UserInfoPGMembership( - project_group_id=i["tdei_project_group_id"], - project_group_name=i["project_group_name"], - tdeiRoles=i["roles"], - ) + async with httpx.AsyncClient() as http_client: + response = await http_client.get( + pg_base_url, + headers=headers, + params={"page_no": 1, "page_size": 1000}, ) + + # token is not valid or server unavailable + if response.status_code != 200: + raise credentials_exception + + try: + pg_data = response.json() + except Exception: + raise credentials_exception + + for i in pg_data: + pgs.append( + UserInfoPGMembership( + project_group_id=i["tdei_project_group_id"], + project_group_name=i["project_group_name"], + tdeiRoles=i["roles"], + ) + ) + r.projectGroups = pgs # workspaces within our set of PGs from tasking manager DB From 5449542b23cb2dfdef7dd2dbf03a9444c24301f1 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 3 Jan 2026 19:31:25 -0800 Subject: [PATCH 4/8] Finish implementation of stubbed roles APIs --- api/core/exceptions.py | 7 ++ api/core/security.py | 14 +++- api/main.py | 3 + api/src/teams/repository.py | 2 +- api/src/teams/routes.py | 45 ++++++++++-- api/src/users/repository.py | 113 +++++++++++++++++++++++++++++++ api/src/users/routes.py | 72 ++++++++++++++++++++ api/src/users/schemas.py | 76 +++++++++++++++++++++ api/src/workspaces/repository.py | 64 +---------------- api/src/workspaces/routes.py | 107 ++++++++++++++--------------- api/src/workspaces/schemas.py | 58 +++++----------- 11 files changed, 390 insertions(+), 171 deletions(-) create mode 100644 api/src/users/repository.py create mode 100644 api/src/users/routes.py create mode 100644 api/src/users/schemas.py diff --git a/api/core/exceptions.py b/api/core/exceptions.py index 9bbbdba..fd1a7d4 100644 --- a/api/core/exceptions.py +++ b/api/core/exceptions.py @@ -15,6 +15,13 @@ def __init__(self, detail: str = "Resource already exists"): super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) +class ConflictException(HTTPException): + """Base exception for conflict errors.""" + + def __init__(self, detail: str = "Conflict"): + super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) + + class UnauthorizedException(HTTPException): """Base exception for unauthorized access errors.""" diff --git a/api/core/security.py b/api/core/security.py index 2520cc5..7394308 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -12,7 +12,7 @@ from api.core.database import get_osm_session, get_task_session from api.core.jwt import validate_and_decode_token from api.core.logging import get_logger -from api.src.workspaces.schemas import WorkspaceUserRoleType +from api.src.users.schemas import WorkspaceUserRoleType # Set up logger for this module logger = get_logger(__name__) @@ -99,13 +99,23 @@ def isWorkspaceValidator(self, workspaceId: int) -> bool: return True return False - # user has has any association with a project group that owns the workspace + # user has has any association with a project group that owns the workspace, + # OR has any explicit role granted in the OSM DB for that workspace def isWorkspaceContributor(self, workspaceId: int) -> bool: for pgid, wsids in self.accessibleWorkspaceIds.items(): if workspaceId in wsids: return True + if workspaceId in self.osmWorkspaceRoles: + return True return False + def effectiveRole(self, workspaceId: int) -> str: + if self.isWorkspaceLead(workspaceId): + return "lead" + if self.isWorkspaceValidator(workspaceId): + return "validator" + return "contributor" + # can't use the ORM here since the ORM uses us! (circular dependency) def get_osm_db_session( diff --git a/api/main.py b/api/main.py index af66673..d39d76f 100644 --- a/api/main.py +++ b/api/main.py @@ -14,6 +14,7 @@ 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.users.routes import router as users_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 @@ -43,8 +44,10 @@ # Include routers app.include_router(teams_router, prefix="/api/v1") +app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") + @app.get("/health") async def health_check(): """Health check endpoint. Used for Docker.""" diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 9604282..da9b94e 100644 --- a/api/src/teams/repository.py +++ b/api/src/teams/repository.py @@ -9,7 +9,7 @@ WorkspaceTeamItem, WorkspaceTeamUpdate, ) -from api.src.workspaces.schemas import User +from api.src.users.schemas import User class WorkspaceTeamRepository: diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py index 5437ab1..5163259 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -9,8 +9,9 @@ WorkspaceTeamItem, WorkspaceTeamUpdate, ) -from api.src.workspaces.repository import OSMRepository, WorkspaceRepository -from api.src.workspaces.schemas import User +from api.src.users.repository import UserRepository +from api.src.users.schemas import User +from api.src.workspaces.repository import WorkspaceRepository router = APIRouter(prefix="/workspaces/{workspace_id}/teams", tags=["teams"]) @@ -22,10 +23,10 @@ def get_workspace_repo( return repo -def get_osm_repo( +def get_user_repo( session: AsyncSession = Depends(get_osm_session), -) -> OSMRepository: - repository = OSMRepository(session) +) -> UserRepository: + repository = UserRepository(session) return repository @@ -56,6 +57,12 @@ async def create_team_for_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ) -> int: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can create teams", + ) + # 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) @@ -84,6 +91,12 @@ async def update_team_for_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can update teams", + ) + # 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) @@ -98,6 +111,12 @@ async def delete_team_from_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can delete teams", + ) + # 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) @@ -123,14 +142,14 @@ async def join_workspace_team( workspace_id: int, team_id: int, workspace_repo=Depends(get_workspace_repo), - osm_repo=Depends(get_osm_repo), + user_repo=Depends(get_user_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) + user = await user_repo.get_current_user(current_user) await team_repo.add_member(team_id, user.id) return user @@ -144,6 +163,12 @@ async def add_member_to_workspace_team( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can add team members", + ) + # 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) @@ -159,6 +184,12 @@ async def delete_member_from_workspace_team( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can remove team members", + ) + # 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) diff --git a/api/src/users/repository.py b/api/src/users/repository.py new file mode 100644 index 0000000..48bcd14 --- /dev/null +++ b/api/src/users/repository.py @@ -0,0 +1,113 @@ +from uuid import UUID + +from sqlalchemy import delete, select, update +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.core.security import UserInfo +from api.src.users.schemas import ( + User, + WorkspaceUserRole, + WorkspaceUserRoleItem, + WorkspaceUserRoleType, +) + + +class UserRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def getUsersForWorkspace( + self, + workspace_id: int, + ) -> list[WorkspaceUserRoleItem]: + query = ( + select(User, WorkspaceUserRole.role) + .join(WorkspaceUserRole, User.auth_uid == WorkspaceUserRole.user_auth_uid) + .where(WorkspaceUserRole.workspace_id == workspace_id) + ) + result = await self.session.execute(query) + + return [ + WorkspaceUserRoleItem( + id=user.id, + auth_uid=user.auth_uid, + email=user.email, + display_name=user.display_name, + role=role, + ) + for user, role in result.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, + workspace_id: int, + user_id: UUID, + role: WorkspaceUserRoleType, + ) -> None: + # Ensure the user has a local user record (signed in at least once): + user_exists = await self.session.scalar( + select(User.id).where(User.auth_uid == str(user_id)) + ) + if not user_exists: + raise NotFoundException( + f"User {user_id} has not signed in to Workspaces yet" + ) + + # Update role if the user already has one for this workspace: + result = await self.session.execute( + update(WorkspaceUserRole) + .where( + (WorkspaceUserRole.user_auth_uid == str(user_id)) + & (WorkspaceUserRole.workspace_id == workspace_id) + ) + .values(role=role) + ) + + if result.rowcount == 0: + self.session.add( + WorkspaceUserRole( + user_auth_uid=str(user_id), + workspace_id=workspace_id, + role=role, + ) + ) + + await self.session.commit() + + async def removeUserFromWorkspace( + self, + current_user: UserInfo, + workspace_id: int, + user_id: UUID, + ) -> None: + query = delete(WorkspaceUserRole).where( + (WorkspaceUserRole.workspace_id == workspace_id) + & (WorkspaceUserRole.user_auth_uid == str(user_id)) + ) + + result = await self.session.execute(query) + + if result.rowcount != 1: + raise NotFoundException( + f"No role assigned for workspace {workspace_id}, user {user_id}" + ) + + await self.session.commit() + + async def deleteRolesForWorkspace(self, workspace_id: int) -> None: + await self.session.execute( + delete(WorkspaceUserRole).where( + WorkspaceUserRole.workspace_id == workspace_id + ) + ) diff --git a/api/src/users/routes.py b/api/src/users/routes.py new file mode 100644 index 0000000..107f3fb --- /dev/null +++ b/api/src/users/routes.py @@ -0,0 +1,72 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session +from api.core.security import UserInfo, validate_token +from api.src.users.repository import UserRepository +from api.src.users.schemas import ( + SetRoleRequest, + WorkspaceUserRoleItem, + WorkspaceUserRoleType, +) + +router = APIRouter(prefix="/workspaces/{workspace_id}/users", tags=["users"]) + + +def get_user_repo( + session: AsyncSession = Depends(get_osm_session), +) -> UserRepository: + repository = UserRepository(session) + return repository + + +@router.get("", response_model=list[WorkspaceUserRoleItem]) +async def get_users( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), +): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User lacks permission to view members of workspace {workspace_id}", + ) + + return await user_repo.getUsersForWorkspace(workspace_id) + + +@router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT) +async def set_user_role( + workspace_id: int, + user_id: UUID, + body: SetRoleRequest, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), +): + if current_user.isWorkspaceLead(workspace_id) is False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User lacks permission to modify workspace {workspace_id}", + ) + + await user_repo.addUserToWorkspaceWithRole( + current_user, workspace_id, user_id, body.role + ) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user_with_role( + workspace_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), +): + if current_user.isWorkspaceLead(workspace_id) is False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User lacks permission to modify workspace {workspace_id}", + ) + + await user_repo.removeUserFromWorkspace(current_user, workspace_id, user_id) diff --git a/api/src/users/schemas.py b/api/src/users/schemas.py new file mode 100644 index 0000000..c548da9 --- /dev/null +++ b/api/src/users/schemas.py @@ -0,0 +1,76 @@ +from typing import TYPE_CHECKING +from enum import StrEnum + +from sqlalchemy import Column, Enum +from sqlmodel import Field, Relationship, SQLModel + +from api.src.teams.schemas import WorkspaceTeamUser + +if TYPE_CHECKING: + from api.src.teams.schemas import WorkspaceTeam + + +class WorkspaceUserRoleType(StrEnum): + LEAD = "lead" + VALIDATOR = "validator" + CONTRIBUTOR = "contributor" + + +class WorkspaceUserRole(SQLModel, table=True): + """Associates users with workspaces and their roles""" + + __tablename__ = "user_workspace_roles" # type: ignore[assignment] + + # this is the TDEI auth user UUID, from the token + user_auth_uid: str = Field(foreign_key="users.auth_uid", primary_key=True) + # workspace_id lives in a different DB (task DB), so no FK constraint here + workspace_id: int = Field(primary_key=True) + + role: WorkspaceUserRoleType = Field( + sa_column=Column( + Enum( + WorkspaceUserRoleType, + name="workspace_role", + create_type=False, + values_callable=lambda e: [m.value for m in e], + ), + nullable=False, + ) + ) + + +class SetRoleRequest(SQLModel): + role: WorkspaceUserRoleType + + +class WorkspaceUserRoleItem(SQLModel): + """ + User with their workspace role. DTO for use in the context of a particular + workspace + """ + + id: int + auth_uid: str + email: str + display_name: str + role: WorkspaceUserRoleType + + +class User(SQLModel, table=True): + """Users in the OSM DB""" + + __tablename__ = "users" # type: ignore[assignment] + + # User ID referred to by parts of the code based on the OSM DB schema: + id: int = Field(default=None, primary_key=True) + + # Principal ID from the TDEI OIDC gateway ("subject" in an access token). + # It differs from the TDEI user ID: + 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 + ) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 080f103..133f90c 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,5 +1,4 @@ -from typing import Any, cast -from uuid import UUID +from typing import Any from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError @@ -9,12 +8,9 @@ from api.core.security import UserInfo from api.src.workspaces.schemas import ( QuestDefinitionType, - User, Workspace, WorkspaceImagery, WorkspaceLongQuest, - WorkspaceUserRole, - WorkspaceUserRoleType, ) @@ -227,61 +223,3 @@ async def getWorkspaceBBox( raise NotFoundException(f"Workspace with id {workspace_id} not found") return retVal - - async def getAllUsers( - self, - ): - query = select(User) - 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, - workspace_id: int, - user_id: UUID, - role: WorkspaceUserRoleType, - ) -> None: - - userRole = WorkspaceUserRole( - auth_user_uid=cast(UUID, current_user.user_uuid), - workspace_id=workspace_id, - role=role, - ) - - try: - self.session.add(userRole) - await self.session.commit() - except IntegrityError: - await self.session.rollback() - raise AlreadyExistsException( - "User association with that workspace already exists" - ) - - async def removeUserFromWorkspace( - self, - current_user: UserInfo, - workspace_id: int, - user_id: UUID, - ) -> None: - query = delete(WorkspaceUserRole).where( - (WorkspaceUserRole.workspace_id == workspace_id) # type: ignore[reportArgumentType] - & (WorkspaceUserRole.auth_user_uid == user_id) - ) - - result = await self.session.execute(query) - - if result.rowcount != 1: - raise NotFoundException( - f"User association removal failed for workspace {workspace_id} and user {user_id}" - ) - - await self.session.commit() diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 86d890d..fdac000 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,5 +1,4 @@ from typing import Any -from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel.ext.asyncio.session import AsyncSession @@ -7,21 +6,44 @@ from api.core.database import get_osm_session, get_task_session from api.core.logging import get_logger from api.core.security import UserInfo, validate_token +from api.src.users.repository import UserRepository +from api.src.users.schemas import WorkspaceUserRoleType from api.src.workspaces.repository import OSMRepository, WorkspaceRepository from api.src.workspaces.schemas import ( QuestDefinitionType, Workspace, WorkspaceImagery, WorkspaceLongQuest, - WorkspaceUserRoleType, + WorkspaceResponse, ) + # Set up logger for this module logger = get_logger(__name__) router = APIRouter(prefix="/workspaces", tags=["workspaces"]) +def _to_response(workspace: Workspace, user: "UserInfo") -> WorkspaceResponse: + """Convert a Workspace ORM object to a response model with the user's effective role.""" + return WorkspaceResponse( + id=workspace.id, + type=workspace.type, + title=workspace.title, + description=workspace.description, + tdeiProjectGroupId=workspace.tdeiProjectGroupId, + tdeiRecordId=workspace.tdeiRecordId, + tdeiServiceId=workspace.tdeiServiceId, + tdeiMetadata=workspace.tdeiMetadata, + createdAt=workspace.createdAt, + createdBy=workspace.createdBy, + createdByName=workspace.createdByName, + externalAppAccess=workspace.externalAppAccess, + kartaViewToken=workspace.kartaViewToken, + role=user.effectiveRole(workspace.id), + ) + + def get_workspace_repository( session: AsyncSession = Depends(get_task_session), ) -> WorkspaceRepository: @@ -36,27 +58,33 @@ def get_osm_repository( return repository +def get_user_repository( + session: AsyncSession = Depends(get_osm_session), +) -> UserRepository: + return UserRepository(session) + + # Returns list of workspaces user has access to as JSON payload on success--returns empty JSON list if none -@router.get("/mine", response_model=list[Workspace]) +@router.get("/mine", response_model=list[WorkspaceResponse]) async def get_my_workspaces( repository: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> list[Workspace]: +) -> list[WorkspaceResponse]: try: workspaces = await repository.getAll(current_user) - return workspaces + return [_to_response(ws, current_user) for ws in workspaces] except Exception as e: logger.error(f"Failed to fetch workspaces: {str(e)}") raise # Returns JSON payload or 204 if not found -@router.get("/{workspace_id}", response_model=Workspace) +@router.get("/{workspace_id}", response_model=WorkspaceResponse) async def get_workspace( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> Workspace: +) -> WorkspaceResponse: try: workspace = await repository_ws.getById(current_user, workspace_id) @@ -66,7 +94,7 @@ async def get_workspace( detail="No Content", ) - return workspace + return _to_response(workspace, current_user) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise @@ -106,10 +134,22 @@ async def get_workspace_bbox( async def create_workspace( workspace_data: dict[str, Any], repository_ws: WorkspaceRepository = Depends(get_workspace_repository), + repository_users: UserRepository = Depends(get_user_repository), current_user: UserInfo = Depends(validate_token), ) -> Workspace: try: workspace = await repository_ws.create(current_user, workspace_data) + + # Assign the creator as lead so that non-POC members can manage their + # own workspace: + # + await repository_users.addUserToWorkspaceWithRole( + current_user, + workspace.id, + current_user.user_uuid, + WorkspaceUserRoleType.LEAD, + ) + return workspace except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") @@ -145,6 +185,7 @@ async def update_workspace( async def delete_workspace( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), + repository_users: UserRepository = Depends(get_user_repository), current_user: UserInfo = Depends(validate_token), ) -> None: if current_user.isWorkspaceLead(workspace_id) is False: @@ -154,6 +195,7 @@ async def delete_workspace( ) try: + await repository_users.deleteRolesForWorkspace(workspace_id) await repository_ws.delete(current_user, workspace_id) except Exception as e: logger.error(f"Failed to delete workspace {workspace_id}: {str(e)}") @@ -264,52 +306,3 @@ async def update_imagery_settings( except Exception as e: logger.error(f"Failed to update workspace {workspace_id}: {str(e)}") raise - - -### USERS - - -@router.get("/{workspace_id}/users") -async def get_users( - workspace_id: int, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - return await repository_osm.getAllUsers() - - -@router.post("/{workspace_id}/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def add_user_with_role( - workspace_id: int, - user_id: UUID, - role: WorkspaceUserRoleType, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - if current_user.isWorkspaceLead(workspace_id) is False: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User does not have permission to edit this workspace", - ) - - return await repository_osm.addUserToWorkspaceWithRole( - current_user, workspace_id, user_id, role - ) - - -@router.delete("/{workspace_id}/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_user_with_role( - workspace_id: int, - user_id: UUID, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - if current_user.isWorkspaceLead(workspace_id) is False: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User does not have permission to edit this workspace", - ) - - return await repository_osm.removeUserFromWorkspace( - current_user, workspace_id, user_id - ) diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 2c4e423..45369d3 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, TYPE_CHECKING +from typing import Any, Optional from uuid import UUID from geoalchemy2 import Geometry @@ -8,11 +8,6 @@ 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.""" @@ -74,12 +69,6 @@ class QuestDefinitionType(IntEnum): URL = 2 -class WorkspaceUserRoleType(StrEnum): - LEAD = "lead" - VALIDATOR = "validator" - CONTRIBUTOR = "contributor" - - class WorkspaceLongQuest(SQLModel, table=True): """Stores mobile app quest definitions for a workspace""" @@ -121,36 +110,23 @@ class WorkspaceImagery(SQLModel, table=True): modifiedByName: str -class WorkspaceUserRole(SQLModel, table=True): - """Associates users with workspaces and their roles""" - - __tablename__ = "user_workspace_roles" # type: ignore[assignment] - - # this is the TDEI auth user UUID, from the token - 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( - sa_column=Column(StrEnumType(WorkspaceUserRoleType), nullable=False) - ) - - -class User(SQLModel, table=True): - """Users""" - - __tablename__ = "users" # type: ignore[assignment] +class WorkspaceResponse(SQLModel): + """Workspace serialized for API responses — includes computed role for the requesting user.""" - id: int = Field(default=None, primary_key=True) - - # this is the user ID from the TDEI authentication system - 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 - ) + id: int + type: WorkspaceType + title: str + description: Optional[str] = None + tdeiProjectGroupId: UUID + tdeiRecordId: Optional[UUID] = None + tdeiServiceId: Optional[UUID] = None + tdeiMetadata: Optional[Any] = None + createdAt: datetime + createdBy: UUID + createdByName: str + externalAppAccess: ExternalAppsDefinitionType + kartaViewToken: Optional[str] = None + role: str # 'lead', 'validator', or 'contributor' class Workspace(SQLModel, table=True): From 7ea263705dac63e6dceb39f61af54d9d06167191 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 11 Jan 2026 13:00:41 -0800 Subject: [PATCH 5/8] Evict user info cache entries when roles change --- api/core/security.py | 36 ++++++++++++++++++++++++++++-------- api/src/users/routes.py | 4 +++- api/src/workspaces/routes.py | 8 +++++++- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index 7394308..d819545 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -17,11 +17,25 @@ # Set up logger for this module logger = get_logger(__name__) -# TTL cache for token validation (1 hour TTL, max 1000 entries) -_token_cache: cachetools.TTLCache[str, "UserInfo"] = cachetools.TTLCache( +# TTL cache keyed by a user's OIDC subject. Evict entries when roles change. We +# still validate the JWT signature and expiry on every request before reading a +# cached record. +_user_info_cache: cachetools.TTLCache[str, "UserInfo"] = cachetools.TTLCache( maxsize=1000, ttl=60 * 60 ) + +def evict_user_from_cache(auth_uid: str) -> None: + """ + Evict a user's cached UserInfo object so that their next request re-fetches + permissions. + + Call this after modifying a user's roles in the OSM DB to ensure the change + takes effect on their next request rather than after the cache TTL expires. + """ + _user_info_cache.pop(auth_uid, None) + + security = HTTPBearer() @@ -129,6 +143,7 @@ def get_task_db_session( ) -> AsyncSession: return session + async def validate_token( credentials: HTTPAuthorizationCredentials = Depends(security), osm_db_session: AsyncSession = Depends(get_osm_db_session), @@ -136,12 +151,12 @@ async def validate_token( ) -> UserInfo: """Dependency to get current authenticated user from TDEI/KeyCloak token and APIs. - Results are cached by token for 1 hour to avoid repeated validation calls. + The JWT signature and expiry are validated on every request. The expensive + TDEI API and DB lookups are cached for 1 hour and should be evicted when a + user's role changes via evict_user_from_cache(). """ token = credentials.credentials - # Check cache first - if token in _token_cache: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials", @@ -152,17 +167,22 @@ async def validate_token( payload = validate_and_decode_token(token) except Exception: raise credentials_exception + user_id: str | None = payload.get("sub") if user_id is None: raise credentials_exception + + # Cache keyed by user ID so roles take effect immediately after eviction: + if user_id in _user_info_cache: logger.info("Token validation cache hit") - return _token_cache[token] + return _user_info_cache[user_id] - # Cache miss - perform full validation - _token_cache[token] = user_info + # Cache miss: fetch TDEI roles and DB data: user_info = await _validate_token_uncached( token, user_id, payload, osm_db_session, task_db_session ) + _user_info_cache[user_id] = user_info + return user_info diff --git a/api/src/users/routes.py b/api/src/users/routes.py index 107f3fb..bc84dde 100644 --- a/api/src/users/routes.py +++ b/api/src/users/routes.py @@ -4,7 +4,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session -from api.core.security import UserInfo, validate_token +from api.core.security import UserInfo, evict_user_from_cache, validate_token from api.src.users.repository import UserRepository from api.src.users.schemas import ( SetRoleRequest, @@ -54,6 +54,7 @@ async def set_user_role( await user_repo.addUserToWorkspaceWithRole( current_user, workspace_id, user_id, body.role ) + evict_user_from_cache(str(user_id)) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -70,3 +71,4 @@ async def remove_user_with_role( ) await user_repo.removeUserFromWorkspace(current_user, workspace_id, user_id) + evict_user_from_cache(str(user_id)) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index fdac000..fc82a59 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -5,7 +5,7 @@ from api.core.database import get_osm_session, get_task_session from api.core.logging import get_logger -from api.core.security import UserInfo, validate_token +from api.core.security import UserInfo, evict_user_from_cache, validate_token from api.src.users.repository import UserRepository from api.src.users.schemas import WorkspaceUserRoleType from api.src.workspaces.repository import OSMRepository, WorkspaceRepository @@ -150,6 +150,12 @@ async def create_workspace( WorkspaceUserRoleType.LEAD, ) + # Evict the creator's cache so their next request reflects the new + # workspace and lead role rather than serving stale data for up to + # an hour: + # + evict_user_from_cache(str(current_user.user_uuid)) + return workspace except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") From e62dfb041b7eab60b2168a202fb9ed71c2fd8037 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Mon, 29 Jun 2026 16:35:13 +0530 Subject: [PATCH 6/8] Alembic config issue resolution --- alembic_osm/env.py | 2 ++ alembic_task/env.py | 1 + 2 files changed, 3 insertions(+) diff --git a/alembic_osm/env.py b/alembic_osm/env.py index 9ec3f3e..dabb05a 100644 --- a/alembic_osm/env.py +++ b/alembic_osm/env.py @@ -35,6 +35,8 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) +config.file_config.interpolation = None + # Set sqlalchemy.url from settings config.set_main_option("sqlalchemy.url", settings.OSM_DATABASE_URL) diff --git a/alembic_task/env.py b/alembic_task/env.py index 3d1a2b3..fcb5d00 100644 --- a/alembic_task/env.py +++ b/alembic_task/env.py @@ -35,6 +35,7 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) +config.file_config.interpolation = None # Set sqlalchemy.url from settings config.set_main_option("sqlalchemy.url", settings.TASK_DATABASE_URL) From 600bcbe9bed7bbb171897b182a8b67b81666b032 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Mon, 29 Jun 2026 17:21:04 +0530 Subject: [PATCH 7/8] escapes % with double %% --- alembic_osm/env.py | 4 ++-- alembic_task/env.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/alembic_osm/env.py b/alembic_osm/env.py index dabb05a..756a877 100644 --- a/alembic_osm/env.py +++ b/alembic_osm/env.py @@ -36,9 +36,9 @@ fileConfig(config.config_file_name) config.file_config.interpolation = None - +escaped_url = settings.OSM_DATABASE_URL.replace("%", "%%") # Set sqlalchemy.url from settings -config.set_main_option("sqlalchemy.url", settings.OSM_DATABASE_URL) +config.set_main_option("sqlalchemy.url", escaped_url) # Add your model's MetaData object here for 'autogenerate' support target_metadata = Base.metadata diff --git a/alembic_task/env.py b/alembic_task/env.py index fcb5d00..e5f8d71 100644 --- a/alembic_task/env.py +++ b/alembic_task/env.py @@ -35,9 +35,10 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) -config.file_config.interpolation = None +# config.file_config.interpolation = None +escaped_url = settings.TASK_DATABASE_URL.replace("%", "%%") # Set sqlalchemy.url from settings -config.set_main_option("sqlalchemy.url", settings.TASK_DATABASE_URL) +config.set_main_option("sqlalchemy.url", escaped_url) # Add your model's MetaData object here for 'autogenerate' support target_metadata = Base.metadata From 37589121ecc3cdf28bef1d93ddd3bd2608b2605d Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Mon, 29 Jun 2026 18:08:57 +0530 Subject: [PATCH 8/8] Adds CORS Middleware ADDS CORS Middleware --- api/core/config.py | 7 ++++++- api/main.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/api/core/config.py b/api/core/config.py index 94edba6..b2cdbaa 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -3,7 +3,12 @@ class Settings(BaseSettings): """Application settings.""" PROJECT_NAME: str = "Workspaces API" - + # JSON array of allowed CORS origins. For example: + # + # ["https://workspaces.example.com", "https://leaderboard.example.com"] + # + CORS_ORIGINS: list[str] = [] + TASK_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" OSM_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" diff --git a/api/main.py b/api/main.py index d39d76f..754645f 100644 --- a/api/main.py +++ b/api/main.py @@ -4,6 +4,7 @@ import httpx import sentry_sdk from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, StreamingResponse from sqlmodel.ext.asyncio.session import AsyncSession from starlette.background import BackgroundTask @@ -42,6 +43,15 @@ swagger_ui_parameters={"syntaxHighlight": False}, ) +# Set up CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, # Adjust this to your needs + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Include routers app.include_router(teams_router, prefix="/api/v1") app.include_router(users_router, prefix="/api/v1")