A portal session names a person, not whoever enrolled
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

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 4e253f41ae
commit 81dad0a449
14 changed files with 553 additions and 64 deletions
+8
View File
@@ -107,6 +107,14 @@ def enrolled(
portal_account=settings.FIRST_SUPERUSER,
)
)
# Enrolment also maps the enrolling account to the portal identity that
# owns the installation; without it a portal session resolves to nobody.
local_user.portal_sub = "portal-user-1"
db.add(local_user)
db.commit()
yield local_user
local_user.portal_sub = None
db.add(local_user)
db.commit()
cloud_config.delete()
settings.CLOUD_CONFIG_FILE = original
+116 -7
View File
@@ -1,14 +1,16 @@
"""Remote access: off by default, and scoped to the account that enabled it.
"""Remote access: off by default, and only ever as much as somebody granted.
The property worth pinning down is the one everything else rests on — a
portal's token is worth nothing here until somebody at this installation
enrolled it, and even then it grants exactly the rights of the account that
did.
enrolled it, and even then it grants exactly the rights of the local account
the portal identity holding it was mapped to. An identity nobody mapped gets
nothing, which is what makes deleting that local account a revocation.
"""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, Mock, patch
import jwt
import pytest
@@ -37,14 +39,18 @@ def test_portal_token_is_refused_when_not_enrolled(
settings.CLOUD_CONFIG_FILE = original
def test_portal_token_resolves_to_the_enrolling_user(
def test_portal_token_resolves_by_portal_identity(
enrolled: User, portal_key: rsa.RSAPrivateKey, db: Session
) -> None:
"""The token names a person on the portal; the mapping names them here."""
claims = decode_token(portal_token(portal_key))
assert claims["sub"] == str(enrolled.id)
# Who they are on the portal is carried for the record, not for rights.
# No local account of its own: the payload carries who they are on the
# portal and nothing else, so an unmapped identity cannot fall back onto
# whoever enrolled.
assert "sub" not in claims
assert claims["portal_sub"] == "portal-user-1"
assert user_from_token(db, portal_token(portal_key)) == enrolled
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
def test_token_for_another_installation_is_refused(
@@ -77,7 +83,7 @@ def test_disconnecting_ends_remote_access(
enrolled: User, portal_key: rsa.RSAPrivateKey
) -> None:
"""Deleting the config is the whole of the local revocation."""
assert decode_token(portal_token(portal_key))["sub"] == str(enrolled.id)
assert decode_token(portal_token(portal_key))["portal_sub"] == enrolled.portal_sub
cloud_config.delete()
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key))
@@ -172,3 +178,106 @@ def test_a_panel_token_naming_no_panel_is_refused(
"""It would otherwise fall through to the account it borrows."""
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key, subject="", scope="panel"))
def test_adding_a_remote_user_maps_and_revokes(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
superuser_token_headers: dict[str, str],
db: Session,
) -> None:
"""Admitting somebody makes an ordinary local user; deleting it ends them."""
portal_reply = Mock(
status_code=200,
json=Mock(
return_value={"user_id": "portal-user-9", "email": "remote@example.com"}
),
)
with patch("app.api.routes.cloud.httpx.post", return_value=portal_reply) as post:
added = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=superuser_token_headers,
json={"code": "ABCD-EFGH"},
)
assert added.status_code == 200, added.text
assert post.call_args.kwargs["headers"]["Authorization"].startswith("Bearer ")
body = added.json()
assert body["email"] == "remote@example.com"
assert body["portal_sub"] == "portal-user-9"
# Never a superuser: a remote user must not be able to admit anyone else.
assert body["is_superuser"] is False
theirs = portal_token(portal_key, subject="portal-user-9")
resolved = user_from_token(db, theirs)
assert resolved is not None and resolved.email == "remote@example.com"
with patch("app.api.routes.cloud.httpx.post", return_value=portal_reply):
again = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=superuser_token_headers,
json={"code": "ABCD-EFGH"},
)
assert again.status_code == 409
with patch(
"app.api.routes.cloud.httpx.delete", return_value=Mock(status_code=200)
) as delete:
removed = client.delete(
f"{settings.API_V1_STR}/users/{body['id']}", headers=superuser_token_headers
)
assert removed.status_code == 200, removed.text
assert delete.call_args.args[0].endswith("/installation-members/portal-user-9")
db.expire_all()
assert user_from_token(db, theirs) is None
def test_adding_a_remote_user_needs_a_superuser(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
normal_user_token_headers: dict[str, str],
) -> None:
"""Widening who can reach this installation stays a superuser's decision."""
response = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=normal_user_token_headers,
json={"code": "ABCD-EFGH"},
)
assert response.status_code == 403
def test_enrolling_against_a_portal_without_an_owner_is_refused(
client: TestClient,
superuser_token_headers: dict[str, str],
tmp_path_factory: pytest.TempPathFactory,
) -> None:
"""A portal too old to name the owner would leave nobody mapped here."""
original = settings.CLOUD_CONFIG_FILE
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("old-portal") / "cloud.json"
reply = Mock(
status_code=200,
json=Mock(
return_value={
"installation_id": str(uuid.uuid4()),
"installation_token": "t",
"ws_url": f"{ISSUER}/api/v1/tunnel/attach",
"issuer": ISSUER,
"jwks": {"keys": []},
}
),
)
try:
with patch("app.api.routes.cloud.httpx.AsyncClient") as client_cls:
client_cls.return_value.__aenter__.return_value.post = AsyncMock(
return_value=reply
)
response = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=superuser_token_headers,
json={"portal_url": ISSUER, "claim_code": "ABCD-EFGH"},
)
assert response.status_code == 502
# Nothing was written: an enrolment nobody can act as is not one to keep.
assert not settings.CLOUD_CONFIG_FILE.exists()
finally:
settings.CLOUD_CONFIG_FILE = original