ADR 0034 Linked Cloud Accounts And Device Discovery - lukislp/mowdeck GitHub Wiki

ADR 0034: Linked cloud accounts and device discovery

Status

Accepted (2026-10-11). Amends ADR 0019 (secrets of the cloud adapter, one client per mower) and ADR 0013 (registrations carry no secrets; the gateway fetches them).

Context

Until now a cloud mower was registered with its own Mammotion credentials in connection_options (account, plus a password or a credential_ref), and the user had to type the exact device name. The owner's first tests with the real LUBA 2 AWD X showed the problems:

  • One Mammotion account usually holds several devices (mowers, an RTK base station, a pool cleaner); the user does not know the device name PyMammotion expects, and typing it is error prone.
  • Each cloud mower opened its own PyMammotion client and logged in separately. A second login with the same account supersedes the first one at the Mammotion cloud (PyMammotion's login_and_initiate_cloud says so), so two mowers of one account, or a re-read of the account while a mower is connected, could log each other out; every extra login also adds to the ban risk R1 of spike 0001.
  • Plain passwords lived unencrypted in Fleet's mowers.connection_options (ADR 0019 accepted that for Phase 2 with a TODO), and every registration (mower.registered, mower.updated, mower.removed, fleet.mowers.list) carried them: the EVENTS stream keeps those events for 30 days, and Telemetry, Notifications, the Scheduler, the Planner, Vision and Realtime read that stream.
  • Fleet is AGPL-3.0 and must not import the GPL-3.0 adapter mowdeck-control-mammotion (ADR 0011); only the Device-Gateway may talk to the vendor cloud.
  • The same physical mower can appear in two Mammotion accounts (Mammotion's device sharing) and therefore could be registered twice, in two MowDeck accounts, with two gateway sessions steering one machine.

Decision

  1. Linked accounts in Fleet. A new object linked_account (id, account_id, provider mammotion, email, password_encrypted, cached devices, last_synced_at, last_sync_error) belongs to a MowDeck account; an account may link several Mammotion accounts (one per e-mail, compared with lower()). The password is encrypted with AES-256-GCM under MOWDECK_CREDENTIAL_ENCRYPTION_KEY (the scheme of Identity's signing keys, ADR 0015: random nonce, the linked account id as associated data, a version byte and a key id byte; MOWDECK_CREDENTIAL_ENCRYPTION_KEY_PREVIOUS decrypts during a rotation and the next re-read re-encrypts with the current key). Without the key linked accounts answer 503; with environment=prod Fleet refuses the development key of .env.example. Managing linked accounts (and seeing e-mail and devices) needs can_manage_mowers on the account. API under /api/v1/fleet/linked-accounts: list, create ({email, password}), get, delete (409 linked-account-in-use while mowers use it, the mowers in errors), POST /{id}/sync (re-read, optionally with a new password), GET /{id}/devices, POST /{id}/mowers (bulk registration). last_sync_error holds canned messages only, never text from the bus; audit rows name the linked account id, not the e-mail.
  2. Discovery over NATS. Fleet never logs in itself: it sends CloudDiscoverRequest{provider, account_id, linked_account_id?, email, password} on the core NATS subject gateway.cloud.discover (queue group device-gateway, outside every stream) and gets CloudDiscoverReply with devices (DiscoveredDevice: device_name, iot_id, product_key, model, online, is_mower) or an error (login_failed becomes 422 linked-account-login-failed, unavailable and no answer 503). The gateway runs at most MOWDECK_CLOUD_DISCOVERY_CONCURRENCY (2) logins at once.
  3. Lockout protection. Every create and re-read is a vendor login. Fleet allows at most 5 failed logins per (MowDeck account, lower-case e-mail) within 15 minutes (then 429 with Retry-After, without asking the vendor), and refuses the last wrong password again at once for 15 minutes (it remembers an HMAC with a per-process key, never the password); a success clears both. The state lives in each Fleet replica's memory.
  4. No secrets in registrations; the gateway pulls them on demand. Registrations never carry password, noise_psk or the vendor account. When Fleet withholds any of them, or the mower uses a linked account, the options say credentials: "fleet" (and linked_account_id, and credentials_rev, a fingerprint of the stored ciphertext that changes with a new password, so the gateway reconnects). Each time the Device-Gateway opens such a mower's connection (first connect, every reconnect, so also after a dead login) it sends MowerCredentialsRequest{mower_id, account_id} on fleet.mowers.credentials (core NATS, queue fleet) and merges the MowerCredentialsReply.secret_options into the options of that connection only; nothing is kept on the session or logged. MowerRegistration.__repr__ masks secret keys as a second line of defence.
  5. One cloud session per account in the gateway. mowdeck-control-mammotion gets a CloudSessionPool: one logged-in MammotionClient per set of credentials (an HMAC with a random key of the process over the lower-case e-mail and the password; neither is kept in the key or logged), reference counted. Every cloud connection borrows it (ClientPort with an OpenedClient that releases instead of stopping), selects its own device handle and keeps its own state, refresh loop, map, camera and device information rounds; the last release stops the client after 120 s (so discovery followed by registering the listed mowers needs one login). A dead login (reauth_required) is replaced on the next acquire; when a login with a new password succeeds, the old password's unused session stops at once and a used one on its last release. The credential cache is one file per address in MOWDECK_CLOUD_SESSION_CACHE_DIR. The discovery uses the same pool: when a mower of the account is connected, the re-read answers from its session instead of logging in again. The gateway is the only service that imports the GPL package directly (the fake cloud module only when it is selected); Fleet depends only on the contracts.
  6. Mowers reference the linked account. POST /mowers for cloud always takes linked_account_id and device_name (the only discriminator; name defaults to the device name, model to the listed model); Fleet checks the device in the cached list (unknown 422, not a mower 422), stores device_name and iot_id as Fleet-managed options and the new columns mowers.linked_account_id (foreign key, RESTRICT) and mowers.iot_id. GET /connection-kinds marks cloud with linked_account: true and the managed options with managed: true. Simulated and BLE bridge registrations are unchanged (the BLE key is withheld from registrations like the password).
  7. One physical mower in one MowDeck account. The unique key is the vendor's stable device id, the IoT id, not the editable device name and not (linked account, device): a device shared between two Mammotion accounts is listed in both and is still one machine. mowers.iot_id has an installation-wide unique index; a registration of a taken IoT id answers 409 mower-already-registered without naming the other account (in the own account the detail names the existing mower). The device list marks such devices registered_elsewhere next to registered (this account). Removing the mower frees the IoT id.
  8. Existing cloud mowers are not migrated (owner's decision). Migration 0008 adds the schema only. Cloud mowers registered before keep connecting with their stored credentials (answered by fleet.mowers.credentials, never in events) until they are deleted; to use the device list they are deleted and added again from a linked account. A PATCH may not introduce a new plain password or change the IoT id (422), and cannot turn another kind into cloud.
  9. Fake cloud for development. MOWDECK_FAKE_MAMMOTION_CLOUD (default false, refused with environment=prod) swaps pool, factory and discovery for the fake cloud of the adapter package: accounts *@fake.mowdeck.test with the password fake-cloud-password, two mowers (online, offline) and an RTK base station, connections on FakeMammotionPort. The browser tests switch it on.

Alternatives considered:

  • Fleet talks to the Mammotion HTTP API itself (login and device list are plain HTTPS): rejected, it would put vendor code into the AGPL service and split the vendor knowledge across two services.
  • Credentials inside the registrations, as before: rejected, the events are persisted for 30 days and read by services that never need a password.
  • The gateway pulls credentials on demand (fleet.mowers.credentials): chosen. One extra request per connect; Fleet stays the only holder of the secrets and the gateway holds them only inside open connections.
  • Credentials only as credential_ref in the gateway's environment: kept for mowers that use it, but a household cannot add a Mammotion account through the web app that way.
  • A separate secret store service: still the long term option (ADR 0019); AES-GCM in Fleet's database with a key outside it is the same protection the Identity and Vision secrets have today.
  • Unique key (linked account, device name): rejected, Mammotion's sharing lists one machine under several accounts and device names can be renamed in the app.
  • Moving existing cloud mowers automatically: dropped at the owner's request; the few existing mowers are re-added.

Consequences

  • Adding a cloud mower is: link the account once (e-mail, password), pick the mowers from the list, name them and choose their sites; several at once.
  • Former exposure: until this change the plain passwords of cloud mowers (and the ESPHome keys of BLE bridges) were in every mower.* event and fleet.mowers.list reply, so they are still in the EVENTS stream of existing installations for up to 30 days and may appear in logs of services that dumped those payloads. Operators who registered real mowers before should change those Mammotion passwords (and ESPHome keys) after the upgrade, or purge the stream.
  • The mowers of one Mammotion account share one login per gateway instance. With several gateway shards (ADR 0013) mowers of one account can land on different instances, each with its own session, and a discovery may be answered by an instance without a session; both can supersede each other's login. Linked accounts are therefore unsupported with shard_count > 1; the gateway warns once when it gets such a mower.
  • The login throttle is per Fleet replica; several replicas multiply the limit by their number.
  • The device list is what PyMammotion registered at the session's login: a device added in the Mammotion app appears after the session is opened again (no mower of the account connected for two minutes, or a gateway restart). Unverified against the real cloud: the online flag right after login (PyMammotion's MowerDevice.online defaults to true until a status message arrives), the model name before the device information rounds, several mowers on one MammotionClient over MQTT at the same time, and a second login superseding the first one.
  • MOWDECK_CREDENTIAL_ENCRYPTION_KEY must be backed up: without it the stored passwords cannot be read and every linked account has to be re-read with its password. Helm's existingSecret needs the keys fleet-credential-encryption-key and fleet-credential-encryption-key-previous (both may be empty).
  • Migration numbering: 0008 follows 0007 (area names, merged first).
  • The Home Assistant integration does not register mowers; it only redacts the new iot_id and linked_account_id in its diagnostics.

Mirrored from docs/adr/0034-linked-cloud-accounts-and-device-discovery.md at commit 94c46ef36cabac4c3a0bacfc0cc0c0af4defdca3. Edit the file in the repository, not this wiki page.