Files
HomeAssistantVS/custom_components/maintenance_supporter/helpers/permissions.py
T
2026-07-08 10:43:39 -04:00

95 lines
4.0 KiB
Python

"""Authorization helpers for write-capable WebSocket commands.
Home Assistant admins may always write. A non-admin user may write only when
operator write delegation is switched on (``operator_write_enabled`` global
option, default OFF) AND their user id is on the operator allowlist
(``admin_panel_user_ids``), which an admin manages under Settings → Panel
Access. With delegation off — the shipped default — content create / edit /
delete is admin-only and the allowlist grants read-only operator access.
IMPORTANT — escalation boundary: ``require_write`` must be used ONLY on
content-CRUD commands (object / task / group create-update-delete, user
assignment, per-task analysis writes). Global-config, bulk-import and vacation
commands keep ``@websocket_api.require_admin``, so an operator can never edit
the allowlist nor flip the delegation switch (both live in the global options,
gated by ``global/update``) — write access cannot be self-granted.
"""
from __future__ import annotations
from functools import wraps
from typing import TYPE_CHECKING, Any
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.const import WebSocketCommandHandler
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import Unauthorized
from ..const import CONF_ADMIN_PANEL_USER_IDS, CONF_OPERATOR_WRITE_ENABLED
from .global_options import get_global_options
if TYPE_CHECKING:
from homeassistant.auth.models import User
def operator_user_ids(hass: HomeAssistant) -> list[str]:
"""Return the operator allowlist (``admin_panel_user_ids``) as string ids."""
raw = get_global_options(hass).get(CONF_ADMIN_PANEL_USER_IDS, []) or []
return [uid for uid in raw if isinstance(uid, str)]
def operator_write_enabled(hass: HomeAssistant) -> bool:
"""Whether operator write delegation is switched on (default False).
Reads the ``operator_write_enabled`` global option. While False — the
shipped default — only HA admins may write and the panel-access allowlist
is read-only; an admin must explicitly enable this for allowlisted
non-admins to gain content CRUD.
"""
return get_global_options(hass).get(CONF_OPERATOR_WRITE_ENABLED, False) is True
def user_can_write(hass: HomeAssistant, user: User | None) -> bool:
"""Whether a specific user may perform content writes.
True for HA admins. For non-admin users, true only when operator write
delegation is enabled AND their id is on the operator allowlist. False for
anonymous / missing users. This is the user-object variant used by the HTTP
document views (which resolve ``request["hass_user"]``); the WS layer calls
:func:`user_may_write`, which reads the user off the connection.
"""
if user is None:
return False
if user.is_admin:
return True
return operator_write_enabled(hass) and user.id in operator_user_ids(hass)
def user_may_write(hass: HomeAssistant, connection: ActiveConnection) -> bool:
"""Whether the connection's user may perform content writes.
True for HA admins. For non-admin users, true only when operator write
delegation is enabled AND their id is on the operator allowlist. False for
anonymous connections.
"""
return user_can_write(hass, connection.user)
def require_write(func: WebSocketCommandHandler) -> WebSocketCommandHandler:
"""Drop-in for ``@websocket_api.require_admin`` that also allows operators.
Mirrors HA's ``require_admin`` exactly (same decorator position: between
``@websocket_command`` and ``@async_response``), but authorises any user for
whom :func:`user_may_write` is true. Use ONLY on content-CRUD commands.
"""
@wraps(func)
def with_write(hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]) -> None:
"""Check write permission and call the wrapped handler."""
if not user_may_write(hass, connection):
raise Unauthorized
func(hass, connection, msg)
return with_write