Skip to content

Commit

Permalink
Add user freeze feature
Browse files Browse the repository at this point in the history
  • Loading branch information
touilleMan committed Feb 1, 2024
1 parent 4932d46 commit cc3bc1e
Show file tree
Hide file tree
Showing 15 changed files with 628 additions and 24 deletions.
110 changes: 110 additions & 0 deletions server/parsec/asgi/administration.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
BootstrapToken,
DateTime,
OrganizationID,
UserID,
UserProfile,
)
from parsec.components.organization import (
Expand All @@ -33,6 +34,7 @@
OrganizationUpdateBadOutcome,
Unset,
)
from parsec.components.user import UserFreezeUserBadOutcome, UserInfo, UserListUsersBadOutcome
from parsec.events import OrganizationIDField

if TYPE_CHECKING:
Expand Down Expand Up @@ -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,
},
)
14 changes: 14 additions & 0 deletions server/parsec/asgi/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
17 changes: 16 additions & 1 deletion server/parsec/components/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -39,6 +41,7 @@
"ORGANIZATION_EXPIRED",
"ORGANIZATION_NOT_FOUND",
"USER_REVOKED",
"USER_FROZEN",
"DEVICE_NOT_FOUND",
"INVALID_SIGNATURE",
),
Expand Down Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions server/parsec/components/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
EventOrganizationConfig,
EventOrganizationExpired,
EventRealmCertificate,
EventUserRevoked,
EventUserRevokedOrFrozen,
EventUserUpdated,
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions server/parsec/components/memory/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions server/parsec/components/memory/datamodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion server/parsec/components/memory/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit cc3bc1e

Please sign in to comment.