Skip to content

Commit e4c848f

Browse files
committed
Finish implementation of stubbed roles APIs
1 parent 3a38839 commit e4c848f

11 files changed

Lines changed: 410 additions & 175 deletions

File tree

api/core/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ def __init__(self, detail: str = "Resource already exists"):
1515
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
1616

1717

18+
class ConflictException(HTTPException):
19+
"""Base exception for conflict errors."""
20+
21+
def __init__(self, detail: str = "Conflict"):
22+
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
23+
24+
1825
class UnauthorizedException(HTTPException):
1926
"""Base exception for unauthorized access errors."""
2027

api/core/security.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from api.core.database import get_osm_session, get_task_session
1313
from api.core.jwt import validate_and_decode_token
1414
from api.core.logging import get_logger
15-
from api.src.workspaces.schemas import WorkspaceUserRoleType
15+
from api.src.users.schemas import WorkspaceUserRoleType
1616

1717
# Set up logger for this module
1818
logger = get_logger(__name__)
@@ -125,6 +125,13 @@ def isWorkspaceContributor(self, workspaceId: int) -> bool:
125125
return True
126126
return False
127127

128+
def effective_role(self, workspaceId: int) -> WorkspaceUserRoleType:
129+
if self.isWorkspaceLead(workspaceId):
130+
return WorkspaceUserRoleType.LEAD
131+
if self.isWorkspaceValidator(workspaceId):
132+
return WorkspaceUserRoleType.VALIDATOR
133+
return WorkspaceUserRoleType.CONTRIBUTOR
134+
128135

129136
# can't use the ORM here since the ORM uses us! (circular dependency)
130137
def get_osm_db_session(

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
validate_token,
2323
)
2424
from api.src.teams.routes import router as teams_router
25+
from api.src.users.routes import router as users_router
2526
from api.src.workspaces.repository import WorkspaceRepository
2627
from api.src.workspaces.routes import router as workspaces_router
2728
from api.utils.migrations import run_migrations
@@ -85,6 +86,7 @@ async def lifespan(_app: FastAPI):
8586

8687
# Include routers
8788
app.include_router(teams_router, prefix="/api/v1")
89+
app.include_router(users_router, prefix="/api/v1")
8890
app.include_router(workspaces_router, prefix="/api/v1")
8991

9092

api/src/teams/repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
WorkspaceTeamItem,
1010
WorkspaceTeamUpdate,
1111
)
12-
from api.src.workspaces.schemas import User
12+
from api.src.users.schemas import User
1313

1414

1515
class WorkspaceTeamRepository:

api/src/teams/routes.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from fastapi import APIRouter, Depends, status
1+
from fastapi import APIRouter, Depends, HTTPException, status
22
from sqlmodel.ext.asyncio.session import AsyncSession
33

44
from api.core.database import get_osm_session, get_task_session
@@ -9,8 +9,9 @@
99
WorkspaceTeamItem,
1010
WorkspaceTeamUpdate,
1111
)
12-
from api.src.workspaces.repository import OSMRepository, WorkspaceRepository
13-
from api.src.workspaces.schemas import User
12+
from api.src.users.repository import UserRepository
13+
from api.src.users.schemas import User
14+
from api.src.workspaces.repository import WorkspaceRepository
1415

1516
router = APIRouter(prefix="/workspaces/{workspace_id}/teams", tags=["teams"])
1617

