95 lines
4.0 KiB
Python
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
|