A dashboard went live the moment it was created — an empty document straight to the panels — while a new flow starts as a draft. It now works the way flows do: published means `dashboard.json` exists, so every dashboard on every running installation is already published and nothing needs migrating. Only the ones created from here on start as drafts. Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard that had never been published unlinked its only file, and the read that followed raised out of a 200 handler. It answers 400 now, the way a flow does. Publishing all of them was 2N requests, because a publish has to name the version it expects and the summaries did not carry one. They do now — and so do the flow summaries, which had the same defect nobody had written down. A panel had no way to hear about any of this. A publish, or a change to which dashboards a panel carries, now puts one event on the bus and the screen refetches what changed: no reload, so a wall display never blanks or asks for its credential again. The subtle half is that a socket's message allowlist was computed once at handshake — a reassigned panel would have fetched its new document and then shown tiles that never updated. The panels dialog logged non-superusers out. Every write in it needs a superuser, not only the checkboxes the report mentioned, so the dialog is read-only for everyone else. The logout itself was `main.tsx` treating 403 as a dead session, against the contract deps.py spells out: only a 401 ends a session, and a 403 now says so rather than silently signing someone out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
332 lines
12 KiB
Python
332 lines
12 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 installation
|
|
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
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from fastapi.testclient import TestClient
|
|
from sqlmodel import Session
|
|
|
|
from app.api.deps import decode_token, user_from_token
|
|
from app.cloud import config as cloud_config
|
|
from app.core.config import settings
|
|
from app.flow.panels import PanelDef, PanelsConfig, write_config
|
|
from app.models import User
|
|
from tests.utils.portal import ISSUER, portal_token
|
|
|
|
|
|
def test_portal_token_is_refused_when_not_enrolled(
|
|
portal_key: rsa.RSAPrivateKey, tmp_path_factory: pytest.TempPathFactory
|
|
) -> None:
|
|
"""An installation 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_installation_is_refused(
|
|
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
|
portal_key: rsa.RSAPrivateKey,
|
|
) -> None:
|
|
"""The audience is this installation'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 installation."""
|
|
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_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_needs_a_superuser(
|
|
client: TestClient, normal_user_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""Remote access is an installation-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
|
|
installation'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("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
|
|
|
|
|
|
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 app.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()
|