ADR-0002: Group and Tag Membership Keys Off ThingAccess, Not Thing

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_id as the membership key, enrichToDto on 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 by current_tenant at the controller layer (commit 48c0c22) papers over the structural problem.

  • Cleanup is loose. Revoking ThingAccess for 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 to thing_accesses(id) ON DELETE CASCADE.

  • device_tags(access_id, tag_key, tag_value) — FK to thing_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_accesses rows owned by the same account.

  • Revoking ThingAccess cascades 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:

    1. Add the new access_id FK column.

    2. Backfill access_id by joining device_group_memberships.thing_idthing_accesses(thing_id, account_id = device_groups.tenant_id). Rows where no matching thing_accesses row exists are orphans and must be deleted (they were never legitimately scoped).

    3. Drop the thing_id column and add the FK + cascade.

    4. Same migration shape for device_tags.

  • Domain model changes. DeviceGroupMembership and DeviceTag entities currently expose thingId; consumers (controllers, enrichment) need to adopt ThingAccess and 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

  • ADR-0001

  • thing-access-model

  • ScopeThing (precedent — already keys off ThingAccess)

  • Commit 48c0c22 — current tenant-scoping workaround at the enrichment layer (to be removed after this migration)