Skip to content
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

Feature/workspace user management #4337

Open
wants to merge 52 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
880f7c2
Added User Management (WIP)
Feb 6, 2025
b2dde32
Added config flag to disable user management
Feb 6, 2025
1addc5e
Merge branch 'microsoft:main' into feature/workspace_user_management
fortunkam Feb 7, 2025
685c92e
fixed UI and added unit tests
Feb 7, 2025
a2484b8
added additional permissions to auth setup script
Feb 7, 2025
fef0af9
upped docker version
Feb 7, 2025
75ffaa3
fixed unit tests
Feb 13, 2025
4bb3b36
Added more tests
Feb 13, 2025
b145db9
more tests
Feb 13, 2025
96f849b
Merge branch 'feature/workspace_user_management' into main
fortunkam Feb 14, 2025
b69beb8
Merge pull request #1 from fortunkam/main
fortunkam Feb 14, 2025
57d5c70
added combo box to select users from
Feb 14, 2025
c1f6b84
increase UI version number
Feb 14, 2025
79248b6
fix linting issues
Feb 14, 2025
c8908e0
more linting issues addressed
Feb 14, 2025
9d195a2
more linting fixes
Feb 14, 2025
93356b7
final linting pass
Feb 14, 2025
f398b0e
added option to deploy
Feb 14, 2025
a5eedd4
upped version of core for linting
Feb 17, 2025
901c843
Merge branch 'main' of https://github.com/microsoft/AzureTRE into fea…
Feb 17, 2025
611e3f3
Upped version number to clear linting error
Feb 17, 2025
1a3a20c
Merge branch 'main' of https://github.com/microsoft/AzureTRE into fea…
Feb 17, 2025
7817520
fixed issue with merge
Feb 17, 2025
e772458
Replaced Combo Box with PeoplePicker, reworked assignable users to al…
Feb 18, 2025
3071739
Merge branch 'main' of https://github.com/microsoft/AzureTRE into fea…
Feb 18, 2025
a7cf115
fixed linting issue
Feb 18, 2025
44351e4
Update api_app/tests_ma/test_api/test_routes/test_workspace_users.py
fortunkam Feb 18, 2025
081055c
Removed extra method
Feb 19, 2025
fee832a
Moved to use ID instead of email
Feb 19, 2025
d18a392
refactored to removed unused classes
Feb 19, 2025
a8ece9b
fixed unit tests
Feb 19, 2025
408144f
removed reference to email per PR comment
Feb 19, 2025
091ab2a
added timeouts to graph and REST calls
Feb 19, 2025
140614d
Merge branch 'main' of https://github.com/microsoft/AzureTRE into pr/…
marrobi Feb 19, 2025
38980cf
Update api_app/tests_ma/test_api/test_routes/test_workspace_users.py
fortunkam Feb 19, 2025
cb30ba7
Update api_app/tests_ma/test_services/test_aad_access_service.py
fortunkam Feb 19, 2025
836e119
Merge branch 'feature/workspace_user_management' of https://github.co…
marrobi Feb 19, 2025
acf011c
add core_api_client_id to resource processor env var
marrobi Feb 19, 2025
3c17ac4
bump RP version to 0.12.2 and add core_api_client_id to config
marrobi Feb 19, 2025
bf160b7
Add core_api_client_id variable, update aad provider, adn output grou…
marrobi Feb 19, 2025
ce2669d
WIP: trying to run appRoleAssignment with Owner permissions on enterp…
Feb 20, 2025
f033b0f
Fixed return values
Feb 21, 2025
149af84
Moved to only allow user management if AAD groups are enabled
Feb 21, 2025
4e166f3
fixed typo
Feb 21, 2025
3b4e239
updated unit tests
Feb 21, 2025
4fecce3
Merge branch 'main' of https://github.com/microsoft/AzureTRE into fea…
Feb 21, 2025
8696094
upped version number of base workspace to avoid conflicts
Feb 21, 2025
9d1169f
fix linting errors
Feb 21, 2025
d7f423f
fixed missed linting issues
Feb 21, 2025
511df5c
added azuread provider
Feb 21, 2025
b92d765
fixed provider issue
Feb 21, 2025
2ce6937
increased version of resource processor
Feb 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.21.1"
__version__ = "0.22.0"
6 changes: 5 additions & 1 deletion api_app/api/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from api.helpers import get_repository
from db.repositories.workspaces import WorkspaceRepository
from api.routes import health, ping, workspaces, workspace_templates, workspace_service_templates, user_resource_templates, \
shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests
shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests, workspace_users
from core import config
from resources import strings

Expand Down Expand Up @@ -51,6 +51,10 @@
core_router.include_router(costs.costs_workspace_router, tags=["costs"])
core_router.include_router(requests.router, tags=["requests"])

