From c3b1da57dedc590c3efec7ae554ff757f00ce87e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:01:31 -0700 Subject: [PATCH] feat(control): new installs let only administrators operate the panel An install that has made no choice should not hand every non-admin user the panel's relays, so the control mode for an entry that never set one is now administrators only. New entries store it, so a release from before this default reads the entry the same way after a downgrade. Entries created before this default allowed any user. Migration 7.4 writes that into each one whose stored mode is absent or unreadable, the way 7.2 did for control without a logged-in user, so upgrading takes no control away from anyone. A mode already chosen is kept, and an unrecognised value still reads as any user. The README's options table gives the new default. --- CHANGELOG.md | 1 + README.md | 12 +- custom_components/span_panel/config_flow.py | 10 +- .../span_panel/config_flow_options.py | 8 +- custom_components/span_panel/control_gate.py | 20 +++- custom_components/span_panel/migrations.py | 29 ++++- tests/test_control_gate.py | 10 +- tests/test_v2_config_flow.py | 107 +++++++++++++++++- 8 files changed, 166 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6dd8d5..826f1ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. updating, and diagnostics redact both values. - **The panel view draws only the breaker positions the panel has**, and shows a battery charge the panel has not reported as unknown rather than 0%. - **Setup stops with a repair issue when the panel reports a hardware version this release has not been validated with.** +- **New installs let only administrators operate the panel**, and an existing install keeps the Who may operate the panel setting it had. ### Removed diff --git a/README.md b/README.md index 447ecaca..3f43a274 100644 --- a/README.md +++ b/README.md @@ -657,12 +657,12 @@ Troubleshooting. Four options in **Settings → Devices & Services → Span Panel → Configure**. Each is enforced where commands are published rather than only at the entity, so a refusal holds however the command arrived. -| Option | Default | What it refuses | What it does not do | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| **Who may operate the panel** | Any Home Assistant user — Home Assistant's own policy, which grants every non-admin control of every entity | `Administrators only` refuses circuit switches, priority selects, the GFE override, EVSE limits and adopted controls from non-admin users. `Nobody` also stops those entities being created. | Neither touches sensors. Neither reaches an automation, which runs with no user at all — pair it with the next option. | -| **Allow control without a logged-in user** | Off for a new install. An install set up before this default changed keeps its setting: on, unless you had turned it off | While off, refuses every command with no user attached: all automations, and any script or integration an automation calls. A script a person runs keeps that person's user and is unaffected. Turn it on if your automations operate circuits. | It cannot tell a well-behaved automation from a runaway one — only that neither has a user. | -| **Control lock auto-relock** | Off, and no switch exists | A switch that, while armed, refuses every control command. Anyone can arm it; only a logged-in administrator can disarm it. `0` holds the disarm until someone re-arms; any other value re-arms after that many minutes. | It is not a password. It defends against misclicks and runaway automations, which is what a local control can actually do. | -| **Relay debounce** | 2 seconds — **the one default that is not what your panel did before this release**; set `0` to restore that | A second command to the same circuit's relay inside the window. Refused, never queued. | It is per circuit, so an automation cycling many circuits still gets through. | +| Option | Default | What it refuses | What it does not do | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| **Who may operate the panel** | Administrators only for a new install. An install set up before this default changed keeps its setting: any Home Assistant user, which is Home Assistant's own policy, unless you had chosen another | `Administrators only` refuses circuit switches, priority selects, the GFE override, EVSE limits and adopted controls from non-admin users. `Nobody` also stops those entities being created. | Neither touches sensors. Neither reaches an automation, which runs with no user at all — pair it with the next option. | +| **Allow control without a logged-in user** | Off for a new install. An install set up before this default changed keeps its setting: on, unless you had turned it off | While off, refuses every command with no user attached: all automations, and any script or integration an automation calls. A script a person runs keeps that person's user and is unaffected. Turn it on if your automations operate circuits. | It cannot tell a well-behaved automation from a runaway one — only that neither has a user. | +| **Control lock auto-relock** | Off, and no switch exists | A switch that, while armed, refuses every control command. Anyone can arm it; only a logged-in administrator can disarm it. `0` holds the disarm until someone re-arms; any other value re-arms after that many minutes. | It is not a password. It defends against misclicks and runaway automations, which is what a local control can actually do. | +| **Relay debounce** | 2 seconds — **the one default that is not what your panel did before this release**; set `0` to restore that | A second command to the same circuit's relay inside the window. Refused, never queued. | It is per circuit, so an automation cycling many circuits still gets through. | **What none of this defends against.** These options constrain callers arriving through Home Assistant, and nothing else. Anything already holding the broker credential — a second Home Assistant instance, a script you wrote, a malicious custom integration reading it out of this process's memory — publishes to the diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 82f28659..2ec114c1 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -89,10 +89,11 @@ USE_DEVICE_PREFIX, EntityNamingPattern, ) -from .control_gate import DEFAULT_ALLOW_CONTEXTLESS_CONTROL +from .control_gate import DEFAULT_ALLOW_CONTEXTLESS_CONTROL, DEFAULT_CONTROL_MODE from .hardware_guard import registers_by_passphrase_only from .options import ( ALLOW_CONTEXTLESS_CONTROL, + CONTROL_MODE, ENERGY_DISPLAY_PRECISION, POWER_DISPLAY_PRECISION, ) @@ -178,7 +179,7 @@ class SpanPanelConfigFlow(config_entries.ConfigFlow): """Handle a config flow for Span Panel.""" VERSION = 7 - MINOR_VERSION = 3 + MINOR_VERSION = 4 domain = DOMAIN def is_matching(self, other_flow: SpanPanelConfigFlow) -> bool: @@ -1365,10 +1366,11 @@ def create_new_entry( POWER_DISPLAY_PRECISION: self.power_display_precision, ENERGY_DISPLAY_PRECISION: self.energy_display_precision, ENABLE_ENERGY_DIP_COMPENSATION: self._enable_dip_compensation, - # Stored rather than left to the default, so a release from + # Both stored rather than left to the default, so a release from # before that default existed reads this entry the same way - # instead of treating the absent key as on. + # instead of treating the absent key as its own, looser default. ALLOW_CONTEXTLESS_CONTROL: DEFAULT_ALLOW_CONTEXTLESS_CONTROL, + CONTROL_MODE: DEFAULT_CONTROL_MODE.value, }, ) diff --git a/custom_components/span_panel/config_flow_options.py b/custom_components/span_panel/config_flow_options.py index 55dc84fb..112960c3 100644 --- a/custom_components/span_panel/config_flow_options.py +++ b/custom_components/span_panel/config_flow_options.py @@ -26,6 +26,7 @@ ) from .control_gate import ( DEFAULT_ALLOW_CONTEXTLESS_CONTROL, + DEFAULT_CONTROL_MODE, DEFAULT_RELAY_DEBOUNCE_SECONDS, ControlMode, ) @@ -103,9 +104,10 @@ def get_general_options_defaults( # Every default below reproduces what an entry already does. A silent # tightening on upgrade would break a household's automations with an # error the user cannot diagnose from the entity. Contextless control - # can default to off only because migration 7.2 stored the old `True` - # in every entry that predates it. - CONTROL_MODE: config_entry.options.get(CONTROL_MODE, ControlMode.ALL_USERS.value), + # can default to off, and the mode to administrators, only because + # migrations 7.2 and 7.4 stored the old values in every entry that + # predates them. + CONTROL_MODE: config_entry.options.get(CONTROL_MODE, DEFAULT_CONTROL_MODE.value), ALLOW_CONTEXTLESS_CONTROL: config_entry.options.get( ALLOW_CONTEXTLESS_CONTROL, DEFAULT_ALLOW_CONTEXTLESS_CONTROL ), diff --git a/custom_components/span_panel/control_gate.py b/custom_components/span_panel/control_gate.py index cc1a6d97..30b0aed7 100644 --- a/custom_components/span_panel/control_gate.py +++ b/custom_components/span_panel/control_gate.py @@ -88,6 +88,16 @@ class ControlMode(StrEnum): see the platforms.""" +DEFAULT_CONTROL_MODE = ControlMode.ADMIN_ONLY +"""Who may operate the panel, for an entry that never said. + +Administrators, because an install that has made no choice should not hand +every non-admin user the panel's relays. Entries created before this default +existed allowed any user; migration 7.4 writes that into each one's options, so +this constant only ever decides for an entry created since. +""" + + @dataclass(frozen=True, slots=True) class ControlCaller: """Who issued the command currently being published, if anyone. @@ -143,9 +153,9 @@ class ControlPolicy: Every default reproduces the behaviour an existing entry already has. A silent tightening on upgrade that breaks a household's automations is a worse outcome than the status quo, and the user cannot diagnose it from the - entity's error. `allow_contextless` departs from what an older entry did, and - can only because migration 7.2 stored that older behaviour in every such - entry before this reads it. + entity's error. `mode` and `allow_contextless` depart from what an older + entry did, and can only because migrations 7.4 and 7.2 stored that older + behaviour in every such entry before this reads it. """ mode: ControlMode @@ -166,7 +176,7 @@ def default(cls) -> ControlPolicy: four constants repeated at each reader. """ return cls( - mode=ControlMode.ALL_USERS, + mode=DEFAULT_CONTROL_MODE, allow_contextless=DEFAULT_ALLOW_CONTEXTLESS_CONTROL, lock_timeout_minutes=None, relay_debounce_seconds=DEFAULT_RELAY_DEBOUNCE_SECONDS, @@ -177,7 +187,7 @@ def from_options(cls, options: Mapping[str, object]) -> ControlPolicy: """Resolve the policy from one entry's options.""" raw_mode = options.get(CONTROL_MODE) try: - mode = ControlMode(raw_mode) if isinstance(raw_mode, str) else ControlMode.ALL_USERS + mode = ControlMode(raw_mode) if isinstance(raw_mode, str) else DEFAULT_CONTROL_MODE except ValueError: _LOGGER.warning("Unknown control_mode %r; falling back to all_users", raw_mode) mode = ControlMode.ALL_USERS diff --git a/custom_components/span_panel/migrations.py b/custom_components/span_panel/migrations.py index fbf02a10..2c2aa3d5 100644 --- a/custom_components/span_panel/migrations.py +++ b/custom_components/span_panel/migrations.py @@ -10,7 +10,8 @@ from homeassistant.helpers import entity_registry as er from .const import CONF_API_VERSION, CONF_HOP_PASSPHRASE, PANEL_CA_PENDING -from .options import ALLOW_CONTEXTLESS_CONTROL +from .control_gate import ControlMode +from .options import ALLOW_CONTEXTLESS_CONTROL, CONTROL_MODE if TYPE_CHECKING: from .runtime import SpanPanelConfigEntry @@ -19,17 +20,19 @@ # Must match the storage version produced by the latest supported entry format. CURRENT_CONFIG_VERSION = 7 -CURRENT_CONFIG_MINOR_VERSION = 3 +CURRENT_CONFIG_MINOR_VERSION = 4 _UNMAPPED_TAB_SENSORS_OPTION = "enable_unmapped_circuit_sensors" """The retired option's key, spelled out because nothing else reads it any more.""" +_CONTROL_MODE_VALUES = frozenset(mode.value for mode in ControlMode) + async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfigEntry) -> bool: """Migrate config entry through successive versions. Supports upgrades from v1.3.1+ (config version 2) through to the - current version 7.3. Each step mutates only the fields relevant to + current version 7.4. Each step mutates only the fields relevant to that version boundary. Core also calls this for an entry a newer release has already moved past @@ -203,6 +206,26 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfig ) _LOGGER.debug("Migrated config entry %s to version 7.3", config_entry.entry_id) + # --- v7.3 → v7.4: pin who may operate the panel to what it was --- + if config_entry.version == 7 and config_entry.minor_version < 4: + updated_options = dict(config_entry.options) + # The default for an entry that never stored this became administrators + # only. Every entry reaching this step was created before that, when + # anything but a known mode resolved to any user, so that is written + # down here rather than letting an upgrade take the panel's controls + # away from a household's non-admin users. A known mode is the user's + # own choice and is kept. + stored_mode = updated_options.get(CONTROL_MODE) + if not (isinstance(stored_mode, str) and stored_mode in _CONTROL_MODE_VALUES): + updated_options[CONTROL_MODE] = ControlMode.ALL_USERS.value + + hass.config_entries.async_update_entry( + config_entry, + options=updated_options, + minor_version=4, + ) + _LOGGER.debug("Migrated config entry %s to version 7.4", config_entry.entry_id) + return True diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py index e6b68b8e..2aae1c7c 100644 --- a/tests/test_control_gate.py +++ b/tests/test_control_gate.py @@ -668,16 +668,16 @@ def test_only_failed_means_it_will_never_be_delivered( def test_an_entry_with_no_control_options_gets_the_defaults() -> None: - """Permissive except for contextless control, which an older entry has stored. + """Administrators only, with no contextless control, both of which an older entry has stored. - A silent tightening on upgrade is worse than the status quo, so the one - default that tightens is safe only because migration 7.2 wrote the old - behaviour into every entry from before it; see the two tests above. + A silent tightening on upgrade is worse than the status quo, so the two + defaults that tighten are safe only because migrations 7.2 and 7.4 wrote + the old behaviour into every entry from before them. """ policy = ControlPolicy.from_options({}) assert policy == ControlPolicy.default() - assert policy.mode is ControlMode.ALL_USERS + assert policy.mode is ControlMode.ADMIN_ONLY assert policy.allow_contextless is False assert policy.lock_enabled is False assert policy.relay_debounce_seconds == 2.0 diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index fd5b1252..1ab3c6a9 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -52,14 +52,14 @@ DOMAIN, PANEL_CA_PENDING, ) -from custom_components.span_panel.control_gate import ControlPolicy +from custom_components.span_panel.control_gate import ControlMode, ControlPolicy from custom_components.span_panel.id_builder import ( build_circuit_unique_id, build_panel_unique_id, build_switch_unique_id, ) from custom_components.span_panel.migrations import CURRENT_CONFIG_MINOR_VERSION -from custom_components.span_panel.options import ALLOW_CONTEXTLESS_CONTROL +from custom_components.span_panel.options import ALLOW_CONTEXTLESS_CONTROL, CONTROL_MODE # Shared mock detection for a different panel (used in reconfigure/duplicate tests) MOCK_V2_DETECTION_OTHER = DetectionResult( @@ -486,6 +486,24 @@ async def test_a_new_entry_refuses_control_without_a_logged_in_user( assert ControlPolicy.from_options(entry.options).allow_contextless is False +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_a_new_entry_lets_only_administrators_operate_the_panel( + hass: HomeAssistant, +) -> None: + """A new install starts with control restricted to administrators, and says so in its options. + + Stored rather than left absent, so an older release reading the entry after + a downgrade does not take the missing key for its own default of any user. + """ + result = await _create_v2_entry(hass) + + assert result["type"] == FlowResultType.CREATE_ENTRY + entry = result["result"] + assert entry.options[CONTROL_MODE] == ControlMode.ADMIN_ONLY.value + assert ControlPolicy.from_options(entry.options).mode is ControlMode.ADMIN_ONLY + + async def _reach_the_ca_step(hass: HomeAssistant): """Drive a user flow as far as the CA step and return that result. @@ -945,7 +963,7 @@ async def test_v7_3_migration_removes_the_unmapped_tab_sensors_and_nothing_else( ): assert await hass.config_entries.async_setup(entry.entry_id) is True - assert (entry.version, entry.minor_version) == (7, 3) + assert (entry.version, entry.minor_version) == (CURRENT_CONFIG_VERSION, CURRENT_CONFIG_MINOR_VERSION) assert _UNMAPPED_TAB_OPTION not in entry.options assert entry.options[ALLOW_CONTEXTLESS_CONTROL] is False @@ -975,8 +993,87 @@ async def test_v7_3_migration_on_an_entry_that_never_set_the_option( assert await async_migrate_entry(hass, entry) is True - assert entry.minor_version == 3 - assert dict(entry.options) == {ALLOW_CONTEXTLESS_CONTROL: True} + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION + # Only the 7.4 step's pin is added; the 7.3 step itself had nothing to take. + assert dict(entry.options) == { + ALLOW_CONTEXTLESS_CONTROL: True, + CONTROL_MODE: ControlMode.ALL_USERS.value, + } + + +def _v7_3_entry(options: dict[str, object]) -> MockConfigEntry: + """Build a v2 entry as the release before control defaulted to administrators left it.""" + return MockConfigEntry( + version=7, + minor_version=3, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "v2-token-abc", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options=options, + unique_id="SPAN-V2-001", + ) + + +@pytest.mark.asyncio +async def test_v7_4_migration_keeps_control_open_to_every_user( + hass: HomeAssistant, +) -> None: + """An entry that never chose who may operate the panel read it as any user, and still does.""" + entry = _v7_3_entry({}) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION + assert entry.options[CONTROL_MODE] == ControlMode.ALL_USERS.value + assert ControlPolicy.from_options(entry.options).mode is ControlMode.ALL_USERS + + +@pytest.mark.parametrize("mode", list(ControlMode)) +@pytest.mark.asyncio +async def test_v7_4_migration_keeps_a_chosen_mode(hass: HomeAssistant, mode: ControlMode) -> None: + """A user who already chose made a choice the migration must not undo.""" + entry = _v7_3_entry({CONTROL_MODE: mode.value}) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.options[CONTROL_MODE] == mode.value + + +@pytest.mark.parametrize("stored", ["nonsense", None, 1, ["admin_only"]]) +@pytest.mark.asyncio +async def test_v7_4_migration_reads_an_unreadable_mode_as_it_was_read( + hass: HomeAssistant, stored: object +) -> None: + """Anything but a known mode resolved to any user before, so the migration writes that.""" + entry = _v7_3_entry({CONTROL_MODE: stored}) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.options[CONTROL_MODE] == ControlMode.ALL_USERS.value + + +@pytest.mark.asyncio +async def test_home_assistant_actually_runs_the_v7_4_migration(hass: HomeAssistant) -> None: + """Core must decide to migrate a v7.3 entry, which it does only if the flow's minor version moved too.""" + entry = _v7_3_entry({}) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.async_setup_entry", + AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) is True + + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION + assert entry.options[CONTROL_MODE] == ControlMode.ALL_USERS.value # ---------- zeroconf v2 discovery ----------