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:
Paulus
2026-06-03 05:37:45 -04:00
parent 3bf251eb83
commit 5141f96ebe
14 changed files with 198 additions and 378 deletions
-2
View File
@@ -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
+4 -7
View File
@@ -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()
-7
View File
@@ -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:
+23 -50
View File
@@ -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:
+5 -7
View File
@@ -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
View File
@@ -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) |
+47 -72
View File
@@ -373,7 +373,7 @@
<a href="#entity-bridge">The entity bridge (Option&nbsp;B)</a>
</li>
<li><a href="#mirrors">Service &amp; event mirroring</a></li>
<li><a href="#auth">Scoped auth &amp; opt-in data sharing</a></li>
<li><a href="#auth">Sandbox auth &amp; opt-in data sharing</a></li>
<li>
<a href="#store"
>Store routing &mdash; <code>current_sandbox</code></a
@@ -428,7 +428,7 @@
through a public, deliberately-added hook &mdash;
<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 &lt;group&gt; \
--url ws://localhost:8123/api/websocket \
--token &lt;scoped sandbox access token&gt; \
--token &lt;sandbox access token&gt; \
[--share-states] [--share-entity-registry] [--share-areas]</code></pre>
<p>
@@ -1986,68 +1986,47 @@
</p>
<!-- ======================================================== -->
<h2 id="auth">9. Scoped auth &amp; opt-in data sharing</h2>
<h2 id="auth">9. Sandbox auth &amp; opt-in data sharing</h2>
<p>
A subprocess running arbitrary integration code &mdash; especially
custom integrations from HACS &mdash; 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&nbsp;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
&mdash; 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> &mdash; 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 &mdash; 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" &mdash; 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&rarr;main websocket transport actually lands, scope
enforcement is a green-field redesign with a real consumer in hand. The
prior thinking &mdash; the optional-field-on-<code>RefreshToken</code>
decision, the prefix-grant + exact-match grammar, and why a token
subclass was rejected &mdash; is preserved in
<code>docs/auth-scoping-decision.md</code> (marked SUPERSEDED) as the
starting point for that work.
</p>
<h3>Data sharing &mdash; 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 &mdash;
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>&mdash;</td>
</tr>
@@ -2708,15 +2692,6 @@ class RefreshToken:
<code>EntityComponent.async_register_remote_platform</code>
(Phase&nbsp;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
>
&mdash; optional <code>RefreshToken.scopes</code> + dispatcher
enforcement (Phase&nbsp;7).
</li>
</ul>
<h3>Running the tests</h3>
+38
View File
@@ -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.
+8
View File
@@ -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
+46
View File
@@ -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:
-25
View File
@@ -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(
+6 -15
View File
@@ -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