Gate every NestQuest service behind the admin permission check
permission_gate wraps all registered services at registration time: admin-only policy reads call.context.user_id against admin_allowlist is_admin (fail closed on no context); complete_quest stays open.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""Permission gate for NestQuest services (Feature 09).
|
||||
|
||||
A wrapper factory, applied to EVERY registered service at registration
|
||||
time (no handler is exempt), reads ``call.context.user_id``, consults
|
||||
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
||||
:func:`~.admin_allowlist.is_admin`, and raises
|
||||
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
||||
operation called by a non-admin — including a call with NO user
|
||||
context, which fails closed. ``complete_quest`` (policy ``open``)
|
||||
proceeds regardless of the allowlist.
|
||||
|
||||
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
||||
warning and every successful one logs a debug line, both carrying the
|
||||
service, the user id (or ``none``), and a UTC timestamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from .admin_allowlist import is_admin
|
||||
from .service_policy import ADMIN_ONLY
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
|
||||
def _context_user_id(call: Any) -> str | None:
|
||||
context = getattr(call, "context", None)
|
||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
return user_id
|
||||
return None
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def permission_gate(
|
||||
service: str,
|
||||
policy: str,
|
||||
database_of: Callable[[Any], Any],
|
||||
) -> Callable[[ServiceHandler], ServiceHandler]:
|
||||
"""Wrap ``handler`` with the Feature 09 permission check.
|
||||
|
||||
``database_of`` resolves the live database from the call-time
|
||||
runtime (the same ``find_runtime`` the handler uses), so the gate
|
||||
never opens its own connection. The verdict is fail-closed:
|
||||
absent allowlist row, unknown id, no user context — all not admin.
|
||||
"""
|
||||
|
||||
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
||||
if policy != ADMIN_ONLY:
|
||||
return handler
|
||||
|
||||
async def _gated(call: Any) -> None:
|
||||
user_id = _context_user_id(call)
|
||||
allowed = user_id is not None and await is_admin(
|
||||
database_of(call), user_id
|
||||
)
|
||||
if not allowed:
|
||||
LOGGER.warning(
|
||||
"NestQuest denied %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id if user_id is not None else "none",
|
||||
_stamp(),
|
||||
)
|
||||
raise Unauthorized(
|
||||
f"User '{user_id if user_id is not None else 'none'}' "
|
||||
f"is not allowed to call nestquest.{service}"
|
||||
)
|
||||
LOGGER.debug(
|
||||
"NestQuest allowed %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id,
|
||||
_stamp(),
|
||||
)
|
||||
await handler(call)
|
||||
|
||||
return _gated
|
||||
|
||||
return _wrap
|
||||
@@ -43,8 +43,10 @@ from .const import (
|
||||
)
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .materialize import regenerate_for_child
|
||||
from .permissions import permission_gate
|
||||
from .presence import PresenceSchedule
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
FindRuntime = Callable[[HomeAssistant], Any]
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
@@ -521,72 +523,78 @@ def async_register_services(
|
||||
find_runtime: FindRuntime,
|
||||
regenerate_handler: ServiceHandler,
|
||||
) -> None:
|
||||
"""Register every canonical service once at domain scope."""
|
||||
"""Register every canonical service once at domain scope.
|
||||
|
||||
Every service — regenerate included — passes through the Feature
|
||||
09 permission gate before its handler runs; the gate reads
|
||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
||||
registered exempt from the policy.
|
||||
"""
|
||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||
return
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REGENERATE, regenerate_handler
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
schema=SCHEMA_COMPLETE_QUEST,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
_uncomplete_quest(hass, find_runtime),
|
||||
schema=SCHEMA_UNCOMPLETE_QUEST,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
_create_quest_definition(hass, find_runtime),
|
||||
schema=SCHEMA_CREATE_QUEST_DEFINITION,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
_update_quest_definition(hass, find_runtime),
|
||||
schema=SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
_set_quest_definition_active(hass, find_runtime),
|
||||
schema=SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
_set_presence_pattern(hass, find_runtime),
|
||||
schema=SCHEMA_SET_PRESENCE_PATTERN,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
_create_presence_override(hass, find_runtime),
|
||||
schema=SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
_delete_presence_override(hass, find_runtime),
|
||||
schema=SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
_export_history_csv,
|
||||
schema=SCHEMA_EXPORT_HISTORY_CSV,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
_manage_child(hass, find_runtime),
|
||||
schema=SCHEMA_MANAGE_CHILD,
|
||||
)
|
||||
gate = permission_gate
|
||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
||||
(
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
SCHEMA_COMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
_uncomplete_quest(hass, find_runtime),
|
||||
SCHEMA_UNCOMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
_create_quest_definition(hass, find_runtime),
|
||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
_update_quest_definition(hass, find_runtime),
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
_set_quest_definition_active(hass, find_runtime),
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
_set_presence_pattern(hass, find_runtime),
|
||||
SCHEMA_SET_PRESENCE_PATTERN,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
_create_presence_override(hass, find_runtime),
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
_delete_presence_override(hass, find_runtime),
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
_export_history_csv,
|
||||
SCHEMA_EXPORT_HISTORY_CSV,
|
||||
),
|
||||
(
|
||||
SERVICE_MANAGE_CHILD,
|
||||
_manage_child(hass, find_runtime),
|
||||
SCHEMA_MANAGE_CHILD,
|
||||
),
|
||||
]
|
||||
|
||||
def _database_of(_call: Any) -> Any:
|
||||
return _require_runtime(hass, find_runtime).database
|
||||
|
||||
for service, handler, schema in specs:
|
||||
gated = gate(
|
||||
service, SERVICE_POLICY[service], _database_of
|
||||
)(handler)
|
||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
||||
|
||||
|
||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||
|
||||
Reference in New Issue
Block a user