diff --git a/backend/fluksio/cloud/connector.py b/backend/fluksio/cloud/connector.py index 51189d4..ab1ee0d 100644 --- a/backend/fluksio/cloud/connector.py +++ b/backend/fluksio/cloud/connector.py @@ -21,6 +21,7 @@ import contextlib import logging import time import uuid +from dataclasses import replace from datetime import timedelta from typing import Any @@ -171,7 +172,7 @@ class CloudConnector: select(User).where(User.portal_sub == owner_id) ).first(): return - user = session.get(User, uuid.UUID(config.local_user_id)) + user = CloudConnector._local_account(session, config) if user is None or user.portal_sub: return user.portal_sub = owner_id @@ -183,6 +184,52 @@ class CloudConnector: # link: everything else this connection does still works. logger.exception("Could not map the enrolling account to the portal owner") + @staticmethod + def _local_account(session: Any, config: cloud_config.CloudConfig) -> Any: + """The account this installation acts as, or the one that now stands for it. + + ``local_user_id`` names whoever redeemed the claim code. A database + restored from a backup, or rebuilt under an enrolment that outlived it, + has the same operator behind a different row — and then this resolves to + nobody, which would refuse the owner on a machine they may only be able + to reach through the portal. That is the lockout the enrolment handshake + exists to prevent, so it is closed here too: one superuser is not a guess, + it is the account enrolment would have used. Several is a guess, and this + code does not make it. + + The new id is written back, because the same field is what a screen + paired through the portal borrows — a panel would otherwise be refused + for the same reason and with no way to say so. + """ + from fluksio.models import User + + try: + user = session.get(User, uuid.UUID(config.local_user_id)) + except ValueError: + user = None + if user is not None: + return user + + from sqlmodel import select + + superusers = list( + session.exec(select(User).where(User.is_superuser == True)) # noqa: E712 + ) + if len(superusers) != 1: + logger.warning( + "The account this installation enrolled as is gone, and there " + "%s to adopt unambiguously — enrol again from Settings.", + "is no superuser" if not superusers else "are several superusers", + ) + return None + adopted = superusers[0] + cloud_config.save(replace(config, local_user_id=str(adopted.id))) + logger.warning( + "The account this installation enrolled as is gone; acting as %s instead", + adopted.email, + ) + return adopted + async def _keepalive(self, socket: Any, config: cloud_config.CloudConfig) -> None: """Heartbeats, plus a health snapshot the portal can show while offline.""" last_status = 0.0 diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index bc44c12..1b192ec 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -10,6 +10,7 @@ nothing, which is what makes deleting that local account a revocation. from __future__ import annotations import uuid +from dataclasses import replace from unittest.mock import Mock, patch import jwt @@ -326,3 +327,34 @@ def test_an_older_enrolment_adopts_the_owner_on_attach( assert db.get(User, enrolled.id).portal_sub == "portal-user-1" db.delete(other) db.commit() + + +def test_the_owner_is_adopted_when_the_enrolling_row_is_gone( + enrolled: User, db: Session, portal_key: rsa.RSAPrivateKey +) -> None: + """A rebuilt database keeps the enrolment and loses the account it names. + + Restoring a backup, or the move to a different database, gives the same + operator a new row — and then `local_user_id` resolves to nobody, so a + portal session lands on no local user and is refused. That is a lockout on + a machine whose only route in is the portal, which is exactly what adopting + the owner exists to prevent. + """ + from fluksio.cloud.connector import CloudConnector + + enrolled.portal_sub = None + db.add(enrolled) + db.commit() + # The enrolment now names an account that is not here any more. + stale = cloud_config.load() + assert stale is not None + cloud_config.save(replace(stale, local_user_id=str(uuid.uuid4()))) + assert user_from_token(db, portal_token(portal_key)) is None + + CloudConnector._adopt_owner(cloud_config.load(), "portal-user-1") + db.expire_all() + assert user_from_token(db, portal_token(portal_key)) == enrolled + # And recorded, so a panel borrowing the same field is not refused either. + healed = cloud_config.load() + assert healed is not None + assert healed.local_user_id == str(enrolled.id)