Device security and robustness (openpanel C0): check code, pnc, lenient report

- 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
This commit is contained in:
Melvin Strobl
2026-10-16 19:26:05 +02:00
parent 13d23f6baf
commit c604cfdf3b
21 changed files with 773 additions and 119 deletions
+35 -2
View File
@@ -24,6 +24,7 @@ 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
@@ -290,7 +291,9 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
json={"version": version},
)
token = portal_token(portal_key, subject="hallway", scope="panel")
# 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
@@ -312,9 +315,39 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
client.get(f"{settings.API_V1_STR}/flows/", headers=headers).status_code == 403
)
# Deleting the device is how the screen is retired, whoever minted its token.
# 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(