ADR-0002: Group and Tag Membership Keys Off ThingAccess, Not Thing¶
Status: Accepted (target shape; migration pending)
Date: 2026-05-07
Deciders: Chao Jiang
Related: ADR-0001, thing-access-model
Context¶
Per ADR-0001, ThingHub treats the repository (thing_accesses) as the single source of truth for whether an account can see a thing. Two collateral entities — device groups and device tags — are scoped to an account’s view of its devices and need the same scoping rule:
A “group” is an account-private organizational bucket of devices the account can see.
A “tag” is an account-private label applied to a device the account can see.
The current schema attaches both to the thing directly:
device_group_memberships(group_id, thing_id)device_tags(thing_id, tag_key, tag_value)
This shape conflicts with ADR-0001’s repository-only model in two observable ways:
Cross-tenant leakage. Multiple accounts can independently add the same thing to their own groups. With
thing_idas the membership key,enrichToDtoon the device list joins the thing to every group row that mentions it, including groups owned by other tenants. The fix that filters group names bycurrent_tenantat the controller layer (commit 48c0c22) papers over the structural problem.Cleanup is loose. Revoking
ThingAccessfor an account does not cascade-clean that account’s group memberships or tags for the thing — those rows reference the thing PK, which still exists. The application has to remember to do the cleanup, and historically has not.
There is already a precedent in the codebase that gets the shape right: scope_things (the M:N for “scopes” — sub-account groupings) keys off thing_accesses.id, not things.id. From its docstring:
Scopes can only reference things that the account has access to (i.e., things with a ThingAccess record). This is enforced by the FK to thing_accesses with ON DELETE CASCADE — revoking access automatically removes the thing from all scopes.
Groups and tags should follow the same shape.
Decision¶
Group memberships and device tags MUST key off the repository entry (thing_accesses.id), not the thing PK. Concretely, the target schema is:
device_group_memberships(group_id, access_id)— FK tothing_accesses(id)ON DELETE CASCADE.device_tags(access_id, tag_key, tag_value)— FK tothing_accesses(id)ON DELETE CASCADE.
A “group” continues to be owned by an account (device_groups.tenant_id or, after this migration, an explicit account FK). The membership row references the account’s reference into the thing, not the thing itself.
Consequences¶
Positive¶
Cross-tenant chip leakage becomes structurally impossible — a group can only contain
thing_accessesrows owned by the same account.Revoking
ThingAccesscascades through memberships and tags automatically. No application-level cleanup logic required.The data model becomes uniform with
scope_things; one rule applies across all repository-scoped collections.The controller-layer “filter group chips by current tenant” workaround (commit 48c0c22) becomes unnecessary and can be removed when the migration lands.
Negative / Trade-offs¶
Schema migration touching two tables with existing rows. The migration must:
Add the new
access_idFK column.Backfill
access_idby joiningdevice_group_memberships.thing_id→thing_accesses(thing_id, account_id = device_groups.tenant_id). Rows where no matchingthing_accessesrow exists are orphans and must be deleted (they were never legitimately scoped).Drop the
thing_idcolumn and add the FK + cascade.Same migration shape for
device_tags.
Domain model changes.
DeviceGroupMembershipandDeviceTagentities currently exposethingId; consumers (controllers, enrichment) need to adoptThingAccessand follow the join to the thing where they need the underlying device.Frontend impact is minimal — the API surface continues to expose thing-shaped DTOs to the client. The change is internal to the service.
Implementation Notes¶
The migration is staged as a separate phase. Until it lands, the controller-layer filter introduced by commit 48c0c22 remains the live cross-tenant defense for group chip names. ADR-0001’s repository-only check does NOT depend on this migration — it is implemented at the service layer using thing_accesses directly.
References¶
ScopeThing(precedent — already keys offThingAccess)Commit
48c0c22— current tenant-scoping workaround at the enrichment layer (to be removed after this migration)