Skip to content

Add UniFi Access local-API client#5

Merged
RyanMorash merged 25 commits into
mainfrom
feature/unifi-client
May 23, 2026
Merged

Add UniFi Access local-API client#5
RyanMorash merged 25 commits into
mainfrom
feature/unifi-client

Conversation

@RyanMorash

Copy link
Copy Markdown
Member

Summary

  • Implements UnifiClient.fetch_users() and UnifiClient.apply(diff) for read/write reconciliation against the UniFi Access local API on port 12445 (self-signed cert pinned via SHA-256 fingerprint). Sync httpx, mirrors the CivicrmClient shape — same retry helper (3 attempts, exponential backoff with jitter, Retry-After honored), context-manager protocol, single public exception (UnifiClientError).
  • Bridges CiviCRM card_id (decimal HID Prox 26-bit) and UniFi NFC tokens via the documented 2-column CSV import endpoint. The encoding nfc_id = (facility_code << 16) | card_id (uppercase hex) was verified end-to-end during design against two real cards, including a door unlock after API-imported binding.
  • Adds facility_code to UnifiConfig. Honors dry_run (zero HTTP writes; redacted log lines with last-4 only). 75ms inter-call delay between writes per architecture §5. Apply order: pre-import → deactivate → update_credential → update_policy → add (create + reactivate paths).

Test Plan

  • uv run pytest — 201/201 passing (50 new UniFi-client tests + 2 new config tests, plus 149 pre-existing)
  • uv run mypy --strict src tests — clean
  • uv run ruff check . — clean
  • Verify config.example.toml parses cleanly with a minimal env file (covered by test_unifi_client_constructs_from_loaded_config and the existing example-config drift test)
  • When wiring this into the orchestrator (separate slice), confirm against the real controller: TLS fingerprint match, fetch_users round-trip, dry-run report against production data

🤖 Generated with Claude Code

