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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user