From cc3bc1e8ee05b1655162c3b2bd55f2c8b5192a45 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 --- server/parsec/asgi/administration.py | 110 +++++++++++ server/parsec/asgi/rpc.py | 14 ++ 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 | 79 +++++++- server/parsec/components/user.py | 41 +++++ server/parsec/events.py | 15 +- .../tests/administration/test_freeze_user.py | 174 ++++++++++++++++++ .../tests/administration/test_list_users.py | 113 ++++++++++++ .../api_v4/authenticated/test_user_revoke.py | 41 ++++- server/tests/common/client.py | 11 ++ server/tests/test_sse.py | 13 +- 15 files changed, 628 insertions(+), 24 deletions(-) create mode 100644 server/tests/administration/test_freeze_user.py create mode 100644 server/tests/administration/test_list_users.py diff --git a/server/parsec/asgi/administration.py b/server/parsec/asgi/administration.py index 5dd45a93289..23a5763eaab 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: @@ -357,3 +359,111 @@ 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 + + try: + organization_id = OrganizationID(raw_organization_id) + except ValueError: + raise HTTPException( + status_code=404, + detail="Invalid 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_active_users_limit(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 + + try: + organization_id = OrganizationID(raw_organization_id) + except ValueError: + raise HTTPException( + status_code=404, + detail="Invalid 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 3d5112b2fb1..d28fb10455d 100644 --- a/server/parsec/asgi/rpc.py +++ b/server/parsec/asgi/rpc.py @@ -204,6 +204,7 @@ def settle_compatible_versions( # - 422: Unsupported API version # - 460: Organization is expired # - 461: User is revoked +# - 462: User is frozen class CustomHttpStatus(Enum): @@ -215,6 +216,7 @@ class CustomHttpStatus(Enum): UnsupportedApiVersion = 422 OrganizationExpired = 460 UserRevoked = 461 + UserFrozen = 462 def _handshake_abort(status_code: int, api_version: ApiVersion) -> NoReturn: @@ -226,6 +228,8 @@ def _handshake_abort(status_code: int, api_version: ApiVersion) -> NoReturn: detail = "Organization expired" case 461: detail = "User revoked" + case 462: + detail = "User frozen" case _: detail = None raise HTTPException( @@ -547,6 +551,11 @@ async def authenticated_api(raw_organization_id: str, request: Request) -> Respo CustomHttpStatus.UserRevoked.value, api_version=parsed.settled_api_version, ) + case AuthAuthenticatedAuthBadOutcome.USER_FROZEN: + _handshake_abort( + CustomHttpStatus.UserFrozen.value, + api_version=parsed.settled_api_version, + ) case unknown: assert_never(unknown) @@ -621,6 +630,11 @@ async def authenticated_events_api(raw_organization_id: str, request: Request) - CustomHttpStatus.UserRevoked.value, api_version=parsed.settled_api_version, ) + case AuthAuthenticatedAuthBadOutcome.USER_FROZEN: + _handshake_abort( + CustomHttpStatus.UserFrozen.value, + api_version=parsed.settled_api_version, + ) case unknown: assert_never(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 8e8656357f7..0ccd462bf0c 100644 --- a/server/parsec/components/memory/datamodel.py +++ b/server/parsec/components/memory/datamodel.py @@ -197,6 +197,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..4864ad09c0f 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,74 @@ 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 _: + return UserFreezeUserBadOutcome.BOTH_USER_ID_AND_EMAIL + + 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_freeze_user.py b/server/tests/administration/test_freeze_user.py new file mode 100644 index 00000000000..2ff6fbf1fae --- /dev/null +++ b/server/tests/administration/test_freeze_user.py @@ -0,0 +1,174 @@ +# 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 + + +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_list_users.py b/server/tests/administration/test_list_users.py new file mode 100644 index 00000000000..2c1d07a80bb --- /dev/null +++ b/server/tests/administration/test_list_users.py @@ -0,0 +1,113 @@ +# 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 + # Invalid Authorization header + response = await client.get( + url, + headers={ + "Authorization": "DUMMY", + }, + ) + assert response.status_code == 403 + # Bad bearer token + response = await client.get( + url, + headers={ + "Authorization": "Bearer BADTOKEN", + }, + ) + assert response.status_code == 403 + + +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 + 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 + 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 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 e8e8b352615..ade713c62f8 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