fix: respect Home Assistant read-only ACL

Issue: #626
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-25 10:47:27 +00:00
committed by claude[bot]
parent 2529052749
commit e6987459a5
11 changed files with 318 additions and 24 deletions
+19 -1
View File
@@ -7,6 +7,7 @@ one behaviour.
"""
from __future__ import annotations
from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY
@@ -28,4 +29,21 @@ def may_write(hass: HomeAssistant, user) -> bool:
# UI has always been admin-gated, and an unset option must not open every
# write WS/HTTP path to every authenticated household user.
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
return is_admin if admin_only else True
if admin_only:
return is_admin
# Turning off ``admin_only`` grants editing to ordinary household users,
# not to HA's explicitly read-only role (#626). ``groups`` is part of the
# supported HA User model; if a non-admin connection cannot provide a
# complete group list, the safe interpretation is that write permission
# has not been established.
groups = getattr(user, "groups", None)
if not isinstance(groups, list) or not groups:
return False
group_ids: list[str] = []
for group in groups:
group_id = getattr(group, "id", None)
if not isinstance(group_id, str):
return False
group_ids.append(group_id)
return GROUP_ID_READ_ONLY not in group_ids
+20 -2
View File
@@ -1056,6 +1056,9 @@ async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
works as a policy if the user can see them: detaching a plan keeps the
image, and this is how it gets picked up again — or deleted on purpose.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only writers may list plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
@@ -2386,12 +2389,27 @@ async def _purge_trail_recorder(hass: HomeAssistant, config: dict[str, Any]) ->
return await rec.async_purge_orphans(config) if rec else 0
def _public_trails(value: Any) -> Any:
"""Copy the trail book for View without exposing source entity ids (#626)."""
if isinstance(value, dict):
return {
key: _public_trails(item)
for key, item in value.items()
if key != "source"
}
if isinstance(value, list):
return [_public_trails(item) for item in value]
return value
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
@websocket_api.async_response
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Current + previous cleanup runs per marker, raw robot coordinates."""
"""Current + previous runs and raw coordinates, without source entity ids."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
connection.send_result(
msg["id"], {"trails": _public_trails(rec.book.data) if rec else {}}
)
@websocket_api.websocket_command(
+13 -2
View File
@@ -1220,6 +1220,17 @@ transmit light is the separate `zero_wall_style` policy.
## Integration WS API
`may_write` is the single writer policy for WebSocket and HTTP: administrators
always write; with `admin_only=true` nobody else writes; with
`admin_only=false` ordinary non-admin groups write while
`system-read-only` remains denied. Missing or incomplete user/group data fails
closed. Read ACL is intentionally different: authenticated View receives the
complete config and layout without per-entity projection. The maintenance
catalogs `plans/list` and `assets/list` are writer-only; `trail/get` keeps the
coordinates needed by View but projects every `source` entity ID out of the
response without mutating the recorder store. `virtual_light/toggle` remains an
authenticated View action, not a writer operation.
| Command | Parameters | Response |
|---|---|---|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
@@ -1227,13 +1238,13 @@ transmit light is the separate `zero_wall_style` policy.
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}, decor_assets_api?}` (runtime capabilities are optional for rolling compatibility) |
| `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs and raw robot coords, with internal `source` entity IDs removed |
| `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion |
| `houseplan/config/set` | `config`, `expected_rev` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/optimize` | `config`, `layout`, both expected revisions | crash-resumable two-store commit + one-deep backup |
| `houseplan/plan/optimize_undo` | both expected revisions | restores backup only before any later edit |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
| `houseplan/plans/list` | — | writer-only `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/layout/delete` | `device_id` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/geometry/repair` | `space_id`, `aspect`, `dry_run?`, `undo?` | preview / `{ok, rev, moved}` / `{restored}`; errs `nothing_to_repair`, `no_backup` |
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Home Assistant's `system-read-only` users now remain read-only even when
House Plan editing is opened to ordinary household members. They keep the
complete View experience, including vacuum paths, but cannot edit, upload or
delete data; the maintenance list of stored plan files is writer-only and
vacuum responses no longer expose the internal map-source entity ID
([#626](https://github.com/Matysh/houseplan-card/issues/626)).
- The main toolbar no longer shows the device count (“37 dev.”), and it stops
jumping sideways when you open, close or switch editors: the editor’s close
**×** now has its own fixed place right after the mode buttons, kept empty
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Пользователи группы Home Assistant `system-read-only` теперь остаются только
зрителями, даже если редактирование House Plan разрешено обычным домочадцам.
Полноценный просмотр, включая маршруты пылесоса, сохраняется, но редактировать,
загружать и удалять данные нельзя; служебный список файлов планов доступен
только редакторам, а ответ с маршрутом больше не раскрывает внутренний entity
ID источника карты
([#626](https://github.com/Matysh/houseplan-card/issues/626)).
- Основная панель больше не показывает счётчик устройств («37 устр.») и не
дёргается по горизонтали при входе в редактор, выходе из него и
переключении редакторов: крестик **×** закрытия редактора теперь стоит на
+7 -2
View File
@@ -30,8 +30,13 @@ obvious" is somebody else's job.
| **Guests / kiosk** | View-only glance at the home | Wall tablet |
Design consequence: **View mode is the product** for two of the three personas.
Editors are admin-only tools and must never leak interactions into View
(established by UX-MODES; lock guard, inert openings, no drag in View).
Editors are writer-only tools and must never leak interactions into View
(established by UX-MODES; lock guard, inert openings, no drag in View). By
default only administrators are writers; an installation may explicitly let
ordinary household users edit, but Home Assistant's `system-read-only` group
never becomes a writer. Authenticated View deliberately shares the complete
spatial plan and represented-device configuration with household members rather
than pretending to be an entity-by-entity privacy boundary.
The desktop browser with mouse/keyboard is the reference and recommended
editing environment. Editor parity on touch is outside the product guarantee;
deliberate degradation is allowed under `TOUCH-SUPPORT.md`.
+22 -2
View File
@@ -154,8 +154,10 @@ update: it shows a panel asking to reload the page.
Every signed-in user can view the plan. Home Assistant permissions still govern
device service calls. With the default integration option, only administrators
can edit configuration or upload files. Plan optimization and its undo always
require an administrator.
can edit configuration or upload files. If that option is disabled, ordinary
household users may edit, but members of Home Assistant's `system-read-only`
group never become writers. Plan optimization and its undo always require an
administrator.
The **House Plan** sidebar item is visible to every signed-in user. A user who
cannot edit gets the complete View experience without editor controls. On an
@@ -1289,6 +1291,24 @@ the recovery record remains for another attempt or a Home Assistant restart.
## 20. Storage, multiple cards and backups
### Who can see and change shared data
| Home Assistant role | Visible in House Plan | May change House Plan |
|---|---|---|
| Administrator | The complete plan, represented devices and the maintenance catalog of plan files | Yes |
| Ordinary user | The complete plan and every device represented on it | Only when “administrators only” editing is disabled for the integration |
| `system-read-only` user | The complete plan and every device represented on it | Never |
House Plan is a shared spatial map of the home, not a separate per-entity
privacy boundary. The plan configuration is therefore not filtered through the
viewer's entity permissions: a household member who can open View sees its rooms
and represented devices. Normal Home Assistant permissions still govern actions
against real devices.
The maintenance catalog of stored plan files (names, sizes and usage) is
writer-only. Vacuum coordinates remain available because View renders the path,
but the map source's internal entity ID is removed from the response.
### Portable JSON backup
**Global settings → Backup and transfer** exports either the complete House
+20 -1
View File
@@ -159,7 +159,7 @@ storage-панель в устаревший YAML только ради House Pl
| Настройка интеграции | Просмотр | Управление устройствами | Редактирование планов и загрузка файлов |
|---|---:|---:|---:|
| Только администраторы — включено (по умолчанию) | Все вошедшие пользователи | По обычным правам HA | Только администраторы |
| Только администраторы — выключено | Все вошедшие пользователи | По обычным правам HA | Все вошедшие пользователи |
| Только администраторы — выключено | Все вошедшие пользователи | По обычным правам HA | Все, кроме пользователей группы HA `system-read-only` |
Оптимизация планов и отмена оптимизации всегда требуют администратора, независимо от этой опции.
@@ -2103,6 +2103,25 @@ House Plan не позволит следующей правке перезап
## 20. Хранение, совместная работа и резервные копии
### Кто видит и изменяет общие данные
| Роль Home Assistant | Что видно в House Plan | Можно изменять House Plan |
|---|---|---|
| Администратор | Весь план, устройства и служебный каталог файлов планов | Да |
| Обычный пользователь | Весь план и все представленные на нём устройства | Только если в интеграции выключено «редактировать план только администраторам» |
| Пользователь группы `system-read-only` | Весь план и все представленные на нём устройства | Нет при любом значении настройки |
House Plan — общая пространственная карта дома, а не отдельная граница
приватности для каждой сущности. Поэтому конфигурация плана не фильтруется по
entity-разрешениям конкретного пользователя: домочадец, которому доступен View,
видит комнаты и представленные на плане устройства. Обычные права Home
Assistant по-прежнему применяются к действиям с реальными устройствами.
Служебный список файлов планов (имена, размеры и использование) доступен только
тем, кто может редактировать. Маршруты пылесоса нужны режиму View, поэтому их
координаты доступны зрителю, но внутренний entity ID источника карты в ответ не
передаётся.
### Переносимая JSON-копия House Plan
Откройте **Общие настройки → Резервная копия и перенос**. Экспорт предлагает
+42
View File
@@ -72,6 +72,48 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'acl-readonly-group-becomes-writer',
guard: 'node scripts/backend-test-guard.mjs '
+ 'may_write_honours_explicit_admin_only_false '
+ 'tests_backend/test_ha_websocket.py',
because: '#626 AC1/AC5: disabling admin_only admits household editors, but HA system-read-only '
+ 'must remain unable to mutate config or upload content.',
patches: [{
file: 'custom_components/houseplan/auth.py',
find: ' return GROUP_ID_READ_ONLY not in group_ids\n',
replace: ' return True # mutant: read-only group inherits household writer access\n',
}],
},
{
id: 'acl-plan-catalog-open-to-viewers',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_626_plans_list_refuses_viewer_before_scanning '
+ 'tests_backend/test_ha_websocket.py',
because: '#626 AC2/AC3: the stored plan filename and usage catalog is maintenance data for '
+ 'writers, not part of authenticated View.',
patches: [{
file: 'custom_components/houseplan/websocket_api.py',
find: ' if not _check_write(hass, connection):\n'
+ ' connection.send_error(msg["id"], "unauthorized", "Only writers may list plans")\n'
+ ' return\n'
+ ' rt = _runtime(hass, connection, msg["id"])\n',
replace: ' rt = _runtime(hass, connection, msg["id"]) # mutant: viewers list plan files\n',
}],
},
{
id: 'acl-trail-view-exposes-source-entity',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_626_authenticated_read_acl_matrix_and_trail_projection '
+ 'tests_backend/test_ha_websocket.py',
because: '#626 AC4: View needs robot coordinates, not private source entity ids embedded at '
+ 'arbitrary levels of current and previous route records.',
patches: [{
file: 'custom_components/houseplan/websocket_api.py',
find: ' if key != "source"\n',
replace: ' if True # mutant: expose source entity ids\n',
}],
},
{
id: 'view-conflict-requires-editor-runtime',
guard: 'node --test test/config-write-conflict.test.mjs',
+23 -6
View File
@@ -8,20 +8,31 @@ def _enable_custom_integrations(enable_custom_integrations):
yield
from aiohttp import FormData
from homeassistant.auth.const import GROUP_ID_USER
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
from custom_components.houseplan.const import DOMAIN
from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN
async def _setup(hass: HomeAssistant) -> None:
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
async def _setup(hass: HomeAssistant, *, options: dict | None = None) -> None:
entry = MockConfigEntry(
domain=DOMAIN, title="House Plan", data={}, options=options or {}
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def _household_access_token(hass: HomeAssistant) -> str:
user = await hass.auth.async_create_user(
"House Plan household", group_ids=[GROUP_ID_USER]
)
refresh_token = await hass.auth.async_create_refresh_token(user)
return hass.auth.async_create_access_token(refresh_token)
async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
await _setup(hass)
client = await hass_client()
@@ -285,11 +296,11 @@ async def test_issue_617_plan_upload_limit_is_inclusive_and_refusal_leaves_nothi
assert after == before, "a refused plan leaves neither a file nor a temporary behind"
async def test_issue_617_plan_upload_refuses_non_admin(
async def test_issue_626_plan_upload_refuses_read_only_but_allows_household(
hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_read_only_access_token: str,
) -> None:
"""#617 AC4: the same write policy as ws_plan_set."""
await _setup(hass)
"""#626 AC5: HTTP upload follows the same group-aware writer policy as WS."""
await _setup(hass, options={CONF_ADMIN_ONLY: False})
client = await hass_client(hass_read_only_access_token)
before = await hass.async_add_executor_job(_plans_listing, hass)
resp = await client.post("/api/houseplan/plans/upload", data=_plan_form(b"PLAN"))
@@ -297,6 +308,12 @@ async def test_issue_617_plan_upload_refuses_non_admin(
assert (await resp.json())["error"] == "unauthorized"
assert await hass.async_add_executor_job(_plans_listing, hass) == before
household = await hass_client(await _household_access_token(hass))
allowed = await household.post(
"/api/houseplan/plans/upload", data=_plan_form(b"PLAN", "household")
)
assert allowed.status == 200, await allowed.text()
async def test_issue_617_plan_upload_validates_fields_like_ws_plan_set(
hass: HomeAssistant, hass_client: ClientSessionGenerator,
+139 -8
View File
@@ -6,6 +6,7 @@ import logging
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -15,6 +16,7 @@ def _enable_custom_integrations(enable_custom_integrations):
"""Allow loading custom_components in the test hass."""
yield
from homeassistant.auth.const import GROUP_ID_READ_ONLY, GROUP_ID_USER
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.typing import WebSocketGenerator
@@ -31,14 +33,27 @@ from custom_components.houseplan.websocket_api import (
)
async def _setup(hass: HomeAssistant) -> MockConfigEntry:
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
async def _setup(
hass: HomeAssistant, *, options: dict | None = None
) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN, title="House Plan", data={}, options=options or {}
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
async def _access_token_for_group(hass: HomeAssistant, group_id: str) -> str:
"""Create an authenticated non-owner client for one real HA system group."""
user = await hass.auth.async_create_user(
f"House Plan {group_id}", group_ids=[group_id]
)
refresh_token = await hass.auth.async_create_refresh_token(user)
return hass.auth.async_create_access_token(refresh_token)
async def test_config_get_advertises_radar_only_while_coordinator_is_ready(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
) -> None:
@@ -1950,7 +1965,7 @@ async def test_may_write_defaults_admin_only_when_option_missing(hass):
async def test_may_write_honours_explicit_admin_only_false(hass):
"""Household users may write only when the option is explicitly off."""
"""#626: admin_only off admits household users, never HA read-only users."""
from custom_components.houseplan.auth import may_write
entry = MockConfigEntry(
@@ -1960,10 +1975,125 @@ async def test_may_write_honours_explicit_admin_only_false(hass):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
class _User:
is_admin = False
household = SimpleNamespace(
is_admin=False, groups=[SimpleNamespace(id=GROUP_ID_USER)]
)
read_only = SimpleNamespace(
is_admin=False, groups=[SimpleNamespace(id=GROUP_ID_READ_ONLY)]
)
mixed = SimpleNamespace(
is_admin=False,
groups=[
SimpleNamespace(id=GROUP_ID_USER),
SimpleNamespace(id=GROUP_ID_READ_ONLY),
],
)
admin = SimpleNamespace(is_admin=True)
assert may_write(hass, _User()) is True
assert may_write(hass, admin) is True
assert may_write(hass, household) is True
assert may_write(hass, read_only) is False
assert may_write(hass, mixed) is False
assert may_write(hass, SimpleNamespace(is_admin=False, groups=[])) is False
assert may_write(hass, SimpleNamespace(is_admin=False)) is False
assert may_write(
hass, SimpleNamespace(is_admin=False, groups=[SimpleNamespace()])
) is False
async def test_issue_626_authenticated_read_acl_matrix_and_trail_projection(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
) -> None:
"""#626 AC2/AC4/AC5/AC7: one observable matrix pins every read role."""
await _setup(hass, options={CONF_ADMIN_ONLY: False})
admin = await hass_ws_client(hass)
household = await hass_ws_client(
hass, access_token=await _access_token_for_group(hass, GROUP_ID_USER)
)
read_only = await hass_ws_client(
hass, access_token=hass_read_only_access_token
)
recorder = hass.data[DOMAIN]["trail_recorder"]
stored_trails = {
"robot": {
"current": {
"source": "camera.private_map",
"route": {"source": "camera.private_route", "id": "ground"},
"points": [[10.0, 20.0]],
},
"previous": {
"source": "sensor.private_map",
"points": [[30.0, 40.0]],
},
}
}
recorder.book.data = copy.deepcopy(stored_trails)
for client, can_write in (
(admin, True),
(household, True),
(read_only, False),
):
await client.send_json_auto_id({"type": "houseplan/config/get"})
config = await client.receive_json()
assert config["success"] and config["result"]["can_write"] is can_write
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({"type": "houseplan/trail/get"})
trails_response = await client.receive_json()
assert trails_response["success"]
public_trails = trails_response["result"]["trails"]
assert public_trails["robot"]["current"]["points"] == [[10.0, 20.0]]
assert "source" not in json.dumps(public_trails)
for command in ("houseplan/plans/list", "houseplan/assets/list"):
await client.send_json_auto_id({"type": command})
response = await client.receive_json()
if can_write:
assert response["success"], (command, response)
else:
assert not response["success"]
assert response["error"]["code"] == "unauthorized"
assert recorder.book.data == stored_trails, "View projection must not mutate storage"
candidate = {"spaces": [], "markers": [], "settings": {}}
await household.send_json_auto_id({
"type": "houseplan/config/set", "config": candidate, "expected_rev": 0,
})
assert (await household.receive_json())["success"]
await read_only.send_json_auto_id({
"type": "houseplan/config/set", "config": candidate, "expected_rev": 1,
})
refused = await read_only.receive_json()
assert not refused["success"] and refused["error"]["code"] == "unauthorized"
async def test_issue_626_plans_list_refuses_viewer_before_scanning(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""#626 AC3: refusal happens before filesystem work or catalog disclosure."""
await _setup(hass, options={CONF_ADMIN_ONLY: False})
read_only = await hass_ws_client(
hass, access_token=hass_read_only_access_token
)
def unexpected_executor_call(*_args, **_kwargs):
raise AssertionError("plans/list touched the filesystem for a viewer")
monkeypatch.setattr(hass, "async_add_executor_job", unexpected_executor_call)
await read_only.send_json_auto_id({"type": "houseplan/plans/list"})
response = await read_only.receive_json()
assert not response["success"]
assert response["error"]["code"] == "unauthorized"
async def test_config_get_reports_can_write(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
@@ -2955,7 +3085,6 @@ async def test_decor_asset_resolve_readonly_is_limited_to_referenced_ids(
async def test_decor_asset_resolve_non_admin_is_writer_when_admin_only_is_off(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
) -> None:
"""#432 AC2: resolve follows may_write instead of hard-coding admin."""
import hashlib
@@ -2979,7 +3108,9 @@ async def test_decor_asset_resolve_non_admin_is_writer_when_admin_only_is_off(
"created_at": "2026-01-01T00:00:00Z",
}), encoding="utf-8")
client = await hass_ws_client(hass, access_token=hass_read_only_access_token)
client = await hass_ws_client(
hass, access_token=await _access_token_for_group(hass, GROUP_ID_USER)
)
await client.send_json_auto_id({
"type": "houseplan/assets/resolve", "asset_ids": [aid],
})