Adopt the owner when the enrolling account is gone
An enrolment outlives the database it was made in. Restore a backup, or move to a different one, and the same operator is a different row — `local_user_id` then names nobody, every portal session resolves to no local user, and the machine answers 401 to the only route into it. That is the lockout the welcome frame's owner exists to prevent, and it was prevented only for the case where the row still existed. One superuser is not a guess: it is the account enrolment would have used, so it is adopted and written back. Several is a guess, and this says so instead. Writing it back matters beyond this: a screen paired through the portal borrows the same field, so it was refused for the same reason with no way to say so. Found on the production instance after the move to SQLite, which is exactly this case — DEPLOY.md said to enrol again, and the machine should not need telling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import contextlib
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ class CloudConnector:
|
|||||||
select(User).where(User.portal_sub == owner_id)
|
select(User).where(User.portal_sub == owner_id)
|
||||||
).first():
|
).first():
|
||||||
return
|
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:
|
if user is None or user.portal_sub:
|
||||||
return
|
return
|
||||||
user.portal_sub = owner_id
|
user.portal_sub = owner_id
|
||||||
@@ -183,6 +184,52 @@ class CloudConnector:
|
|||||||
# link: everything else this connection does still works.
|
# link: everything else this connection does still works.
|
||||||
logger.exception("Could not map the enrolling account to the portal owner")
|
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:
|
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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ nothing, which is what makes deleting that local account a revocation.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import replace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import jwt
|
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"
|
assert db.get(User, enrolled.id).portal_sub == "portal-user-1"
|
||||||
db.delete(other)
|
db.delete(other)
|
||||||
db.commit()
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user