From e4c848fbceb4529be446846a8187768bfa7a70c2 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 3 Jan 2026 19:31:25 -0800 Subject: [PATCH 1/2] Finish implementation of stubbed roles APIs --- api/core/exceptions.py | 7 ++ api/core/security.py | 9 ++- api/main.py | 2 + api/src/teams/repository.py | 2 +- api/src/teams/routes.py | 47 ++++++++++--- api/src/users/repository.py | 109 +++++++++++++++++++++++++++++ api/src/users/routes.py | 87 ++++++++++++++++++++++++ api/src/users/schemas.py | 84 +++++++++++++++++++++++ api/src/workspaces/repository.py | 64 +---------------- api/src/workspaces/routes.py | 113 ++++++++++++++----------------- api/src/workspaces/schemas.py | 61 ++++++----------- 11 files changed, 410 insertions(+), 175 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 3e60de1..c036621 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__) @@ -125,6 +125,13 @@ def isWorkspaceContributor(self, workspaceId: int) -> bool: return True return False + def effective_role(self, workspaceId: int) -> WorkspaceUserRoleType: + if self.isWorkspaceLead(workspaceId): + return WorkspaceUserRoleType.LEAD + if self.isWorkspaceValidator(workspaceId): + return WorkspaceUserRoleType.VALIDATOR + return WorkspaceUserRoleType.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 d8c4978..1c4ee21 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,7 @@ 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 @@ -85,6 +86,7 @@ async def lifespan(_app: FastAPI): # 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") diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 93be2a3..2b6c09e 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 c031fae..5163259 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status +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 @@ -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..b5d67e8 --- /dev/null +++ b/api/src/users/repository.py @@ -0,0 +1,109 @@ +from uuid import UUID + +from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +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 get_privileged_workspace_members( + self, + workspace_id: int, + ) -> list[WorkspaceUserRoleItem]: + # The table only stores "lead" and "validator" role assignments. Project + # group members implicitly have the base "contributor" role. + # + 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 assign_member_role( + self, + 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" + ) + + await self.session.execute( + pg_insert(WorkspaceUserRole) + .values( + user_auth_uid=str(user_id), + workspace_id=workspace_id, + role=role, + ) + .on_conflict_do_update( + index_elements=["user_auth_uid", "workspace_id"], + set_={"role": role}, + ) + ) + await self.session.commit() + + async def remove_member_role( + self, + 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 remove_all_member_roles(self, workspace_id: int) -> None: + await self.session.execute( + delete(WorkspaceUserRole).where( + WorkspaceUserRole.workspace_id == workspace_id + ) + ) + await self.session.commit() diff --git a/api/src/users/routes.py b/api/src/users/routes.py new file mode 100644 index 0000000..e9914bb --- /dev/null +++ b/api/src/users/routes.py @@ -0,0 +1,87 @@ +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, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.users.repository import UserRepository +from api.src.users.schemas import SetRoleRequest, WorkspaceUserRoleItem +from api.src.workspaces.repository import WorkspaceRepository + +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 + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +@router.get("", response_model=list[WorkspaceUserRoleItem]) +async def get_privileged_workspace_members( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), +): + if not current_user.isWorkspaceContributor(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Project group membership required to view members", + ) + + return await user_repo.get_privileged_workspace_members(workspace_id) + + +@router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT) +async def assign_member_role( + workspace_id: int, + user_id: UUID, + body: SetRoleRequest, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), +): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Must be a workspace owner to assign roles", + ) + + # Ensure that the workspace exists in the tasks DB before we write to the + # OSM DB. TODO: remove the check when we merge the DBs with the proper FK + # constraints that enforce referential integrity internally. + # + await workspace_repo.getById(current_user, workspace_id) + + await user_repo.assign_member_role(workspace_id, user_id, body.role) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_member_role( + workspace_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), +): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Must be a workspace owner to remove roles", + ) + + # Ensure that the workspace exists in the tasks DB before we write to the + # OSM DB. TODO: remove the check when we merge the DBs with the proper FK + # constraints that enforce referential integrity internally. + # + await workspace_repo.getById(current_user, workspace_id) + + await user_repo.remove_member_role(workspace_id, user_id) diff --git a/api/src/users/schemas.py b/api/src/users/schemas.py new file mode 100644 index 0000000..82c9d62 --- /dev/null +++ b/api/src/users/schemas.py @@ -0,0 +1,84 @@ +from enum import StrEnum +from typing import TYPE_CHECKING + +from pydantic import field_validator +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 + + @field_validator("role") + @classmethod + def privileged_roles_only(cls, v: WorkspaceUserRoleType) -> WorkspaceUserRoleType: + if v == WorkspaceUserRoleType.CONTRIBUTOR: + raise ValueError("cannot assign implicit role 'contributor' directly") + return v + + +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 62a8e85..c56b7eb 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, ) @@ -232,61 +228,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, user_id), - 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: # type: ignore[attr-defined] - 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..92af22c 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,13 +6,15 @@ 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 @@ -22,6 +23,26 @@ 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.effective_role(workspace.id), + ) + + def get_workspace_repository( session: AsyncSession = Depends(get_task_session), ) -> WorkspaceRepository: @@ -36,27 +57,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 +93,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 +133,21 @@ 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.assign_member_role( + workspace.id, + current_user.user_uuid, + WorkspaceUserRoleType.LEAD, + ) + return workspace except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") @@ -124,7 +162,7 @@ async def update_workspace( repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> Workspace: - if current_user.isWorkspaceLead(workspace_id) is False: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to update this workspace", @@ -145,9 +183,10 @@ 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: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to delete this workspace", @@ -155,6 +194,7 @@ async def delete_workspace( try: await repository_ws.delete(current_user, workspace_id) + await repository_users.remove_all_member_roles(workspace_id) except Exception as e: logger.error(f"Failed to delete workspace {workspace_id}: {str(e)}") raise @@ -200,7 +240,7 @@ async def update_long_quest_settings( repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to edit this workspace", @@ -253,7 +293,7 @@ async def update_imagery_settings( repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to edit this workspace", @@ -264,52 +304,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 af8c859..cd97152 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 TYPE_CHECKING, Any, Optional +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,26 @@ 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 the effective role for the + user making the request. + """ - 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 class Workspace(SQLModel, table=True): From 583ad8d7d65da91fef269dcc458ec5ea9acb15b4 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 11 Jan 2026 13:00:41 -0800 Subject: [PATCH 2/2] Evict user info cache entries when roles change --- api/core/security.py | 68 +++++++++++++++++++++++++----------- api/src/users/routes.py | 4 ++- api/src/workspaces/routes.py | 12 ++++++- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index c036621..c283af4 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -17,8 +17,10 @@ # 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[UUID, "UserInfo"] = cachetools.TTLCache( maxsize=1000, ttl=60 * 60 ) @@ -41,6 +43,17 @@ async def close_tdei_client() -> None: _tdei_client = None +def evict_user_from_cache(auth_uid: UUID) -> 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() @@ -72,6 +85,7 @@ class UserInfo: credentials: str user_uuid: UUID user_name: str + token_jti: str # JWT ID used to detect token rotation on cache hits # workspaceId, role from OSM DB osmWorkspaceRoles: dict[int, list[WorkspaceUserRoleType]] @@ -151,9 +165,13 @@ async def validate_token( osm_db_session: AsyncSession = Depends(get_osm_db_session), task_db_session: AsyncSession = Depends(get_task_db_session), ) -> UserInfo: - """Dependency to get current authenticated user from TDEI/KeyCloak token and APIs. + """ + Dependency that gets the current authenticated user from the TDEI/KeyCloak + access token and fetches permissions from TDEI APIs. - Results are cached by token for 1 hour to avoid repeated validation calls. + We validate the JWT's signature and expiry 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 @@ -168,27 +186,39 @@ async def validate_token( except Exception: raise credentials_exception - user_id: str | None = payload.get("sub") - if user_id is None: + user_id_str: str | None = payload.get("sub") + if user_id_str is None: raise credentials_exception - # Check cache first - if token in _token_cache: - logger.info("Token validation cache hit") - return _token_cache[token] + try: + user_uuid = UUID(user_id_str) + except ValueError: + raise credentials_exception from None - # Cache miss - perform full validation + # Cache keyed by user UUID. If the token rotated (new "jti") since we + # created the cache entry, evict it so we fetch fresh claims: + # + if user_uuid in _user_info_cache: + cached = _user_info_cache[user_uuid] + current_jti = payload.get("jti", "") + if cached.token_jti == current_jti: + logger.info("Token validation cache hit") + return cached + logger.info("Token validation cache miss: token rotated") + del _user_info_cache[user_uuid] + + # Cache miss: fetch TDEI roles and DB data: user_info = await _validate_token_uncached( - token, user_id, payload, osm_db_session, task_db_session + token, user_uuid, payload, osm_db_session, task_db_session ) - _token_cache[token] = user_info + _user_info_cache[user_uuid] = user_info return user_info async def _validate_token_uncached( token: str, - user_id: str, + user_uuid: UUID, payload: dict, osm_db_session: AsyncSession, task_db_session: AsyncSession, @@ -207,13 +237,9 @@ async def _validate_token_uncached( } r = UserInfo() - - try: - r.user_uuid = UUID(user_id) - except ValueError: - raise credentials_exception from None - + r.user_uuid = user_uuid r.credentials = token + r.token_jti = payload.get("jti", "") r.user_name = payload.get("preferred_username", "unknown") # get user's project groups and roles from TDEI @@ -221,7 +247,7 @@ async def _validate_token_uncached( try: response = await _tdei_client.get( - f"project-group-roles/{user_id}", + f"project-group-roles/{user_uuid}", headers=headers, params={"page_no": 1, "page_size": 1000}, ) diff --git a/api/src/users/routes.py b/api/src/users/routes.py index e9914bb..54d3527 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, get_task_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, WorkspaceUserRoleItem from api.src.workspaces.repository import WorkspaceRepository @@ -62,6 +62,7 @@ async def assign_member_role( await workspace_repo.getById(current_user, workspace_id) await user_repo.assign_member_role(workspace_id, user_id, body.role) + evict_user_from_cache(user_id) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -85,3 +86,4 @@ async def remove_member_role( await workspace_repo.getById(current_user, workspace_id) await user_repo.remove_member_role(workspace_id, user_id) + evict_user_from_cache(user_id) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 92af22c..097e31a 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,11 +1,12 @@ from typing import Any +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, 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 @@ -148,6 +149,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(current_user.user_uuid) + return workspace except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") @@ -193,8 +200,11 @@ async def delete_workspace( ) try: + members = await repository_users.get_privileged_workspace_members(workspace_id) await repository_ws.delete(current_user, workspace_id) await repository_users.remove_all_member_roles(workspace_id) + for member in members: + evict_user_from_cache(UUID(member.auth_uid)) except Exception as e: logger.error(f"Failed to delete workspace {workspace_id}: {str(e)}") raise