ADR-0001: ThingHub Visibility Is Repository-Only¶
Status: Accepted
Date: 2026-05-07
Deciders: Chao Jiang
Related: thing-access-model, ADR-0002
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
ThingAccessrow) 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
ThingAccessrow 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/paginatedandGET /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¶
ThingAccessRepository.hasValidAccessTelemetryQueryController.verifyDeviceAccessConnectivityService.verifyDeviceAccess