Delete a member's local user when the portal removes them

On a member_removed frame the connector deletes the local user mapped to
that portal account, never the owner and never a superuser, since the
portal only ever creates ordinary accounts here. An instance offline at
the time keeps the inert row, as before.
This commit is contained in:
2026-10-15 10:31:08 +02:00
parent dd50d7a16a
commit 8b47b32d3f
3 changed files with 68 additions and 3 deletions
+39 -1
View File
@@ -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.
+26
View File
@@ -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()
+3 -2
View File
@@ -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.