-
Notifications
You must be signed in to change notification settings - Fork 0
[US-11.9 / OBT-237] Let project managers manage member roles (not other managers) #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b425778
a51298e
7b7f794
f61e27e
77f87b0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from app.core.exceptions import AuthorizationError | ||
| from app.db.models.auth import User | ||
| from app.services.project.is_project_manager import is_project_manager | ||
|
|
||
|
|
||
| async def assert_can_grant_access(db: AsyncSession, actor: User, project_id: str) -> None: | ||
| if actor.is_platform_admin: | ||
| return | ||
| if not await is_project_manager(db, actor.id, project_id): | ||
| raise AuthorizationError("You must be a manager of this project") | ||
|
Comment on lines
+8
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Eliminate the authorization TOCTOU before granting access. The shown route checks this helper and subsequently calls As per coding guidelines, 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from app.core.exceptions import AuthorizationError, NotFoundError | ||
| from app.db.models.auth import User | ||
| from app.db.models.org import MemberRole | ||
| from app.services.project.get_user_project_access import get_user_project_access | ||
| from app.services.project.is_project_manager import is_project_manager | ||
|
|
||
|
|
||
| async def assert_can_modify_member_role( | ||
| db: AsyncSession, actor: User, project_id: str, target_user_id: str | ||
| ) -> None: | ||
| if actor.is_platform_admin: | ||
| return | ||
| if not await is_project_manager(db, actor.id, project_id): | ||
| raise AuthorizationError("You must be a manager of this project") | ||
| target = await get_user_project_access(db, project_id, target_user_id) | ||
| if target is None: | ||
| raise NotFoundError("User access not found for this project") | ||
| if target.role == MemberRole.MANAGER: | ||
| raise AuthorizationError("Managers cannot change or remove another manager") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| from sqlalchemy import select | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from app.db.models.project import ProjectUserAccess | ||
|
|
||
|
|
||
| async def get_user_project_access( | ||
| db: AsyncSession, project_id: str, user_id: str | ||
| ) -> ProjectUserAccess | None: | ||
| stmt = select(ProjectUserAccess).where( | ||
| ProjectUserAccess.project_id == project_id, | ||
| ProjectUserAccess.user_id == user_id, | ||
| ) | ||
| result = await db.execute(stmt) | ||
| return result.scalar_one_or_none() |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,18 @@ | ||||||||
| from sqlalchemy import select | ||||||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||||||
|
|
||||||||
| from app.db.models.org import MemberRole | ||||||||
| from app.db.models.project import ProjectUserAccess | ||||||||
|
|
||||||||
|
|
||||||||
| async def is_project_manager(db: AsyncSession, user_id: str, project_id: str) -> bool: | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Document this public service function. Proposed fix async def is_project_manager(db: AsyncSession, user_id: str, project_id: str) -> bool:
+ """Return whether a user has the manager role for a project."""As per coding guidelines, public service functions need concise docstrings. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||
| result = await db.execute( | ||||||||
| select(ProjectUserAccess.id) | ||||||||
| .where( | ||||||||
| ProjectUserAccess.project_id == project_id, | ||||||||
| ProjectUserAccess.user_id == user_id, | ||||||||
| ProjectUserAccess.role == MemberRole.MANAGER, | ||||||||
| ) | ||||||||
| .limit(1) | ||||||||
| ) | ||||||||
| return result.scalar_one_or_none() is not None | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import pytest | ||
|
|
||
| from app.core.exceptions import AuthorizationError, NotFoundError | ||
| from app.services import project_service | ||
| from tests.baker import ( | ||
| make_language, | ||
| make_project, | ||
| make_project_user_access, | ||
| make_user, | ||
| ) | ||
|
|
||
|
|
||
| async def _project(db): | ||
| lang = await make_language(db, code="prj") | ||
| return await make_project(db, language_id=lang.id) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_is_project_manager(db_session) -> None: | ||
| project = await _project(db_session) | ||
| manager = await make_user(db_session, email="[email protected]") | ||
| member = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, manager.id, role="manager") | ||
| await make_project_user_access(db_session, project.id, member.id, role="member") | ||
|
|
||
| assert await project_service.is_project_manager(db_session, manager.id, project.id) is True | ||
| assert await project_service.is_project_manager(db_session, member.id, project.id) is False | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_grant_access_allows_admin_and_manager(db_session) -> None: | ||
| project = await _project(db_session) | ||
| admin = await make_user(db_session, email="[email protected]", is_platform_admin=True) | ||
| manager = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, manager.id, role="manager") | ||
|
|
||
| await project_service.assert_can_grant_access(db_session, admin, project.id) | ||
| await project_service.assert_can_grant_access(db_session, manager, project.id) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_grant_access_forbidden_for_member(db_session) -> None: | ||
| project = await _project(db_session) | ||
| member = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, member.id, role="member") | ||
|
|
||
| with pytest.raises(AuthorizationError, match="manager of this project"): | ||
| await project_service.assert_can_grant_access(db_session, member, project.id) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_manager_can_modify_a_member(db_session) -> None: | ||
| project = await _project(db_session) | ||
| manager = await make_user(db_session, email="[email protected]") | ||
| member = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, manager.id, role="manager") | ||
| await make_project_user_access(db_session, project.id, member.id, role="member") | ||
|
|
||
| await project_service.assert_can_modify_member_role(db_session, manager, project.id, member.id) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_manager_cannot_modify_another_manager(db_session) -> None: | ||
| project = await _project(db_session) | ||
| manager = await make_user(db_session, email="[email protected]") | ||
| other_manager = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, manager.id, role="manager") | ||
| await make_project_user_access(db_session, project.id, other_manager.id, role="manager") | ||
|
|
||
| with pytest.raises(AuthorizationError, match="another manager"): | ||
| await project_service.assert_can_modify_member_role( | ||
| db_session, manager, project.id, other_manager.id | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_admin_can_modify_a_manager(db_session) -> None: | ||
| project = await _project(db_session) | ||
| admin = await make_user(db_session, email="[email protected]", is_platform_admin=True) | ||
| manager = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, manager.id, role="manager") | ||
|
|
||
| await project_service.assert_can_modify_member_role(db_session, admin, project.id, manager.id) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_non_manager_cannot_modify_roles(db_session) -> None: | ||
| project = await _project(db_session) | ||
| member = await make_user(db_session, email="[email protected]") | ||
| target = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, member.id, role="member") | ||
| await make_project_user_access(db_session, project.id, target.id, role="member") | ||
|
|
||
| with pytest.raises(AuthorizationError, match="manager of this project"): | ||
| await project_service.assert_can_modify_member_role( | ||
| db_session, member, project.id, target.id | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_modify_missing_target_raises_not_found(db_session) -> None: | ||
| project = await _project(db_session) | ||
| manager = await make_user(db_session, email="[email protected]") | ||
| ghost = await make_user(db_session, email="[email protected]") | ||
| await make_project_user_access(db_session, project.id, manager.id, role="manager") | ||
|
|
||
| with pytest.raises(NotFoundError, match="access not found"): | ||
| await project_service.assert_can_modify_member_role( | ||
| db_session, manager, project.id, ghost.id | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe here you could call these two via
project_service.like the rest of the file already does — you exported them on the__init__in this same PR, and your own tests call them that way. I am not too sure we have direct imports like this on other routers, please verify that and if the namespaced usage is majority adapt this file.