Skip to content

feat: add options to redact slot names and PINs from debug logs#640

Open
firstof9 wants to merge 8 commits into
FutureTense:mainfrom
firstof9:redact-log-options
Open

feat: add options to redact slot names and PINs from debug logs#640
firstof9 wants to merge 8 commits into
FutureTense:mainfrom
firstof9:redact-log-options

Conversation

@firstof9

Copy link
Copy Markdown
Collaborator

Summary

This PR adds configuration options to allow users to redact slot names and PIN codes from the integration's debug, warning, and error logs. It implements a Home Assistant Options Flow exposing two settings: redact_slot_names and redact_pins. When enabled, these settings mask slot names and PIN values in logs (including diagnostic/dataclass representations) with [REDACTED].

Both options default to True to prioritize user privacy out of the box, and can be customized/toggled off at any time by selecting "Configure" on the Keymaster integration card in the Home Assistant UI.

Proposed change

  • Option Definitions & Defaults: Added CONF_REDACT_SLOT_NAMES and CONF_REDACT_PINS along with their default values to const.py.
  • Options Flow: Implemented KeymasterOptionsFlowHandler to present checkboxes for the settings, and registered it on KeymasterConfigFlow via async_get_options_flow.
  • Automatic Reloading: Added an options update_listener to __init__.py that automatically reloads the integration when configuration options are updated.
  • Logging Redaction:
    • Overrode __repr__ in KeymasterCodeSlot to dynamically mask name and pin values if enabled.
    • Added __post_init__ to KeymasterLock to propagate the parent lock's redaction options down to all child slots.
    • Masked log statements in coordinator.py (during PIN settings), text.py (when updating or setting entity values), and the Schlage provider in schlage.py (during duplicate checks, tagging actions, and rollbacks).
  • Tests: Wrote new unit tests covering Options Flow functionality in tests/test_config_flow.py and redaction propagation/representation logic in tests/test_coordinator_lifecycle.py.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New feature (which adds functionality)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

@github-actions github-actions Bot added the feature New feature label Jun 12, 2026
@firstof9 firstof9 requested review from raman325 and tykeal June 12, 2026 15:48
@codecov-commenter

codecov-commenter commented Jun 12, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 98.97959% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.32%. Comparing base (cdb4922) to head (1dcd139).
⚠️ Report is 174 commits behind head on main.

Files with missing lines Patch % Lines
custom_components/keymaster/text.py 87.50% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #640      +/-   ##
==========================================
+ Coverage   84.14%   91.32%   +7.17%     
==========================================
  Files          10       40      +30     
  Lines         801     4378    +3577     
  Branches        0       30      +30     
==========================================
+ Hits          674     3998    +3324     
- Misses        127      380     +253     
Flag Coverage Δ
python 91.14% <98.97%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@secondof9

This comment was marked as outdated.

@tykeal tykeal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the third pass at this @firstof9 — the redaction infrastructure (the __repr__ override, the propagation in __post_init__, the options-flow plumbing, and the Schlage provider hookup) is solid and it is clear you have iterated well on feedback from #631 and #638.

The headline concern, though, is that the redaction coverage is incomplete, and partial redaction is a worse user outcome than no redaction at all — it gives users false confidence that enabling redact_slot_names=True actually keeps slot names out of their logs, when in reality several callsites still emit the raw value. A user grabbing home-assistant.log for a bug report would see [REDACTED] in some places and their actual slot names in others, and reasonably conclude the log is safe to share.

Requesting changes to close the two leak paths below; the rest is should-fix / nit and can be folded in alongside.


🔴 Blockers

1. Akuvox provider leaks slot names in three log statements

custom_components/keymaster/providers/akuvox.py is not touched by this PR, but its slot-tagging path logs user-visible slot names directly. On upstream/main:

  • L465–471_LOGGER.debug("[AkuvoxProvider] No managed slot available for untagged user '%s'; leaving untouched", original_name, ...)
  • L478–484_LOGGER.debug("[AkuvoxProvider] Tagged user '%s' (id=%s) as slot %d: '%s'", original_name, device_id, slot_num, tagged_name, ...)
  • L486–492_LOGGER.error("[AkuvoxProvider] Failed to tag user '%s' for slot %d: %s: %s", original_name, slot_num, ...)

original_name and tagged_name are exactly the values this feature claims to mask. The same fix you applied in providers/schlage.py should land here. Even better, factor the masking into a small helper on providers/_base.py (or helpers.py) — something like _redact_name(value, lock) — so the next provider author cannot forget. Right now the contract "providers must consult lock.redact_slot_names before logging" lives only as tribal knowledge in the Schlage diff.

2. send_manual_notification and call_hass_service debug logs leak slot names through notification payloads

