Files
app/backend/app/cloud/config.py
T
stroblmeandClaude Opus 5 4c8339e643 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
2026-08-20 23:42:58 +02:00

141 lines
4.7 KiB
Python

"""What this installation knows about the portal it is enrolled with.
One file on the data volume, beside the OAuth keypair and for the same reason:
it is a credential, it must survive a rebuild, and it must never be in the
flows repository. Deleting it is the local, unilateral way to sever the
connection — the portal's tokens stop verifying immediately, whatever the
portal still believes.
The portal's public keys are stored here rather than fetched: they are pinned
at enrolment, when a person was holding a claim code they had just read off the
portal's own screen. A hub whose DNS or TLS is later hijacked cannot re-key an
installation that already enrolled; it can only fail to verify.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass
from typing import Any
import jwt
from jwt.exceptions import InvalidTokenError
from app.core.config import settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CloudConfig:
portal_url: str
ws_url: str
installation_id: str
token: str
issuer: str
jwks: dict[str, Any]
#: The local account every portal session acts as. Recorded at enrolment
#: from whoever performed it, so remote access can never exceed the rights
#: of the person who granted it.
local_user_id: str
enrolled_at: str
portal_account: str | None = None
_cache: tuple[float, CloudConfig | None] | None = None
def _read() -> CloudConfig | None:
"""Load the config, re-reading only when the file has changed."""
global _cache
path = settings.CLOUD_CONFIG_FILE
try:
mtime = path.stat().st_mtime
except OSError:
_cache = (0.0, None)
return None
if _cache is not None and _cache[0] == mtime:
return _cache[1]
try:
data = json.loads(path.read_text())
config = CloudConfig(**data)
except (OSError, ValueError, TypeError):
logger.exception("Could not read %s; remote access stays off", path)
_cache = (mtime, None)
return None
_cache = (mtime, config)
return config
def load() -> CloudConfig | None:
return _read()
def exists() -> bool:
return settings.CLOUD_CONFIG_FILE.exists()
def save(config: CloudConfig) -> None:
path = settings.CLOUD_CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
# Written through a temporary file so a crash mid-write cannot leave a
# half-parsed credential behind, and never group- or world-readable.
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(config.__dict__, indent=2))
os.chmod(tmp, 0o600)
tmp.replace(path)
global _cache
_cache = None
def delete() -> None:
settings.CLOUD_CONFIG_FILE.unlink(missing_ok=True)
global _cache
_cache = None
def decode_portal_token(token: str) -> dict[str, Any]:
"""Validate a token the portal minted for this installation.
Raises ``InvalidTokenError`` for everything else, including the ordinary
case of not being enrolled at all — which is what makes this branch free
for an installation nobody connected.
"""
config = _read()
if config is None:
raise InvalidTokenError("this installation is not enrolled with a portal")
try:
key = jwt.PyJWKSet.from_dict(config.jwks).keys[0]
except (IndexError, jwt.PyJWKError) as exc:
raise InvalidTokenError(f"the pinned portal key is unusable: {exc}") from exc
claims: dict[str, Any] = jwt.decode(
token,
key,
algorithms=["RS256"],
# The audience is this installation's own id, so a token the portal
# minted for somebody else's machine fails here.
audience=config.installation_id,
issuer=config.issuer,
)
scope = claims.get("scope")
if scope == "panel":
# A screen that paired through the portal. The portal named the panel
# and nothing else; what that panel may read is decided here, by the
# same check a panel paired on this network passes. It acts as the
# enrolling account for want of any other, but the scope check is what
# actually bounds it — so a token of this scope that names no panel is
# refused rather than left holding the account it borrows.
panel = str(claims.get("sub") or "")
if not panel:
raise InvalidTokenError("a panel token must name its panel")
return {"sub": config.local_user_id, "panel": panel}
if scope != "proxy":
raise InvalidTokenError("not a proxy token")
# Every portal session acts as the enrolling local user. Who they are on
# the portal is kept for the audit trail, not for authorization.
return {"sub": config.local_user_id, "portal_sub": claims.get("sub")}