"""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