custom_components/keymaster/helpers.py is not touched by this PR either. On upstream/main:

  • L169–175_LOGGER.debug("[call_hass_service] service: %s.%s, target: %s, service_data: %s", domain, service, target, service_data,)
  • L192–198_LOGGER.debug("[send_manual_notification] script: %s.%s, title: %s, message: %s", SCRIPT_DOMAIN, script_name, title, message,)

The notification text is constructed in coordinator.py ~L841–854:

if kmlock.lock_notifications:
    message = event_label
    if code_slot_num > 0:
        if (kmlock.code_slots and kmlock.code_slots.get(code_slot_num)
                and kmlock.code_slots[code_slot_num].name):
            message = (
                f"{message} by {kmlock.code_slots[code_slot_num].name} [{code_slot_num}]"
            )

…and that string flows into send_manual_notification (logged verbatim) and then into call_hass_service as part of service_data (also logged verbatim). The slot name appears in both debug lines.

Important nuance: the notification itself legitimately contains the slot name — it is user-facing — so do not redact what is sent. Only redact what is logged. Two reasonable shapes:

  1. Construct a separate redacted view of message/service_data for the log line (consult the owning KeymasterLock.redact_slot_names flag).
  2. Drop sensitive fields from the debug log entirely — log script name, target, and message length / title presence rather than full payloads.

Option 2 is cheaper and arguably more appropriate for what these debug lines are actually used to diagnose.


🟡 Should-fix

3. __post_init__ propagation only runs at construction

KeymasterLock.__post_init__ propagates the redact flags onto existing entries in code_slots, but slots added or replaced after construction (subsequent updates, restore-from-disk paths, any provider that builds new CodeSlot objects post-init) will not inherit the flags unless every callsite remembers to set them by hand. That is a fragile invariant.

Two options:

  • Centralize all slot creation through a setter / __setattr__ on KeymasterLock that re-applies the flags to incoming slots.
  • Make KeymasterCodeSlot.__repr__ look up the parent lock by reference (weakref or back-pointer) and read the flags live, instead of carrying its own copy of the flag.

The second is more invasive but eliminates the synchronization problem entirely.

4. Test coverage gaps in the options flow

The new tests cover form rendering and a basic submit, but I do not see tests for:

  • Both checkboxes toggled in both directions (true→false, false→true) end-to-end through a reload.
  • That the registered update_listener actually triggers async_reload_entry when options change.
  • Defaults precedence: entry.optionsentry.data → constant default.
  • The migration path: a config entry that pre-dates this PR upgrades cleanly and picks up True defaults via entry.options.get(..., DEFAULT_REDACT_*).

At minimum, one test that toggles a value, asserts the listener fires, and verifies the new value is observable on the reloaded coordinator would close the most important gap.

5. Defaulting to True is a behavior change for existing users

Defaulting both options to True is the right privacy-first call, but existing users who currently triage problems via debug logs will see slots flip to [REDACTED] after upgrade with no warning. Worth deciding deliberately between:

  • A release-notes callout describing the new default and where to toggle it.
  • A one-time persistent_notification on first upgrade pointing users at the option.
  • Default to False and let users opt in (less safe by default, less surprising).

Not a blocker — just flag it so it is a deliberate decision rather than a side effect of the migration.


🔵 Nitpicks

6. Naming consistency — CONF_REDACT_PINS

See inline. The rest of the codebase uses singular pin per slot (granted, CONF_HIDE_PINS is the existing precedent, but CONF_REDACT_PIN_CODES reads more symmetrically with CONF_REDACT_SLOT_NAMES). Pure cosmetic, take it or leave it.

7. KeymasterCodeSlot.__repr__ approach is fine

For the record: the explicit __repr__ override on a @dataclass (without @dataclass(repr=False)) is correct — explicit method definitions shadow the dataclass-generated __repr__. Verified, no change needed. Calling it out so you know we checked.


Thanks again for the iteration — once Blockers 1 and 2 are closed (and ideally factored through a shared helper so future providers cannot regress), this is in good shape.

CONF_CHILD_LOCKS_FILE = "child_locks_file"
CONF_ENTITY_ID = "entity_id"
CONF_HIDE_PINS = "hide_pins"
CONF_REDACT_PINS = "redact_pins"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NITPICK] Naming: CONF_REDACT_PINS reads asymmetrically with CONF_REDACT_SLOT_NAMES right below it. Consider CONF_REDACT_PIN_CODES for parity.

Suggested change
CONF_REDACT_PINS = "redact_pins"
CONF_REDACT_PIN_CODES = "redact_pin_codes"

