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:
+4
-1
@@ -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
|
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`
|
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
|
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
|
## Phase 5 — Website and docs
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ class CloudConnector:
|
|||||||
str(welcome.get("reason") or "refused by the portal")
|
str(welcome.get("reason") or "refused by the portal")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._adopt_owner(config, welcome.get("owner"))
|
||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._connected_since = time.time()
|
self._connected_since = time.time()
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
@@ -141,6 +143,45 @@ class CloudConnector:
|
|||||||
self._calls.clear()
|
self._calls.clear()
|
||||||
self._streams.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:
|
async def _keepalive(self, socket: Any, config: cloud_config.CloudConfig) -> None:
|
||||||
"""Heartbeats, plus a health snapshot the portal can show while offline."""
|
"""Heartbeats, plus a health snapshot the portal can show while offline."""
|
||||||
last_status = 0.0
|
last_status = 0.0
|
||||||
|
|||||||
@@ -281,3 +281,39 @@ def test_enrolling_against_a_portal_without_an_owner_is_refused(
|
|||||||
assert not settings.CLOUD_CONFIG_FILE.exists()
|
assert not settings.CLOUD_CONFIG_FILE.exists()
|
||||||
finally:
|
finally:
|
||||||
settings.CLOUD_CONFIG_FILE = original
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user