if config.USER_MANAGEMENT_ENABLED:
core_router.include_router(workspace_users.workspaces_users_admin_router, tags=["users"])
core_router.include_router(workspace_users.workspaces_users_shared_router, tags=["users"])

core_swagger_router = APIRouter()
swagger_disabled_router = APIRouter()

Expand Down
57 changes: 57 additions & 0 deletions api_app/api/routes/workspace_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, Response, status
from api.dependencies.workspaces import get_workspace_by_id_from_path
from models.schemas.workspace_users import UserRoleAssignmentRequest
from resources import strings
from services.authentication import get_access_service
from models.schemas.users import UsersInResponse, AssignableUsersInResponse, WorkspaceUserOperationResponse
from models.schemas.roles import RolesInResponse
from services.authentication import get_current_admin_user, get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin

workspaces_users_admin_router = APIRouter(dependencies=[Depends(get_current_admin_user)])
workspaces_users_shared_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin)])


@workspaces_users_shared_router.get("/workspaces/{workspace_id}/users", response_model=UsersInResponse, name=strings.API_GET_WORKSPACE_USERS)
async def get_workspace_users(workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> UsersInResponse:
users = access_service.get_workspace_users(workspace)
return UsersInResponse(users=users)


@workspaces_users_admin_router.get("/workspaces/{workspace_id}/assignable-users", response_model=AssignableUsersInResponse, name=strings.API_GET_ASSIGNABLE_USERS)
async def get_assignable_users(filter: str = "", maxResultCount: int = 5, access_service=Depends(get_access_service)) -> AssignableUsersInResponse:
assignable_users = access_service.get_assignable_users(filter, maxResultCount)
return AssignableUsersInResponse(assignable_users=assignable_users)


@workspaces_users_admin_router.get("/workspaces/{workspace_id}/roles", response_model=RolesInResponse, name=strings.API_GET_WORKSPACE_ROLES)
async def get_workspace_roles(workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> RolesInResponse:
roles = access_service.get_workspace_roles(workspace)
return RolesInResponse(roles=roles)


@workspaces_users_admin_router.post("/workspaces/{workspace_id}/users/assign", status_code=status.HTTP_202_ACCEPTED, name=strings.API_ASSIGN_WORKSPACE_USER)
async def assign_workspace_user(response: Response, userRoleAssignmentRequest: UserRoleAssignmentRequest, workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> WorkspaceUserOperationResponse:

for user_id in userRoleAssignmentRequest.user_ids:
access_service.assign_workspace_user(
user_id,
workspace,
userRoleAssignmentRequest.role_id
)

return WorkspaceUserOperationResponse(user_ids=userRoleAssignmentRequest.user_ids, role_id=userRoleAssignmentRequest.role_id)


@workspaces_users_admin_router.delete("/workspaces/{workspace_id}/users/assign", status_code=status.HTTP_202_ACCEPTED, name=strings.API_REMOVE_WORKSPACE_USER_ASSIGNMENT)
async def remove_workspace_user_assignment(user_id: str,
role_id: str,
workspace=Depends(get_workspace_by_id_from_path),
access_service=Depends(get_access_service)) -> WorkspaceUserOperationResponse:

access_service.remove_workspace_role_user_assignment(
user_id,
role_id,
workspace
)

return WorkspaceUserOperationResponse(user_ids=[user_id], role_id=role_id)
9 changes: 0 additions & 9 deletions api_app/api/routes/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from models.schemas.workspace_service import WorkspaceServiceInCreate, WorkspaceServicesInList, WorkspaceServiceInResponse
from models.schemas.resource import ResourceHistoryInList, ResourcePatch
from models.schemas.resource_template import ResourceTemplateInformationInList
from models.schemas.users import UsersInResponse
from resources import strings
from services.access_service import AuthConfigValidationError
from services.authentication import get_current_admin_user, \
Expand All @@ -38,7 +37,6 @@
from models.domain.request_action import RequestAction
from services.logging import logger


workspaces_core_router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)])
workspaces_shared_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin)])
workspace_services_workspace_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)])
Expand Down Expand Up @@ -188,13 +186,6 @@ async def invoke_action_on_workspace(response: Response, action: str, user=Depen
return OperationInResponse(operation=operation)


@workspaces_shared_router.get("/workspaces/{workspace_id}/users", response_model=UsersInResponse, name=strings.API_GET_WORKSPACE_USERS)
async def get_workspace_users(workspace=Depends(get_workspace_by_id_from_path)) -> UsersInResponse:
access_service = get_access_service()
users = access_service.get_workspace_users(workspace)
return UsersInResponse(users=users)


# workspace operations
# This method only returns templates that the authenticated user is authorized to use
@workspaces_shared_router.get("/workspaces/{workspace_id}/workspace-service-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_WORKSPACE_SERVICE_TEMPLATES_IN_WORKSPACE)
Expand Down
4 changes: 3 additions & 1 deletion api_app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
SUBSCRIPTION_ID: str = config("SUBSCRIPTION_ID", default="")
RESOURCE_GROUP_NAME: str = config("RESOURCE_GROUP_NAME", default="")


# Service bus configuration
SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE: str = config("SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE", default="")
SERVICE_BUS_RESOURCE_REQUEST_QUEUE: str = config("SERVICE_BUS_RESOURCE_REQUEST_QUEUE", default="")
Expand Down Expand Up @@ -72,3 +71,6 @@
ENABLE_AIRLOCK_EMAIL_CHECK: bool = config("ENABLE_AIRLOCK_EMAIL_CHECK", cast=bool, default=False)

API_ROOT_SCOPE: str = f"api://{API_CLIENT_ID}/user_impersonation"

# User Management
USER_MANAGEMENT_ENABLED: bool = config("USER_MANAGEMENT_ENABLED", cast=bool, default=False)
2 changes: 0 additions & 2 deletions api_app/models/domain/authentication.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from collections import namedtuple
from typing import List

from pydantic import BaseModel, Field


RoleAssignment = namedtuple("RoleAssignment", "resource_id, role_id")


Expand Down
32 changes: 32 additions & 0 deletions api_app/models/domain/workspace_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import List
from pydantic import BaseModel, Field
from enum import Enum


class AssignableUser(BaseModel):
id: str
displayName: str
userPrincipalName: str


class AssignmentType(Enum):
APP_ROLE = "ApplicationRole"
GROUP = "Group"


class Role(BaseModel):
id: str
displayName: str

def __eq__(self, other):
return self.id == other.id

def __hash__(self):
return hash(self.id)


class AssignedUser(BaseModel):
id: str
displayName: str
userPrincipalName: str
roles: List[Role] = Field([])
7 changes: 7 additions & 0 deletions api_app/models/schemas/roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel, Field
from typing import List
from models.domain.workspace_users import Role


class RolesInResponse(BaseModel):
roles: List[Role] = Field(..., title="Roles", description="List of roles in a workspace")
13 changes: 11 additions & 2 deletions api_app/models/schemas/users.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from pydantic import BaseModel, Field
from typing import List

from models.domain.authentication import User
from models.domain.workspace_users import AssignedUser, AssignableUser


class UsersInResponse(BaseModel):
users: List[User] = Field(..., title="Users", description="List of users assigned to the workspace")
users: List[AssignedUser] = Field(..., title="Users", description="List of users assigned to the workspace")

class Config:
schema_extra = {
Expand All @@ -26,3 +26,12 @@ class Config:
]
}
}