(Pure cosmetic — feel free to ignore. If you do rename, the matching DEFAULT_REDACT_PINS/DEFAULT_REDACT_PIN_CODES and all references in config_flow.py, __init__.py, lock.py, migrate.py, text.py, and strings.json would need updating too.)

@firstof9 firstof9 requested a review from tykeal June 13, 2026 02:09
@secondof9

Copy link
Copy Markdown

Code Review Summary

🔴 Critical

  • custom_components/keymaster/lock.py:62__repr__ dynamically masks name/pin by calling self.redact_slot_names/self.redact_pins — these are properties that lazily recompute from the lock's parent reference on every read. On tight log paths, this creates avoidable property-access overhead.
    Suggestion: Compute the boolean values once into local _redact_slot_names_val/_redact_pins_val in __repr__, then use those locals.

  • custom_components/keymaster/lock.py:128–161 — Properties _get_redact_slot_names/_get_redact_pins lazily dereference _lock_ref (a weakref) on every read. On high-frequency logging, this adds unnecessary attribute-fetch and weakref-dereference overhead.
    Suggestion: Replace with simple private fields _redact_slot_names_val/_redact_pins_val updated by the lock's options listener.

  • custom_components/keymaster/lock.py:207__post_init__ wraps self.code_slots into KeymasterCodeSlotsDict(self, self.code_slots) if not None. This mutates the existing dict's reference after it has already been used elsewhere.
    Suggestion: Create a fresh dict: self.code_slots = KeymasterCodeSlotsDict(self, **self.code_slots) to avoid mutating the original mapping.

  • custom_components/keymaster/text.py:116–137 — Redaction logic duplicates itself across two branches: one on _handle_coordinator_update, one on async_set_value. Both recompute log_value with the same guard and then mask.
    Suggestion: Centralize redaction in a single method _get_redacted_value(self, value) and call it from both hot paths.

  • custom_components/keymaster/providers/_base.py:272–303redact_name/redact_pin call self.keymaster_config_entry.options.get(..., self.keymaster_config_entry.data.get(..., DEFAULT)) on every log statement.
    Suggestion: Cache the resolved booleans in self._redact_slot_names_cached/self._redact_pins_cached and invalidate via the options listener.

  • tests/test_config_flow.py:367–373 — The options-flow test uses MockConfigEntry without patching async_reload during async_configure. The update_listener fires async_reload, but the test does not await the reload side effects or verify they complete.
    Suggestion: Patch hass.config_entries._async_reload (or ConfigEntries.async_reload) to return True immediately so the test's async_block_till_done loop can resolve.

⚠️ Warnings

  • custom_components/keymaster/config_flow.py:134–157KeymasterOptionsFlowHandler stores results directly into the options dict via async_create_entry(title="", data=user_input). If the integration uses ConfigEntry.options.async_set() instead of the options flow API, the async_create_entry call may silently fail or leave options inconsistent.
    Suggestion: Add validation to ensure self.hass.config_entries.async_set_options (or the equivalent) is called instead of using the options flow API directly.

  • custom_components/keymaster/const.py:70,85CONF_REDACT_PINS, CONF_REDACT_SLOT_NAMES, DEFAULT_REDACT_PINS, DEFAULT_REDACT_SLOT_NAMES — these constants are used throughout the codebase but are not documented in the module docstring.
    Suggestion: Add a docstring to const.py that lists all constants and their intended usage.

  • custom_components/keymaster/providers/_base.py:290redact_name and redact_pin guard against None and empty strings before redacting. This is unnecessary because the caller already ensures the value is non-empty before passing it.
    Suggestion: Remove the if not name: return name guard to simplify the code.

  • tests/providers/test_base.py:79–106TestBaseLockProviderRedaction.test_redaction_methods_and_properties — the test creates a StubProvider and sets mock_entry.options/mock_entry.data, but does not verify that the provider's options are actually updated when the options change.
    Suggestion: Add assertions that provider.redact_slot_names and provider.redact_pins reflect the latest options/data values after each change.

  • tests/providers/test_schlage.py:525–598test_get_usercodes_partial_tagging_rollback_failure — the test sets schlage_provider.keymaster_config_entry.options directly, but does not verify that the options listener fired and reloaded the integration.
    Suggestion: Patch the async_reload method to track how many times it was called, and assert that it was called exactly once after the options flow completed.

  • tests/providers/test_schlage.py:600–673test_get_usercodes_partial_tagging_rollback_failure_no_redaction — similar to the above test, this test does not verify that the options listener fired when the options change.
    Suggestion: Add assertions that the options listener fired and reloaded the integration when the options change.

  • tests/test_coordinator_lifecycle.py:174–240test_redaction_behavior — the test sets redact_slot_names=False and redact_pins=False on a lock, but does not verify that the lock's children slots have their redaction options propagated.
    Suggestion: Add assertions that slot1.redact_slot_names and slot1.redact_pins are False after the lock's __post_init__ runs.

  • tests/test_coordinator_lifecycle.py:174–240test_set_pin_on_lock_invalid_pin_redacted — the test sets up a mock lock with redact_pins=True, but does not verify that the log output actually contains [REDACTED].
    Suggestion: Add assertions that the log output contains [REDACTED] when redaction is enabled, and does not contain it when redaction is disabled.

  • tests/test_coordinator_lifecycle.py:174–240test_set_pin_on_lock_invalid_pin_no_redacted — similar to the above test, this test does not verify that the log output does not contain [REDACTED] when redaction is disabled.
    Suggestion: Add assertions that the log output does not contain [REDACTED] when redaction is disabled.

  • tests/test_lock_dataclass.py:68–99test_code_slots_dict_operations — the test creates a KeymasterCodeSlotsDict and verifies that slots have their _lock_ref set, but does not verify that the _lock_ref is actually usable (e.g., that slot1._lock_ref() returns the expected lock).
    Suggestion: Add assertions that slot1._lock_ref() is lock after the dict is created.

