ADR-0001: ThingHub Visibility Is Repository-Only

Context

The things table carries a registrar_id column naming the registrar that registered the thing. The thing_accesses table is the M:N join between accounts and things — the repository in thing-access-model terms — and is the canonical record of which things are referenced into which account’s repository.

Two of TrustHub’s data-access endpoints (/api/v1/telemetry/devices/{id} and the new /api/v1/things/{id}/connectivity) historically performed a service-layer tenant check that compared thing.registrar_id against the caller’s accountId. This conflated two distinct concepts:

  • Registrar relation — who manages the device’s identity and lifecycle.

  • Repository relation — who is allowed to see and act on the device.

The conflation produces real failures the moment they diverge:

  • Devices with registrar_id = '_platform' (system-owned shared devices) match no tenant — registrar-equality always fails despite the devices being intentionally shared.

  • Devices registered by a manufacturing-line registrar (e.g. seed-reg-bob-chen) match no end-user account — registrar-equality fails for legitimate visibility paths granted via access codes or auto-grants.

  • Sessions that present an identity scope (X-Session-Identity-Scope) distinct from the JWT-issued realm cannot be modeled in registrar-equality terms at all.

The data-access endpoints listed above were the visible failure surface, but the principle applies to every ThingHub endpoint that needs a visibility check.

Decision

ThingHub treats the repository (thing_accesses) as the single source of truth for whether an account can see a thing. ThingHub code MUST NOT inspect thing.registrar_id for visibility, ownership, or scoping purposes. The check, expressed as JPQL on ThingAccessRepository.hasValidAccess, is:

SELECT COUNT(ta) > 0
FROM ThingAccess ta
JOIN ta.thing t
WHERE ta.accountId   = :accountId
  AND ta.thing.id    = :thingId
  AND ta.accessCode  = t.accessCode

A null access_code on the ThingAccess row counts as auto-granted (e.g. auto-granted access). Returns 404 (not 403) on a miss to prevent cross-tenant existence probing (T-09-05 / T-s2g-01).

The “inherent access” case — an account being the registrar’s owner — is not handled inline at the visibility check. Instead, the registrar/repository system is responsible for inserting a ThingAccess row at registration time, so that the registrar’s owning account shows up in the repository view as a normal entry. ThingHub does not know or care that the row originated from a registrar relationship.

Consequences

Positive

  • Single source of truth. Visibility lives in one table, queryable with one predicate.

  • Composes with auto-grant flows (access-code redemption, shared-device propagation) without special cases.

  • Survives the registrar-vs-repository split for _platform, manufacturing-registrar, and identity-scope sessions that registrar equality cannot express.

  • Revoking access (deleting a ThingAccess row) is the only action required to remove visibility — there is no shadow path through registrar equality that would still leak.

Negative / Trade-offs

  • The registrar/repository system MUST insert a ThingAccess row whenever a registrar’s owning account should retain access to a registered thing. A regression in that auto-insert silently locks the registrar’s owner out of telemetry. A schema-level test at the registrar/repository seam should assert this round-trip.

  • The listing endpoint /api/v1/things/list/paginated and GET /api/v1/things/{id} currently apply no repository filter — they return every thing. Until that gap is closed, the data-access endpoints will appear inconsistent with the listing endpoint: a user can browse a device row but see 404 on its detail tabs. The listing endpoint is the lagging surface; this ADR’s decision is the target shape for both.

Implementation Notes

In the trustmint codebase, the canonical implementation lives at TelemetryQueryController.verifyDeviceAccess and ConnectivityService.verifyDeviceAccess. New ThingHub endpoints that require per-device authorization SHOULD reuse the same shape: resolve the Thing by its business id, then call thingAccessRepository.hasValidAccess(accountId, thing.getId()).

Equivalent checks belong in any service that:

  • Reads device telemetry, twin state, history, or commands.

  • Mutates device state, tags, group membership, or access records.

  • Lists devices in scope (the listing endpoint is the lagging surface).

References

  • thing-access-model

  • ThingAccessRepository.hasValidAccess

  • TelemetryQueryController.verifyDeviceAccess

  • ConnectivityService.verifyDeviceAccess