class AssignableUsersInResponse(BaseModel):
assignable_users: List[AssignableUser] = Field(..., title="Assignable Users", description="List of users assignable to a workspace")


class WorkspaceUserOperationResponse(BaseModel):
user_ids: List[str] = Field(..., title="User IDs", description="List of user IDs")
role_id: str = Field(..., title="Role ID", description="Role ID")
15 changes: 15 additions & 0 deletions api_app/models/schemas/workspace_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from typing import List
from pydantic import BaseModel, Field


class UserRoleAssignmentRequest(BaseModel):
role_id: str = Field(title="Role Id", description="Role to assign users to")
user_ids: List[str] = Field([], title="List of User Ids", description="List of User Ids to assign the role to")

class Config:
schema_extra = {
"example": {
"role_id": "1234",
"user_ids": ["1", "2"]
}
}
7 changes: 7 additions & 0 deletions api_app/resources/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
API_INVOKE_ACTION_ON_WORKSPACE = "Invoke action on a workspace"

API_GET_WORKSPACE_USERS = "Get all users for a workspace"
API_GET_ASSIGNABLE_USERS = "Get all users assignable to a workspace"
API_GET_WORKSPACE_ROLES = "Get all the roles belonging to a workspace"
API_ASSIGN_WORKSPACE_USER = "Assign a user to a workspace role"
API_REMOVE_WORKSPACE_USER_ASSIGNMENT = "Remove a user from a workspace role"

API_GET_ALL_WORKSPACE_SERVICES = "Get all workspace services for workspace"
API_GET_WORKSPACE_SERVICE_BY_ID = "Get workspace service by Id"
Expand Down Expand Up @@ -256,3 +260,6 @@

# Value that a sensitive is replaced with in Cosmos
REDACTED_SENSITIVE_VALUE = "REDACTED"

# User Management
USER_MANAGEMENT_DISABLED = "User management is disabled"
Loading
Loading