126 lines
5.0 KiB
Python
126 lines
5.0 KiB
Python
"""Business layer for the admin allowlist (Feature 03).
|
|||
|
|
|
||
|
|
Sits between the options flow / Feature 09 service gate and the typed
|
||
|
|
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
||
|
|
Assistant user IDs — never usernames or display names (feature
|
||
|
|
guardrail) — and an empty allowlist must fail closed: every function
|
||
|
|
here refuses to write an empty allowlist, so the owner can never be
|
||
|
|
accidentally locked out of admin operations by a bad save.
|
||
|
|
|
||
|
|
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
|
||
|
|
in one write are rejected up front — they would silently map to one
|
||
|
|
row and hide a caller bug. Writes replace the allowlist atomically:
|
||
|
|
the diff (additions and removals) is computed and applied inside one
|
||
|
|
transaction under the connection-scoped lock shared with the DAOs, so
|
||
|
|
a concurrent reader never observes a half-applied sync.
|
||
|
|
|
||
|
|
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
|
||
|
|
when it is EMPTY, so re-running setup never resurrects a deliberately
|
||
|
|
narrowed list. The setup user (the HA user who completed the config
|
||
|
|
flow, via the entry context) is added automatically so the owner is
|
||
|
|
never locked out on first setup; a stored entry-data copy of the list
|
||
|
|
re-seeds a lost database.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import datetime
|
||
|
|
import logging
|
||
|
|
|
||
|
|
from .dao_children import AdminUsersDao, _connection_lock
|
||
|
|
from .db import NestQuestDatabase
|
||
|
|
|
||
|
|
LOGGER = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
def _now_stamp() -> str:
|
||
|
|
"""UTC second-precision stamp, matching the registry's policy."""
|
||
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||
|
|
timespec="seconds"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_user_id(value: object) -> str:
|
||
|
|
"""Return ``value`` trimmed; raise unless a non-empty string."""
|
||
|
|
if not isinstance(value, str):
|
||
|
|
raise ValueError(
|
||
|
|
f"admin user id must be a string, got {value!r}"
|
||
|
|
)
|
||
|
|
trimmed = value.strip()
|
||
|
|
if not trimmed:
|
||
|
|
raise ValueError("admin user id must not be empty")
|
||
|
|
return trimmed
|
||
|
|
|
||
|
|
|
||
|
|
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
|
||
|
|
"""Return the allowlisted HA user IDs in storage order."""
|
||
|
|
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
|
||
|
|
|
||
|
|
|
||
|
|
async def set_admin_ids(
|
||
|
|
database: NestQuestDatabase, user_ids: list[str]
|
||
|
|
) -> None:
|
||
|
|
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
|
||
|
|
|
||
|
|
Refuses an empty list — an empty allowlist must fail closed, and
|
||
|
|
the only way to reach one through this API would be removing the
|
||
|
|
last admin, so that removal is refused here with a clear error.
|
||
|
|
Refuses non-string, empty, or duplicate ids up front. The diff
|
||
|
|
against the current allowlist (additions and removals) applies
|
||
|
|
inside one transaction under the connection lock.
|
||
|
|
"""
|
||
|
|
if not isinstance(user_ids, list):
|
||
|
|
raise ValueError("user_ids must be a list of HA user id strings")
|
||
|
|
normalized: list[str] = []
|
||
|
|
for value in user_ids:
|
||
|
|
normalized.append(_validate_user_id(value))
|
||
|
|
if len(set(normalized)) != len(normalized):
|
||
|
|
raise ValueError("user_ids must not contain duplicates")
|
||
|
|
if not normalized:
|
||
|
|
raise ValueError(
|
||
|
|
"refusing to empty the admin allowlist: at least one admin "
|
||
|
|
"must remain (an empty allowlist fails closed)"
|
||
|
|
)
|
||
|
|
dao = AdminUsersDao(database)
|
||
|
|
async with _connection_lock(database):
|
||
|
|
async with database.transaction():
|
||
|
|
current = {row.ha_user_id for row in await dao.list()}
|
||
|
|
target = set(normalized)
|
||
|
|
for user_id in sorted(current - target):
|
||
|
|
await dao.remove(user_id)
|
||
|
|
for user_id in normalized:
|
||
|
|
await dao.add(user_id, _now_stamp())
|
||
|
|
|
||
|
|
|
||
|
|
async def seed_setup_admin(
|
||
|
|
database: NestQuestDatabase,
|
||
|
|
*,
|
||
|
|
stored_admin_ids: list[str] | None = None,
|
||
|
|
context_user_id: str | None = None,
|
||
|
|
) -> list[str]:
|
||
|
|
"""Seed an empty allowlist so the owner is never locked out.
|
||
|
|
|
||
|
|
Only acts when the allowlist is EMPTY. Preference order: the
|
||
|
|
stored entry-data copy of the list (disaster recovery for a lost
|
||
|
|
database), then the HA user who completed the config flow
|
||
|
|
(``context_user_id``). Returns the allowlist after seeding.
|
||
|
|
Non-empty allowlists are returned untouched — re-running setup
|
||
|
|
must never resurrect a deliberately narrowed list.
|
||
|
|
"""
|
||
|
|
dao = AdminUsersDao(database)
|
||
|
|
async with _connection_lock(database):
|
||
|
|
current = [row.ha_user_id for row in await dao.list()]
|
||
|
|
if current:
|
||
|
|
return current
|
||
|
|
candidates: list[str] = []
|
||
|
|
if stored_admin_ids is not None:
|
||
|
|
if not isinstance(stored_admin_ids, list):
|
||
|
|
raise ValueError(
|
||
|
|
"stored_admin_ids must be a list of HA user id strings"
|
||
|
|
)
|
||
|
|
candidates = [_validate_user_id(value) for value in stored_admin_ids]
|
||
|
|
elif context_user_id is not None:
|
||
|
|
candidates = [_validate_user_id(context_user_id)]
|
||
|
|
for user_id in candidates:
|
||
|
|
await dao.add(user_id, _now_stamp())
|
||
|
|
return [row.ha_user_id for row in await dao.list()]
|