From a251876e1d3dfd227661b15b00bea950f17883a1 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 31 Jan 2024 22:48:04 +0100 Subject: [PATCH] Add user freeze feature --- docs/rfcs/1007-frozen-users.md | 12 +- server/parsec/asgi/administration.py | 134 +++++++++-- server/parsec/asgi/rpc.py | 84 ++++--- server/parsec/components/auth.py | 17 +- server/parsec/components/events.py | 19 +- server/parsec/components/memory/auth.py | 2 + server/parsec/components/memory/datamodel.py | 1 + server/parsec/components/memory/factory.py | 2 +- server/parsec/components/memory/user.py | 83 ++++++- server/parsec/components/user.py | 41 ++++ server/parsec/events.py | 15 +- .../test_create_organization.py | 43 +++- .../tests/administration/test_freeze_user.py | 212 ++++++++++++++++++ .../administration/test_get_organization.py | 21 +- .../tests/administration/test_list_users.py | 124 ++++++++++ .../administration/test_patch_organization.py | 47 +++- server/tests/administration/test_stats.py | 46 ++-- .../api_v4/authenticated/test_user_revoke.py | 41 +++- server/tests/common/client.py | 11 + server/tests/test_sse.py | 13 +- 20 files changed, 852 insertions(+), 116 deletions(-) create mode 100644 server/tests/administration/test_freeze_user.py create mode 100644 server/tests/administration/test_list_users.py diff --git a/docs/rfcs/1007-frozen-users.md b/docs/rfcs/1007-frozen-users.md index 53e7a4937e8..e6aa7e0c88b 100644 --- a/docs/rfcs/1007-frozen-users.md +++ b/docs/rfcs/1007-frozen-users.md @@ -101,11 +101,11 @@ For parsec v2: - Invalid administration token: `403` with JSON body `{"error": "not_allowed"}` - Wrong request format: `400` with JSON body `{"error": "bad_data"}` -For parsec v3 (note the different `detail` field due to the migration to `FastAPI`): +For parsec v3, with an arbitrary JSON body only aimed at human consuption (and hence free to change at any time): -- Organization not found: `404` with JSON body `{"detail": "not_found"}` -- Invalid administration token: `403` with JSON body `{"detail": "not_allowed"}` -- Wrong request format: `400` with JSON body `{"detail": "bad_data"}` +- Organization not found: `404` +- Invalid administration token: `403` +- Wrong request format: `422` On top of it, an extra error is handled when the `POST` request contains a user that does not exist in the organization. @@ -113,9 +113,9 @@ For parsec v2: - User not found: `404` with JSON body `{"error": "user_not_found"}` -For parsec v3 (note the different `detail` field due to the migration to `FastAPI`): +For parsec v3: -- User not found: `404` with JSON body `{"detail": "user_not_found"}` +- User not found: `404` (again with arbitrary JSON body) ## Implementation diff --git a/server/parsec/asgi/administration.py b/server/parsec/asgi/administration.py index 5dd45a93289..a04e9043ff8 100644 --- a/server/parsec/asgi/administration.py +++ b/server/parsec/asgi/administration.py @@ -22,6 +22,7 @@ BootstrapToken, DateTime, OrganizationID, + UserID, UserProfile, ) from parsec.components.organization import ( @@ -33,6 +34,7 @@ OrganizationUpdateBadOutcome, Unset, ) +from parsec.components.user import UserFreezeUserBadOutcome, UserInfo, UserListUsersBadOutcome from parsec.events import OrganizationIDField if TYPE_CHECKING: @@ -50,6 +52,18 @@ def check_administration_auth( raise HTTPException(status_code=403, detail="Bad authorization token") +# This function is a workaround for FastAPI's broken custom type in query parameters +# (see https://github.com/tiangolo/fastapi/issues/10259) +def parse_organization_id_or_die(raw_organization_id: str) -> OrganizationID: + try: + return OrganizationID(raw_organization_id) + except ValueError: + raise HTTPException( + status_code=404, + detail="Invalid organization ID", + ) + + class CreateOrganizationIn(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, strict=True) organization_id: OrganizationIDField @@ -122,13 +136,7 @@ async def administration_get_organization( ) -> GetOrganizationOut: backend: Backend = request.app.state.backend - try: - organization_id = OrganizationID(raw_organization_id) - except ValueError: - raise HTTPException( - status_code=404, - detail="Invalid organization ID", - ) + organization_id = parse_organization_id_or_die(raw_organization_id) # Check whether the organization actually exists outcome = await backend.organization.get(id=organization_id) @@ -186,13 +194,7 @@ async def administration_patch_organization( ) -> PatchOrganizationOut: backend: Backend = request.app.state.backend - try: - organization_id = OrganizationID(raw_organization_id) - except ValueError: - raise HTTPException( - status_code=404, - detail="Invalid organization ID", - ) + organization_id = parse_organization_id_or_die(raw_organization_id) outcome = await backend.organization.update( id=organization_id, @@ -219,13 +221,7 @@ async def administration_organization_stat( ) -> Response: backend: Backend = request.app.state.backend - try: - organization_id = OrganizationID(raw_organization_id) - except ValueError: - raise HTTPException( - status_code=404, - detail="Invalid organization ID", - ) + organization_id = parse_organization_id_or_die(raw_organization_id) outcome = await backend.organization.organization_stats(organization_id) match outcome: @@ -357,3 +353,99 @@ async def administration_server_stats( case unknown: assert_never(unknown) + + +@administration_router.get("/administration/organizations/{raw_organization_id}/users") +async def administration_organization_users( + raw_organization_id: str, + auth: Annotated[None, Depends(check_administration_auth)], + request: Request, +) -> Response: + backend: Backend = request.app.state.backend + + organization_id = parse_organization_id_or_die(raw_organization_id) + + outcome = await backend.user.list_users(organization_id) + match outcome: + case list() as users: + pass + case UserListUsersBadOutcome.ORGANIZATION_NOT_FOUND: + raise HTTPException(status_code=404, detail="Organization not found") + case unknown: + assert_never(unknown) + + return JSONResponse( + status_code=200, + content={ + "users": [ + { + "user_id": user.user_id.str, + "user_email": user.human_handle.email, + "user_name": user.human_handle.label, + "frozen": user.frozen, + } + for user in users + ] + }, + ) + + +class UserFreezeIn(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, strict=True) + frozen: bool + user_email: str | None = None + user_id: UserID | None = None + + @field_validator("user_id", mode="plain") + @classmethod + def validate_user_id(cls, v: Any) -> UserID | None: + match v: + case UserID(): + return v + case None: + return None + case raw: + return UserID(raw) + + +@administration_router.post("/administration/organizations/{raw_organization_id}/users/freeze") +async def administration_organization_users_freeze( + raw_organization_id: str, + auth: Annotated[None, Depends(check_administration_auth)], + body: UserFreezeIn, + request: Request, +) -> Response: + backend: Backend = request.app.state.backend + + organization_id = parse_organization_id_or_die(raw_organization_id) + + outcome = await backend.user.freeze_user( + organization_id, user_id=body.user_id, user_email=body.user_email, frozen=body.frozen + ) + match outcome: + case UserInfo() as user: + pass + case UserFreezeUserBadOutcome.ORGANIZATION_NOT_FOUND: + raise HTTPException(status_code=404, detail="Organization not found") + case UserFreezeUserBadOutcome.USER_NOT_FOUND: + raise HTTPException(status_code=404, detail="User not found") + case UserFreezeUserBadOutcome.BOTH_USER_ID_AND_EMAIL: + raise HTTPException( + status_code=400, detail="Both `user_id` and `user_email` fields are provided" + ) + case UserFreezeUserBadOutcome.NO_USER_ID_NOR_EMAIL: + raise HTTPException( + status_code=400, detail="Missing either `user_id` or `user_email` field" + ) + case unknown: + assert_never(unknown) + + return JSONResponse( + status_code=200, + content={ + "user_id": user.user_id.str, + "user_email": user.human_handle.email, + "user_name": user.human_handle.label, + "frozen": user.frozen, + }, + ) diff --git a/server/parsec/asgi/rpc.py b/server/parsec/asgi/rpc.py index b238dbf11cb..07afb5bd65c 100644 --- a/server/parsec/asgi/rpc.py +++ b/server/parsec/asgi/rpc.py @@ -196,7 +196,8 @@ def settle_compatible_versions( # we are not settled on what should be used yet (who knows ! maybe in the future # we will use another serialization format for the command processing). # Instead we rely on the following HTTP status code: -# - 401: Bad authentication info (bad Author/Signature/Timestamp headers) +# - 401: Missing authentication info +# - 403: Bad authentication info (bad Author/Signature/Timestamp headers) # - 404: Organization / Invitation not found or invalid organization ID # - 406: Bad accept type (for the SSE events route) # - 410: Invitation already deleted / used @@ -204,10 +205,12 @@ def settle_compatible_versions( # - 422: Unsupported API version # - 460: Organization is expired # - 461: User is revoked +# - 462: User is frozen class CustomHttpStatus(Enum): - BadAuthenticationInfo = 401 + MissingAuthenticationInfo = 401 + BadAuthenticationInfo = 403 OrganizationOrInvitationInvalidOrNotFound = 404 BadAcceptType = 406 InvitationAlreadyUsedOrDeleted = 410 @@ -215,30 +218,29 @@ class CustomHttpStatus(Enum): UnsupportedApiVersion = 422 OrganizationExpired = 460 UserRevoked = 461 + UserFrozen = 462 -def _handshake_abort(status_code: int, api_version: ApiVersion) -> NoReturn: - detail: str | None - match status_code: - case 422: - detail = "Unsupported api version" - case 460: - detail = "Organization expired" - case 461: - detail = "User revoked" - case _: - detail = None +# e.g. `InvitationAlreadyUsedOrDeleted` -> `Invitation already used or deleted` +CUSTOM_HTTP_STATUS_DETAILS = { + status: ("".join(" " + c.lower() if c.isupper() else c for c in status.name)) + .strip() + .capitalize() + for status in CustomHttpStatus +} + + +def _handshake_abort(status: CustomHttpStatus, api_version: ApiVersion) -> NoReturn: + detail = CUSTOM_HTTP_STATUS_DETAILS[status] raise HTTPException( - status_code=status_code, + status_code=status.value, headers={"Api-Version": str(api_version)}, detail=detail, ) def _handshake_abort_bad_content(api_version: ApiVersion) -> NoReturn: - _handshake_abort( - CustomHttpStatus.BadContentTypeOrInvalidBodyOrUnknownCommand.value, api_version - ) + _handshake_abort(CustomHttpStatus.BadContentTypeOrInvalidBodyOrUnknownCommand, api_version) @dataclass @@ -290,7 +292,7 @@ def _parse_auth_headers_or_abort( organization_id = OrganizationID(raw_organization_id) except ValueError: _handshake_abort( - CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound.value, + CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound, api_version=settled_api_version, ) @@ -299,7 +301,7 @@ def _parse_auth_headers_or_abort( if expected_content_type and headers.get("Content-Type") != expected_content_type: _handshake_abort_bad_content(api_version=settled_api_version) if expected_accept_type and headers.get("Accept") != expected_accept_type: - _handshake_abort(CustomHttpStatus.BadAcceptType.value, api_version=settled_api_version) + _handshake_abort(CustomHttpStatus.BadAcceptType, api_version=settled_api_version) # 4) Check authenticated headers if not with_authenticated_headers: @@ -311,12 +313,12 @@ def _parse_auth_headers_or_abort( authorization_method = headers["Authorization"] except KeyError: _handshake_abort( - CustomHttpStatus.BadAuthenticationInfo.value, api_version=settled_api_version + CustomHttpStatus.MissingAuthenticationInfo, api_version=settled_api_version ) if authorization_method != AUTHORIZATION_PARSEC_ED25519: _handshake_abort( - CustomHttpStatus.BadAuthenticationInfo.value, api_version=settled_api_version + CustomHttpStatus.BadAuthenticationInfo, api_version=settled_api_version ) try: @@ -324,7 +326,7 @@ def _parse_auth_headers_or_abort( raw_signature_b64 = headers["Signature"] except KeyError: _handshake_abort( - CustomHttpStatus.BadAuthenticationInfo.value, api_version=settled_api_version + CustomHttpStatus.BadAuthenticationInfo, api_version=settled_api_version ) try: @@ -332,7 +334,7 @@ def _parse_auth_headers_or_abort( authenticated_device_id = DeviceID(b64decode(raw_device_id).decode("utf8")) except ValueError: _handshake_abort( - CustomHttpStatus.BadAuthenticationInfo.value, api_version=settled_api_version + CustomHttpStatus.BadAuthenticationInfo, api_version=settled_api_version ) # 5) Check invited headers @@ -391,11 +393,11 @@ async def anonymous_api(raw_organization_id: str, request: Request) -> Response: pass case AuthAnonymousAuthBadOutcome.ORGANIZATION_EXPIRED: _handshake_abort( - CustomHttpStatus.OrganizationExpired.value, api_version=parsed.settled_api_version + CustomHttpStatus.OrganizationExpired, api_version=parsed.settled_api_version ) case AuthAnonymousAuthBadOutcome.ORGANIZATION_NOT_FOUND: _handshake_abort( - CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound.value, + CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound, api_version=parsed.settled_api_version, ) case unknown: @@ -455,19 +457,19 @@ async def invited_api(raw_organization_id: str, request: Request) -> Response: pass case AuthInvitedAuthBadOutcome.ORGANIZATION_EXPIRED: _handshake_abort( - CustomHttpStatus.OrganizationExpired.value, api_version=parsed.settled_api_version + CustomHttpStatus.OrganizationExpired, api_version=parsed.settled_api_version ) case ( AuthInvitedAuthBadOutcome.ORGANIZATION_NOT_FOUND | AuthInvitedAuthBadOutcome.INVITATION_NOT_FOUND ): _handshake_abort( - CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound.value, + CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound, api_version=parsed.settled_api_version, ) case AuthInvitedAuthBadOutcome.INVITATION_ALREADY_USED: _handshake_abort( - CustomHttpStatus.InvitationAlreadyUsedOrDeleted.value, + CustomHttpStatus.InvitationAlreadyUsedOrDeleted, api_version=parsed.settled_api_version, ) case unknown: @@ -526,11 +528,11 @@ async def authenticated_api(raw_organization_id: str, request: Request) -> Respo pass case AuthAuthenticatedAuthBadOutcome.ORGANIZATION_EXPIRED: _handshake_abort( - CustomHttpStatus.OrganizationExpired.value, api_version=parsed.settled_api_version + CustomHttpStatus.OrganizationExpired, api_version=parsed.settled_api_version ) case AuthAuthenticatedAuthBadOutcome.ORGANIZATION_NOT_FOUND: _handshake_abort( - CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound.value, + CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound, api_version=parsed.settled_api_version, ) case ( @@ -538,12 +540,17 @@ async def authenticated_api(raw_organization_id: str, request: Request) -> Respo | AuthAuthenticatedAuthBadOutcome.INVALID_SIGNATURE ): _handshake_abort( - CustomHttpStatus.BadAuthenticationInfo.value, + CustomHttpStatus.BadAuthenticationInfo, api_version=parsed.settled_api_version, ) case AuthAuthenticatedAuthBadOutcome.USER_REVOKED: _handshake_abort( - CustomHttpStatus.UserRevoked.value, + CustomHttpStatus.UserRevoked, + api_version=parsed.settled_api_version, + ) + case AuthAuthenticatedAuthBadOutcome.USER_FROZEN: + _handshake_abort( + CustomHttpStatus.UserFrozen, api_version=parsed.settled_api_version, ) case unknown: @@ -600,11 +607,11 @@ async def authenticated_events_api(raw_organization_id: str, request: Request) - pass case AuthAuthenticatedAuthBadOutcome.ORGANIZATION_EXPIRED: _handshake_abort( - CustomHttpStatus.OrganizationExpired.value, api_version=parsed.settled_api_version + CustomHttpStatus.OrganizationExpired, api_version=parsed.settled_api_version ) case AuthAuthenticatedAuthBadOutcome.ORGANIZATION_NOT_FOUND: _handshake_abort( - CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound.value, + CustomHttpStatus.OrganizationOrInvitationInvalidOrNotFound, api_version=parsed.settled_api_version, ) case ( @@ -612,12 +619,17 @@ async def authenticated_events_api(raw_organization_id: str, request: Request) - | AuthAuthenticatedAuthBadOutcome.INVALID_SIGNATURE ): _handshake_abort( - CustomHttpStatus.BadAuthenticationInfo.value, + CustomHttpStatus.BadAuthenticationInfo, api_version=parsed.settled_api_version, ) case AuthAuthenticatedAuthBadOutcome.USER_REVOKED: _handshake_abort( - CustomHttpStatus.UserRevoked.value, + CustomHttpStatus.UserRevoked, + api_version=parsed.settled_api_version, + ) + case AuthAuthenticatedAuthBadOutcome.USER_FROZEN: + _handshake_abort( + CustomHttpStatus.UserFrozen, api_version=parsed.settled_api_version, ) case unknown: diff --git a/server/parsec/components/auth.py b/server/parsec/components/auth.py index 4f20054e613..24140c9eb7e 100644 --- a/server/parsec/components/auth.py +++ b/server/parsec/components/auth.py @@ -12,7 +12,9 @@ OrganizationID, VerifyKey, ) +from parsec.components.events import EventBus from parsec.config import BackendConfig +from parsec.events import Event, EventUserRevokedOrFrozen, EventUserUnfrozen CACHE_TIME = 60 # seconds @@ -39,6 +41,7 @@ "ORGANIZATION_EXPIRED", "ORGANIZATION_NOT_FOUND", "USER_REVOKED", + "USER_FROZEN", "DEVICE_NOT_FOUND", "INVALID_SIGNATURE", ), @@ -70,12 +73,24 @@ class AuthenticatedAuthInfo: class BaseAuthComponent: - def __init__(self, config: BackendConfig): + def __init__(self, event_bus: EventBus, config: BackendConfig): self._config = config self._device_cache: dict[ tuple[OrganizationID, DeviceID], tuple[DateTime, AuthenticatedAuthInfo | AuthAuthenticatedAuthBadOutcome], ] = {} + event_bus.connect(self._on_event) + + def _on_event(self, event: Event) -> None: + match event: + # Revocation and freezing/unfreezing affect the authentication process, + # so we clear the cache when such events occur. + case EventUserUnfrozen() | EventUserRevokedOrFrozen(): + self._device_cache = { + (org_id, device_id): v + for ((org_id, device_id), v) in self._device_cache.items() + if org_id != event.organization_id or device_id.user_id != event.user_id + } # # Public methods diff --git a/server/parsec/components/events.py b/server/parsec/components/events.py index c4b30d48faf..0227f3fe95a 100644 --- a/server/parsec/components/events.py +++ b/server/parsec/components/events.py @@ -21,7 +21,7 @@ EventOrganizationConfig, EventOrganizationExpired, EventRealmCertificate, - EventUserRevoked, + EventUserRevokedOrFrozen, EventUserUpdated, ) @@ -59,11 +59,20 @@ def clear(self) -> None: @dataclass(repr=False, eq=False) class EventBusSpy: - events: list[Event] = field(default_factory=list) + _connected: bool = False + _events: list[Event] = field(default_factory=list) _waiters: set[Callable[[Event], None]] = field(default_factory=set) + @property + def events(self) -> list[Event]: + if not self._connected: + raise RuntimeError( + "Spy is no longer connected to the event bus (using it outside of its context manager ?)" + ) + return self._events + def __repr__(self): - return f"<{type(self).__name__}({self.events})>" + return f"<{type(self).__name__}({self._events})>" def _on_event_cb(self, event: Event) -> None: self.events.append(event) @@ -197,11 +206,13 @@ def spy(self) -> Iterator[EventBusSpy]: """Only for tests !""" spy = EventBusSpy() self.connect(spy._on_event_cb) + spy._connected = True try: yield spy finally: self.disconnect(spy._on_event_cb) + spy._connected = False async def send(self, event: Event) -> None: raise NotImplementedError @@ -252,7 +263,7 @@ def _on_event(self, event: Event) -> None: registered.cancel_scope.cancel() return - if isinstance(event, EventUserRevoked): + if isinstance(event, EventUserRevokedOrFrozen): for registered in self._registered_clients.values(): if ( registered.organization_id == event.organization_id diff --git a/server/parsec/components/memory/auth.py b/server/parsec/components/memory/auth.py index 8d418bca8be..c5e53385942 100644 --- a/server/parsec/components/memory/auth.py +++ b/server/parsec/components/memory/auth.py @@ -92,6 +92,8 @@ async def _get_authenticated_info( user = org.users[device_id.user_id] if user.is_revoked: return AuthAuthenticatedAuthBadOutcome.USER_REVOKED + if user.is_frozen: + return AuthAuthenticatedAuthBadOutcome.USER_FROZEN return AuthenticatedAuthInfo( organization_id=organization_id, diff --git a/server/parsec/components/memory/datamodel.py b/server/parsec/components/memory/datamodel.py index 3b406e590b9..3490c70789d 100644 --- a/server/parsec/components/memory/datamodel.py +++ b/server/parsec/components/memory/datamodel.py @@ -195,6 +195,7 @@ class MemoryUser: revoked_user_certificate: bytes | None = field(default=None, repr=False) # Should be updated each time a new vlob is created/updated last_vlob_operation_timestamp: DateTime | None = None + is_frozen: bool = False @property def current_profile(self) -> UserProfile: diff --git a/server/parsec/components/memory/factory.py b/server/parsec/components/memory/factory.py index 8bcb28daaf2..15cad235292 100644 --- a/server/parsec/components/memory/factory.py +++ b/server/parsec/components/memory/factory.py @@ -35,7 +35,7 @@ async def components_factory(config: BackendConfig) -> AsyncGenerator[dict[str, async with event_bus_factory() as event_bus: async with httpx.AsyncClient(verify=SSL_CONTEXT) as http_client: webhooks = WebhooksComponent(config, http_client) - auth = MemoryAuthComponent(data, config) + auth = MemoryAuthComponent(data, event_bus, config) organization = MemoryOrganizationComponent(data, event_bus, webhooks, config) user = MemoryUserComponent(data, event_bus) invite = MemoryInviteComponent(data, event_bus, config) diff --git a/server/parsec/components/memory/user.py b/server/parsec/components/memory/user.py index ba4f44adec4..1ca35cf65a3 100644 --- a/server/parsec/components/memory/user.py +++ b/server/parsec/components/memory/user.py @@ -32,8 +32,11 @@ UserCreateUserStoreBadOutcome, UserCreateUserValidateBadOutcome, UserDump, + UserFreezeUserBadOutcome, UserGetActiveDeviceVerifyKeyBadOutcome, UserGetCertificatesAsUserBadOutcome, + UserInfo, + UserListUsersBadOutcome, UserRevokeUserStoreBadOutcome, UserRevokeUserValidateBadOutcome, UserUpdateUserStoreBadOutcome, @@ -45,7 +48,8 @@ ) from parsec.events import ( EventCommonCertificate, - EventUserRevoked, + EventUserRevokedOrFrozen, + EventUserUnfrozen, EventUserUpdated, ) @@ -294,7 +298,7 @@ async def revoke_user( ) ) await self._event_bus.send( - EventUserRevoked( + EventUserRevokedOrFrozen( organization_id=organization_id, user_id=certif.user_id, ) @@ -593,3 +597,78 @@ async def test_dump_current_users( ) return items + + async def list_users( + self, organization_id: OrganizationID + ) -> list[UserInfo] | UserListUsersBadOutcome: + try: + org = self._data.organizations[organization_id] + except KeyError: + return UserListUsersBadOutcome.ORGANIZATION_NOT_FOUND + + users = [] + for user in org.users.values(): + users.append( + UserInfo( + user_id=user.cooked.user_id, + human_handle=user.cooked.human_handle, + frozen=user.is_frozen, + ) + ) + + return users + + async def freeze_user( + self, + organization_id: OrganizationID, + user_id: UserID | None, + user_email: str | None, + frozen: bool, + ) -> UserInfo | UserFreezeUserBadOutcome: + try: + org = self._data.organizations[organization_id] + except KeyError: + return UserFreezeUserBadOutcome.ORGANIZATION_NOT_FOUND + + match (user_id, user_email): + case (None, None): + return UserFreezeUserBadOutcome.NO_USER_ID_NOR_EMAIL + case (UserID() as user_id, None): + try: + user = org.users[user_id] + except KeyError: + return UserFreezeUserBadOutcome.USER_NOT_FOUND + case (None, str() as user_email): + for user in org.users.values(): + if user.cooked.human_handle.email == user_email: + break + else: + return UserFreezeUserBadOutcome.USER_NOT_FOUND + case (UserID(), str()): + return UserFreezeUserBadOutcome.BOTH_USER_ID_AND_EMAIL + case _: + assert ( + False + ) # Can't use assert_never here due to https://github.com/python/mypy/issues/16650 + + user.is_frozen = frozen + if user.is_frozen: + await self._event_bus.send( + EventUserRevokedOrFrozen( + organization_id=organization_id, + user_id=user.cooked.user_id, + ) + ) + else: + await self._event_bus.send( + EventUserUnfrozen( + organization_id=organization_id, + user_id=user.cooked.user_id, + ) + ) + + return UserInfo( + user_id=user.cooked.user_id, + human_handle=user.cooked.human_handle, + frozen=user.is_frozen, + ) diff --git a/server/parsec/components/user.py b/server/parsec/components/user.py index beae07deca9..7e1450ff619 100644 --- a/server/parsec/components/user.py +++ b/server/parsec/components/user.py @@ -11,6 +11,7 @@ DeviceCertificate, DeviceID, DeviceName, + HumanHandle, OrganizationID, RevokedUserCertificate, UserCertificate, @@ -46,6 +47,13 @@ class UserDump: is_revoked: bool +@dataclass(slots=True) +class UserInfo: + user_id: UserID + human_handle: HumanHandle + frozen: bool + + UserCreateUserValidateBadOutcome = Enum( "UserCreateUserValidateBadOutcome", ( @@ -304,6 +312,25 @@ def user_update_user_validate( "USER_REVOKED", ), ) +UserListUsersBadOutcome = Enum( + "UserListUsersBadOutcome", + ( + "ORGANIZATION_NOT_FOUND", + # Note we don't care the organization is expired here, this is because this + # command is used by the administration. + ), +) +UserFreezeUserBadOutcome = Enum( + "UserFreezeUserBadOutcome", + ( + "ORGANIZATION_NOT_FOUND", + # Note we don't care the organization is expired here, this is because this + # command is used by the administration. + "USER_NOT_FOUND", + "BOTH_USER_ID_AND_EMAIL", + "NO_USER_ID_NOR_EMAIL", + ), +) class BaseUserComponent: @@ -400,6 +427,20 @@ async def test_dump_current_users( ) -> dict[UserID, UserDump]: raise NotImplementedError + async def list_users( + self, organization_id: OrganizationID + ) -> list[UserInfo] | UserListUsersBadOutcome: + raise NotImplementedError + + async def freeze_user( + self, + organization_id: OrganizationID, + user_id: UserID | None, + user_email: str | None, + frozen: bool, + ) -> UserInfo | UserFreezeUserBadOutcome: + raise NotImplementedError + # # API commands # diff --git a/server/parsec/events.py b/server/parsec/events.py index cd92e7cf8aa..418f18c9f37 100644 --- a/server/parsec/events.py +++ b/server/parsec/events.py @@ -338,9 +338,17 @@ class EventOrganizationExpired(BaseModel): organization_id: OrganizationIDField -class EventUserRevoked(BaseModel): +class EventUserRevokedOrFrozen(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, strict=True) - type: Literal["USER_REVOKED"] = "USER_REVOKED" + type: Literal["USER_REVOKED_OR_FROZEN"] = "USER_REVOKED_OR_FROZEN" + event_id: UUID = Field(default_factory=uuid4) + organization_id: OrganizationIDField + user_id: UserID + + +class EventUserUnfrozen(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, strict=True) + type: Literal["USER_UNFROZEN"] = "USER_UNFROZEN" event_id: UUID = Field(default_factory=uuid4) organization_id: OrganizationIDField user_id: UserID @@ -367,7 +375,8 @@ class EventUserUpdated(BaseModel): | EventOrganizationConfig | EventEnrollmentConduit | EventOrganizationExpired - | EventUserRevoked + | EventUserRevokedOrFrozen + | EventUserUnfrozen | EventUserUpdated ) diff --git a/server/tests/administration/test_create_organization.py b/server/tests/administration/test_create_organization.py index 4089cfdbb28..d983baa06b2 100644 --- a/server/tests/administration/test_create_organization.py +++ b/server/tests/administration/test_create_organization.py @@ -17,7 +17,7 @@ async def test_create_organization_auth(client: httpx.AsyncClient) -> None: url = "http://parsec.invalid/administration/organizations" # No Authorization header response = await client.post(url) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Invalid Authorization header response = await client.post( url, @@ -25,7 +25,7 @@ async def test_create_organization_auth(client: httpx.AsyncClient) -> None: "Authorization": "DUMMY", }, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Bad bearer token response = await client.post( url, @@ -33,7 +33,42 @@ async def test_create_organization_auth(client: httpx.AsyncClient) -> None: "Authorization": "Bearer BADTOKEN", }, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content + + +@pytest.mark.parametrize("kind", ("bad_json", "bad_data")) +async def test_bad_data(client: httpx.AsyncClient, backend: Backend, kind: str) -> None: + url = "http://parsec.invalid/administration/organizations" + + body_args: dict + match kind: + case "bad_json": + body_args = {"content": b""} + case "bad_data": + body_args = {"json": {"dummy": "dummy"}} + case unknown: + assert False, unknown + + response = await client.post( + url, headers={"Authorization": f"Bearer {backend.config.administration_token}"}, **body_args + ) + assert response.status_code == 422, response.content + + +async def test_bad_method( + client: httpx.AsyncClient, + backend: Backend, +) -> None: + url = "http://parsec.invalid/administration/organizations" + org_id = OrganizationID("MyNewOrg") + response = await client.patch( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "organization_id": org_id.str, + }, + ) + assert response.status_code == 405, response.content @pytest.mark.parametrize( @@ -59,7 +94,7 @@ async def test_ok( **args, }, ) - assert response.status_code == 200 + assert response.status_code == 200, response.content assert response.json() == {"bootstrap_token": ANY} expected_active_users_limit = ActiveUsersLimit.from_maybe_int( diff --git a/server/tests/administration/test_freeze_user.py b/server/tests/administration/test_freeze_user.py new file mode 100644 index 00000000000..eb59f44c53a --- /dev/null +++ b/server/tests/administration/test_freeze_user.py @@ -0,0 +1,212 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +import httpx +import pytest + +from parsec._parsec import authenticated_cmds +from parsec.events import EventUserRevokedOrFrozen, EventUserUnfrozen +from tests.common import Backend, CoolorgRpcClients, RpcTransportError + + +async def test_bad_auth( + client: httpx.AsyncClient, backend: Backend, coolorg: CoolorgRpcClients +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users/freeze" + # No Authorization header + response = await client.post(url) + assert response.status_code == 403, response.content + # Invalid Authorization header + response = await client.post( + url, + headers={ + "Authorization": "DUMMY", + }, + ) + assert response.status_code == 403, response.content + # Bad bearer token + response = await client.post( + url, + headers={ + "Authorization": "Bearer BADTOKEN", + }, + ) + assert response.status_code == 403, response.content + + +@pytest.mark.parametrize("kind", ("bad_json", "bad_data")) +async def test_bad_data( + client: httpx.AsyncClient, backend: Backend, coolorg: CoolorgRpcClients, kind: str +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users/freeze" + + body_args: dict + match kind: + case "bad_json": + body_args = {"content": b""} + case "bad_data": + body_args = {"json": {"dummy": "dummy"}} + case unknown: + assert False, unknown + + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + **body_args, + ) + assert response.status_code == 422, response.content + + +async def test_bad_method( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users/freeze" + + response = await client.patch( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={"user_id": coolorg.alice.user_id.str, "frozen": True}, + ) + assert response.status_code == 405, response.content + + +async def test_disconnect_sse( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users/freeze" + + async with coolorg.alice.events_listen() as alice_sse: + rep = ( + await alice_sse.next_event() + ) # Server always starts by returning a `ServerConfig` event + + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={"user_id": coolorg.alice.user_id.str, "frozen": True}, + ) + assert response.status_code == 200, response.content + + # Now the server should disconnect us... + + with pytest.raises(StopAsyncIteration): + # Loop given the server might have send us some events before the freeze + while True: + await alice_sse.next_event() + + # ...and we cannot reconnect ! + + rep = await coolorg.alice.raw_sse_connection() + assert rep.status_code == 462 + + +async def test_ok( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users/freeze" + + # 1) Search by user ID and freeze + + for _ in range(2): # Re-freeze is idempotent + with backend.event_bus.spy() as spy: + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={"user_id": coolorg.alice.user_id.str, "frozen": True}, + ) + assert response.status_code == 200, response.content + assert response.json() == { + "user_id": "alice", + "user_email": "alice@example.com", + "user_name": "Alicey McAliceFace", + "frozen": True, + } + + await spy.wait_event_occurred( + EventUserRevokedOrFrozen( + organization_id=coolorg.organization_id, + user_id=coolorg.alice.device_id.user_id, + ) + ) + + # 2) Ensure the user is frozen + + with pytest.raises(RpcTransportError) as exc: + await coolorg.alice.ping(ping="hello") + assert exc.value.rep.status_code == 462 + + # 3) Search by email and un-freeze + + for _ in range(2): + with backend.event_bus.spy() as spy: + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={"user_email": coolorg.alice.human_handle.email, "frozen": False}, + ) + assert response.status_code == 200, response.content + assert response.json() == { + "user_id": "alice", + "user_email": "alice@example.com", + "user_name": "Alicey McAliceFace", + "frozen": False, + } + + await spy.wait_event_occurred( + EventUserUnfrozen( + organization_id=coolorg.organization_id, + user_id=coolorg.alice.device_id.user_id, + ) + ) + + # 4) Finally ensure the user is no longer frozen + + rep = await coolorg.alice.ping(ping="hello") + assert rep == authenticated_cmds.latest.ping.RepOk(pong="hello") + + +async def test_unknown_organization( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = "http://parsec.invalid/administration/organizations/DummyOrg/users/freeze" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={"user_id": coolorg.alice.user_id.str, "frozen": True}, + ) + assert response.status_code == 404, response.content + + +async def test_unknown_user_id( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users/freeze" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={"user_id": "Dummy", "frozen": True}, + ) + assert response.status_code == 404, response.content + + +async def test_unknown_email( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users/freeze" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={"user_email": "dummy@example.invalid", "frozen": True}, + ) + assert response.status_code == 404, response.content diff --git a/server/tests/administration/test_get_organization.py b/server/tests/administration/test_get_organization.py index 61048d91b2e..98697797f0e 100644 --- a/server/tests/administration/test_get_organization.py +++ b/server/tests/administration/test_get_organization.py @@ -9,7 +9,7 @@ async def test_get_organization_auth(client: httpx.AsyncClient, coolorg: Coolorg url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}" # No Authorization header response = await client.get(url) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Invalid Authorization header response = await client.get( url, @@ -17,7 +17,7 @@ async def test_get_organization_auth(client: httpx.AsyncClient, coolorg: Coolorg "Authorization": "DUMMY", }, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Bad bearer token response = await client.get( url, @@ -25,7 +25,18 @@ async def test_get_organization_auth(client: httpx.AsyncClient, coolorg: Coolorg "Authorization": "Bearer BADTOKEN", }, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content + + +async def test_bad_method( + client: httpx.AsyncClient, backend: Backend, coolorg: CoolorgRpcClients +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + ) + assert response.status_code == 405, response.content async def test_ok( @@ -38,7 +49,7 @@ async def test_ok( url, headers={"Authorization": f"Bearer {backend.config.administration_token}"}, ) - assert response.status_code == 200 + assert response.status_code == 200, response.content assert response.json() == { "active_users_limit": None, "is_bootstrapped": True, @@ -56,5 +67,5 @@ async def test_404( url, headers={"Authorization": f"Bearer {backend.config.administration_token}"}, ) - assert response.status_code == 404 + assert response.status_code == 404, response.content assert response.json() == {"detail": "Organization not found"} diff --git a/server/tests/administration/test_list_users.py b/server/tests/administration/test_list_users.py new file mode 100644 index 00000000000..b5989f2d6ce --- /dev/null +++ b/server/tests/administration/test_list_users.py @@ -0,0 +1,124 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +import httpx + +from parsec.components.user import UserInfo +from tests.common import Backend, CoolorgRpcClients + + +async def test_bad_auth(client: httpx.AsyncClient, coolorg: CoolorgRpcClients) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users" + # No Authorization header + response = await client.get(url) + assert response.status_code == 403, response.content + # Invalid Authorization header + response = await client.get( + url, + headers={ + "Authorization": "DUMMY", + }, + ) + assert response.status_code == 403, response.content + # Bad bearer token + response = await client.get( + url, + headers={ + "Authorization": "Bearer BADTOKEN", + }, + ) + assert response.status_code == 403, response.content + + +async def test_bad_method( + client: httpx.AsyncClient, backend: Backend, coolorg: CoolorgRpcClients +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + ) + assert response.status_code == 405, response.content + + +async def test_ok( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users" + response = await client.get( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + ) + assert response.status_code == 200, response.content + assert response.json() == { + "users": [ + { + "user_id": "alice", + "user_email": "alice@example.com", + "user_name": "Alicey McAliceFace", + "frozen": False, + }, + { + "user_id": "bob", + "user_email": "bob@example.com", + "user_name": "Boby McBobFace", + "frozen": False, + }, + { + "user_id": "mallory", + "user_email": "mallory@example.com", + "user_name": "Malloryy McMalloryFace", + "frozen": False, + }, + ], + } + + outcome = await backend.user.freeze_user( + organization_id=coolorg.organization_id, + user_id=coolorg.alice.user_id, + user_email=None, + frozen=True, + ) + assert isinstance(outcome, UserInfo) + + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/users" + response = await client.get( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + ) + assert response.status_code == 200, response.content + assert response.json() == { + "users": [ + { + "user_id": "alice", + "user_email": "alice@example.com", + "user_name": "Alicey McAliceFace", + "frozen": True, + }, + { + "user_id": "bob", + "user_email": "bob@example.com", + "user_name": "Boby McBobFace", + "frozen": False, + }, + { + "user_id": "mallory", + "user_email": "mallory@example.com", + "user_name": "Malloryy McMalloryFace", + "frozen": False, + }, + ], + } + + +async def test_unknown_organization( + client: httpx.AsyncClient, + backend: Backend, +) -> None: + url = "http://parsec.invalid/administration/organizations/Dummy/users" + response = await client.get( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + ) + assert response.status_code == 404, response.content diff --git a/server/tests/administration/test_patch_organization.py b/server/tests/administration/test_patch_organization.py index 4bc398f9117..29449222d1f 100644 --- a/server/tests/administration/test_patch_organization.py +++ b/server/tests/administration/test_patch_organization.py @@ -12,7 +12,7 @@ async def test_get_organization_auth(client: httpx.AsyncClient, coolorg: Coolorg url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}" # No Authorization header response = await client.patch(url, json={"is_expired": True}) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Invalid Authorization header response = await client.patch( url, @@ -21,7 +21,7 @@ async def test_get_organization_auth(client: httpx.AsyncClient, coolorg: Coolorg }, json={"is_expired": True}, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Bad bearer token response = await client.patch( url, @@ -30,7 +30,44 @@ async def test_get_organization_auth(client: httpx.AsyncClient, coolorg: Coolorg }, json={"is_expired": True}, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content + + +@pytest.mark.parametrize("kind", ("bad_json", "bad_data")) +async def test_bad_data( + client: httpx.AsyncClient, backend: Backend, coolorg: CoolorgRpcClients, kind: str +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}" + + body_args: dict + match kind: + case "bad_json": + body_args = {"content": b""} + case "bad_data": + body_args = {"json": {"is_expired": "dummy"}} + case unknown: + assert False, unknown + + response = await client.patch( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + **body_args, + ) + assert response.status_code == 422, response.content + + +async def test_bad_method( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={}, + ) + assert response.status_code == 405, response.content @pytest.mark.parametrize( @@ -55,7 +92,7 @@ async def test_ok( headers={"Authorization": f"Bearer {backend.config.administration_token}"}, json=params, ) - assert response.status_code == 200 + assert response.status_code == 200, response.content assert response.json() == {} dump = await backend.organization.test_dump_organizations() @@ -82,5 +119,5 @@ async def test_404( headers={"Authorization": f"Bearer {backend.config.administration_token}"}, json={"is_expired": True}, ) - assert response.status_code == 404 + assert response.status_code == 404, response.content assert response.json() == {"detail": "Organization not found"} diff --git a/server/tests/administration/test_stats.py b/server/tests/administration/test_stats.py index b34de5d2a34..9552b309de3 100644 --- a/server/tests/administration/test_stats.py +++ b/server/tests/administration/test_stats.py @@ -29,7 +29,7 @@ async def test_organization_stats_auth( url = "http://parsec.invalid" + route.format(organization_id=coolorg.organization_id.str) # No Authorization header response = await client.get(url) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Invalid Authorization header response = await client.get( url, @@ -37,7 +37,7 @@ async def test_organization_stats_auth( "Authorization": "DUMMY", }, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content # Bad bearer token response = await client.get( url, @@ -45,7 +45,23 @@ async def test_organization_stats_auth( "Authorization": "Bearer BADTOKEN", }, ) - assert response.status_code == 403 + assert response.status_code == 403, response.content + + +@pytest.mark.parametrize( + "route", ("/administration/stats", "/administration/organizations/{organization_id}/stats") +) +async def test_bad_method( + route: str, client: httpx.AsyncClient, backend: Backend, coolorg: CoolorgRpcClients +) -> None: + url = "http://parsec.invalid" + route.format(organization_id=coolorg.organization_id.str) + response = await client.post( + url, + headers={ + "Authorization": f"Bearer {backend.config.administration_token}", + }, + ) + assert response.status_code == 405, response.content async def test_ok( @@ -61,24 +77,24 @@ async def test_ok( ) async def server_stats(): - r = await client.get( + response = await client.get( "http://parsec.invalid/administration/stats", headers={ "Authorization": f"Bearer {backend.config.administration_token}", }, ) - assert r.status_code == 200 - return _strip_template_orgs(r.json()) + assert response.status_code == 200, response.content + return _strip_template_orgs(response.json()) async def org_stats(organization_id: OrganizationID): - r = await client.get( + response = await client.get( f"http://parsec.invalid/administration/organizations/{organization_id.str}/stats", headers={ "Authorization": f"Bearer {backend.config.administration_token}", }, ) - assert r.status_code == 200 - return r.json() + assert response.status_code == 200, response.content + return response.json() expected_coolorg_stats = { "active_users": 3, @@ -236,14 +252,14 @@ def _strip_template_orgs(stats: dict) -> dict: return stats async def server_stats(format: str): - r = await client.get( + response = await client.get( f"http://parsec.invalid/administration/stats?format={format}", headers={ "Authorization": f"Bearer {backend.config.administration_token}", }, ) - assert r.status_code == 200 - return r + assert response.status_code == 200, response.content + return response response = await server_stats("json") assert _strip_template_orgs(response.json()) == { @@ -279,14 +295,14 @@ async def test_server_stats_at( client: httpx.AsyncClient, backend: Backend, minimalorg: MinimalorgRpcClients ) -> None: async def server_stats(at: str): - r = await client.get( + response = await client.get( f"http://parsec.invalid/administration/stats?format=json&at={at}", headers={ "Authorization": f"Bearer {backend.config.administration_token}", }, ) - assert r.status_code == 200 - return r.json() + assert response.status_code == 200, response.content + return response.json() response = await server_stats("1990-01-01T00:00:00Z") expected = { diff --git a/server/tests/api_v4/authenticated/test_user_revoke.py b/server/tests/api_v4/authenticated/test_user_revoke.py index 680dab8a174..551b9bc7452 100644 --- a/server/tests/api_v4/authenticated/test_user_revoke.py +++ b/server/tests/api_v4/authenticated/test_user_revoke.py @@ -1,9 +1,10 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +import httpx import pytest from parsec._parsec import DateTime, RevokedUserCertificate, authenticated_cmds -from parsec.events import EventUserRevoked +from parsec.events import EventUserRevokedOrFrozen from tests.common import Backend, CoolorgRpcClients, RpcTransportError @@ -25,7 +26,7 @@ async def test_authenticated_user_revoke_ok(coolorg: CoolorgRpcClients, backend: assert rep == authenticated_cmds.v4.user_revoke.RepOk() await spy.wait_event_occurred( - EventUserRevoked( + EventUserRevokedOrFrozen( organization_id=coolorg.organization_id, user_id=coolorg.bob.device_id.user_id, ) @@ -38,3 +39,39 @@ async def test_authenticated_user_revoke_ok(coolorg: CoolorgRpcClients, backend: with pytest.raises(RpcTransportError) as raised: await coolorg.bob.ping(ping="hello") assert raised.value.rep.status_code == 461 + + +async def test_disconnect_sse( + client: httpx.AsyncClient, + backend: Backend, + coolorg: CoolorgRpcClients, +) -> None: + now = DateTime.now() + certif = RevokedUserCertificate( + author=coolorg.alice.device_id, + timestamp=now, + user_id=coolorg.bob.device_id.user_id, + ) + + async with coolorg.bob.events_listen() as bob_sse: + # 1) Bob starts listening SSE + rep = await bob_sse.next_event() # Server always starts by returning a `ServerConfig` event + + # 2) Then Alice revokes Bob + + rep = await coolorg.alice.user_revoke( + revoked_user_certificate=certif.dump_and_sign(coolorg.alice.signing_key) + ) + assert rep == authenticated_cmds.v4.user_revoke.RepOk() + + # 3) Hence Bob gets disconnected... + + with pytest.raises(StopAsyncIteration): + # Loop given the server might have send us some events before the freeze + while True: + await bob_sse.next_event() + + # 4) ...and cannot reconnect ! + + rep = await coolorg.bob.raw_sse_connection() + assert rep.status_code == 461 diff --git a/server/tests/common/client.py b/server/tests/common/client.py index 4c292a7d34c..a771248f23e 100644 --- a/server/tests/common/client.py +++ b/server/tests/common/client.py @@ -138,6 +138,17 @@ async def events_listen( ) as event_source: yield EventsListenSSE(event_source) + async def raw_sse_connection(self) -> Response: + signature = b64encode(self.signing_key.sign_only_signature(b"")).decode() + return await self.raw_client.get( + f"{self.url}/events", + headers={ + **self.headers, + "Signature": signature, + "Accept": "text/event-stream", + }, + ) + class InvitedRpcClient(BaseInvitedRpcClient): def __init__( diff --git a/server/tests/test_sse.py b/server/tests/test_sse.py index 8f93588ddf5..4a0bd900ef8 100644 --- a/server/tests/test_sse.py +++ b/server/tests/test_sse.py @@ -1,7 +1,5 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS -from base64 import b64encode - import pytest from parsec._parsec import SigningKey @@ -22,15 +20,8 @@ async def test_events_listen_auth_then_not_allowed( minimalorg.alice.signing_key = SigningKey.generate() # ...which cause authentication failure - signature = b64encode(minimalorg.alice.signing_key.sign_only_signature(b"")).decode() - rep = await minimalorg.raw_client.get( - f"{minimalorg.alice.url}/events", - headers={ - **minimalorg.alice.headers, - "Signature": signature, - "Accept": "text/event-stream", - }, - ) + + rep = await minimalorg.alice.raw_sse_connection() assert rep.status_code == 401