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) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 12:07:43 +02:00
co-authored by Claude Opus 5
parent c27ed98cb8
commit 000c5abf91
3 changed files with 81 additions and 1 deletions
+4 -1
View File
@@ -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
+41
View File
@@ -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
+36
View File
@@ -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()