"""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 instance 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 asyncio import uuid from dataclasses import replace from unittest.mock import Mock, patch import httpx import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient from sqlmodel import Session from fluksio.api.deps import decode_token, user_from_token from fluksio.cloud import config as cloud_config from fluksio.core.config import settings from fluksio.flow.panels import PanelDef, PanelsConfig, write_config from fluksio.models import User from tests.utils.portal import ISSUER, jwks, portal_token from tests.utils.user import create_random_user def test_portal_token_is_refused_when_not_enrolled( portal_key: rsa.RSAPrivateKey, tmp_path_factory: pytest.TempPathFactory ) -> None: """An instance nobody connected trusts no portal at all.""" original = settings.CLOUD_CONFIG_FILE settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("empty") / "cloud.json" try: with pytest.raises(jwt.exceptions.InvalidTokenError): decode_token(portal_token(portal_key)) finally: settings.CLOUD_CONFIG_FILE = original 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)) # 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_instance_is_refused( enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, ) -> None: """The audience is this instance's id, so someone else's is worthless.""" with pytest.raises(jwt.exceptions.InvalidTokenError): decode_token(portal_token(portal_key, audience=str(uuid.uuid4()))) def test_token_from_an_unpinned_key_is_refused( enrolled: User, # noqa: ARG001 (fixture installs the enrolment) ) -> None: """A different portal, or a hijacked one, cannot sign for this instance.""" impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048) with pytest.raises(jwt.exceptions.InvalidTokenError): decode_token(portal_token(impostor)) def test_non_proxy_scope_is_refused( enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, ) -> None: with pytest.raises(jwt.exceptions.InvalidTokenError): decode_token(portal_token(portal_key, scope="session")) 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))["portal_sub"] == enrolled.portal_sub cloud_config.delete() with pytest.raises(jwt.exceptions.InvalidTokenError): decode_token(portal_token(portal_key)) def test_disconnecting_deletes_the_users_the_portal_let_in( client: TestClient, enrolled: User, superuser_token_headers: dict[str, str], db: Session, ) -> None: """Nothing reaches them once the portal is gone; the rest stay.""" member = User(email="member-dc@example.com", hashed_password="x", portal_sub="p-1") boss = User( email="boss-dc@example.com", hashed_password="x", portal_sub="p-2", is_superuser=True, ) plain = create_random_user(db) db.add(member) db.add(boss) db.commit() member_id, boss_id = member.id, boss.id response = client.delete( f"{settings.API_V1_STR}/cloud", headers=superuser_token_headers ) assert response.status_code == 200, response.text db.expire_all() assert db.get(User, member_id) is None assert db.get(User, boss_id) is not None assert db.get(User, plain.id) is not None assert db.get(User, enrolled.id) is not None assert cloud_config.load() is None db.delete(db.get(User, boss_id)) db.delete(db.get(User, plain.id)) db.commit() def test_status_reports_not_enrolled( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: response = client.get( f"{settings.API_V1_STR}/cloud/status", headers=superuser_token_headers ) assert response.status_code == 200 assert response.json()["enrolled"] is False def test_enrolling_again_replaces_the_connection( client: TestClient, enrolled: User, portal_key: rsa.RSAPrivateKey, superuser_token_headers: dict[str, str], db: Session, ) -> None: """A new claim code re-pairs rather than being refused. Deleting cloud.json by hand used to be the only way through, and it takes every remote connection with it. The mappings of the portal being left go too: nothing in the row says which portal issued the subject, so one kept from the old one would resolve a stranger onto a local account. """ stale = create_random_user(db) stale.portal_sub = "portal-user-7" db.add(stale) db.commit() elsewhere = "https://other.example.test" reply = Mock( status_code=200, json=Mock( return_value={ "ws_url": f"{elsewhere}/api/v1/tunnel/attach", "instance_id": "11111111-2222-3333-4444-555555555555", "instance_token": "the-new-token", "issuer": elsewhere, "jwks": jwks(portal_key), "owner_id": "portal-user-9", } ), ) with patch("fluksio.cloud.enroll.httpx.post", return_value=reply): again = client.post( f"{settings.API_V1_STR}/cloud/enroll", headers=superuser_token_headers, json={"portal_url": elsewhere, "claim_code": "ABCD-EFGH"}, ) assert again.status_code == 200, again.text config = cloud_config.load() assert config is not None assert config.portal_url == elsewhere assert config.token == "the-new-token" db.expire_all() assert db.get(User, enrolled.id).portal_sub == "portal-user-9" # The other portal's mapping is not carried over to this one. assert db.get(User, stale.id).portal_sub is None def test_a_claim_the_portal_refuses_keeps_the_connection( client: TestClient, enrolled: User, superuser_token_headers: dict[str, str], db: Session, ) -> None: """Nothing is written until the portal has accepted the code. That ordering is what makes replacing safe: a mistyped code leaves the instance connected to the portal it was connected to. """ before = cloud_config.load() with patch("fluksio.cloud.enroll.httpx.post", return_value=Mock(status_code=404)): refused = client.post( f"{settings.API_V1_STR}/cloud/enroll", headers=superuser_token_headers, json={"portal_url": ISSUER, "claim_code": "NOPE-NOPE"}, ) assert refused.status_code == 400, refused.text after = cloud_config.load() assert before is not None and after is not None assert (before.instance_id, before.token) == (after.instance_id, after.token) db.expire_all() assert db.get(User, enrolled.id).portal_sub == "portal-user-1" def test_a_cleartext_portal_must_be_this_machine() -> None: """An mDNS name is answered by whoever claims it on the LAN. Over http, that is who would receive the claim code and the credential. """ from fluksio.cloud import enroll with patch("fluksio.cloud.enroll.httpx.post") as post: for portal in ("http://hub.local", "http://192.168.1.20:8000"): with pytest.raises(enroll.EnrollError) as refused: enroll.redeem_claim(portal, "ABCD-EFGH") assert refused.value.status == 422 post.assert_not_called() post.return_value = Mock(status_code=404) for portal in ("http://localhost:8000", "http://127.0.0.2", "http://[::1]"): with pytest.raises(enroll.EnrollError) as unknown: enroll.redeem_claim(portal, "ABCD-EFGH") assert unknown.value.status == 400 # past the check, at the portal def test_enrolling_needs_a_superuser( client: TestClient, normal_user_token_headers: dict[str, str] ) -> None: """Remote access is an instance-wide grant, not a personal setting.""" response = client.post( f"{settings.API_V1_STR}/cloud/enroll", headers=normal_user_token_headers, json={"portal_url": ISSUER, "claim_code": "ABCD-EFGH"}, ) assert response.status_code == 403 def test_a_panel_scoped_portal_token_reaches_only_its_panel( client: TestClient, enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, superuser_token_headers: dict[str, str], ) -> None: """A screen paired through the portal is bounded here, not there. The portal names the panel; everything about what that means is this instance's, which is the whole reason it may mint one at all. """ write_config( PanelsConfig( panels=[ PanelDef(id="hallway", dashboards=["kitchen"]), PanelDef(id="workshop", dashboards=["bench"]), ] ) ) for name in ("kitchen", "bench"): created = client.post( f"{settings.API_V1_STR}/dashboards/{name}", headers=superuser_token_headers ) assert created.status_code in (200, 201, 409), created.text # A new dashboard is a draft, and a panel only reaches what is # published — so promote it before asking as one. version = client.get( f"{settings.API_V1_STR}/dashboards/{name}", headers=superuser_token_headers, params={"draft": "true"}, ).json()["version"] client.post( f"{settings.API_V1_STR}/dashboards/{name}/publish", headers=superuser_token_headers, json={"version": version}, ) token = portal_token(portal_key, subject="hallway", scope="panel") headers = {"Authorization": f"Bearer {token}"} assert ( client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code == 200 ) assert ( client.get( f"{settings.API_V1_STR}/dashboards/kitchen", headers=headers ).status_code == 200 ) # Another panel's dashboard, the panel list, and a draft are all refused. assert ( client.get( f"{settings.API_V1_STR}/dashboards/bench", headers=headers ).status_code == 403 ) assert ( client.get(f"{settings.API_V1_STR}/panels/", headers=headers).status_code == 403 ) assert ( client.get(f"{settings.API_V1_STR}/flows/", headers=headers).status_code == 403 ) # Deleting the panel is how the screen is retired, whoever minted its token. write_config(PanelsConfig(panels=[PanelDef(id="workshop", dashboards=["bench"])])) assert ( client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code == 401 ) def test_a_panel_token_naming_no_panel_is_refused( enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, ) -> None: """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("fluksio.cloud.enroll.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("fluksio.cloud.enroll.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( "fluksio.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("/instance-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 instance 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={ "instance_id": str(uuid.uuid4()), "instance_token": "t", "ws_url": f"{ISSUER}/api/v1/tunnel/attach", "issuer": ISSUER, "jwks": {"keys": []}, } ), ) try: with patch("fluksio.cloud.enroll.httpx.post", 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 def test_an_older_enrolment_adopts_the_owner_on_attach( enrolled: User, db: Session, portal_key: rsa.RSAPrivateKey ) -> None: """An enrolment made before per-user mapping is fixed by reconnecting. Without this its owner would be refused until somebody enrolled the machine again, which for a machine reached only through the portal means standing in front of it. """ from fluksio.cloud.connector import CloudConnector enrolled.portal_sub = None db.add(enrolled) db.commit() config = cloud_config.load() assert config is not None assert user_from_token(db, portal_token(portal_key)) is None CloudConnector._adopt_owner(config, "portal-user-1") db.expire_all() assert user_from_token(db, portal_token(portal_key)) == enrolled # Somebody else already holding that identity is left alone: enrolment was # told who this is, and this is only ever a repair. other = User( email="other@example.com", hashed_password="x", portal_sub="portal-user-2" ) db.add(other) db.commit() CloudConnector._adopt_owner(config, "portal-user-2") db.expire_all() 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) # ----------------------------------------------------------------------------- # Enrolling an engine that is already running # # `fluksio enroll` writes the config against the database from another process # entirely, with no idea whether an engine is up. Noticing that is what saves a # restart. # ----------------------------------------------------------------------------- @pytest.mark.anyio async def test_a_config_that_appears_while_running_is_dialled(monkeypatch) -> None: from fluksio.cloud import connector app = Mock() app.state = Mock(cloud_task=None, cloud_connector=None) started: list[object] = [] monkeypatch.setattr(connector, "ENROL_POLL_S", 0.01) monkeypatch.setattr(connector, "start", lambda one: started.append(one)) monkeypatch.setattr(cloud_config, "load", lambda: object()) watcher = asyncio.create_task(connector.watch_enrolment(app)) await asyncio.sleep(0.05) watcher.cancel() assert started @pytest.mark.anyio async def test_an_enrolment_replaced_while_running_is_redialled(monkeypatch) -> None: """`fluksio enroll` is its own process and cannot cancel the live link. Without this the tunnel would stay up on the credential that was replaced, since the watcher only ever started a link where there was none. """ from types import SimpleNamespace from fluksio.cloud import connector live = asyncio.create_task(asyncio.sleep(30)) app = Mock() app.state = Mock( cloud_task=live, cloud_connector=Mock(), cloud_identity=("old", "old-token") ) started: list[object] = [] monkeypatch.setattr(connector, "ENROL_POLL_S", 0.01) monkeypatch.setattr(connector, "start", lambda one: started.append(one)) monkeypatch.setattr( cloud_config, "load", lambda: SimpleNamespace(instance_id="new", token="new-token"), ) watcher = asyncio.create_task(connector.watch_enrolment(app)) await asyncio.sleep(0.05) watcher.cancel() assert live.cancelled() or live.done() assert started @pytest.mark.anyio async def test_a_config_that_cannot_be_read_is_not_dialled(monkeypatch) -> None: """Otherwise the connector gives up at once and this restarts it forever.""" from fluksio.cloud import connector app = Mock() app.state = Mock(cloud_task=None, cloud_connector=None) started: list[object] = [] monkeypatch.setattr(connector, "ENROL_POLL_S", 0.01) monkeypatch.setattr(connector, "start", lambda one: started.append(one)) # The file is there; it just does not parse, which `load` reports as None. monkeypatch.setattr(cloud_config, "load", lambda: None) watcher = asyncio.create_task(connector.watch_enrolment(app)) await asyncio.sleep(0.05) watcher.cancel() assert not started def test_a_member_the_portal_vouches_for_is_adopted( enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, db: Session, ) -> None: """A share link admits at the portal; the account appears here on arrival.""" theirs = portal_token(portal_key, subject="portal-user-7") vouched = Mock( status_code=200, json=Mock( return_value={"user_id": "portal-user-7", "email": "invited@example.com"} ), ) with patch("fluksio.cloud.enroll.httpx.get", return_value=vouched) as get: adopted = user_from_token(db, theirs) assert get.call_args.args[0].endswith("/instance-members/portal-user-7") assert adopted is not None assert adopted.email == "invited@example.com" # Never a superuser, and never able to sign in with a password. assert adopted.is_superuser is False # Once mapped, no further asking: the local row is the answer. with patch("fluksio.cloud.enroll.httpx.get") as unused: assert user_from_token(db, theirs) == adopted unused.assert_not_called() # Somebody the portal does not vouch for gets nothing, and neither does a # portal that cannot be reached. stranger = portal_token(portal_key, subject="portal-user-8") with patch("fluksio.cloud.enroll.httpx.get", return_value=Mock(status_code=404)): assert user_from_token(db, stranger) is None with patch( "fluksio.cloud.enroll.httpx.get", side_effect=httpx.ConnectError("no route") ): assert user_from_token(db, stranger) is None def test_the_tunnel_opens_the_live_socket_with_a_ticket( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: """A browser under the portal brings a ticket, which the engine redeems.""" import json from fluksio.cloud.connector import CloudConnector ticket = client.post( f"{settings.API_V1_STR}/flows/ws-ticket", headers=superuser_token_headers ).json()["ticket"] connector = CloudConnector(client.app) frame = {"id": "s1", "path": "/api/v1/flows/ws", "query": f"ticket={ticket}"} async def opened() -> list[str]: sent: list[str] = [] class Socket: async def send(self, text: str) -> None: sent.append(text) task = asyncio.create_task(connector._stream(Socket(), frame)) while len(sent) < 1: await asyncio.sleep(0.01) task.cancel() return [str(json.loads(text)["op"]) for text in sent] assert asyncio.run(opened())[0] == "ws_open_ok" # Used once: the same ticket again is refused. assert asyncio.run(opened()) == ["ws_close"] @pytest.mark.anyio async def test_the_link_sends_its_credential_as_a_header() -> None: """Not in the URL, which access logs keep.""" import json from types import SimpleNamespace from websockets.asyncio.server import ServerConnection, serve from fluksio.cloud.connector import CloudConnector seen: dict[str, str | None] = {} async def hub(socket: ServerConnection) -> None: assert socket.request is not None seen["path"] = socket.request.path seen["auth"] = socket.request.headers.get("Authorization") await socket.recv() await socket.send(json.dumps({"op": "refused", "reason": "test hub"})) async with serve(hub, "127.0.0.1", 0) as server: port = next(iter(server.sockets)).getsockname()[1] config = SimpleNamespace( ws_url=f"ws://127.0.0.1:{port}/api/v1/tunnel/attach", token="t0k" ) with pytest.raises(RuntimeError, match="test hub"): await CloudConnector(Mock())._session(config) # type: ignore[arg-type] assert seen == {"path": "/api/v1/tunnel/attach", "auth": "Bearer t0k"} @pytest.mark.anyio async def test_a_revoked_link_stands_down(enrolled: User) -> None: # noqa: ARG001 """A deleted instance is told so once, rather than refused forever. The config stays, so enrolling again is all it takes to come back. """ from websockets.asyncio.server import ServerConnection, serve from fluksio.cloud import connector dials = 0 async def hub(socket: ServerConnection) -> None: nonlocal dials dials += 1 await socket.recv() await socket.close(code=connector.REVOKED_CLOSE, reason="revoked") async with serve(hub, "127.0.0.1", 0) as server: port = next(iter(server.sockets)).getsockname()[1] config = cloud_config.load() assert config is not None cloud_config.save(replace(config, ws_url=f"ws://127.0.0.1:{port}/attach")) link = connector.CloudConnector(Mock()) await asyncio.wait_for(link.serve_forever(), timeout=5) assert dials == 1 assert link.status()["revoked"] is True assert cloud_config.load() is not None # And the watcher leaves it down until the enrolment changes. app = Mock() done = asyncio.get_running_loop().create_future() done.set_result(None) app.state = Mock( cloud_task=done, cloud_connector=link, cloud_identity=connector._identity(cloud_config.load()), ) started: list[object] = [] with ( patch.object(connector, "ENROL_POLL_S", 0.01), patch.object(connector, "start", lambda one: started.append(one)), ): watcher = asyncio.create_task(connector.watch_enrolment(app)) await asyncio.sleep(0.05) watcher.cancel() assert not started def test_a_member_removed_at_the_portal_loses_their_user(db: Session) -> None: """The portal takes away only the ordinary accounts it gave.""" from fluksio.cloud.connector import CloudConnector member = User(email="gone@example.com", hashed_password="x", portal_sub="p-9") boss = User( email="boss@example.com", hashed_password="x", portal_sub="p-8", is_superuser=True, ) db.add(member) db.add(boss) db.commit() member_id, boss_id = member.id, boss.id CloudConnector._forget_member("p-9", "p-8") CloudConnector._forget_member("p-8", "p-8") CloudConnector._forget_member("p-8", None) db.expire_all() assert db.get(User, member_id) is None assert db.get(User, boss_id) is not None db.delete(boss) db.commit() def test_admin_lists_and_revokes_portal_members( client: TestClient, enrolled: User, # noqa: ARG001 (fixture installs the enrolment) superuser_token_headers: dict[str, str], normal_user_token_headers: dict[str, str], db: Session, ) -> None: """Who the portal admits, and which of them have arrived here.""" arrived = create_random_user(db) arrived.portal_sub = str(uuid.uuid4()) db.add(arrived) db.commit() waiting = str(uuid.uuid4()) listed = Mock( status_code=200, json=Mock( return_value={ "data": [ {"user_id": arrived.portal_sub, "email": arrived.email}, {"user_id": waiting, "email": "invited@example.com"}, ] } ), ) with patch("fluksio.api.routes.cloud.httpx.get", return_value=listed) as get: members = client.get( f"{settings.API_V1_STR}/cloud/members", headers=superuser_token_headers ) assert members.status_code == 200, members.text assert get.call_args.args[0].endswith("/instance-members/") assert {row["user_id"]: row["local_user_id"] for row in members.json()} == { arrived.portal_sub: str(arrived.id), waiting: None, } with patch( "fluksio.api.routes.cloud.httpx.delete", return_value=Mock(status_code=200) ) as delete: revoked = client.delete( f"{settings.API_V1_STR}/cloud/members/{waiting}", headers=superuser_token_headers, ) assert revoked.status_code == 200, revoked.text assert delete.call_args.args[0].endswith(f"/instance-members/{waiting}") assert ( client.get( f"{settings.API_V1_STR}/cloud/members", headers=normal_user_token_headers ).status_code == 403 ) db.delete(arrived) db.commit() def test_a_superuser_links_a_pending_member_to_their_local_user( client: TestClient, enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, superuser_token_headers: dict[str, str], normal_user_token_headers: dict[str, str], db: Session, ) -> None: """Arriving alone they are refused; linked, they arrive as that user.""" local = create_random_user(db) boss = create_random_user(db) boss.is_superuser = True db.add(boss) db.commit() sub, boss_sub = str(uuid.uuid4()), str(uuid.uuid4()) def member(user_id: str, email: str) -> Mock: return Mock( status_code=200, json=Mock(return_value={"user_id": user_id, "email": email}), ) def link(user_id: str, reply: Mock, headers: dict[str, str]) -> httpx.Response: with patch("fluksio.cloud.enroll.httpx.get", return_value=reply): return client.post( f"{settings.API_V1_STR}/cloud/members/{user_id}/link", headers=headers ) # Never adopted on their own: the local row might be anyone's. with patch("fluksio.cloud.enroll.httpx.get", return_value=member(sub, local.email)): assert user_from_token(db, portal_token(portal_key, subject=sub)) is None listed = Mock( status_code=200, json=Mock( return_value={ "data": [ {"user_id": sub, "email": local.email}, {"user_id": boss_sub, "email": boss.email}, ] } ), ) with patch("fluksio.api.routes.cloud.httpx.get", return_value=listed): members = client.get( f"{settings.API_V1_STR}/cloud/members", headers=superuser_token_headers ).json() assert {row["user_id"]: row["linkable"] for row in members} == { sub: True, boss_sub: False, } su = superuser_token_headers assert link(boss_sub, member(boss_sub, boss.email), su).status_code == 403 assert link(sub, Mock(status_code=404), su).status_code == 404 assert link(sub, member(sub, "nobody-here@example.com"), su).status_code == 404 assert ( link(sub, member(sub, local.email), normal_user_token_headers).status_code == 403 ) linked = link(sub, member(sub, local.email), su) assert linked.status_code == 200, linked.text assert linked.json()["portal_sub"] == sub assert link(sub, member(sub, local.email), su).status_code == 409 db.expire_all() resolved = user_from_token(db, portal_token(portal_key, subject=sub)) assert resolved is not None and resolved.id == local.id db.delete(db.get(User, local.id)) db.delete(db.get(User, boss.id)) db.commit() def test_the_portal_keys_follow_the_tunnel( enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, ) -> None: """A rotated keypair arrives on attach, and either key verifies by kid.""" import json from cryptography.hazmat.primitives import serialization from fluksio.cloud.connector import CloudConnector pinned = cloud_config.load() assert pinned is not None successor = rsa.generate_private_key(public_exponent=65537, key_size=2048) jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(successor.public_key())) jwk.update({"use": "sig", "alg": "RS256", "kid": "next"}) rotated = {"keys": [*jwks(portal_key)["keys"], jwk]} claims = jwt.decode(portal_token(portal_key), options={"verify_signature": False}) pem = successor.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) signed_by_next = jwt.encode(claims, pem, algorithm="RS256", headers={"kid": "next"}) # Over cleartext to another machine, a key is not worth taking. CloudConnector._adopt_jwks(rotated) assert cloud_config.load().jwks == pinned.jwks with pytest.raises(jwt.exceptions.InvalidTokenError): decode_token(signed_by_next) cloud_config.save(replace(pinned, ws_url="wss://hub.example.test/attach")) CloudConnector._adopt_jwks({"keys": []}) CloudConnector._adopt_jwks(rotated) assert decode_token(portal_token(portal_key))["portal_sub"] == "portal-user-1" assert decode_token(signed_by_next)["portal_sub"] == "portal-user-1"