A portal session names a person, not whoever enrolled

Remote access used to collapse every portal session onto the account that
performed the enrolment. That was the only thing it could do while nothing
here knew who was at the other end, and it is why letting a second person
in meant handing them the first one's account.

`user.portal_sub` is where a portal identity meets a local one: set for the
enrolling superuser at enrolment, and for each person a superuser admits
afterwards through Settings -> Remote access -> Add remote user. The code
they type comes from the newcomer's own portal account, and it is redeemed
against the hub with this installation's tunnel credential rather than with
a portal session, so being let in is not itself the power to let others in.
The account created is never a superuser, which closes the same door from
this side.

A proxy token now resolves through that mapping and nowhere else. An
identity nobody mapped resolves to no user rather than falling back on the
enroller, so deleting the local row under Admin -> Users is the whole of
the revocation: it bites on a credential already in flight, and it does not
wait on the portal being reachable to be told. Telling the portal is best
effort for exactly that reason.

The cost is stated where it lands, in DEPLOY.md: an installation enrolled
before this has no mapping, so its owner reconnects once with a fresh code.
Panels and the health summary still act as the enrolling account - neither
of them is a person, and neither gained a way to name one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:34:01 +02:00
co-authored by Claude Opus 5
parent 3e084c06cf
commit c27ed98cb8
14 changed files with 553 additions and 64 deletions
+14 -6
View File
@@ -46,9 +46,11 @@ class CloudConfig:
token: str
issuer: str
jwks: dict[str, Any]
#: The local account every portal session acts as. Recorded at enrolment
#: from whoever performed it, so remote access can never exceed the rights
#: of the person who granted it.
#: The local account this installation acts as on its own behalf: what the
#: health summary is collected as, and what a screen paired through the
#: portal borrows for want of a person. Recorded at enrolment from whoever
#: performed it. Portal *sessions* no longer come through here — they name
#: a person, and are resolved to the local account mapped to them.
local_user_id: str
enrolled_at: str
portal_account: str | None = None
@@ -145,6 +147,12 @@ def decode_portal_token(token: str) -> dict[str, Any]:
return {"sub": config.local_user_id, "panel": panel}
if scope != "proxy":
raise InvalidTokenError("not a proxy token")
# Every portal session acts as the enrolling local user. Who they are on
# the portal is kept for the audit trail, not for authorization.
return {"sub": config.local_user_id, "portal_sub": claims.get("sub")}
# A portal session names the person holding it, and that name is the whole
# of their identity here: the caller resolves it to the local account it
# was mapped to, and a portal identity nobody mapped resolves to nothing.
# Deliberately no local account by default — the failure of a mapping must
# be a refusal, not a fallback onto whoever enrolled.
portal_sub = str(claims.get("sub") or "")
if not portal_sub:
raise InvalidTokenError("a proxy token must name its user")
return {"portal_sub": portal_sub}