Skip to content

Commit 6f5adff

Browse files
committed
[owl] Implement endpoints to update owner (#864)
1 parent 9cacf63 commit 6f5adff

File tree

6 files changed

+375
-8
lines changed

6 files changed

+375
-8
lines changed

clients/python/src/jamaibase/client.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,19 @@ async def update_organization(
12781278
**kwargs,
12791279
)
12801280

1281+
async def update_owner(
1282+
self,
1283+
new_owner_id: str,
1284+
organization_id: str,
1285+
**kwargs,
1286+
) -> OrganizationRead:
1287+
return await self._patch(
1288+
"/v1/organizations/owner",
1289+
params=dict(organization_id=organization_id, new_owner_id=new_owner_id),
1290+
response_model=OrganizationRead,
1291+
**kwargs,
1292+
)
1293+
12811294
async def delete_organization(
12821295
self,
12831296
organization_id: str,
@@ -1697,6 +1710,19 @@ async def update_project(
16971710
**kwargs,
16981711
)
16991712

1713+
async def update_owner(
1714+
self,
1715+
new_owner_id: str,
1716+
project_id: str,
1717+
**kwargs,
1718+
) -> ProjectRead:
1719+
return await self._patch(
1720+
"/v1/projects/owner",
1721+
params=dict(project_id=project_id, new_owner_id=new_owner_id),
1722+
response_model=ProjectRead,
1723+
**kwargs,
1724+
)
1725+
17001726
async def delete_project(
17011727
self,
17021728
project_id: str,
@@ -4605,6 +4631,18 @@ def delete_organization(
46054631
super().delete_organization(organization_id, missing_ok=missing_ok, **kwargs)
46064632
)
46074633

4634+
def update_owner(
4635+
self,
4636+
new_owner_id: str,
4637+
organization_id: str,
4638+
**kwargs,
4639+
) -> OrganizationRead:
4640+
return LOOP.run(
4641+
super().update_owner(
4642+
new_owner_id=new_owner_id, organization_id=organization_id, **kwargs
4643+
)
4644+
)
4645+
46084646
def join_organization(
46094647
self,
46104648
user_id: str,
@@ -4963,6 +5001,16 @@ def delete_project(
49635001
) -> OkResponse:
49645002
return LOOP.run(super().delete_project(project_id, missing_ok=missing_ok, **kwargs))
49655003

5004+
def update_owner(
5005+
self,
5006+
new_owner_id: str,
5007+
project_id: str,
5008+
**kwargs,
5009+
) -> ProjectRead:
5010+
return LOOP.run(
5011+
super().update_owner(new_owner_id=new_owner_id, project_id=project_id, **kwargs)
5012+
)
5013+
49665014
def join_project(
49675015
self,
49685016
user_id: str,

services/api/src/owl/routers/organizations/oss.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,67 @@ async def delete_organization(
346346
return OkResponse()
347347

348348

349+
@router.patch(
350+
"/v1/organizations/owner",
351+
summary="Transfer organization ownership to another user.",
352+
description="Permissions: Only the owner of the organization can transfer its ownership.",
353+
)
354+
@handle_exception
355+
async def update_organization_owner(
356+
request: Request,
357+
user: Annotated[UserAuth, Depends(auth_user_service_key)],
358+
session: Annotated[AsyncSession, Depends(yield_async_session)],
359+
new_owner_id: Annotated[str, Query(min_length=1, description="New owner User ID.")],
360+
organization_id: Annotated[str, Query(min_length=1, description="Organization ID.")],
361+
) -> OrganizationReadDecrypt:
362+
# Fetch
363+
organization = await session.get(Organization, organization_id)
364+
if organization is None:
365+
raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.')
366+
new_owner_user = await session.get(User, new_owner_id)
367+
if new_owner_user is None:
368+
raise ResourceNotFoundError(f'User "{new_owner_id}" is not found.')
369+
370+
# Check if user is the owner
371+
if organization.owner != user.id:
372+
raise ForbiddenError("Only the owner can transfer the ownership of an organization.")
373+
374+
if organization.owner == new_owner_id:
375+
return organization
376+
377+
# Check if the new owner exist and is a member of the organization
378+
new_owner_membership = await session.get(OrgMember, (new_owner_id, organization_id))
379+
if new_owner_membership is None:
380+
raise ForbiddenError("The new owner is not a member of this organization.")
381+
382+
# Promote new owner to ADMIN
383+
# Update organization
384+
new_owner_membership.role = Role.ADMIN
385+
organization.owner = new_owner_id
386+
organization.updated_at = now()
387+
session.add(new_owner_membership)
388+
session.add(organization)
389+
await session.commit()
390+
await session.refresh(organization)
391+
392+
logger.bind(user_id=user.id, org_id=organization_id).success(
393+
(
394+
f'{user.name} ({user.email}) transferred the ownership of organization "{organization.name}" ({organization_id}) '
395+
f"to {new_owner_user.name} ({new_owner_user.email}). ({request.state.id})"
396+
)
397+
)
398+
logger.bind(user_id=new_owner_id, org_id=organization_id).success(
399+
(
400+
f'{user.name} ({user.email}) transferred the ownership of organization "{organization.name}" ({organization_id}) '
401+
f"to you. ({request.state.id})"
402+
)
403+
)
404+
405+
# Clear cache
406+
await CACHE.refresh_organization_async(organization_id, session)
407+
return organization
408+
409+
349410
@router.post(
350411
"/v2/organizations/members",
351412
summary="Join an organization.",
@@ -546,7 +607,7 @@ async def leave_organization(
546607
organization = await session.get(Organization, organization_id)
547608
if organization is None:
548609
raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.')
549-
if user_id == organization.created_by:
610+
if user_id == organization.owner:
550611
raise ForbiddenError("Owner cannot leave the organization.")
551612
org_member = await session.get(OrgMember, (user_id, organization_id))
552613
if org_member is None:

services/api/src/owl/routers/projects/oss.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,64 @@ async def delete_project(
341341
return OkResponse()
342342

343343

344+
@router.patch(
345+
"/v1/projects/owner",
346+
summary="Transfer project ownership to another user.",
347+
description="Permissions: Only the owner of the project can transfer its ownership.",
348+
)
349+
@handle_exception
350+
async def update_project_owner(
351+
request: Request,
352+
user: Annotated[UserAuth, Depends(auth_user)],
353+
session: Annotated[AsyncSession, Depends(yield_async_session)],
354+
new_owner_id: Annotated[str, Query(min_length=1, description="New owner User ID.")],
355+
project_id: Annotated[str, Query(min_length=1, description="Project ID.")],
356+
) -> ProjectRead:
357+
# Fetch
358+
project = await session.get(Project, project_id)
359+
if project is None:
360+
raise ResourceNotFoundError(f'Project "{project_id}" is not found.')
361+
new_owner_user = await session.get(User, new_owner_id)
362+
if new_owner_user is None:
363+
raise ResourceNotFoundError(f'User "{new_owner_id}" is not found.')
364+
365+
# Check if user is the owner
366+
if project.owner != user.id:
367+
raise ForbiddenError("Only the owner can transfer the ownership of a project.")
368+
369+
if project.owner == new_owner_id:
370+
return project
371+
372+
# Check if the new owner exist and is a member of the project
373+
new_owner_membership = await session.get(ProjectMember, (new_owner_id, project_id))
374+
if new_owner_membership is None:
375+
raise ForbiddenError("The new owner is not a member of this project.")
376+
377+
# Promote new owner to ADMIN
378+
# Update project
379+
new_owner_membership.role = Role.ADMIN
380+
project.owner = new_owner_id
381+
project.updated_at = now()
382+
session.add(new_owner_membership)
383+
session.add(project)
384+
await session.commit()
385+
await session.refresh(project)
386+
387+
logger.bind(user_id=user.id, org_id=project.organization_id, proj_id=project_id).success(
388+
(
389+
f'{user.name} ({user.email}) transferred the ownership of project "{project.name}" ({project_id}) '
390+
f"to {new_owner_user.name} ({new_owner_user.email}). ({request.state.id})"
391+
)
392+
)
393+
logger.bind(user_id=new_owner_id, org_id=project.organization_id, proj_id=project_id).success(
394+
(
395+
f'{user.name} ({user.email}) transferred the ownership of project "{project.name}" ({project_id}) '
396+
f"to you. ({request.state.id})"
397+
)
398+
)
399+
return project
400+
401+
344402
@router.post(
345403
"/v2/projects/members",
346404
summary="Join a project.",
@@ -555,6 +613,8 @@ async def leave_project(
555613
project = await session.get(Project, project_id)
556614
if project is None:
557615
raise ResourceNotFoundError(f'Project "{project_id}" is not found.')
616+
if user_id == project.owner:
617+
raise ForbiddenError("Owner cannot leave the project.")
558618
if user.id != user_id:
559619
has_permissions(
560620
user,

services/api/src/owl/routers/users/oss.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from owl.db.models import (
1010
Organization,
1111
OrgMember,
12+
Project,
1213
ProjectMember,
1314
User,
1415
)
@@ -24,6 +25,7 @@
2425
from owl.utils.auth import auth_service_key, auth_user_service_key, has_permissions
2526
from owl.utils.dates import now
2627
from owl.utils.exceptions import (
28+
BadInputError,
2729
ResourceExistsError,
2830
ResourceNotFoundError,
2931
handle_exception,
@@ -173,6 +175,23 @@ async def delete_user(
173175
user = await session.get(User, user.id)
174176
if user is None:
175177
raise ResourceNotFoundError(f'User "{user.id}" is not found.')
178+
# Block if the user is owner of any organizations or projects
179+
num_owned_projs = (
180+
await session.exec(select(func.count(Project.id)).where(Project.owner == user.id))
181+
).one()
182+
if num_owned_projs > 0:
183+
raise BadInputError(
184+
f"Unable to delete user since there are still {num_owned_projs:,d} projects with the user as owner."
185+
)
186+
num_owned_orgs = (
187+
await session.exec(
188+
select(func.count(Organization.id)).where(Organization.owner == user.id)
189+
)
190+
).one()
191+
if num_owned_orgs > 0:
192+
raise BadInputError(
193+
f"Unable to delete user since there are still {num_owned_orgs:,d} organizations with the user as owner."
194+
)
176195
org_ids = [m.organization_id for m in user.org_memberships]
177196
# Delete all related resources
178197
logger.info(f'{request.state.id} - Deleting user: "{user.id}"')

services/api/tests/routers/test_organizations.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,84 @@ def test_delete_org_permission():
298298
client.organizations.delete_organization(ctx.superorg.id, missing_ok=False)
299299

300300

301+
def test_update_organization_owner():
302+
with (
303+
setup_organizations() as ctx,
304+
create_user(dict(email="[email protected]", name="Claudia Tiedemann")) as org_admin,
305+
):
306+
first_owner_client = JamAI(user_id=ctx.user.id)
307+
org_admin_client = JamAI(user_id=org_admin.id)
308+
309+
# Should fail because organization does not exist
310+
with pytest.raises(ResourceNotFoundError, match="is not found."):
311+
first_owner_client.organizations.update_owner(
312+
new_owner_id="fake", organization_id=ctx.org.id
313+
)
314+
315+
# Should fail because new owner is not a current member of the organization
316+
with pytest.raises(
317+
ForbiddenError, match="The new owner is not a member of this organization"
318+
):
319+
first_owner_client.organizations.update_owner(
320+
new_owner_id=org_admin.id, organization_id=ctx.org.id
321+
)
322+
323+
# Should fail because the User sending the request is not the owner.
324+
membership = first_owner_client.organizations.join_organization(
325+
org_admin.id, organization_id=ctx.org.id, role=Role.ADMIN
326+
)
327+
assert isinstance(membership, OrgMemberRead)
328+
329+
with pytest.raises(
330+
ForbiddenError, match="Only the owner can transfer the ownership of an organization."
331+
):
332+
org_admin_client.organizations.update_owner(
333+
new_owner_id=org_admin.id, organization_id=ctx.org.id
334+
)
335+
336+
# Should return the same org since the new owner id is the same as the current one
337+
first_owner_client.organizations.update_owner(
338+
new_owner_id=ctx.user.id, organization_id=ctx.org.id
339+
)
340+
341+
# Should succeed since the new owner is now a member of the organization
342+
new_org = first_owner_client.organizations.update_owner(
343+
new_owner_id=org_admin.id, organization_id=ctx.org.id
344+
)
345+
assert new_org.model_dump(
346+
exclude=["owner", "updated_at", "credit_grant"]
347+
) == ctx.org.model_dump(exclude=["owner", "updated_at", "credit_grant"])
348+
assert new_org.owner != ctx.org.owner
349+
assert new_org.updated_at != ctx.org.updated_at
350+
assert new_org.owner == org_admin.id
351+
352+
# Should fail because this user is no longer the owner
353+
with pytest.raises(
354+
ForbiddenError, match="Only the owner can transfer the ownership of an organization."
355+
):
356+
first_owner_client.organizations.update_owner(
357+
new_owner_id=org_admin.id, organization_id=ctx.org.id
358+
)
359+
# New owner will be ADMIN
360+
membership = org_admin_client.organizations.get_member(
361+
user_id=org_admin.id, organization_id=ctx.org.id
362+
)
363+
assert isinstance(membership, OrgMemberRead)
364+
assert membership.role == Role.ADMIN
365+
366+
# Should fail because this is the last membership for this user and he is the current owner of the organization
367+
with pytest.raises(ForbiddenError, match="Owner cannot leave the organization."):
368+
org_admin_client.organizations.leave_organization(org_admin.id, ctx.org.id)
369+
370+
# Return the organization to the first owner
371+
org_admin_client.organizations.update_owner(
372+
new_owner_id=ctx.user.id, organization_id=ctx.org.id
373+
)
374+
375+
# Should succeed after returning the organization to the old owner.
376+
org_admin_client.organizations.leave_organization(org_admin.id, ctx.org.id)
377+
378+
301379
def test_organisation_model_catalogue():
302380
"""
303381
Test listing model configs:

0 commit comments

Comments
 (0)