RyanMorash and others added 21 commits May 22, 2026 22:21
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Wiegand-26 facility code (0-255) as a required integer field in
UnifiConfig so the UniFi client can encode card_ids into nfc_id format.
Validation enforces type and range; config.example.toml and all tests updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces UnifiClient and UnifiClientError. The constructor performs a
raw SSL handshake before building the httpx.Client, SHA-256-hashes the
peer certificate in DER form, and rejects mismatches (including colon-
separated fingerprints). Implements context-manager protocol via __enter__
/ __exit__ -> close(). 18 tests pass; mypy --strict and ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements _unwrap, _with_retries, _backoff_seconds, _parse_retry_after,
and _request on UnifiClient, mirroring the CiviCRM client's retry semantics.
Adds pytest-httpx-based envelope and retry tests (red: fetch_users pending Task 6).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds fetch_users() with paginated GET /api/v1/developer/users, _row_to_unifi_user
helper, instance caches (_unifi_user_id_by_contact, _nfc_cards_by_contact,
_nfc_token_map, _fetched_users_done), logging with card-ID redaction, and 6 new
tests. Also removes now-stale type: ignore[attr-defined] comments from Task 5 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds _apply_update_credential to UnifiClient: pre-imports new cards,
PUTs name changes, DELETEs old NFC cards, and binds the new card token.
Wires it into apply() and covers it with two new tests (card-swap and
name-only update paths). ResolvedMember import dropped as unused (mypy
infers the type from Diff's annotation; ruff F401 confirmed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds _preimport_unknown_cards (called from apply() before all buckets),
_apply_add, and helpers _reactivate_existing, _create_user,
_bind_card_if_set, _assign_policy_if_set. Removes the redundant per-bucket
pre-import that was inside _apply_update_credential.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- apply() in dry-run now calls _populate_token_map_for_dry_run() so the
  read-side token-map fetch runs when the diff has card-bearing entries,
  satisfying spec §8 ("reads execute normally in dry-run").
- Added test_fetch_users_warning_does_not_leak_card_id to assert the
  multi-card warning never contains raw card numbers (architecture §11).
- Added test_apply_dry_run_still_fetches_token_map_when_diff_has_cards
  and test_apply_dry_run_no_token_map_when_diff_has_no_cards to cover
  the new dry-run token-map behaviour (spec §13 #11).
- Updated test_apply_dry_run_makes_no_writes to register the token-map
  response and assert on write-method absence rather than request count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d would-import dry-run log

- Fix 1 (CRITICAL): treat config.host as a full URL (matching CivicrmClient
  pattern); use urlsplit to extract hostname+port for the TLS socket call so
  a validated https://host:port value no longer produces https://https://...
- Update _unifi_config() in tests to use host="https://192.0.2.1:12445" and
  add test_unifi_client_constructs_from_loaded_config to catch this regression.
- Fix 2 (IMPORTANT): remove nfc_id from the debug log in _ensure_nfc_token_map
  to prevent card CN leakage (architecture §11).
- Fix 3 (MINOR): emit would-import log lines in _log_dry_run_actions for any
  card_id in to_add/to_update_credential not already in the token map; add
  test_apply_dry_run_logs_would_import_for_unknown_cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§9 is correct (verified end-to-end against the real controller during design); the §8 step 2 wording was a stale draft. Implementation already follows §9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 23, 2026 03:36
Comment thread src/door_sync/unifi/client.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a synchronous UniFi Access local-API client to support read/write reconciliation (fetch users and apply diffs), including credential import/binding and policy updates, plus the required config and documentation updates.

Changes:

  • Introduces UnifiClient with TLS fingerprint pinning, retries/envelope unwrapping, fetch_users(), and apply(diff) write orchestration.
  • Extends UniFi configuration with facility_code (validated 0–255) and updates example config + config tests.
  • Adds a comprehensive UniFi client test suite (TLS pinning, retries, pagination, dry-run, import/bind/update/deactivate flows) and supporting docs/spec/plan.

Reviewed changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/door_sync/unifi/client.py New UniFi Access local-API client implementation (TLS pinning, retries, fetch/apply, card import/bind).
src/door_sync/config.py Adds facility_code to UnifiConfig and validates it during config load.
config.example.toml Documents and provides an example facility_code value for UniFi.
tests/test_unifi_client.py New end-to-end unit tests for UniFi client behaviors using pytest-httpx.
tests/test_config.py Updates existing config tests and adds facility-code validation tests.
docs/superpowers/specs/2026-05-22-unifi-client-design.md Design spec describing the UniFi client API, flows, and constraints.
docs/superpowers/plans/2026-05-22-unifi-client.md Implementation plan/checklist for the UniFi client slice.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/door_sync/unifi/client.py
Comment thread src/door_sync/unifi/client.py
Comment thread src/door_sync/unifi/client.py Outdated
RyanMorash and others added 4 commits May 22, 2026 23:43
If config.host omits a port (e.g. https://controller.example.org),
_verify_tls_fingerprint defaulted to 12445 while httpx.Client defaulted
to 443 — TLS pinned on one endpoint, API calls hit another. Resolve
hostname+port once in __init__, store on the instance, and use them for
both. Adds two regression tests covering no-port and custom-port hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CiviCRM contact_ids are positive integers (auto-increment from 1), so a
UniFi user with employee_number "0" or negative was not provisioned by
this reconciler. Without this guard, such a user would land in
to_deactivate on every cycle since the reconciler's diff finds no
matching ResolvedMember and treats them as a stale sync-managed user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous error message included the raw nfc_id from a malformed
import response, which encodes both facility code and card number — a
violation of architecture §11 "never log full card IDs". The replacement
surfaces only the FC byte (operational, not credential) when the value
parses as hex, and falls back to a structural "not valid hex" message
otherwise. Two regression tests assert neither the raw string nor the
card-number portion ever appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL flagged ssl.SSLContext(PROTOCOL_TLS_CLIENT) for permitting TLS
1.0/1.1. Modern Python+OpenSSL default to TLS 1.2+ already, but setting
minimum_version explicitly is defense in depth (and silences the
finding) — even with fingerprint pinning, refusing to negotiate older
protocol versions removes a downgrade attack vector.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@RyanMorash RyanMorash merged commit da31a7b into main May 23, 2026
5 checks passed
@RyanMorash RyanMorash deleted the feature/unifi-client branch May 23, 2026 03:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants