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
+32
View File
@@ -10,6 +10,7 @@ nothing, which is what makes deleting that local account a revocation.
from __future__ import annotations
import uuid
from dataclasses import replace
from unittest.mock import Mock, patch
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"
db.delete(other)
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)