@@ -22,10 +23,10 @@ def get_workspace_repo(
2223
return repo
2324

2425

25-
def get_osm_repo(
26+
def get_user_repo(
2627
session: AsyncSession = Depends(get_osm_session),
27-
) -> OSMRepository:
28-
repository = OSMRepository(session)
28+
) -> UserRepository:
29+
repository = UserRepository(session)
2930
return repository
3031

3132

@@ -56,6 +57,12 @@ async def create_team_for_workspace(
5657
team_repo=Depends(get_team_repo),
5758
current_user: UserInfo = Depends(validate_token),
5859
) -> int:
60+
if not current_user.isWorkspaceLead(workspace_id):
61+
raise HTTPException(
62+
status_code=status.HTTP_403_FORBIDDEN,
63+
detail="Only workspace leads can create teams",
64+
)
65+
5966
# Repo guards if workspace doesn't exist or user cannot access:
6067
await workspace_repo.getById(current_user, workspace_id)
6168
return await team_repo.create(workspace_id, team)
@@ -84,6 +91,12 @@ async def update_team_for_workspace(
8491
team_repo=Depends(get_team_repo),
8592
current_user: UserInfo = Depends(validate_token),
8693
):
94+
if not current_user.isWorkspaceLead(workspace_id):
95+
raise HTTPException(
96+
status_code=status.HTTP_403_FORBIDDEN,
97+
detail="Only workspace leads can update teams",
98+
)
99+
87100
# Repo guards if workspace doesn't exist or user cannot access:
88101
await workspace_repo.getById(current_user, workspace_id)
89102
await team_repo.assert_team_in_workspace(team_id, workspace_id)
@@ -98,6 +111,12 @@ async def delete_team_from_workspace(
98111
team_repo=Depends(get_team_repo),
99112
current_user: UserInfo = Depends(validate_token),
100113
):
114+
if not current_user.isWorkspaceLead(workspace_id):
115+
raise HTTPException(
116+
status_code=status.HTTP_403_FORBIDDEN,
117+
detail="Only workspace leads can delete teams",
118+
)
119+
101120
# Repo guards if workspace doesn't exist or user cannot access:
102121
await workspace_repo.getById(current_user, workspace_id)
103122
await team_repo.assert_team_in_workspace(team_id, workspace_id)
@@ -123,14 +142,14 @@ async def join_workspace_team(
123142
workspace_id: int,
124143
team_id: int,
125144
workspace_repo=Depends(get_workspace_repo),
126-
osm_repo=Depends(get_osm_repo),
145+
user_repo=Depends(get_user_repo),
127146
team_repo=Depends(get_team_repo),
128147
current_user: UserInfo = Depends(validate_token),
129148
) -> User:
130149
# Repo guards if workspace doesn't exist or user cannot access:
131150
await workspace_repo.getById(current_user, workspace_id)
132151
await team_repo.assert_team_in_workspace(team_id, workspace_id)
133-
user = await osm_repo.get_current_user(current_user)
152+
user = await user_repo.get_current_user(current_user)
134153
await team_repo.add_member(team_id, user.id)
135154
return user
136155

@@ -144,6 +163,12 @@ async def add_member_to_workspace_team(
144163
team_repo=Depends(get_team_repo),
145164
current_user: UserInfo = Depends(validate_token),
146165
):
166+
if not current_user.isWorkspaceLead(workspace_id):
167+
raise HTTPException(
168+
status_code=status.HTTP_403_FORBIDDEN,
169+
detail="Only workspace leads can add team members",
170+
)
171+
147172
# Repo guards if workspace doesn't exist or user cannot access:
148173
await workspace_repo.getById(current_user, workspace_id)
149174
await team_repo.assert_team_in_workspace(team_id, workspace_id)
@@ -159,6 +184,12 @@ async def delete_member_from_workspace_team(
159184
team_repo=Depends(get_team_repo),
160185
current_user: UserInfo = Depends(validate_token),
161186
):
187+
if not current_user.isWorkspaceLead(workspace_id):
188+
raise HTTPException(
189+
status_code=status.HTTP_403_FORBIDDEN,
190+
detail="Only workspace leads can remove team members",
191+
)
192+
162193
# Repo guards if workspace doesn't exist or user cannot access:
163194
await workspace_repo.getById(current_user, workspace_id)
164195
await team_repo.assert_team_in_workspace(team_id, workspace_id)

api/src/users/repository.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from uuid import UUID
2+
3+
from sqlalchemy import delete, select
4+
from sqlalchemy.dialects.postgresql import insert as pg_insert
5+
from sqlmodel.ext.asyncio.session import AsyncSession
6+
7+
from api.core.exceptions import NotFoundException
8+
from api.core.security import UserInfo
9+
from api.src.users.schemas import (
10+
User,
11+
WorkspaceUserRole,
12+
WorkspaceUserRoleItem,
13+
WorkspaceUserRoleType,
14+
)
15+
16+
17+
class UserRepository:
18+
19+
def __init__(self, session: AsyncSession):
20+
self.session = session
21+
22+
async def get_privileged_workspace_members(
23+
self,
24+
workspace_id: int,
25+
) -> list[WorkspaceUserRoleItem]:
26+
# The table only stores "lead" and "validator" role assignments. Project
27+
# group members implicitly have the base "contributor" role.
28+
#
29+
query = (
30+
select(User, WorkspaceUserRole.role)
31+
.join(WorkspaceUserRole, User.auth_uid == WorkspaceUserRole.user_auth_uid)
32+
.where(WorkspaceUserRole.workspace_id == workspace_id)
33+
)
34+
result = await self.session.execute(query)
35+
36+
return [
37+
WorkspaceUserRoleItem(
38+
id=user.id,
39+
auth_uid=user.auth_uid,
40+
email=user.email,
41+
display_name=user.display_name,
42+
role=role,
43+
)
44+
for user, role in result.all()
45+
]
46+
47+
async def get_current_user(self, current_user: UserInfo) -> User:
48+
result = await self.session.exec(
49+
select(User).where(User.auth_uid == str(current_user.user_uuid))
50+
)
51+
52+
# Current user should exist--throw if it doesn't:
53+
return result.scalar_one()
54+
55+
async def assign_member_role(
56+
self,
57+
workspace_id: int,
58+
user_id: UUID,
59+
role: WorkspaceUserRoleType,
60+
) -> None:
61+
# Ensure the user has a local user record (signed in at least once):
62+
user_exists = await self.session.scalar(
63+
select(User.id).where(User.auth_uid == str(user_id))
64+
)
65+
if not user_exists:
66+
raise NotFoundException(
67+
f"User {user_id} has not signed in to Workspaces yet"
68+
)
69+
70+
await self.session.execute(
71+
pg_insert(WorkspaceUserRole)
72+
.values(
73+
user_auth_uid=str(user_id),
74+
workspace_id=workspace_id,
75+
role=role,
76+
)
77+
.on_conflict_do_update(
78+
index_elements=["user_auth_uid", "workspace_id"],
79+
set_={"role": role},
80+
)
81+
)
82+
await self.session.commit()
83+
84+
async def remove_member_role(
85+
self,
86+
workspace_id: int,
87+
user_id: UUID,
88+
) -> None:
89+
query = delete(WorkspaceUserRole).where(
90+
(WorkspaceUserRole.workspace_id == workspace_id)
91+
& (WorkspaceUserRole.user_auth_uid == str(user_id))
92+
)
93+
94+
result = await self.session.execute(query)
95+
96+
if result.rowcount != 1:
97+
raise NotFoundException(
98+
f"No role assigned for workspace {workspace_id}, user {user_id}"
99+
)
100+
101+
await self.session.commit()
102+
103+
async def remove_all_member_roles(self, workspace_id: int) -> None:
104+
await self.session.execute(
105+
delete(WorkspaceUserRole).where(
106+
WorkspaceUserRole.workspace_id == workspace_id
107+
)
108+
)
109+
await self.session.commit()

api/src/users/routes.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from uuid import UUID
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlmodel.ext.asyncio.session import AsyncSession
5+
6+
from api.core.database import get_osm_session, get_task_session
7+
from api.core.security import UserInfo, validate_token
8+
from api.src.users.repository import UserRepository
9+
from api.src.users.schemas import SetRoleRequest, WorkspaceUserRoleItem
10+
from api.src.workspaces.repository import WorkspaceRepository
11+
12+
router = APIRouter(prefix="/workspaces/{workspace_id}/users", tags=["users"])
13+
14+
15+
def get_user_repo(
16+
session: AsyncSession = Depends(get_osm_session),
17+
) -> UserRepository:
18+
repository = UserRepository(session)
19+
return repository
20+
21+
22+
def get_workspace_repo(
23+
session: AsyncSession = Depends(get_task_session),
24+
) -> WorkspaceRepository:
25+
return WorkspaceRepository(session)
26+
27+
28+
@router.get("", response_model=list[WorkspaceUserRoleItem])
29+
async def get_privileged_workspace_members(
30+
workspace_id: int,
31+
current_user: UserInfo = Depends(validate_token),
32+
user_repo: UserRepository = Depends(get_user_repo),
33+
):
34+
if not current_user.isWorkspaceContributor(workspace_id):
35+
raise HTTPException(
36+
status_code=status.HTTP_403_FORBIDDEN,
37+
detail="Project group membership required to view members",
38+
)
39+
40+
return await user_repo.get_privileged_workspace_members(workspace_id)
41+
42+
43+
@router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT)
44+
async def assign_member_role(
45+
workspace_id: int,
46+
user_id: UUID,
47+
body: SetRoleRequest,
48+
current_user: UserInfo = Depends(validate_token),
49+
user_repo: UserRepository = Depends(get_user_repo),
50+
workspace_repo: WorkspaceRepository = Depends(get_workspace_repo),
51+
):
52+
if not current_user.isWorkspaceLead(workspace_id):
53+
raise HTTPException(
54+
status_code=status.HTTP_403_FORBIDDEN,
55+
detail="Must be a workspace owner to assign roles",
56+
)
57+
58+
# Ensure that the workspace exists in the tasks DB before we write to the
59+
# OSM DB. TODO: remove the check when we merge the DBs with the proper FK
60+
# constraints that enforce referential integrity internally.
61+
#
62+
await workspace_repo.getById(current_user, workspace_id)
63+
64+
await user_repo.assign_member_role(workspace_id, user_id, body.role)
65+
66+
67+
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
68+
async def remove_member_role(
69+
workspace_id: int,
70+
user_id: UUID,
71+
current_user: UserInfo = Depends(validate_token),
72+
user_repo: UserRepository = Depends(get_user_repo),
73+
workspace_repo: WorkspaceRepository = Depends(get_workspace_repo),
74+
):
75+
if not current_user.isWorkspaceLead(workspace_id):
76+
raise HTTPException(
77+
status_code=status.HTTP_403_FORBIDDEN,
78+
detail="Must be a workspace owner to remove roles",
79+
)
80+
81+
# Ensure that the workspace exists in the tasks DB before we write to the
82+
# OSM DB. TODO: remove the check when we merge the DBs with the proper FK
83+
# constraints that enforce referential integrity internally.
84+
#
85+
await workspace_repo.getById(current_user, workspace_id)
86+
87+
await user_repo.remove_member_role(workspace_id, user_id)

0 commit comments

Comments
 (0)