Pair a wall panel through the portal
A screen somewhere this installation is not reachable from asks the portal for a code instead, and the portal mints its credential — because a token signed here is one such a device could never present. Where it was minted changes nothing about what it may do. The panel gate moved off the branch that decodes a local panel token and onto whatever claims name a panel, so the portal's and this installation's are bounded by the same check against the same panel's dashboards. A token of that scope naming no panel is refused rather than left holding the account it borrows. The connector marks what arrives on its socket, since that is the only thing that makes it true, and the approval screen now names what is holding a code — approving adopts whatever answers, so it is worth a look first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
This commit is contained in:
@@ -203,3 +203,106 @@ def test_removing_the_panel_revokes_its_credential(
|
||||
assert (
|
||||
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# A screen that reached the portal but not this installation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_device_asking_is_named_before_anyone_approves(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Approving a code adopts whatever holds it, so it is worth a look."""
|
||||
started = client.post(
|
||||
f"{PREFIX}/pair",
|
||||
headers={
|
||||
"user-agent": "Mozilla/5.0 (X11; CrOS aarch64)",
|
||||
"x-forwarded-for": "203.0.113.7, 10.0.0.1",
|
||||
},
|
||||
).json()
|
||||
|
||||
looked = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
|
||||
)
|
||||
assert looked.status_code == 200
|
||||
assert "CrOS" in looked.json()["device"]
|
||||
# The first hop, not the proxy that relayed it.
|
||||
assert "203.0.113.7" in looked.json()["device"]
|
||||
assert looked.json()["remote"] is False
|
||||
|
||||
# Nobody without an account gets to enumerate what is waiting.
|
||||
assert client.get(f"{PREFIX}/pair/{started['code']}/device").status_code == 401
|
||||
assert (
|
||||
client.get(
|
||||
f"{PREFIX}/pair/ZZZZZZ/device", headers=superuser_token_headers
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_a_remote_device_is_paired_at_the_portal(
|
||||
client: TestClient,
|
||||
superuser_token_headers: dict[str, str],
|
||||
enrolled: object, # noqa: ARG001 (fixture installs the enrolment)
|
||||
monkeypatch, # type: ignore[no-untyped-def]
|
||||
) -> None:
|
||||
"""A device that arrived through the tunnel gets the portal's credential.
|
||||
|
||||
It could never present one this installation signed: the portal verifies
|
||||
what crosses it, and it verifies against its own key.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from app.api.routes import panels as panels_route
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_post(url: str, **kwargs: object) -> httpx.Response:
|
||||
calls.append({"url": url, **kwargs})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"access_token": "minted-by-the-portal", "expires_in": 31536000},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(panels_route.httpx, "post", fake_post)
|
||||
|
||||
_panels(client, superuser_token_headers, {"panels": [{"id": "hallway"}]})
|
||||
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
|
||||
|
||||
looked = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
|
||||
).json()
|
||||
assert looked["remote"] is True
|
||||
|
||||
approved = client.post(
|
||||
f"{PREFIX}/hallway/pair",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": started["code"]},
|
||||
)
|
||||
assert approved.status_code == 200, approved.text
|
||||
|
||||
assert calls[0]["url"].endswith("/api/v1/panel-tokens/") # type: ignore[union-attr]
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer installation-token" # type: ignore[index]
|
||||
assert calls[0]["json"] == {"panel": "hallway"} # type: ignore[index]
|
||||
|
||||
collected = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
|
||||
).json()
|
||||
assert collected["access_token"] == "minted-by-the-portal"
|
||||
|
||||
|
||||
def test_a_remote_device_needs_an_enrolment(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Unenrolled, there is nowhere to ask — and no token to invent locally."""
|
||||
_panels(client, superuser_token_headers, {"panels": [{"id": "shed"}]})
|
||||
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
|
||||
|
||||
approved = client.post(
|
||||
f"{PREFIX}/shed/pair",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": started["code"]},
|
||||
)
|
||||
assert approved.status_code == 409
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlmodel import Session, SQLModel
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
|
||||
from app.cloud import config as cloud_config
|
||||
from app.core.config import settings
|
||||
from app.core.db import engine, init_db
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
|
||||
from tests.utils.user import authentication_token_from_email
|
||||
from tests.utils.utils import get_superuser_token_headers
|
||||
|
||||
@@ -66,3 +73,40 @@ def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]
|
||||
return authentication_token_from_email(
|
||||
client=client, email=settings.EMAIL_TEST_USER, db=db
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portal_key() -> rsa.RSAPrivateKey:
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enrolled(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
db: Session,
|
||||
) -> Any:
|
||||
"""Enrol this installation with a fake portal, then undo it."""
|
||||
local_user = db.exec(
|
||||
select(User).where(User.email == settings.FIRST_SUPERUSER)
|
||||
).one()
|
||||
original = settings.CLOUD_CONFIG_FILE
|
||||
settings.CLOUD_CONFIG_FILE = (
|
||||
tmp_path_factory.mktemp(f"cloud-{uuid.uuid4().hex[:6]}") / "cloud.json"
|
||||
)
|
||||
cloud_config.save(
|
||||
cloud_config.CloudConfig(
|
||||
portal_url=ISSUER,
|
||||
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
|
||||
installation_id=INSTALLATION_ID,
|
||||
token="installation-token",
|
||||
issuer=ISSUER,
|
||||
jwks=jwks(portal_key),
|
||||
local_user_id=str(local_user.id),
|
||||
enrolled_at=datetime.now(UTC).isoformat(),
|
||||
portal_account=settings.FIRST_SUPERUSER,
|
||||
)
|
||||
)
|
||||
yield local_user
|
||||
cloud_config.delete()
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
+80
-88
@@ -8,97 +8,20 @@ did.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
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
|
||||
|
||||
INSTALLATION_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
|
||||
ISSUER = "https://hub.example.test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portal_key() -> rsa.RSAPrivateKey:
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def _jwks(key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key()))
|
||||
jwk.update({"use": "sig", "alg": "RS256", "kid": "test-portal"})
|
||||
return {"keys": [jwk]}
|
||||
|
||||
|
||||
def _portal_token(
|
||||
key: rsa.RSAPrivateKey,
|
||||
*,
|
||||
subject: str = "portal-user-1",
|
||||
audience: str = INSTALLATION_ID,
|
||||
issuer: str = ISSUER,
|
||||
scope: str = "proxy",
|
||||
) -> str:
|
||||
now = datetime.now(UTC)
|
||||
pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": subject,
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"scope": scope,
|
||||
},
|
||||
pem,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "test-portal"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enrolled(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
db: Session,
|
||||
) -> Any:
|
||||
"""Enrol this installation with a fake portal, then undo it."""
|
||||
local_user = db.exec(
|
||||
select(User).where(User.email == settings.FIRST_SUPERUSER)
|
||||
).one()
|
||||
original = settings.CLOUD_CONFIG_FILE
|
||||
settings.CLOUD_CONFIG_FILE = (
|
||||
tmp_path_factory.mktemp(f"cloud-{uuid.uuid4().hex[:6]}") / "cloud.json"
|
||||
)
|
||||
cloud_config.save(
|
||||
cloud_config.CloudConfig(
|
||||
portal_url=ISSUER,
|
||||
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
|
||||
installation_id=INSTALLATION_ID,
|
||||
token="installation-token",
|
||||
issuer=ISSUER,
|
||||
jwks=_jwks(portal_key),
|
||||
local_user_id=str(local_user.id),
|
||||
enrolled_at=datetime.now(UTC).isoformat(),
|
||||
portal_account=settings.FIRST_SUPERUSER,
|
||||
)
|
||||
)
|
||||
yield local_user
|
||||
cloud_config.delete()
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
from tests.utils.portal import ISSUER, portal_token
|
||||
|
||||
|
||||
def test_portal_token_is_refused_when_not_enrolled(
|
||||
@@ -109,7 +32,7 @@ def test_portal_token_is_refused_when_not_enrolled(
|
||||
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("empty") / "cloud.json"
|
||||
try:
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(portal_key))
|
||||
decode_token(portal_token(portal_key))
|
||||
finally:
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
@@ -117,11 +40,11 @@ def test_portal_token_is_refused_when_not_enrolled(
|
||||
def test_portal_token_resolves_to_the_enrolling_user(
|
||||
enrolled: User, portal_key: rsa.RSAPrivateKey, db: Session
|
||||
) -> None:
|
||||
claims = decode_token(_portal_token(portal_key))
|
||||
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.
|
||||
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)) == enrolled
|
||||
|
||||
|
||||
def test_token_for_another_installation_is_refused(
|
||||
@@ -130,7 +53,7 @@ def test_token_for_another_installation_is_refused(
|
||||
) -> 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())))
|
||||
decode_token(portal_token(portal_key, audience=str(uuid.uuid4())))
|
||||
|
||||
|
||||
def test_token_from_an_unpinned_key_is_refused(
|
||||
@@ -139,7 +62,7 @@ def test_token_from_an_unpinned_key_is_refused(
|
||||
"""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))
|
||||
decode_token(portal_token(impostor))
|
||||
|
||||
|
||||
def test_non_proxy_scope_is_refused(
|
||||
@@ -147,17 +70,17 @@ def test_non_proxy_scope_is_refused(
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
) -> None:
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(portal_key, scope="session"))
|
||||
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))["sub"] == str(enrolled.id)
|
||||
assert decode_token(portal_token(portal_key))["sub"] == str(enrolled.id)
|
||||
cloud_config.delete()
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(portal_key))
|
||||
decode_token(portal_token(portal_key))
|
||||
|
||||
|
||||
def test_status_reports_not_enrolled(
|
||||
@@ -180,3 +103,72 @@ def test_enrolling_needs_a_superuser(
|
||||
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
|
||||
|
||||
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"))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""A stand-in portal: its key, its JWKS, and the tokens it would mint.
|
||||
|
||||
Shared by the remote-access tests and the panel ones, since a screen paired
|
||||
through a portal is a portal token that happens to name a panel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
INSTALLATION_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
|
||||
ISSUER = "https://hub.example.test"
|
||||
|
||||
|
||||
def jwks(key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key()))
|
||||
jwk.update({"use": "sig", "alg": "RS256", "kid": "test-portal"})
|
||||
return {"keys": [jwk]}
|
||||
|
||||
|
||||
def portal_token(
|
||||
key: rsa.RSAPrivateKey,
|
||||
*,
|
||||
subject: str = "portal-user-1",
|
||||
audience: str = INSTALLATION_ID,
|
||||
issuer: str = ISSUER,
|
||||
scope: str = "proxy",
|
||||
) -> str:
|
||||
now = datetime.now(UTC)
|
||||
pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": subject,
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"scope": scope,
|
||||
},
|
||||
pem,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "test-portal"},
|
||||
)
|
||||
Reference in New Issue
Block a user