Adopt the portal owner on attach, rather than demanding a re-enrolment
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

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 81dad0a449
commit 335e9182af
3 changed files with 81 additions and 1 deletions
+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