Use the hassio-provided Supervisor user for Unix socket auth (#182302)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Stefan Agner
2026-09-21 19:58:06 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Copilot Autofix powered by AI
parent 1ca85dc3bc
commit cf3580a8e4
13 changed files with 95 additions and 83 deletions
+3 -3
View File
@@ -18,6 +18,7 @@ from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
from homeassistant.components import frontend
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http.const import DATA_SUPERVISOR_USER
from homeassistant.components.onboarding import async_is_onboarded
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, Platform
@@ -57,7 +58,6 @@ from .const import (
ADDONS_COORDINATOR,
DATA_COMPONENT,
DATA_HASSIO_HOST,
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
DOMAIN,
ENTRY_DATA_USER,
@@ -340,7 +340,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if legacy_data is not None:
legacy_user_id = legacy_data.get("hassio_user")
hass.data[DATA_HASSIO_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user(
hass.data[DATA_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user(
hass, entry, legacy_user_id
)
@@ -386,7 +386,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
remove_legacy_store = True
# Async setup runs first unconditionally and always populates this field
user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
user = hass.data[DATA_SUPERVISOR_USER]
if entry.data.get(ENTRY_DATA_USER) != user.id:
hass.config_entries.async_update_entry(
entry,
+6 -3
View File
@@ -15,12 +15,15 @@ import probatio
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.const import (
DATA_SUPERVISOR_USER,
is_supervisor_unix_socket_request,
)
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, DATA_HASSIO_SUPERVISOR_USER
from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME
_LOGGER = logging.getLogger(__name__)
@@ -41,7 +44,7 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
user = self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)
user = self.hass.data.get(DATA_SUPERVISOR_USER)
if user is None:
raise HTTPServiceUnavailable
@@ -2,15 +2,11 @@
from typing import Any
from homeassistant.components.http.const import DATA_SUPERVISOR_USER
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import HASSIO_USER_NAME
from .const import (
DATA_HASSIO_SUPERVISOR_USER,
DEFAULT_UPDATE_OPTIONS,
DOMAIN,
ENTRY_DATA_USER,
)
from .const import DEFAULT_UPDATE_OPTIONS, DOMAIN, ENTRY_DATA_USER
class HassIoConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -23,7 +19,7 @@ class HassIoConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
data: dict[str, Any] = {}
if (user := self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None:
if (user := self.hass.data.get(DATA_SUPERVISOR_USER)) is not None:
data[ENTRY_DATA_USER] = user.id
return self.async_create_entry(
-3
View File
@@ -20,8 +20,6 @@ if TYPE_CHECKING:
SupervisorInfo,
)
from homeassistant.auth.models import User
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
@@ -153,7 +151,6 @@ DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssuesCoordinator] = HassKey(
)
DATA_KEY_MOUNTS = "mounts"
DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host")
DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
ENTRY_DATA_USER = "user"
+3 -10
View File
@@ -9,11 +9,7 @@ from typing import Any, Final
import probatio
from homeassistant.components.network import async_get_source_ip
from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
HASSIO_USER_NAME,
)
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
@@ -48,6 +44,7 @@ from .const import ( # noqa: F401
CONF_TRUSTED_PROXIES,
CONF_USE_X_FORWARDED_FOR,
CONF_USE_X_FRAME_OPTIONS,
DATA_SUPERVISOR_USER,
DEFAULT_CORS,
DOMAIN,
KEY_HASS_REFRESH_TOKEN_ID,
@@ -215,11 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def start_supervisor_unix_socket(*_: Any) -> None:
"""Start the Unix socket after the Supervisor user is available."""
if any(
user
for user in await hass.auth.async_get_users()
if user.system_generated and user.name == HASSIO_USER_NAME
):
if hass.data.get(DATA_SUPERVISOR_USER) is not None:
await server.async_start_supervisor_unix_socket()
else:
_LOGGER.error("Supervisor user not found; not starting Unix socket")
+18 -28
View File
@@ -22,7 +22,6 @@ from yarl import URL
from homeassistant.auth import jwt_wrapper
from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.components import websocket_api
from homeassistant.const import HASSIO_USER_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.http import current_request
from homeassistant.helpers.json import json_bytes
@@ -30,6 +29,7 @@ from homeassistant.helpers.storage import Store
from .auth_util import async_user_not_allowed_do_auth
from .const import (
DATA_SUPERVISOR_USER,
KEY_AUTHENTICATED,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
@@ -94,7 +94,7 @@ def async_sign_path(
return f"{url.path}?{url.query_string}"
async def async_setup_auth( # noqa: C901
async def async_setup_auth(
hass: HomeAssistant,
app: Application,
) -> None:
@@ -187,40 +187,30 @@ async def async_setup_auth( # noqa: C901
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
return True
supervisor_user_id: str | None = None
async def async_authenticate_supervisor_unix_socket(request: Request) -> bool:
"""Authenticate a request from a Unix socket as the Supervisor user.
The Unix Socket is dedicated and only available to Supervisor. To
avoid the extra overhead and round trips for the authentication and
refresh tokens, we directly authenticate requests from the socket as
the Supervisor user.
the Supervisor user provided by the hassio integration. The user is
looked up in the auth store so a user removed at runtime is not
authenticated any longer.
"""
nonlocal supervisor_user_id
if (supervisor_user := hass.data.get(DATA_SUPERVISOR_USER)) is None or (
user := await hass.auth.async_get_user(supervisor_user.id)
) is None:
# The Unix socket should not be serving before the hassio integration
# has provided the Supervisor user. If we get here, something is wrong.
_LOGGER.error(
"Supervisor user not found; cannot authenticate Unix socket request"
)
raise HTTPInternalServerError
# Fast path: use cached user ID
if supervisor_user_id is not None:
if user := await hass.auth.async_get_user(supervisor_user_id):
request[KEY_HASS_USER] = user
return True
supervisor_user_id = None
# Slow path: find the Supervisor user by name
for user in await hass.auth.async_get_users():
if user.system_generated and user.name == HASSIO_USER_NAME:
supervisor_user_id = user.id
# Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user
# doesn't use refresh tokens.
request[KEY_HASS_USER] = user
return True
# The Unix socket should not be serving before the hassio integration
# has created the Supervisor user. If we get here, something is wrong.
_LOGGER.error(
"Supervisor user not found; cannot authenticate Unix socket request"
)
raise HTTPInternalServerError
# Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user
# doesn't use refresh tokens.
request[KEY_HASS_USER] = user
return True
@middleware
async def auth_middleware(
+10 -1
View File
@@ -1,10 +1,14 @@
"""HTTP specific constants."""
from typing import Final
from typing import TYPE_CHECKING, Final
from aiohttp.web import Request
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.auth.models import User
DOMAIN: Final = "http"
@@ -12,6 +16,11 @@ KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
KEY_SUPERVISOR_UNIX_SOCKET: Final = "ha_supervisor_unix_socket"
# System user used to authenticate requests over the Supervisor Unix socket.
# Set by the hassio integration during its setup; the Unix socket is only
# started once it is available.
DATA_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
CONF_SERVER_HOST: Final = "server_host"
CONF_SERVER_PORT: Final = "server_port"
CONF_BASE_URL: Final = "base_url"
+2 -2
View File
@@ -10,7 +10,7 @@ import pytest
from homeassistant.auth.providers.homeassistant import InvalidAuth
from homeassistant.components.hassio.auth import HassIOBaseAuth
from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER
from homeassistant.components.http.const import DATA_SUPERVISOR_USER
from homeassistant.core import HomeAssistant
@@ -187,7 +187,7 @@ async def test_check_access_unix_socket_or_missing_peername(
expectation: AbstractContextManager,
) -> None:
"""Test _check_access handles Unix socket requests and missing peername."""
user = hass.data.get(DATA_HASSIO_SUPERVISOR_USER)
user = hass.data.get(DATA_SUPERVISOR_USER)
assert user is not None
auth_view = HassIOBaseAuth(hass)
+2 -2
View File
@@ -5,10 +5,10 @@ from unittest.mock import patch
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components.hassio import DOMAIN
from homeassistant.components.hassio.const import (
DATA_HASSIO_SUPERVISOR_USER,
DEFAULT_UPDATE_OPTIONS,
ENTRY_DATA_USER,
)
from homeassistant.components.http.const import DATA_SUPERVISOR_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -53,7 +53,7 @@ async def test_config_flow_uses_bootstrap_user(hass: HomeAssistant) -> None:
user = await hass.auth.async_create_system_user(
"Supervisor", group_ids=[GROUP_ID_ADMIN]
)
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
hass.data[DATA_SUPERVISOR_USER] = user
with (
patch("homeassistant.components.hassio.async_setup", return_value=True),
+2 -2
View File
@@ -54,7 +54,6 @@ from homeassistant.components.hassio import (
hostname_from_addon_slug,
)
from homeassistant.components.hassio.const import (
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
DEFAULT_UPDATE_OPTIONS,
ENTRY_DATA_USER,
@@ -68,6 +67,7 @@ from homeassistant.components.homeassistant import (
DOMAIN as HOMEASSISTANT_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.components.http.const import DATA_SUPERVISOR_USER
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import HASSIO_USER_NAME
@@ -368,7 +368,7 @@ async def test_setup_api_push_api_data_default(
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=80, refresh_token=None)
)
hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
hassio_user = hass.data[DATA_SUPERVISOR_USER]
assert hassio_user.system_generated
assert len(hassio_user.groups) == 1
assert hassio_user.groups[0].id == GROUP_ID_ADMIN
+36 -17
View File
@@ -28,6 +28,7 @@ from homeassistant.components.http.auth import (
async_sign_path,
async_user_not_allowed_do_auth,
)
from homeassistant.components.http.const import DATA_SUPERVISOR_USER
from homeassistant.components.http.forwarded import async_setup_forwarded
from homeassistant.components.http.request_context import (
current_request,
@@ -766,7 +767,7 @@ async def test_unix_socket_auth_with_supervisor_user(
supervisor_user = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
await hass.auth.async_create_refresh_token(supervisor_user)
hass.data[DATA_SUPERVISOR_USER] = supervisor_user
await async_setup_auth(hass, app)
client = await aiohttp_client(app)
@@ -798,16 +799,17 @@ async def test_unix_socket_auth_without_supervisor_user(
assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_unix_socket_auth_caches_user_id(
async def test_unix_socket_auth_removed_user(
hass: HomeAssistant,
app: web.Application,
aiohttp_client: ClientSessionGenerator,
) -> None:
"""Test that Unix socket auth caches the Supervisor user ID."""
"""Test that Unix socket requests fail once the Supervisor user was removed."""
supervisor_user = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
await hass.auth.async_create_refresh_token(supervisor_user)
hass.data[DATA_SUPERVISOR_USER] = supervisor_user
await hass.auth.async_remove_user(supervisor_user)
await async_setup_auth(hass, app)
client = await aiohttp_client(app)
@@ -816,20 +818,37 @@ async def test_unix_socket_auth_caches_user_id(
"homeassistant.components.http.auth.is_supervisor_unix_socket_request",
return_value=True,
):
# First request triggers user lookup
req = await client.get("/")
assert req.status == HTTPStatus.OK
assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR
# Second request should use cached user ID
with (
patch(
"homeassistant.components.http.auth.is_supervisor_unix_socket_request",
return_value=True,
),
patch.object(
hass.auth, "async_get_users", wraps=hass.auth.async_get_users
) as mock_get_users,
async def test_unix_socket_auth_uses_provided_user(
hass: HomeAssistant,
app: web.Application,
aiohttp_client: ClientSessionGenerator,
) -> None:
"""Test that Unix socket auth uses the user provided by hassio, not a name match.
A stale duplicate system user named Supervisor can be left behind in the
auth store; requests must be authenticated as the user hassio actually uses.
"""
stale_user = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
supervisor_user = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
hass.data[DATA_SUPERVISOR_USER] = supervisor_user
await async_setup_auth(hass, app)
client = await aiohttp_client(app)
with patch(
"homeassistant.components.http.auth.is_supervisor_unix_socket_request",
return_value=True,
):
req = await client.get("/")
assert req.status == HTTPStatus.OK
mock_get_users.assert_not_called()
assert req.status == HTTPStatus.OK
data = await req.json()
assert data["user_id"] == supervisor_user.id
assert data["user_id"] != stale_user.id
+7 -3
View File
@@ -31,7 +31,11 @@ from homeassistant.components.http.config import (
async_get_and_load_store,
default_server_port,
)
from homeassistant.components.http.const import ENV_SETUP_PORT, ENV_SUPERVISOR
from homeassistant.components.http.const import (
DATA_SUPERVISOR_USER,
ENV_SETUP_PORT,
ENV_SUPERVISOR,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME, SERVER_PORT
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -958,7 +962,7 @@ async def test_unix_socket_started_with_supervisor(
tmp_path: Path,
) -> None:
"""Test unix socket is started when running under Supervisor."""
await hass.auth.async_create_system_user(
hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=["system-admin"]
)
socket_path = tmp_path / "core.sock"
@@ -1027,7 +1031,7 @@ async def test_supervisor_http_config_view(
tmp_path: Path,
) -> None:
"""Test the HTTP config view is registered and served over the socket only."""
await hass.auth.async_create_system_user(
hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=["system-admin"]
)
socket_path = tmp_path / "core.sock"
+3 -2
View File
@@ -7,6 +7,7 @@ from aiohttp import WSMsgType, web
import pytest
from homeassistant.auth.providers.homeassistant import HassAuthProvider
from homeassistant.components.http.const import DATA_SUPERVISOR_USER
from homeassistant.components.websocket_api import DOMAIN
from homeassistant.components.websocket_api.auth import (
TYPE_AUTH,
@@ -436,8 +437,8 @@ async def test_unix_socket_auth_bypass(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
"""Test that Unix socket connections skip websocket auth phase."""
# Create the Supervisor system user
await hass.auth.async_create_system_user(
# Create the Supervisor system user, as the hassio integration would
hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=["system-admin"]
)