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:
2026-08-22 08:18:01 +02:00
co-authored by Claude Opus 5
parent 56f030e541
commit a067ff997d
2 changed files with 80 additions and 1 deletions
+48 -1
View File
@@ -21,6 +21,7 @@ import contextlib
import logging
import time
import uuid
from dataclasses import replace
from datetime import timedelta
from typing import Any
@@ -171,7 +172,7 @@ class CloudConnector:
select(User).where(User.portal_sub == owner_id)
).first():
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:
return
user.portal_sub = owner_id
@@ -183,6 +184,52 @@ class CloudConnector:
# link: everything else this connection does still works.
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:
"""Heartbeats, plus a health snapshot the portal can show while offline."""
last_status = 0.0