mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: respect Home Assistant read-only ACL
Issue: #626 User-Visible: yes
This commit is contained in:
committed by
claude[bot]
parent
2529052749
commit
e6987459a5
@@ -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
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Откройте **Общие настройки → Резервная копия и перенос**. Экспорт предлагает
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user