mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
sandbox_v2: strip RefreshToken.scopes from core; sandbox token goes plain
Phase 7's RefreshToken.scopes + websocket-dispatcher enforcement was built for a sandbox->main websocket that never shipped, so no code path ever exercised the scope check end-to-end. Revert the whole mechanism from core HA and keep the sandbox on a plain system-user token. Phase A (core revert, lockstep): - auth/models.py: delete the RefreshToken.scopes field. - auth/__init__.py + auth/auth_store.py: delete the scopes= parameter and the on-disk serialize/deserialize of the scopes key. The load path now pops a legacy scopes key silently (option A: no migration, no storage-version bump) so pre-existing scoped tokens load fine. - websocket_api/connection.py: delete self.scopes, the _scope_allows helper, and the async_handle enforcement branch. Phase B (sandbox helper): - sandbox_v2/auth.py: delete SANDBOX_TOKEN_SCOPES; identify the refresh token by the one-token-per-system-user invariant instead of matching a scope set. System-user token type is unchanged. Tests: - Delete tests/components/websocket_api/test_scopes.py. - Delete the scoped-token round-trip cases from tests/auth/test_init.py. - Add a regression test that an on-disk token with a legacy scopes key loads without error and drops the field. - Update sandbox_v2 test_auth assertions to the plain-token contract. Phase C (docs): mark auth-scoping-decision.md SUPERSEDED; drop the auth row from the core-HA-modified lists in CLAUDE.md / architecture .html; rewrite the scoped-auth sections in OVERVIEW.md and architecture.html; add a re-introduce follow-up in FOLLOWUPS.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -459,7 +459,6 @@ class AuthManager:
|
||||
token_type: str | None = None,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new refresh token for a user."""
|
||||
if not user.is_active:
|
||||
@@ -515,7 +514,6 @@ class AuthManager:
|
||||
access_token_expiration,
|
||||
expire_at,
|
||||
credential,
|
||||
scopes,
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -211,7 +211,6 @@ class AuthStore:
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
expire_at: float | None = None,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -221,7 +220,6 @@ class AuthStore:
|
||||
"access_token_expiration": access_token_expiration,
|
||||
"expire_at": expire_at,
|
||||
"credential": credential,
|
||||
"scopes": scopes,
|
||||
}
|
||||
if client_name:
|
||||
kwargs["client_name"] = client_name
|
||||
@@ -477,7 +475,10 @@ class AuthStore:
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
scopes = rt_dict.get("scopes")
|
||||
# Silently drop the legacy ``scopes`` key written by the
|
||||
# reverted Phase-7 sandbox auth-scoping mechanism. Pre-existing
|
||||
# on-disk tokens may still carry it; it is no longer read.
|
||||
rt_dict.pop("scopes", None)
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict["id"],
|
||||
user=users[rt_dict["user_id"]],
|
||||
@@ -496,7 +497,6 @@ class AuthStore:
|
||||
last_used_ip=rt_dict.get("last_used_ip"),
|
||||
expire_at=rt_dict.get("expire_at"),
|
||||
version=rt_dict.get("version"),
|
||||
scopes=frozenset(scopes) if scopes else None,
|
||||
)
|
||||
if "credential_id" in rt_dict:
|
||||
token.credential = credentials.get(rt_dict["credential_id"])
|
||||
@@ -585,9 +585,6 @@ class AuthStore:
|
||||
if refresh_token.credential
|
||||
else None,
|
||||
"version": refresh_token.version,
|
||||
"scopes": sorted(refresh_token.scopes)
|
||||
if refresh_token.scopes is not None
|
||||
else None,
|
||||
}
|
||||
for user in self._users.values()
|
||||
for refresh_token in user.refresh_tokens.values()
|
||||
|
||||
@@ -129,13 +129,6 @@ class RefreshToken:
|
||||
|
||||
version: str | None = attr.ib(default=__version__)
|
||||
|
||||
# Optional set of websocket-API command scopes. ``None`` means the token
|
||||
# has no scope restriction (the default for normal user/system tokens).
|
||||
# When set, the token may only call commands matching an entry in the
|
||||
# set: a scope ending in ``/`` matches any command whose type starts
|
||||
# with the prefix; otherwise the scope is an exact ``type`` match.
|
||||
scopes: frozenset[str] | None = attr.ib(default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Credentials:
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
"""Scoped auth tokens for sandbox runtimes (Phase 7).
|
||||
"""Auth tokens for sandbox runtimes.
|
||||
|
||||
Each sandbox group runs against a dedicated system user; the access token
|
||||
the manager hands to the subprocess is issued from a refresh token whose
|
||||
``scopes`` set restricts the websocket API to the ``sandbox_v2/``
|
||||
namespace plus a short allow-list (e.g. ``auth/current_user``). The
|
||||
websocket dispatcher enforces the scope per command — see
|
||||
``homeassistant.components.websocket_api.connection._scope_allows``.
|
||||
|
||||
The sandbox does not currently open a websocket back to main, but the
|
||||
scoped token is still issued and passed on the CLI so that:
|
||||
|
||||
* the manager and runtime agree on a real credential rather than a
|
||||
placeholder, and
|
||||
* the opt-in subscription consumer designed in
|
||||
``sandbox_v2/docs/design-share-states.md`` inherits the same scope
|
||||
without a separate code path.
|
||||
the manager hands to the subprocess is issued from that user's refresh
|
||||
token. The token is a plain system-user credential — there is no scope
|
||||
restriction. The sandbox does not currently open a websocket back to main,
|
||||
so no enforcement surface exists; scope enforcement is deferred until the
|
||||
sandbox→main connection actually lands (see
|
||||
``sandbox_v2/docs/auth-scoping-decision.md``, kept as a historical design
|
||||
record for that future work).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -24,21 +17,6 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Websocket-API scopes granted to sandbox tokens.
|
||||
#
|
||||
# Entries ending in ``/`` are prefix grants — ``sandbox_v2/`` permits any
|
||||
# ``sandbox_v2/...`` command. Plain entries are exact matches. Keep this
|
||||
# allow-list minimal: every entry is a public API surface a sandboxed
|
||||
# integration would otherwise be unable to call, so adding to it widens
|
||||
# the trust boundary.
|
||||
SANDBOX_TOKEN_SCOPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"sandbox_v2/",
|
||||
# Lets the sandbox confirm which user it authenticated as.
|
||||
"auth/current_user",
|
||||
}
|
||||
)
|
||||
|
||||
# Marker stored on the system user's name + refresh_token client_id so the
|
||||
# manager can recognise (and reuse) an existing sandbox credential across
|
||||
# HA restarts.
|
||||
@@ -66,39 +44,34 @@ async def async_get_or_create_sandbox_user(hass: HomeAssistant, group: str) -> U
|
||||
|
||||
|
||||
async def async_issue_sandbox_access_token(hass: HomeAssistant, group: str) -> str:
|
||||
"""Issue a scoped access token for the sandbox runtime of ``group``.
|
||||
"""Issue an access token for the sandbox runtime of ``group``.
|
||||
|
||||
Reuses the dedicated system user across calls; rotates the refresh
|
||||
token on each call so a restart hands the subprocess a fresh
|
||||
credential. The returned JWT is the access token the runtime should
|
||||
pass on the websocket ``auth`` message.
|
||||
Reuses the dedicated system user and its refresh token across calls;
|
||||
the access token is freshly minted each call so a restart hands the
|
||||
subprocess a fresh credential. The returned JWT is the access token
|
||||
the runtime should pass on the websocket ``auth`` message.
|
||||
"""
|
||||
user = await async_get_or_create_sandbox_user(hass, group)
|
||||
refresh_token = await _get_or_create_sandbox_refresh_token(hass, user, group)
|
||||
refresh_token = await _get_or_create_sandbox_refresh_token(hass, user)
|
||||
return hass.auth.async_create_access_token(refresh_token)
|
||||
|
||||
|
||||
async def _get_or_create_sandbox_refresh_token(
|
||||
hass: HomeAssistant, user: User, group: str
|
||||
hass: HomeAssistant, user: User
|
||||
) -> RefreshToken:
|
||||
"""Return (or create) the sandbox refresh token for ``group``.
|
||||
"""Return (or create) the sandbox refresh token for ``user``.
|
||||
|
||||
Sandbox users are ``system_generated`` so their tokens are
|
||||
``TOKEN_TYPE_SYSTEM`` and do not carry a ``client_id``. We identify
|
||||
a group's token by matching the ``scopes`` set against
|
||||
:data:`SANDBOX_TOKEN_SCOPES`; on first use, we create one.
|
||||
Sandbox users are ``system_generated`` and only ever get a single
|
||||
refresh token, so we identify it by that one-token-per-user invariant:
|
||||
reuse the existing token if present, otherwise create one.
|
||||
"""
|
||||
for token in user.refresh_tokens.values():
|
||||
if token.scopes == SANDBOX_TOKEN_SCOPES:
|
||||
return token
|
||||
return await hass.auth.async_create_refresh_token(
|
||||
user,
|
||||
scopes=SANDBOX_TOKEN_SCOPES,
|
||||
)
|
||||
tokens = list(user.refresh_tokens.values())
|
||||
if tokens:
|
||||
return tokens[0]
|
||||
return await hass.auth.async_create_refresh_token(user)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SANDBOX_TOKEN_SCOPES",
|
||||
"async_get_or_create_sandbox_user",
|
||||
"async_issue_sandbox_access_token",
|
||||
]
|
||||
|
||||
@@ -44,22 +44,6 @@ type MessageHandler = Callable[[HomeAssistant, ActiveConnection, dict[str, Any]]
|
||||
type BinaryHandler = Callable[[HomeAssistant, ActiveConnection, bytes], None]
|
||||
|
||||
|
||||
def _scope_allows(scopes: frozenset[str], type_: str) -> bool:
|
||||
"""Return True if ``type_`` is allowed by the connection's scope set.
|
||||
|
||||
A scope entry ending in ``/`` is a prefix grant
|
||||
(e.g. ``"sandbox_v2/"`` permits any ``sandbox_v2/...`` command).
|
||||
Other entries must match the command type exactly.
|
||||
"""
|
||||
for scope in scopes:
|
||||
if scope.endswith("/"):
|
||||
if type_.startswith(scope):
|
||||
return True
|
||||
elif type_ == scope:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ActiveConnection:
|
||||
"""Handle an active websocket client connection."""
|
||||
|
||||
@@ -72,7 +56,6 @@ class ActiveConnection:
|
||||
"logger",
|
||||
"refresh_token_id",
|
||||
"remote",
|
||||
"scopes",
|
||||
"send_message",
|
||||
"subscriptions",
|
||||
"supported_features",
|
||||
@@ -94,7 +77,6 @@ class ActiveConnection:
|
||||
self.send_message = send_message
|
||||
self.user = user
|
||||
self.refresh_token_id = refresh_token.id if refresh_token else None
|
||||
self.scopes = refresh_token.scopes if refresh_token else None
|
||||
self.remote = remote
|
||||
self.subscriptions: dict[Hashable, Callable[[], Any]] = {}
|
||||
self.last_id = 0
|
||||
@@ -256,20 +238,6 @@ class ActiveConnection:
|
||||
)
|
||||
return
|
||||
|
||||
if (scopes := self.scopes) is not None and not _scope_allows(scopes, type_):
|
||||
self.logger.info(
|
||||
"Rejecting %s — not in connection scope %s", type_, sorted(scopes)
|
||||
)
|
||||
self.send_message(
|
||||
messages.error_message(
|
||||
cur_id,
|
||||
const.ERR_UNAUTHORIZED,
|
||||
f"Command {type_!r} is not in the connection's allowed scope.",
|
||||
)
|
||||
)
|
||||
self.last_id = cur_id
|
||||
return
|
||||
|
||||
handler, schema = handler_schema
|
||||
|
||||
try:
|
||||
|
||||
@@ -34,9 +34,10 @@ second condition), as a deliberate call relying on git history for rollback.
|
||||
forwarding via the shared `sandbox_v2/call_service` channel) is
|
||||
the protocol every entity proxy uses.
|
||||
- [`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md) —
|
||||
why `scopes` lives on `RefreshToken` itself, the
|
||||
`_scope_allows` grammar, and what's deferred until the sandbox
|
||||
websocket back to main is wired up.
|
||||
**SUPERSEDED.** The Phase-7 `RefreshToken.scopes` mechanism it
|
||||
describes was reverted from core HA (`plans/plan-strip-auth-scopes.md`);
|
||||
the sandbox now uses a plain system-user token. Kept as the design
|
||||
record for whenever the sandbox→main websocket actually lands.
|
||||
- [`docs/design-share-states.md`](docs/design-share-states.md) —
|
||||
design for the post-v2 state-sharing consumer that replaces the
|
||||
Phase 7 `share_*` flags Phase 20 deleted. Covers entity_id
|
||||
@@ -58,7 +59,7 @@ The HA Core side of the integration lives at
|
||||
|
||||
## Core HA files modified (high-review surface)
|
||||
|
||||
v2 touches four core HA files. Each is intentional, small, and was
|
||||
v2 touches three core HA surfaces. Each is intentional, small, and was
|
||||
introduced by a specific phase — see the matching STATUS file for
|
||||
the rationale.
|
||||
|
||||
@@ -77,9 +78,6 @@ the rationale.
|
||||
`EntityComponent.async_register_remote_platform`. Sandbox-built
|
||||
`EntityPlatform` instances attach without re-discovering the
|
||||
local integration. **Phase 5.**
|
||||
- `homeassistant/auth/models.py` + `auth/__init__.py` +
|
||||
`auth/auth_store.py` + `components/websocket_api/connection.py` —
|
||||
optional `RefreshToken.scopes` + dispatcher enforcement. **Phase 7.**
|
||||
- `homeassistant/helpers/sandbox_context.py` (NEW) +
|
||||
`homeassistant/helpers/storage.py` — the `current_sandbox`
|
||||
`ContextVar` + `SandboxBridge` `Protocol`, read by `Store`'s IO
|
||||
|
||||
+21
-21
@@ -44,7 +44,7 @@ inside the sandbox.
|
||||
| Transport | Live websocket connection back to main | JSON-line `Channel` over the subprocess's stdin/stdout |
|
||||
| Entity bridge | Bespoke `sandbox/update_state` + `sandbox/entity_command_result` (Option A) | Shared `sandbox_v2/call_service` (Option B) — see [`docs/entity-bridge-decision.md`](docs/entity-bridge-decision.md) |
|
||||
| Config flow | Forwarded through host integration | Runs inside the sandbox; main owns the canonical `ConfigEntry` store |
|
||||
| Auth | System-user token, full HA scope | Scoped `RefreshToken` (`{"sandbox_v2/", "auth/current_user"}`); dispatcher rejects out-of-scope calls |
|
||||
| Auth | System-user token, full HA scope | System-user token (scope enforcement deferred until the sandbox→main connection lands) |
|
||||
| Data sharing | Sandbox sees all of main's state | Default locked-down; opt-in state/registry/area sharing per group is a future feature ([`docs/design-share-states.md`](docs/design-share-states.md)) |
|
||||
| Store routing | None — sandbox writes to its own tempdir | The `current_sandbox` contextvar makes `Store` IO proxy to main; main writes to `<config>/.storage/sandbox_v2/<group>/<key>` |
|
||||
| Shutdown | Best-effort | Graceful `sandbox_v2/shutdown` round-trip; sandbox unloads entries + dumps `RestoreEntity` state; main persists it for next boot |
|
||||
@@ -148,7 +148,7 @@ is:
|
||||
python -m hass_client.sandbox_v2 \
|
||||
--group <group> \
|
||||
--url ws://localhost:8123/api/websocket \
|
||||
--token <scoped sandbox access token>
|
||||
--token <sandbox access token>
|
||||
```
|
||||
|
||||
The runtime prints `sandbox_v2:ready` on stdout once its
|
||||
@@ -354,24 +354,27 @@ domain by virtue of registering light entities). `ServiceMirror` and
|
||||
carrying a richer `Context` shape across the bridge is post-v2
|
||||
work.
|
||||
|
||||
## Scoped auth & opt-in data sharing
|
||||
|
||||
`RefreshToken` gains an optional `scopes: frozenset[str] | None` field
|
||||
([`homeassistant/auth/models.py`](../homeassistant/auth/models.py)). The
|
||||
websocket dispatcher
|
||||
([`websocket_api/connection.py`](../homeassistant/components/websocket_api/connection.py))
|
||||
checks each incoming command via a module-level `_scope_allows` helper
|
||||
that matches prefix grants (`sandbox_v2/`) and exact-match entries
|
||||
(`auth/current_user`); unscoped tokens (`scopes is None`) are
|
||||
unaffected.
|
||||
## Sandbox auth & opt-in data sharing
|
||||
|
||||
The sandbox issuance helper
|
||||
([`auth.py`](../homeassistant/components/sandbox_v2/auth.py)) creates a
|
||||
dedicated system user per group and a scoped refresh token with
|
||||
`scopes = {"sandbox_v2/", "auth/current_user"}`. Result: a
|
||||
sandboxed integration cannot call `light.turn_on`, `auth/sign_path`,
|
||||
or any other non-`sandbox_v2/` command — the dispatcher rejects with
|
||||
`ERR_UNAUTHORIZED` before the handler runs.
|
||||
dedicated system user per group and hands the subprocess a plain
|
||||
system-user access token, freshly minted from that user's refresh
|
||||
token on each spawn. There is **no scope restriction** on the token.
|
||||
|
||||
Scope enforcement is **deferred until the sandbox→main websocket
|
||||
connection actually lands.** Phase 7 originally shipped a
|
||||
`RefreshToken.scopes` field plus a websocket-dispatcher `_scope_allows`
|
||||
check, but no consumer ever exercised it (the sandbox never opened a
|
||||
connection back to main), so `plan-strip-auth-scopes.md` reverted the
|
||||
whole mechanism from core HA. The posture is unchanged in practice —
|
||||
with no WS path open in either direction, the sandbox token's reach is
|
||||
the same as v1's. When the WS transport ships
|
||||
([`plans/plan-transport.md`](plans/plan-transport.md) T4), scope
|
||||
enforcement is a green-field redesign with a real consumer in hand;
|
||||
the prior thinking is preserved in
|
||||
[`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md)
|
||||
(marked SUPERSEDED).
|
||||
|
||||
Opt-in data sharing (state stream, entity registry, area registry)
|
||||
into the sandbox is a future feature. Phase 7 added unwired
|
||||
@@ -382,9 +385,6 @@ locked-down posture stays — defaults are everything-off; the opt-in
|
||||
subscription consumer lands behind whatever config surface the design
|
||||
doc settles on.
|
||||
|
||||
The full decision rationale for the auth side lives in
|
||||
[`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md).
|
||||
|
||||
## Store routing
|
||||
|
||||
`homeassistant.helpers.storage.Store` reads a `current_sandbox`
|
||||
@@ -536,7 +536,7 @@ deferred, and what it flagged for the next phase. For a quick map:
|
||||
| Config flow | [`router.py`](../homeassistant/components/sandbox_v2/router.py), [`proxy_flow.py`](../homeassistant/components/sandbox_v2/proxy_flow.py) | [`flow_runner.py`](hass_client/hass_client/flow_runner.py) |
|
||||
| Entity bridge | [`bridge.py`](../homeassistant/components/sandbox_v2/bridge.py), [`entity/`](../homeassistant/components/sandbox_v2/entity/) | [`entry_runner.py`](hass_client/hass_client/entry_runner.py), [`entity_bridge.py`](hass_client/hass_client/entity_bridge.py) |
|
||||
| Service/event mirror | [`bridge.py`](../homeassistant/components/sandbox_v2/bridge.py) | [`service_mirror.py`](hass_client/hass_client/service_mirror.py), [`event_mirror.py`](hass_client/hass_client/event_mirror.py), [`approved_domains.py`](hass_client/hass_client/approved_domains.py) |
|
||||
| Auth scopes | [`auth.py`](../homeassistant/components/sandbox_v2/auth.py), `homeassistant/auth/models.py`, `homeassistant/components/websocket_api/connection.py` | — |
|
||||
| Auth | [`auth.py`](../homeassistant/components/sandbox_v2/auth.py) (plain system-user token) | — |
|
||||
| Store routing | [`bridge.py`](../homeassistant/components/sandbox_v2/bridge.py) (`_SandboxStoreServer`), `homeassistant/helpers/sandbox_context.py`, `homeassistant/helpers/storage.py` | [`sandbox_bridge.py`](hass_client/hass_client/sandbox_bridge.py) |
|
||||
| Shutdown | [`__init__.py`](../homeassistant/components/sandbox_v2/__init__.py) (`_on_stop`), `manager.py` | [`sandbox.py`](hass_client/hass_client/sandbox.py) (`_run_graceful_shutdown`) |
|
||||
| Test infra | — | [`testing/`](hass_client/hass_client/testing/), [`run_compat.py`](run_compat.py) |
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
<a href="#entity-bridge">The entity bridge (Option B)</a>
|
||||
</li>
|
||||
<li><a href="#mirrors">Service & event mirroring</a></li>
|
||||
<li><a href="#auth">Scoped auth & opt-in data sharing</a></li>
|
||||
<li><a href="#auth">Sandbox auth & opt-in data sharing</a></li>
|
||||
<li>
|
||||
<a href="#store"
|
||||
>Store routing — <code>current_sandbox</code></a
|
||||
@@ -428,7 +428,7 @@
|
||||
through a public, deliberately-added hook —
|
||||
<code>EntityComponent.async_register_remote_platform</code>,
|
||||
<code>ConfigEntries.router</code>, <code>ConfigEntry.sandbox</code>,
|
||||
<code>RefreshToken.scopes</code>. The PR diff is slightly larger; the
|
||||
<code>current_sandbox</code>. The PR diff is slightly larger; the
|
||||
maintenance story is dramatically smaller.
|
||||
</div>
|
||||
|
||||
@@ -767,7 +767,7 @@
|
||||
fill="#fcd34d"
|
||||
font-size="9.5"
|
||||
>
|
||||
_scope_allows(scopes, type)
|
||||
no scope enforcement
|
||||
</text>
|
||||
<text
|
||||
x="151"
|
||||
@@ -776,7 +776,7 @@
|
||||
fill="#fcd34d"
|
||||
font-size="9.5"
|
||||
>
|
||||
rejects out-of-scope cmds
|
||||
deferred until the
|
||||
</text>
|
||||
<text
|
||||
x="151"
|
||||
@@ -785,7 +785,7 @@
|
||||
fill="#fcd34d"
|
||||
font-size="9.5"
|
||||
>
|
||||
at ERR_UNAUTHORIZED
|
||||
WS transport lands
|
||||
</text>
|
||||
|
||||
<!-- auth issuance -->
|
||||
@@ -824,7 +824,7 @@
|
||||
fill="#fcd34d"
|
||||
font-size="9.5"
|
||||
>
|
||||
scopes = {sandbox_v2/,
|
||||
plain system-user
|
||||
</text>
|
||||
<text
|
||||
x="355"
|
||||
@@ -833,7 +833,7 @@
|
||||
fill="#fcd34d"
|
||||
font-size="9.5"
|
||||
>
|
||||
auth/current_user}
|
||||
access token
|
||||
</text>
|
||||
|
||||
<!-- file-system tag -->
|
||||
@@ -1464,7 +1464,7 @@
|
||||
<pre><code>python -m hass_client.sandbox_v2 \
|
||||
--group <group> \
|
||||
--url ws://localhost:8123/api/websocket \
|
||||
--token <scoped sandbox access token> \
|
||||
--token <sandbox access token> \
|
||||
[--share-states] [--share-entity-registry] [--share-areas]</code></pre>
|
||||
|
||||
<p>
|
||||
@@ -1986,68 +1986,47 @@
|
||||
</p>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<h2 id="auth">9. Scoped auth & opt-in data sharing</h2>
|
||||
<h2 id="auth">9. Sandbox auth & opt-in data sharing</h2>
|
||||
|
||||
<p>
|
||||
A subprocess running arbitrary integration code — especially
|
||||
custom integrations from HACS — needs a token the platform itself
|
||||
treats as restricted. Bolting per-command checks onto individual
|
||||
handlers leaves the rest of the API surface fully reachable; the
|
||||
enforcement has to live where every command passes through.
|
||||
Each sandbox group runs against a dedicated system user. The manager
|
||||
hands the subprocess a plain system-user access token, freshly minted
|
||||
from that user's refresh token on every spawn. There is
|
||||
<strong>no scope restriction</strong> on the token today.
|
||||
</p>
|
||||
|
||||
<h3>The decision: <code>scopes</code> on <code>RefreshToken</code></h3>
|
||||
|
||||
<p><code>RefreshToken</code> grows a single optional field:</p>
|
||||
|
||||
<pre><code># homeassistant/auth/models.py
|
||||
@dataclass
|
||||
class RefreshToken:
|
||||
...
|
||||
scopes: frozenset[str] | None = None # None = today's full-privilege behaviour</code></pre>
|
||||
<div class="callout">
|
||||
<strong>Scope enforcement is deferred.</strong> Phase 7 originally
|
||||
shipped a <code>RefreshToken.scopes</code> field plus a
|
||||
websocket-dispatcher <code>_scope_allows</code> check that rejected any
|
||||
command outside <code>{"sandbox_v2/", "auth/current_user"}</code> with
|
||||
<code>ERR_UNAUTHORIZED</code>. But the sandbox never opened a websocket
|
||||
back to main, so no code path ever exercised the check end-to-end
|
||||
— it guarded a non-existent attack surface.
|
||||
<code>plan-strip-auth-scopes</code> reverted the whole mechanism from
|
||||
core HA (four files: <code>auth/models.py</code>,
|
||||
<code>auth/__init__.py</code>, <code>auth/auth_store.py</code>,
|
||||
<code>websocket_api/connection.py</code>). With no WS path open in
|
||||
either direction, the sandbox token's reach is the same as v1's; the
|
||||
posture is unchanged in practice.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Any non-<code>None</code> value is enforced by the websocket dispatcher
|
||||
via a module-level helper:
|
||||
</p>
|
||||
|
||||
<pre><code>def _scope_allows(scopes: frozenset[str], type_: str) -> bool:
|
||||
for scope in scopes:
|
||||
if scope.endswith("/"):
|
||||
if type_.startswith(scope):
|
||||
return True
|
||||
elif type_ == scope:
|
||||
return True
|
||||
return False</code></pre>
|
||||
|
||||
<p>
|
||||
Sandbox tokens get exactly
|
||||
<code>{"sandbox_v2/", "auth/current_user"}</code> — one prefix
|
||||
grant for the namespace, one exact-match so the runtime can confirm
|
||||
which user it authenticated as. A compromised integration cannot call
|
||||
<code>light.turn_on</code>, <code>auth/sign_path</code>, or any other
|
||||
non-<code>sandbox_v2/</code> command — the dispatcher rejects with
|
||||
<code>ERR_UNAUTHORIZED</code> before the handler runs.
|
||||
</p>
|
||||
|
||||
<h3>Why not a subclass?</h3>
|
||||
<p>
|
||||
The first draft was a <code>SandboxAccessToken</code> subclass with its
|
||||
own <code>token_type</code>. Rejected because token-type fan-out
|
||||
propagates everywhere (<code>auth/__init__.py</code>,
|
||||
<code>auth_store.py</code>, anywhere that switches on
|
||||
<code>TOKEN_TYPE_*</code>) and because "is a sandbox" is orthogonal to
|
||||
"is scoped" — a future "OAuth client scoped to
|
||||
<code>calendar/*</code>" would want the same shape but isn't a sandbox.
|
||||
The optional-attribute approach is small, backwards-compatible (every
|
||||
existing token loads with <code>scopes=None</code>), and reusable.
|
||||
When the sandbox→main websocket transport actually lands, scope
|
||||
enforcement is a green-field redesign with a real consumer in hand. The
|
||||
prior thinking — the optional-field-on-<code>RefreshToken</code>
|
||||
decision, the prefix-grant + exact-match grammar, and why a token
|
||||
subclass was rejected — is preserved in
|
||||
<code>docs/auth-scoping-decision.md</code> (marked SUPERSEDED) as the
|
||||
starting point for that work.
|
||||
</p>
|
||||
|
||||
<h3>Data sharing — the positive opt-in</h3>
|
||||
|
||||
<p>
|
||||
Auth-scope-as-deny ("can't call X") is paired with a positive opt-in for
|
||||
shared <em>data</em>. <code>SandboxGroupConfig</code> ships three knobs:
|
||||
Independently of the token's reach, data sharing <em>into</em> the
|
||||
sandbox is a positive opt-in. <code>SandboxGroupConfig</code> ships
|
||||
three knobs:
|
||||
</p>
|
||||
|
||||
<table>
|
||||
@@ -2361,6 +2340,12 @@ class RefreshToken:
|
||||
system user; <code>SandboxGroupConfig</code> with the three sharing
|
||||
knobs. The riskiest phase from a security-review angle —
|
||||
written up at length in <code>docs/auth-scoping-decision.md</code>.
|
||||
<strong
|
||||
>The <code>scopes</code> mechanism was later reverted</strong
|
||||
>
|
||||
(<code>plan-strip-auth-scopes</code>) because no consumer ever
|
||||
opened the connection it guarded; the per-group system-user token
|
||||
stays.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2645,11 +2630,10 @@ class RefreshToken:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Auth scopes</td>
|
||||
<td>Auth</td>
|
||||
<td>
|
||||
<code>components/sandbox_v2/auth.py</code>,
|
||||
<code>auth/models.py</code>,
|
||||
<code>components/websocket_api/connection.py</code>
|
||||
<code>components/sandbox_v2/auth.py</code>
|
||||
(plain system-user token)
|
||||
</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
@@ -2708,15 +2692,6 @@ class RefreshToken:
|
||||
<code>EntityComponent.async_register_remote_platform</code>
|
||||
(Phase 5).
|
||||
</li>
|
||||
<li>
|
||||
<strong
|
||||
><code>homeassistant/auth/models.py</code> +
|
||||
<code>auth/__init__.py</code> + <code>auth/auth_store.py</code> +
|
||||
<code>components/websocket_api/connection.py</code></strong
|
||||
>
|
||||
— optional <code>RefreshToken.scopes</code> + dispatcher
|
||||
enforcement (Phase 7).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Running the tests</h3>
|
||||
|
||||
@@ -284,6 +284,36 @@ hook. The "monkey-patch the storage module" tension is closed.
|
||||
|
||||
---
|
||||
|
||||
## plan-strip-auth-scopes — revert the Phase-7 `RefreshToken.scopes` mechanism
|
||||
|
||||
**Why.** Phase 7 added `RefreshToken.scopes` + a websocket-dispatcher
|
||||
`_scope_allows` check across four core HA files
|
||||
(`auth/models.py`, `auth/__init__.py`, `auth/auth_store.py`,
|
||||
`websocket_api/connection.py`) plus a persisted `scopes` key in the
|
||||
on-disk auth store. It was built for a sandbox→main websocket that was
|
||||
never wired up, so no code path ever exercised the scope check
|
||||
end-to-end — the feature was asserted only by an isolated dispatcher
|
||||
test. Phase 20 had already deleted the `share_*` opt-in that paired with
|
||||
scope-as-deny, leaving scopes guarding a non-existent attack surface.
|
||||
That's permanent core surface for zero current value.
|
||||
|
||||
**What landed.** The whole `scopes` mechanism reverted from core HA. The
|
||||
sandbox still gets a dedicated system user per group and an access token
|
||||
freshly minted on each spawn — only the scoping disappears.
|
||||
`_get_or_create_sandbox_refresh_token` now identifies the token by the
|
||||
one-token-per-system-user invariant instead of matching a scope set.
|
||||
Back-compat: the auth-store load path pops a legacy `scopes` key if
|
||||
present (option A — silent drop, no storage-version bump), covered by a
|
||||
regression test; v2 is unreleased so the only on-disk scoped tokens are
|
||||
dev machines on this branch.
|
||||
|
||||
**Outcome.** Core HA's auth surface is back to its pre-Phase-7 shape; the
|
||||
v2 core-HA touch list shrinks from four surfaces to three.
|
||||
[`auth-scoping-decision.md`](auth-scoping-decision.md) is kept as a
|
||||
SUPERSEDED design record for the eventual re-introduction.
|
||||
|
||||
---
|
||||
|
||||
## Still open
|
||||
|
||||
These are the items that survived Phase 17 — see
|
||||
@@ -298,6 +328,14 @@ for the per-failure-category remediation table.
|
||||
entity_id alignment constraint, the `share/subscribe_*` protocol,
|
||||
the main-side filter, and the open questions. The actual consumer
|
||||
is owed in a future phase against that design.
|
||||
- **Re-introduce scope enforcement on the sandbox token.**
|
||||
`plan-strip-auth-scopes` reverted the Phase-7 `RefreshToken.scopes`
|
||||
mechanism because no consumer shipped. When the WS transport
|
||||
([`../plans/plan-transport.md`](../plans/plan-transport.md) T4) ships
|
||||
the share-states subscription, the sandbox token needs scope
|
||||
enforcement again — reuse [`auth-scoping-decision.md`](auth-scoping-decision.md)'s
|
||||
design (prefix-grant + exact-match grammar) as the starting point,
|
||||
this time with a real consumer forcing the shape.
|
||||
- **v1 removal. DONE (2026-05-28).** The numeric gate (Phase 11) was
|
||||
cleared by Phase 17; v1 was removed ahead of the "shipped a stable
|
||||
release" condition, relying on git history for rollback.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Auth-scoping decision (Phase 7)
|
||||
|
||||
> **SUPERSEDED 2026-06-03 by `plans/plan-strip-auth-scopes.md`.** No
|
||||
> consumer of this mechanism ever shipped (the sandbox→main WebSocket was
|
||||
> not wired up). The `RefreshToken.scopes` field and dispatcher
|
||||
> enforcement were reverted from core HA; the sandbox now uses a plain
|
||||
> system-user token. The design below is preserved as a historical record
|
||||
> so the next attempt (when the WS transport actually lands) has prior
|
||||
> thinking to reuse.
|
||||
|
||||
> **Decision:** sandbox tokens are scoped `RefreshToken`s. The
|
||||
> `scopes` set lives on `RefreshToken` itself (no subclass, no new
|
||||
> token type); the websocket dispatcher enforces it per command via a
|
||||
|
||||
@@ -277,6 +277,52 @@ async def test_dont_change_expire_at_on_load(
|
||||
assert token2.expire_at == 1724133771.079745
|
||||
|
||||
|
||||
async def test_loading_drops_legacy_scopes_key(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test a refresh token persisted with the legacy ``scopes`` key loads.
|
||||
|
||||
The reverted Phase-7 sandbox auth-scoping mechanism wrote a ``scopes``
|
||||
list onto refresh tokens. ``RefreshToken`` no longer accepts that field,
|
||||
so the load path must silently drop it instead of erroring.
|
||||
"""
|
||||
hass_storage[auth_store.STORAGE_KEY] = {
|
||||
"version": 1,
|
||||
"data": {
|
||||
"credentials": [],
|
||||
"users": [
|
||||
{
|
||||
"id": "system-id",
|
||||
"is_active": True,
|
||||
"is_owner": True,
|
||||
"name": "Sandbox v2: built-in",
|
||||
"system_generated": True,
|
||||
},
|
||||
],
|
||||
"refresh_tokens": [
|
||||
{
|
||||
"access_token_expiration": 1800.0,
|
||||
"client_id": None,
|
||||
"created_at": "2018-10-03T13:43:19.774637+00:00",
|
||||
"id": "scoped-token-id",
|
||||
"jwt_key": "some-key",
|
||||
"token": "some-token",
|
||||
"token_type": "system",
|
||||
"user_id": "system-id",
|
||||
"scopes": ["sandbox_v2/", "auth/current_user"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
store = auth_store.AuthStore(hass)
|
||||
await store.async_load()
|
||||
|
||||
users = await store.async_get_users()
|
||||
token = next(iter(users[0].refresh_tokens.values()))
|
||||
assert not hasattr(token, "scopes")
|
||||
|
||||
|
||||
async def test_loading_does_not_write_right_away(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any], freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
|
||||
@@ -527,31 +527,6 @@ async def test_refresh_token_type_long_lived_access_token(hass: HomeAssistant) -
|
||||
assert token.expire_at is None
|
||||
|
||||
|
||||
async def test_refresh_token_scopes_default_to_none(hass: HomeAssistant) -> None:
|
||||
"""Refresh tokens default to no scope restriction."""
|
||||
manager = await auth.auth_manager_from_config(hass, [], [])
|
||||
user = MockUser().add_to_auth_manager(manager)
|
||||
token = await manager.async_create_refresh_token(user, CLIENT_ID)
|
||||
assert token.scopes is None
|
||||
|
||||
|
||||
async def test_refresh_token_scopes_round_trip_through_store(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A scoped refresh token persists and reloads with the same scopes."""
|
||||
manager = await auth.auth_manager_from_config(hass, [], [])
|
||||
user = MockUser(system_generated=True, is_active=True).add_to_auth_manager(manager)
|
||||
scopes = frozenset({"sandbox_v2/", "auth/current_user"})
|
||||
token = await manager.async_create_refresh_token(user, scopes=scopes)
|
||||
assert token.scopes == scopes
|
||||
|
||||
# Force a save+reload cycle through the underlying store to confirm
|
||||
# scopes survive serialisation.
|
||||
data = manager._store._data_to_save()
|
||||
rt_dict = next(rt for rt in data["refresh_tokens"] if rt["id"] == token.id)
|
||||
assert sorted(rt_dict["scopes"]) == sorted(scopes)
|
||||
|
||||
|
||||
async def test_refresh_token_provider_validation(mock_hass) -> None:
|
||||
"""Test that creating access token from refresh token checks with provider."""
|
||||
manager = await auth.auth_manager_from_config(
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
"""Phase 7 tests for the sandbox_v2 scoped-auth helpers."""
|
||||
"""Tests for the sandbox_v2 auth helpers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.sandbox_v2.auth import (
|
||||
SANDBOX_TOKEN_SCOPES,
|
||||
async_get_or_create_sandbox_user,
|
||||
async_issue_sandbox_access_token,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def test_sandbox_token_scopes_allowlist() -> None:
|
||||
"""The scope set covers `sandbox_v2/*` plus the minimum auth allow-list."""
|
||||
assert "sandbox_v2/" in SANDBOX_TOKEN_SCOPES
|
||||
assert "auth/current_user" in SANDBOX_TOKEN_SCOPES
|
||||
# The set should not pull in broader auth surface like sign_path.
|
||||
assert "auth/sign_path" not in SANDBOX_TOKEN_SCOPES
|
||||
# No service-call shortcut.
|
||||
assert "call_service" not in SANDBOX_TOKEN_SCOPES
|
||||
|
||||
|
||||
async def test_get_or_create_user_is_idempotent(hass: HomeAssistant) -> None:
|
||||
"""Calling the helper twice for the same group returns the same user."""
|
||||
user = await async_get_or_create_sandbox_user(hass, "built-in")
|
||||
@@ -35,20 +24,19 @@ async def test_get_or_create_user_is_idempotent(hass: HomeAssistant) -> None:
|
||||
async def test_issue_token_returns_valid_access_token(
|
||||
hass: HomeAssistant, group: str
|
||||
) -> None:
|
||||
"""The issued access token is a JWT that validates back to a scoped refresh token."""
|
||||
"""The issued access token is a JWT that validates back to a system-user token."""
|
||||
token = await async_issue_sandbox_access_token(hass, group)
|
||||
assert isinstance(token, str)
|
||||
assert token # not empty
|
||||
|
||||
refresh = hass.auth.async_validate_access_token(token)
|
||||
assert refresh is not None
|
||||
assert refresh.scopes == SANDBOX_TOKEN_SCOPES
|
||||
assert refresh.user.system_generated is True
|
||||
assert refresh.user.name == f"Sandbox v2: {group}"
|
||||
|
||||
|
||||
async def test_issue_token_reuses_refresh_token(hass: HomeAssistant) -> None:
|
||||
"""Second call reuses the existing scoped refresh token (no churn)."""
|
||||
"""Second call reuses the existing refresh token (no churn)."""
|
||||
token_a = await async_issue_sandbox_access_token(hass, "built-in")
|
||||
token_b = await async_issue_sandbox_access_token(hass, "built-in")
|
||||
|
||||
@@ -59,6 +47,9 @@ async def test_issue_token_reuses_refresh_token(hass: HomeAssistant) -> None:
|
||||
assert refresh_b is not None
|
||||
assert refresh_a.id == refresh_b.id
|
||||
|
||||
# The one-token-per-system-user invariant the helper relies on.
|
||||
assert len(refresh_a.user.refresh_tokens) == 1
|
||||
|
||||
|
||||
async def test_per_group_users_are_distinct(hass: HomeAssistant) -> None:
|
||||
"""Different groups get different system users and different tokens."""
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Phase 7 tests for refresh-token scope enforcement on the websocket API."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_websocket_api(hass: HomeAssistant) -> None:
|
||||
"""Make sure websocket_api is up before each test runs."""
|
||||
assert await async_setup_component(hass, "websocket_api", {})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def install_sandbox_command(
|
||||
hass: HomeAssistant,
|
||||
) -> Callable[[HomeAssistant, Any, dict[str, Any]], None]:
|
||||
"""Install a fake ``sandbox_v2/ping`` command for the scope tests."""
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "sandbox_v2/ping"})
|
||||
@websocket_api.async_response
|
||||
async def handle_ping(
|
||||
hass: HomeAssistant, connection: Any, msg: dict[str, Any]
|
||||
) -> None:
|
||||
connection.send_result(msg["id"], {"pong": True})
|
||||
|
||||
websocket_api.async_register_command(hass, handle_ping)
|
||||
return handle_ping
|
||||
|
||||
|
||||
async def _make_scoped_token(hass: HomeAssistant, scopes: frozenset[str]) -> str:
|
||||
"""Create a system user + scoped refresh token; return its access token."""
|
||||
admin_group = await hass.auth.async_get_group(GROUP_ID_ADMIN)
|
||||
assert admin_group is not None
|
||||
user = await hass.auth.async_create_system_user(
|
||||
f"Scoped tester {sorted(scopes)}",
|
||||
group_ids=[admin_group.id],
|
||||
)
|
||||
refresh = await hass.auth.async_create_refresh_token(user, scopes=scopes)
|
||||
return hass.auth.async_create_access_token(refresh)
|
||||
|
||||
|
||||
async def test_scoped_token_rejects_out_of_scope_command(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""A token with `sandbox_v2/` scope cannot call `call_service`."""
|
||||
token = await _make_scoped_token(
|
||||
hass, frozenset({"sandbox_v2/", "auth/current_user"})
|
||||
)
|
||||
ws = await hass_ws_client(hass, access_token=token)
|
||||
|
||||
await ws.send_json_auto_id(
|
||||
{
|
||||
"type": "call_service",
|
||||
"domain": "light",
|
||||
"service": "turn_on",
|
||||
"service_data": {"entity_id": "light.kitchen"},
|
||||
}
|
||||
)
|
||||
msg = await ws.receive_json()
|
||||
|
||||
assert msg["success"] is False
|
||||
assert msg["error"]["code"] == websocket_api.ERR_UNAUTHORIZED
|
||||
|
||||
|
||||
async def test_scoped_token_rejects_auth_sign_path(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""A sandbox-style scope must not authorize `auth/sign_path`."""
|
||||
assert await async_setup_component(hass, "auth", {})
|
||||
token = await _make_scoped_token(
|
||||
hass, frozenset({"sandbox_v2/", "auth/current_user"})
|
||||
)
|
||||
ws = await hass_ws_client(hass, access_token=token)
|
||||
|
||||
await ws.send_json_auto_id({"type": "auth/sign_path", "path": "/api/states"})
|
||||
msg = await ws.receive_json()
|
||||
|
||||
assert msg["success"] is False
|
||||
assert msg["error"]["code"] == websocket_api.ERR_UNAUTHORIZED
|
||||
|
||||
|
||||
async def test_scoped_token_allows_prefix_match(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""A `sandbox_v2/` prefix scope authorizes any `sandbox_v2/...` command."""
|
||||
token = await _make_scoped_token(
|
||||
hass, frozenset({"sandbox_v2/", "auth/current_user"})
|
||||
)
|
||||
ws = await hass_ws_client(hass, access_token=token)
|
||||
|
||||
await ws.send_json_auto_id({"type": "sandbox_v2/ping"})
|
||||
msg = await ws.receive_json()
|
||||
|
||||
assert msg["success"] is True
|
||||
assert msg["result"] == {"pong": True}
|
||||
|
||||
|
||||
async def test_scoped_token_allows_exact_match(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""An exact-match scope authorizes that command only."""
|
||||
assert await async_setup_component(hass, "auth", {})
|
||||
token = await _make_scoped_token(
|
||||
hass, frozenset({"sandbox_v2/", "auth/current_user"})
|
||||
)
|
||||
ws = await hass_ws_client(hass, access_token=token)
|
||||
|
||||
await ws.send_json_auto_id({"type": "auth/current_user"})
|
||||
msg = await ws.receive_json()
|
||||
|
||||
assert msg["success"] is True
|
||||
|
||||
|
||||
async def test_unscoped_token_unaffected(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""A token without scopes still goes through the existing perms path."""
|
||||
ws = await hass_ws_client(hass)
|
||||
|
||||
await ws.send_json_auto_id({"type": "sandbox_v2/ping"})
|
||||
msg = await ws.receive_json()
|
||||
|
||||
# The handler itself is unconditional once permitted; the unscoped admin
|
||||
# token must reach it instead of being scope-gated.
|
||||
assert msg["success"] is True
|
||||
Reference in New Issue
Block a user