- POST /devices/pair answers a 2-character `check`, listed by /devices/pending and shown beside each waiting entry; five codes a minute per address, and at 50 waiting the oldest gives way instead of a 429 - portal-minted device tokens are asked for with the device's nonce (`pnc`) and held against it, so unpair and delete revoke them; one without `pnc` stands only while the nonce is 0 - a device credential acts as the device (`device_principal`), not as the approving superuser, so deleting that account unpairs nothing - the report is validated port by port: unknown enum values read as `unknown`, unreadable ports and sections are left out and logged, `config` may name an entry of `config_schemas`; 422 only for no schema 1 or no object - devices.json is parsed once per change (cached by stat), delete drops the report and keeps the nonce, a busy recheck runs in the background - scope and io only from enabled flows (start/stop tells the devices), a port bound twice gives one io row - the Devices tab's saves cancel and hold back device refetches until settled
949 lines
35 KiB
Python
949 lines
35 KiB
Python
"""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.main import app
|
|
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.
|
|
"""
|
|
devices = f"{settings.API_V1_STR}/devices"
|
|
for device_id, dashboard in (("hallway", "kitchen"), ("workshop", "bench")):
|
|
client.delete(f"{devices}/{device_id}", headers=superuser_token_headers)
|
|
created = client.post(
|
|
f"{devices}/{device_id}", headers=superuser_token_headers
|
|
).json()
|
|
saved = client.put(
|
|
f"{devices}/{device_id}",
|
|
headers=superuser_token_headers,
|
|
json={**created, "dashboards": [dashboard]},
|
|
)
|
|
assert saved.status_code == 200, saved.text
|
|
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},
|
|
)
|
|
|
|
# As the hub mints it: with the nonce this instance named when it asked.
|
|
nonce = app.state.device_store.nonce("hallway")
|
|
token = portal_token(portal_key, subject="hallway", scope="panel", pnc=nonce)
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
assert client.get(f"{devices}/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"{devices}/", headers=headers).status_code == 403
|
|
assert (
|
|
client.get(f"{settings.API_V1_STR}/flows/", headers=headers).status_code == 403
|
|
)
|
|
|
|
# Unpairing refuses it as it refuses one minted here, and so does
|
|
# deleting the device, which a device created again does not undo.
|
|
client.post(f"{devices}/hallway/unpair", headers=superuser_token_headers)
|
|
assert client.get(f"{devices}/hallway", headers=headers).status_code == 401
|
|
nonce = app.state.device_store.nonce("hallway")
|
|
token = portal_token(portal_key, subject="hallway", scope="panel", pnc=nonce)
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
assert client.get(f"{devices}/hallway", headers=headers).status_code == 200
|
|
client.delete(f"{devices}/hallway", headers=superuser_token_headers)
|
|
assert client.get(f"{devices}/hallway", headers=headers).status_code == 401
|
|
client.post(f"{devices}/hallway", headers=superuser_token_headers)
|
|
assert client.get(f"{devices}/hallway", headers=headers).status_code == 401
|
|
client.delete(f"{devices}/hallway", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_portal_token_from_before_nonces_stands_until_an_unpair(
|
|
client: TestClient,
|
|
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
|
portal_key: rsa.RSAPrivateKey,
|
|
superuser_token_headers: dict[str, str],
|
|
) -> None:
|
|
"""An older hub minted without ``pnc``, which reads as nonce 0."""
|
|
devices = f"{settings.API_V1_STR}/devices"
|
|
device_id = f"porch_{uuid.uuid4().hex[:6]}"
|
|
assert (
|
|
client.post(f"{devices}/{device_id}", headers=superuser_token_headers)
|
|
).status_code == 200
|
|
token = portal_token(portal_key, subject=device_id, scope="panel")
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
assert client.get(f"{devices}/{device_id}", headers=headers).status_code == 200
|
|
client.post(f"{devices}/{device_id}/unpair", headers=superuser_token_headers)
|
|
assert client.get(f"{devices}/{device_id}", headers=headers).status_code == 401
|
|
client.delete(f"{devices}/{device_id}", headers=superuser_token_headers)
|
|
|
|
|
|
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"
|