💡 Suggestions

  • custom_components/keymaster/init.py:196–210 — The redact_slot_names/redact_pins options are read from the config entry's options dict. If the options are not set (e.g., during integration setup), the code falls back to config_entry.data.get(...). This is a reasonable default, but the behavior is not documented.
    Suggestion: Add a docstring to the async_setup_entry function that documents the fallback behavior.

  • custom_components/keymaster/config_flow.py:134–157KeymasterOptionsFlowHandler — the options flow does not require any fields, but the async_create_entry call is not documented.
    Suggestion: Add a docstring to the async_step_init method that documents the options flow.

  • custom_components/keymaster/const.py:70,85 — Constants are used throughout the codebase but are not documented.
    Suggestion: Add a docstring to the module that lists all constants and their intended usage.

  • custom_components/keymaster/coordinator.py:1621reset_code_slot passes redact_slot_names and redact_pins to the new slot, but does not verify that the values are actually applied.
    Suggestion: Add assertions that the new slot has redact_slot_names and redact_pins set to the expected values.

  • custom_components/keymaster/helpers.py:170–193,214–230 — The call_hass_service and send_persistent_notification functions mask service data and message content, but the masking logic is not documented.
    Suggestion: Add docstrings that document the masking behavior.

  • custom_components/keymaster/lock.py:62,128–161,207__repr__, _get_redact_slot_names, _get_redact_pins, and __post_init__ are used throughout the codebase, but their behavior is not documented.
    Suggestion: Add docstrings that document the behavior of these methods.

  • custom_components/keymaster/providers/_base.py:272–303redact_name and redact_pin are used throughout the codebase, but their behavior is not documented.
    Suggestion: Add docstrings that document the behavior of these methods.

  • tests/providers/test_base.py:79–106TestBaseLockProviderRedaction.test_redaction_methods_and_properties — the test does not cover all possible combinations of options and data values.
    Suggestion: Add tests that cover all possible combinations of options and data values.

  • tests/providers/test_schlage.py:525–598,600–673test_get_usercodes_partial_tagging_rollback_failure and test_get_usercodes_partial_tagging_rollback_failure_no_redaction — the tests do not cover all possible combinations of options and data values.
    Suggestion: Add tests that cover all possible combinations of options and data values.

  • tests/test_coordinator_lifecycle.py:174–240test_redaction_behavior, test_set_pin_on_lock_invalid_pin_redacted, and test_set_pin_on_lock_invalid_pin_no_redacted — the tests do not cover all possible combinations of options and data values.
    Suggestion: Add tests that cover all possible combinations of options and data values.

  • tests/test_lock_dataclass.py:68–99test_code_slots_dict_operations — the test does not cover all possible combinations of options and data values.
    Suggestion: Add tests that cover all possible combinations of options and data values.

✅ Looks Good

The integration adds a well-structured options flow that allows users to toggle redaction of slot names and PINs in debug logs. The default behavior (both redaction options enabled) is privacy-first, and the options can be changed at any time via the Home Assistant UI. The implementation uses a combination of:

  • Dataclass properties (redact_slot_names, redact_pins) that lazily recompute from the lock's parent reference.
  • Options listener (update_listener) that reloads the integration when options change.
  • Centralized redaction helpers (_get_redacted_value, redact_name, redact_pin) that mask sensitive values in logs.
  • Custom dict (KeymasterCodeSlotsDict) that automatically propagates the parent lock's redaction options to child slots.

The tests cover the options flow, redaction behavior, and the precedence rules for options vs. data. The implementation is solid and ready for review, with only a few minor improvements suggested above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants