From 000c5abf913ab33c3068f902d96a9ed65e439ec2 Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 21 Aug 2026 12:07:43 +0200 Subject: [PATCH] Adopt the portal owner on attach, rather than demanding a re-enrolment An installation enrolled before per-user mapping has nobody mapped, and fail-closed means its owner is refused. Re-enrolling fixes it and can only be done from the machine's own network, which is the wrong thing to require of a machine whose only route in is the portal. The hub names the owner in the handshake now, and this takes it: if the enrolling account has no portal identity and nobody else holds that one, it is written once and every later attach is a no-op. A mapping somebody else holds is never moved - enrolment was told who that is, and this is only a repair. A failure to write one does not drop the link. Co-Authored-By: Claude Opus 5 (1M context) --- ROADMAP.md | 5 ++++- backend/app/cloud/connector.py | 41 ++++++++++++++++++++++++++++++++++ backend/tests/test_cloud.py | 36 +++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index dcb0536..5edbab1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -339,7 +339,10 @@ Shares components with the admin view. See `docs/architecture/structure.canvas` the revocation, immediate even against a credential already in flight and even when the portal cannot be reached to be told. The hub's `_authorize` grew one member lookup beside the owner comparison, which is the only - thing that changed about the tunnel + thing that changed about the tunnel. Enrolments made before the mapping + existed adopt it from the handshake — the hub names the owner in its + welcome frame — because the alternative was locking an owner out of a + machine they could only have fixed by standing in front of it ## Phase 5 — Website and docs diff --git a/backend/app/cloud/connector.py b/backend/app/cloud/connector.py index 8f6d52e..ed74943 100644 --- a/backend/app/cloud/connector.py +++ b/backend/app/cloud/connector.py @@ -112,6 +112,8 @@ class CloudConnector: str(welcome.get("reason") or "refused by the portal") ) + self._adopt_owner(config, welcome.get("owner")) + self._connected = True self._connected_since = time.time() self._last_error = None @@ -141,6 +143,45 @@ class CloudConnector: self._calls.clear() self._streams.clear() + @staticmethod + def _adopt_owner(config: cloud_config.CloudConfig, owner: Any) -> None: + """Map the enrolling account to the portal account that owns us. + + Enrolment does this itself. This is for the enrolments that predate + per-user mapping: without it their owner would be refused here until + somebody enrolled the machine again — which, for a machine reached only + through the portal, means standing in front of it. Runs on every attach + because it is a no-op once the mapping is there. + + Never moves a mapping somebody else holds: that would be this code + guessing at something enrolment was told. + """ + from sqlmodel import Session, select + + from app.core.db import engine + from app.models import User + + if not owner: + return + owner_id = str(owner) + try: + with Session(engine) as session: + if session.exec( + select(User).where(User.portal_sub == owner_id) + ).first(): + return + user = session.get(User, config.local_user_id) + if user is None or user.portal_sub: + return + user.portal_sub = owner_id + session.add(user) + session.commit() + logger.info("Mapped %s to the portal account that owns us", user.email) + except Exception: + # A mapping that could not be written is not a reason to drop the + # link: everything else this connection does still works. + logger.exception("Could not map the enrolling account to the portal owner") + 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 c23573e..efd85b8 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -281,3 +281,39 @@ def test_enrolling_against_a_portal_without_an_owner_is_refused( assert not settings.CLOUD_CONFIG_FILE.exists() finally: settings.CLOUD_CONFIG_FILE = original + + +def test_an_older_enrolment_adopts_the_owner_on_attach( + enrolled: User, db: Session, portal_key: rsa.RSAPrivateKey +) -> None: + """An enrolment made before per-user mapping is fixed by reconnecting. + + Without this its owner would be refused until somebody enrolled the machine + again, which for a machine reached only through the portal means standing in + front of it. + """ + from app.cloud.connector import CloudConnector + + enrolled.portal_sub = None + db.add(enrolled) + db.commit() + config = cloud_config.load() + assert config is not None + assert user_from_token(db, portal_token(portal_key)) is None + + CloudConnector._adopt_owner(config, "portal-user-1") + db.expire_all() + assert user_from_token(db, portal_token(portal_key)) == enrolled + + # Somebody else already holding that identity is left alone: enrolment was + # told who this is, and this is only ever a repair. + other = User( + email="other@example.com", hashed_password="x", portal_sub="portal-user-2" + ) + db.add(other) + db.commit() + CloudConnector._adopt_owner(config, "portal-user-2") + db.expire_all() + assert db.get(User, enrolled.id).portal_sub == "portal-user-1" + db.delete(other) + db.commit()