Skip to content

Commit 638913b

Browse files
authored
Workspace user management (#4337)
1 parent e047e74 commit 638913b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+1649
-433
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
**BREAKING CHANGES & MIGRATIONS**:
44

55
ENHANCEMENTS:
6+
* Added ability to manage user workspace roles from the UI (only visible if feature is enabled with the `user_management_enabled` flag, user is a TREAdmin, the workspace has Entra ID (AAD) Groups enabled and workspace version is > 2.2.0 ) [#4337](https://github.com/microsoft/AzureTRE/issues/4337)
67
* Add 7 day retention on workspace storage accounts. ([#4389](https://github.com/microsoft/AzureTRE/issues/4389))
78
* Enabled Structured Azure Firewall logs for TRE firewall. [#4430](https://github.com/microsoft/AzureTRE/issues/4430)
89
* Deny public access to TRE management storage account, and add private endpoint for TRE core [#4353](https://github.com/microsoft/AzureTRE/issues/4353)

api_app/_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.21.1"
1+
__version__ = "0.23.0"

api_app/api/routes/api.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from api.helpers import get_repository
99
from db.repositories.workspaces import WorkspaceRepository
1010
from api.routes import health, ping, workspaces, workspace_templates, workspace_service_templates, user_resource_templates, \
11-
shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests
11+
shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests, workspace_users
1212
from core import config
1313
from resources import strings
1414

@@ -50,6 +50,10 @@
5050
core_router.include_router(costs.costs_core_router, tags=["costs"])
5151
core_router.include_router(costs.costs_workspace_router, tags=["costs"])
5252
core_router.include_router(requests.router, tags=["requests"])
53+
core_router.include_router(workspace_users.workspaces_users_shared_router, tags=["users"])
54+
55+
if config.USER_MANAGEMENT_ENABLED:
56+
core_router.include_router(workspace_users.workspaces_users_admin_router, tags=["users"])
5357

5458
core_swagger_router = APIRouter()
5559
swagger_disabled_router = APIRouter()

api_app/api/routes/workspace_users.py

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from fastapi import APIRouter, Depends, Response, status
2+
from api.dependencies.workspaces import get_workspace_by_id_from_path
3+
from models.schemas.workspace_users import UserRoleAssignmentRequest
4+
from resources import strings
5+
from services.authentication import get_access_service
6+
from models.schemas.users import UsersInResponse, AssignableUsersInResponse, WorkspaceUserOperationResponse
7+
from models.schemas.roles import RolesInResponse
8+
from services.authentication import get_current_admin_user, get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin
9+
10+
workspaces_users_admin_router = APIRouter(dependencies=[Depends(get_current_admin_user)])
11+
workspaces_users_shared_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin)])
12+
13+
14+
@workspaces_users_shared_router.get("/workspaces/{workspace_id}/users", response_model=UsersInResponse, name=strings.API_GET_WORKSPACE_USERS)
15+
async def get_workspace_users(workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> UsersInResponse:
16+
users = access_service.get_workspace_users(workspace)
17+
return UsersInResponse(users=users)
18+
19+
20+
@workspaces_users_admin_router.get("/workspaces/{workspace_id}/assignable-users", response_model=AssignableUsersInResponse, name=strings.API_GET_ASSIGNABLE_USERS)
21+
async def get_assignable_users(filter: str = "", maxResultCount: int = 5, access_service=Depends(get_access_service)) -> AssignableUsersInResponse:
22+
assignable_users = access_service.get_assignable_users(filter, maxResultCount)
23+
return AssignableUsersInResponse(assignable_users=assignable_users)
24+
25+
26+
@workspaces_users_admin_router.get("/workspaces/{workspace_id}/roles", response_model=RolesInResponse, name=strings.API_GET_WORKSPACE_ROLES)
27+
async def get_workspace_roles(workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> RolesInResponse:
28+
roles = access_service.get_workspace_roles(workspace)
29+
return RolesInResponse(roles=roles)
30+
31+
32+
@workspaces_users_admin_router.post("/workspaces/{workspace_id}/users/assign", status_code=status.HTTP_202_ACCEPTED, name=strings.API_ASSIGN_WORKSPACE_USER)
33+
async def assign_workspace_user(response: Response, userRoleAssignmentRequest: UserRoleAssignmentRequest, workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> WorkspaceUserOperationResponse:
34+
35+
for user_id in userRoleAssignmentRequest.user_ids:
36+
access_service.assign_workspace_user(
37+
user_id,
38+
workspace,
39+
userRoleAssignmentRequest.role_id
40+
)
41+
42+
return WorkspaceUserOperationResponse(user_ids=userRoleAssignmentRequest.user_ids, role_id=userRoleAssignmentRequest.role_id)
43+
44+
45+
@workspaces_users_admin_router.delete("/workspaces/{workspace_id}/users/assign", status_code=status.HTTP_202_ACCEPTED, name=strings.API_REMOVE_WORKSPACE_USER_ASSIGNMENT)
46+
async def remove_workspace_user_assignment(user_id: str,
47+
role_id: str,
48+
workspace=Depends(get_workspace_by_id_from_path),
49+
access_service=Depends(get_access_service)) -> WorkspaceUserOperationResponse:
50+
51+
access_service.remove_workspace_role_user_assignment(
52+
user_id,
53+
role_id,
54+
workspace
55+
)
56+
57+
return WorkspaceUserOperationResponse(user_ids=[user_id], role_id=role_id)

api_app/api/routes/workspaces.py

-9
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from models.schemas.workspace_service import WorkspaceServiceInCreate, WorkspaceServicesInList, WorkspaceServiceInResponse
2222
from models.schemas.resource import ResourceHistoryInList, ResourcePatch
2323
from models.schemas.resource_template import ResourceTemplateInformationInList
24-
from models.schemas.users import UsersInResponse
2524
from resources import strings
2625
from services.access_service import AuthConfigValidationError
2726
from services.authentication import get_current_admin_user, \
@@ -38,7 +37,6 @@
3837
from models.domain.request_action import RequestAction
3938
from services.logging import logger
4039

41-
4240
workspaces_core_router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)])
4341
workspaces_shared_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin)])
4442
workspace_services_workspace_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)])
@@ -188,13 +186,6 @@ async def invoke_action_on_workspace(response: Response, action: str, user=Depen
188186
return OperationInResponse(operation=operation)
189187

190188

191-
@workspaces_shared_router.get("/workspaces/{workspace_id}/users", response_model=UsersInResponse, name=strings.API_GET_WORKSPACE_USERS)
192-
async def get_workspace_users(workspace=Depends(get_workspace_by_id_from_path)) -> UsersInResponse:
193-
access_service = get_access_service()
194-
users = access_service.get_workspace_users(workspace)
195-
return UsersInResponse(users=users)
196-
197-
198189
# workspace operations
199190
# This method only returns templates that the authenticated user is authorized to use
200191
@workspaces_shared_router.get("/workspaces/{workspace_id}/workspace-service-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_WORKSPACE_SERVICE_TEMPLATES_IN_WORKSPACE)

api_app/core/config.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
SUBSCRIPTION_ID: str = config("SUBSCRIPTION_ID", default="")
3737
RESOURCE_GROUP_NAME: str = config("RESOURCE_GROUP_NAME", default="")
3838

39-
4039
# Service bus configuration
4140
SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE: str = config("SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE", default="")
4241
SERVICE_BUS_RESOURCE_REQUEST_QUEUE: str = config("SERVICE_BUS_RESOURCE_REQUEST_QUEUE", default="")
@@ -72,3 +71,6 @@
7271
ENABLE_AIRLOCK_EMAIL_CHECK: bool = config("ENABLE_AIRLOCK_EMAIL_CHECK", cast=bool, default=False)
7372

7473
API_ROOT_SCOPE: str = f"api://{API_CLIENT_ID}/user_impersonation"
74+
75+
# User Management
76+
USER_MANAGEMENT_ENABLED: bool = config("USER_MANAGEMENT_ENABLED", cast=bool, default=False)

api_app/models/domain/authentication.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
from collections import namedtuple
22
from typing import List
3-
43
from pydantic import BaseModel, Field
54

6-
75
RoleAssignment = namedtuple("RoleAssignment", "resource_id, role_id")
86

97

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from typing import List
2+
from pydantic import BaseModel, Field
3+
from enum import Enum
4+
5+
6+
class AssignableUser(BaseModel):
7+
id: str
8+
displayName: str
9+
userPrincipalName: str
10+
email: str = Field(default=None)
11+
12+
13+
class AssignmentType(Enum):
14+
APP_ROLE = "ApplicationRole"
15+
GROUP = "Group"
16+
17+
18+
class Role(BaseModel):
19+
id: str
20+
displayName: str
21+
22+
def __eq__(self, other):
23+
if not isinstance(other, Role):
24+
return False
25+
return self.id == other.id
26+
27+
def __hash__(self):
28+
return hash(self.id)
29+
30+
31+
class AssignedUser(BaseModel):
32+
id: str
33+
displayName: str
34+
userPrincipalName: str
35+
email: str = Field(default=None)
36+
roles: List[Role] = Field(default_factory=list)

api_app/models/schemas/roles.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pydantic import BaseModel, Field
2+
from typing import List
3+
from models.domain.workspace_users import Role
4+
5+
6+
class RolesInResponse(BaseModel):
7+
roles: List[Role] = Field(..., title="Roles", description="List of roles in a workspace")

api_app/models/schemas/users.py

+31-8
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,51 @@
11
from pydantic import BaseModel, Field
22
from typing import List
33

4-
from models.domain.authentication import User
4+
from models.domain.workspace_users import AssignedUser, AssignableUser
55

66

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

1010
class Config:
1111
schema_extra = {
1212
"example": {
1313
"users": [
1414
{
1515
"id": 1,
16-
"name": "John Doe",
17-
"email": "[email protected]",
18-
"roles": ["WorkspaceOwner", "WorkspaceResearcher"]
16+
"displayName": "John Doe",
17+
"userPrincipalName": "[email protected]",
18+
"roles": [
19+
{
20+
"id": 1,
21+
"displayName": "WorkspaceOwner"
22+
},
23+
{
24+
"id": 2,
25+
"displayName": "WorkspaceResearcher"
26+
}
27+
]
1928
},
2029
{
2130
"id": 2,
22-
"name": "Jane Smith",
23-
"email": "[email protected]",
24-
"roles": ["WorkspaceResearcher"]
31+
"displayName": "Jane Smith",
32+
"userPrincipalName": "[email protected]",
33+
"roles": [
34+
{
35+
"id": 2,
36+
"displayName": "WorkspaceResearcher"
37+
}
38+
]
2539
}
2640
]
2741
}
2842
}
43+
44+
45+
class AssignableUsersInResponse(BaseModel):
46+
assignable_users: List[AssignableUser] = Field(..., title="Assignable Users", description="List of users assignable to a workspace")
47+
48+
49+
class WorkspaceUserOperationResponse(BaseModel):
50+
user_ids: List[str] = Field(..., title="User IDs", description="List of user IDs")
51+
role_id: str = Field(..., title="Role ID", description="Role ID")
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import List
2+
from pydantic import BaseModel, Field
3+
4+
5+
class UserRoleAssignmentRequest(BaseModel):
6+
role_id: str = Field(title="Role Id", description="Role to assign users to")
7+
user_ids: List[str] = Field(default_factory=list, title="List of User Ids", description="List of User Ids to assign the role to")
8+
9+
class Config:
10+
schema_extra = {
11+
"example": {
12+
"role_id": "1234",
13+
"user_ids": ["1", "2"]
14+
}
15+
}

api_app/resources/strings.py

+7
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
API_INVOKE_ACTION_ON_WORKSPACE = "Invoke action on a workspace"
1717

1818
API_GET_WORKSPACE_USERS = "Get all users for a workspace"
19+
API_GET_ASSIGNABLE_USERS = "Get all users assignable to a workspace"
20+
API_GET_WORKSPACE_ROLES = "Get all the roles belonging to a workspace"
21+
API_ASSIGN_WORKSPACE_USER = "Assign a user to a workspace role"
22+
API_REMOVE_WORKSPACE_USER_ASSIGNMENT = "Remove a user from a workspace role"
1923

2024
API_GET_ALL_WORKSPACE_SERVICES = "Get all workspace services for workspace"
2125
API_GET_WORKSPACE_SERVICE_BY_ID = "Get workspace service by Id"
@@ -256,3 +260,6 @@
256260

257261
# Value that a sensitive is replaced with in Cosmos
258262
REDACTED_SENSITIVE_VALUE = "REDACTED"
263+
264+
# User Management
265+
USER_MANAGEMENT_DISABLED = "User management is disabled"

0 commit comments

Comments
 (0)