diff --git a/backend/fluksio/cloud/connector.py b/backend/fluksio/cloud/connector.py index fc720b9..2aa4ceb 100644 --- a/backend/fluksio/cloud/connector.py +++ b/backend/fluksio/cloud/connector.py @@ -139,6 +139,8 @@ class CloudConnector: self._connected_since: float | None = None self._last_error: str | None = None self._revoked = False + #: The portal account that owns this instance, as the last welcome said. + self._owner: str | None = None @property def revoked(self) -> bool: @@ -235,7 +237,8 @@ class CloudConnector: str(welcome.get("reason") or "refused by the portal") ) - self._adopt_owner(config, welcome.get("owner")) + self._owner = welcome.get("owner") + self._adopt_owner(config, self._owner) attached = True self._connected = True @@ -256,6 +259,12 @@ class CloudConnector: self._start_stream(socket, frame) elif op == "ws_close": self._cancel_stream(str(frame.get("id") or "")) + elif op == "member_removed": + await asyncio.to_thread( + self._forget_member, + str(frame.get("user") or ""), + self._owner, + ) finally: keepalive.cancel() # Awaited, not just cancelled: a keepalive that died of its own @@ -310,6 +319,35 @@ class CloudConnector: # link: everything else this connection does still works. logger.exception("Could not map the enrolling account to the portal owner") + @staticmethod + def _forget_member(portal_sub: str, owner: str | None) -> None: + """Delete the local user of somebody the portal no longer admits. + + Never the owner, and never a superuser: the portal only ever creates + ordinary accounts here, so those are not its to take away. + """ + from sqlmodel import Session, select + + from fluksio.core.db import engine + from fluksio.models import User + + if not portal_sub or portal_sub == owner: + return + try: + with Session(engine) as session: + user = session.exec( + select(User).where(User.portal_sub == portal_sub) + ).first() + if user is None or user.is_superuser: + return + session.delete(user) + session.commit() + logger.info( + "Deleted %s, removed from this instance at the portal", user.email + ) + except Exception: + logger.exception("Could not delete the user the portal removed") + @staticmethod def _local_account(session: Any, config: cloud_config.CloudConfig) -> Any: """The account this instance acts as, or the one that now stands for it. diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index f95f4c7..a51ced1 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -691,3 +691,29 @@ async def test_a_revoked_link_stands_down(enrolled: User) -> None: # noqa: ARG0 await asyncio.sleep(0.05) watcher.cancel() assert not started + + +def test_a_member_removed_at_the_portal_loses_their_user(db: Session) -> None: + """The portal takes away only the ordinary accounts it gave.""" + from fluksio.cloud.connector import CloudConnector + + member = User(email="gone@example.com", hashed_password="x", portal_sub="p-9") + boss = User( + email="boss@example.com", + hashed_password="x", + portal_sub="p-8", + is_superuser=True, + ) + db.add(member) + db.add(boss) + db.commit() + member_id, boss_id = member.id, boss.id + + CloudConnector._forget_member("p-9", "p-8") + CloudConnector._forget_member("p-8", "p-8") + CloudConnector._forget_member("p-8", None) + db.expire_all() + assert db.get(User, member_id) is None + assert db.get(User, boss_id) is not None + db.delete(boss) + db.commit() diff --git a/docs/interface/portal.md b/docs/interface/portal.md index 45a1b07..f5221af 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -58,8 +58,9 @@ time-limited; generate a fresh one if it expires. connection error. The instance needs outbound access to the portal, and any proxy on that route must support WebSockets. -- To remove a person's access, remove them from the portal's sharing dialog - or edit their account under **Admin → Users** and clear **Is active?**. +- To remove a person's access, remove them from the portal's sharing dialog, + which also deletes their local account while the instance is connected, or + delete or deactivate their account under **Admin → Users**. - To end all portal access from the instance, press **Disconnect**. This stops the connection and invalidates portal credentials locally, without requiring the portal to be online.