Group Control Feature Design
1. Overview
This document describes the design for simultaneous control of multiple devices through a single MQTT publish from the phone app. The feature allows a user to define a control group (e.g., “first floor lights”) and send one command that all devices in that group receive and act on concurrently.
The design maps control groups onto the existing subgroup abstraction. No new data model is introduced. The primary changes are: a new MQTT topic namespace for group commands, a single additional statement in the device IoT policy, and a lightweight AWS IoT Thing attribute set on group join/leave.
Group control commands are addressed by device type rather than by device
name. A single broadcast carries an envelope keyed by device type
(e.g., esp.device.light); each receiving node applies the payload only to
its own devices whose type matches one of the top-level keys.
2. Background
2.1 Existing Group and Subgroup Model
A group (
pgrp) is the top-level container for devices in a single home/site. A device belongs to exactly one group.A subgroup is an optional organizational subdivision within a group. A device can belong to up to 3 subgroups simultaneously (stored as
subgrp1,subgrp2,subgrp3in thermng-group-node-assocDynamoDB table).Group IDs and subgroup IDs are randomly generated opaque strings.
2.2 Individual Device Control Topic
The phone app sends commands to a single device on the topic (referred to as the unicast topic throughout this document):
rainmaker/nodes/<nodeID>/user/params-<groupID>[-<sg1>-<sg2>-<sg3>]/params
The suffix params-<groupID>[-<subgroupIDs>] is called the shadow name.
Subgroup IDs in the shadow name are always sorted alphabetically and joined
with -.
Examples:
rainmaker/nodes/node-abc/user/params-grp1/params
rainmaker/nodes/node-abc/user/params-grp1-sgX/params
rainmaker/nodes/node-abc/user/params-grp1-sgX-sgY/params
The device subscribes to exactly one unicast topic, which encodes all of
its subgroup memberships simultaneously. Unicast payloads are device-name
keyed ({ "<deviceId>": { "<paramId>": <value> } }) — this remains
unchanged.
2.3 IAM-Level Access Control (assume_role)
When a user calls POST /assume_role, the Lambda generates a temporary IAM
session policy scoped to the groups the user has access to. For users with
full group access, the policy includes a unicast publish pattern covering
every device in the group:
# Unicast — full group access
arn:aws:iot:<region>:<account>:topic/rainmaker/nodes/*/user/params-<groupID>*/*
Group control topics use a separate path namespace under
nodes/groups/<gid>/... (terminating in a control segment) and require
their own IAM resource patterns. Subgroup-only access is expressed
exclusively against the group control namespace — see Section 3.2.
Because these resources are emitted per group, the policy grows with the caller’s group count and is bounded by STS’s 2048-character session-policy limit. Each full-access group costs 4 resources and each shared subgroup 2, which caps a user at roughly 4 groups per session. Adding a per-group resource pattern here therefore lowers that ceiling for every user — see group.md.
2.4 The getGroupInfo Mechanism
The cloud notifies a device of its current group/subgroup membership by
publishing to rainmaker/nodes/<nodeID>/from_cloud with the following
payload:
{
"event": ["getGroupInfo"],
"getGroupInfo": {
"pgrp": "<groupID>",
"subgrps": ["<sgID1>", "<sgID2>"]
}
}
subgrps is absent (or an empty array) when the device has no subgroup
memberships. pgrp is absent when the device does not belong to any group.
This notification is pushed by the cloud automatically whenever the device’s membership changes:
when a device is added to a group
when a device is added to or removed from a subgroup
The device can also request this information proactively at startup by
publishing {"event": ["getGroupInfo"]} on its to-cloud topic,
rainmaker/nodes/<nodeID>/to_cloud — the single inbound device→cloud event
channel (node_to_cloud_rule selects FROM 'rainmaker/nodes/+/to_cloud'). That
topic is keyed on the node id alone, so a device can ask without knowing its
group membership or its current shadow name. The cloud responds on from_cloud
with the current authoritative state. See
node_params_messaging.md for the full to-cloud event
set.
3. Design
3.1 Group Control MQTT Topic Namespace
Group control topics live under the path groups/<groupID>/... with a
terminating control segment, creating an unambiguous namespace separate
from params topics:
# Group-level broadcast (targets ALL devices in the group)
rainmaker/nodes/groups/<groupID>/control
# Subgroup-level command (targets all devices in one specific subgroup)
rainmaker/nodes/groups/<groupID>/subgroups/<sgID>/control
Topic shape rationale:
The leading
nodes/groups/<groupID>/segment scopes the entire namespace to one group, so a single IoT policy statement suffices for cross-group isolation (Section 3.3).The literal
controlsegment cleanly separates group control from any other group-scoped per-node traffic that might be added later (e.g., shadow topics, telemetry).The
subgroups/collection segment matches the REST convention used elsewhere (e.g.,GET /v1/groups/{groupId}/subgroups/{subGroupId}) and keeps the topic structure self-describing.A group control topic cannot collide with a unicast topic because real AWS IoT thing names cannot contain
/, sonodes/<nodeID>/...andnodes/groups/<groupID>/...are disjoint namespaces.
Subgroup topic semantics: the subgroup component is always a single subgroup ID. This is different from the unicast shadow name, which encodes all of a device’s subgroups simultaneously. One subgroup control topic exists per subgroup; a device in multiple subgroups subscribes to each one independently.
3.2 Phone App: Publishing a Group Command
The phone app publishes once to the appropriate group control topic. All devices subscribed to that topic receive the message simultaneously via AWS IoT Core’s fan-out.
Payload shape (device-type addressed):
{
"esp.device.light": {
"params": {
"esp.param.power": true,
"esp.param.brightness": 75
}
},
"esp.device.fan": {
"params": {
"esp.param.power": false
}
}
}
Top-level keys are device types. Each device’s value is an object that holds
nested sub-keys describing what to apply — currently only params is defined,
with room for additional sub-keys (e.g., cmd, meta) in future without
another topic rename. Each receiving node applies the payload only to its own
devices whose type matches a top-level key; other devices on the node are
ignored.
Examples:
# Send a command to all devices in the group (broadcast)
rainmaker/nodes/groups/grp1/control
# Send a command to all devices in subgroup sgX only
rainmaker/nodes/groups/grp1/subgroups/sgX/control
IAM session policy coverage — new patterns required.
The existing nodes/*/user/params-<groupID>*/* patterns in the assume-role
session policy cover only the unicast topic and do not match the new group
control namespace. New publish-scope patterns must be added:
# Full group access — both group-level and any subgroup
arn:aws:iot:<region>:<account>:topic/rainmaker/nodes/groups/<groupID>/control
arn:aws:iot:<region>:<account>:topic/rainmaker/nodes/groups/<groupID>/subgroups/*/control
# Subgroup-only access
arn:aws:iot:<region>:<account>:topic/rainmaker/nodes/groups/<groupID>/subgroups/<sgID>/control
The single-segment wildcard above is the IAM * wildcard used in IoT topic
ARNs — it matches any character sequence including /, which is why the
broadest “full group access” pattern can use one ARN per resource type. For
subgroup-only access, no wildcard is needed because the exact subgroup ID is
known.
A user with subgroup-only access does not get permission to publish on the group-level broadcast topic. This matches the existing semantics for the unicast topic patterns.
3.3 Device IoT Policy
The existing default device IoT policy is extended with one additional statement. The full modified policy is:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["iot:Connect"],
"Resource": "arn:aws:iot:<region>:<account>:client/${iot:Connection.Thing.ThingName}"
},
{
"Effect": "Allow",
"Action": ["iot:Publish", "iot:Subscribe", "iot:Receive"],
"Resource": [
"arn:aws:iot:<region>:<account>:topic/rainmaker/nodes/${iot:Connection.Thing.ThingName}/*",
"arn:aws:iot:<region>:<account>:topicfilter/rainmaker/nodes/${iot:Connection.Thing.ThingName}/*"
]
},
{
"Effect": "Allow",
"Action": ["iot:Subscribe", "iot:Receive"],
"Resource": "arn:aws:iot:<region>:<account>:topicfilter/rainmaker/nodes/groups/${iot:Connection.Thing.Attributes[group_id]}/*control"
}
]
}
The third statement is new. At MQTT connection time, AWS IoT substitutes
${iot:Connection.Thing.Attributes[group_id]} with the value of the
group_id attribute stored on the AWS IoT Thing. The IAM * wildcard
matches any character sequence including /, so the single *control
suffix covers both the group-level broadcast (groups/<gid>/control, where
* matches the empty string) and any subgroup
(groups/<gid>/subgroups/<sgID>/control, where * matches subgroups/<sgID>/).
A single resource is used here to keep the device policy under the AWS IoT
2048-byte size limit.
Behaviour when group_id attribute is absent or empty: The substitution
produces topicfilter/rainmaker/nodes/groups//*control, which matches no
real topic. The statement grants no effective permission. This is the
correct behaviour for devices not yet assigned to a group.
This is a single static policy covering all devices and all groups. No per-group policy or Thing Group resource is created, so the device policy carries no size or policy-count concern however many groups exist.
This does not extend to the user side. The user session policy issued by
POST /v1/assumed-rolesis built per caller and enumerates ARNs per accessible group, so it grows with group count and is bounded by STS’s 2048-character limit. That bound is a real product limit — roughly 4 groups per user — and is specified in group.md and user_auth.md. Any new per-group ARN added to the user policy lowers that ceiling for every user.
3.4 AWS IoT Thing Attribute: group_id
The group_id attribute on the AWS IoT Thing is the mechanism by which the
static policy (Section 3.3) is scoped to the correct group at runtime.
Event |
|
|---|---|
Device has no group |
Absent or empty string |
Device joins group |
|
Device added to/removed from a subgroup |
Unchanged (still |
Device removed from group |
Cleared (empty string or removed) |
Device moves to a different group |
Updated to new groupID |
The attribute is managed via the AWS IoT UpdateThing API call. This is a
single cheap API call; it does not create any new AWS IoT resources.
Subgroup changes do not affect this attribute. The attribute only encodes
group-level membership. Subgroup-level subscription management is handled
entirely by the firmware via getGroupInfo (Section 3.5).
3.5 Firmware: Subscription Reconciliation
The firmware treats getGroupInfo as the single authoritative source for
which group control topics to subscribe to. Reconciliation runs every time a
getGroupInfo payload is received, whether proactively requested at startup or
pushed by the cloud.
Subscription set computation:
Given getGroupInfo payload with pgrp = "grp1" and subgrps = ["sgX", "sgY"]:
required_subscriptions = {
"rainmaker/nodes/groups/grp1/control", // group broadcast
"rainmaker/nodes/groups/grp1/subgroups/sgX/control", // subgroup sgX
"rainmaker/nodes/groups/grp1/subgroups/sgY/control" // subgroup sgY
}
If pgrp is absent, required_subscriptions is empty.
Reconciliation algorithm:
subscribe(required_subscriptions - current_subscriptions)
unsubscribe(current_subscriptions - required_subscriptions)
current_subscriptions = required_subscriptions
Message processing: On receiving a message on a group control topic, the firmware:
Parses the payload as a map of device type → control envelope.
For each top-level key, finds local devices whose device type matches.
Applies the nested
params(or other recognized sub-keys) to each match.Ignores top-level keys that match no local device.
The unicast topic continues to use the existing device-name-keyed payload format and its existing handler. Group control messages and unicast messages are processed by different handlers because their payload shapes differ.
Startup sequence:
1. Connect to AWS IoT broker
2. Subscribe to: rainmaker/nodes/<nodeID>/from_cloud
3. Publish getGroupInfo request to rainmaker/nodes/<nodeID>/to_cloud
4. Receive getGroupInfo response via from_cloud
5. Run reconciliation → subscribe/unsubscribe group control topics
6. Subscribe to the unicast params topic for the resolved shadow name
The request topic depends only on the node id, so the device does not need a valid shadow name to ask — which is what makes this recoverable after the device was offline during a membership change. The unicast subscription is established from the resolved group info rather than from a remembered shadow name; the firmware documentation is authoritative for the exact ordering on the device side.
4. Lifecycle
4.1 Device Joins a Group
Triggered when a device is added to a group.
1. DB: rmng-group-node-assoc entry created
2. Shadow: migrated to new shadow name
3. Thing attribute: group_id set to groupID
4. Notification: getGroupInfo pushed to device
Device receives getGroupInfo:
5. Firmware reconciles → subscribes to:
rainmaker/nodes/groups/<groupID>/control
4.2 Device Added to a Subgroup
Triggered when a device is added to a subgroup.
1. DB: subgrpN column added in rmng-group-node-assoc
2. Shadow: migrated to new shadow name (adds sgID)
3. Thing attribute: unchanged — the group has not changed
4. Notification: getGroupInfo pushed to device
Device receives getGroupInfo with updated subgrps:
5. Firmware reconciles → subscribes to new subgroup control topic:
rainmaker/nodes/groups/<groupID>/subgroups/<sgID>/control
4.3 Device Removed from a Subgroup
Triggered when a device is removed from a subgroup.
1. DB: subgrpN column removed from rmng-group-node-assoc
2. Shadow: migrated to new shadow name (removes sgID)
3. Thing attribute: unchanged — the group has not changed
4. Notification: getGroupInfo pushed to device
Device receives getGroupInfo with updated subgrps:
5. Firmware reconciles → unsubscribes from removed subgroup control topic:
rainmaker/nodes/groups/<groupID>/subgroups/<sgID>/control
4.4 Device Removed from a Group
1. DB: rmng-group-node-assoc entry deleted
2. Thing attribute: group_id cleared (set to "")
3. Group shadow deleted and user tags cleared from iparams
4. Notification: getGroupInfo pushed to device
Device receives getGroupInfo with pgrp absent:
5. Firmware reconciles → unsubscribes from all group control topics
6. IoT policy: group_id attribute is now empty →
the group-control policy statement grants no permissions
Steps 2–4 are one synchronous sequence, so a device that is online sees the attribute clear and the notification arrive together.
4.5 Subgroup Deleted
Deleting a subgroup iterates over every device holding that subgroup membership
and removes each one from it. Each individual removal follows §4.3 and triggers
its own getGroupInfo push to that device. There is no bulk notification
mechanism.
4.6 Group Deleted
Deleting a group enumerates the group’s devices from rmng-group-node-assoc
first, clearing each device’s group_id Thing attribute and pushing
getGroupInfo to it, then batch-deletes the association entries.
4.7 Device Offline During a Membership Change
When a membership change occurs while the device is offline:
The
getGroupInfopush publishes tofrom_cloudwith QoS 1. AWS IoT Core will queue the message for delivery when the device reconnects (subject to the configured message queue limits).If the queued message expires before the device reconnects, the device recovers via its startup
getGroupInforequest (step 4 of the startup sequence in Section 3.5). The cloud always responds with the current authoritative state.There is no scenario where a device remains permanently out of sync with its group control subscriptions after reconnecting.
5. Security Analysis
5.1 Cross-home isolation (enforced at IoT policy layer)
The group_id attribute is device-specific and set to the device’s own
groupID. The policy statement resolves to
topicfilter/rainmaker/nodes/groups/<ownGroupID>/*control. A device cannot
subscribe to another home’s group control topics because its group_id
attribute does not contain that home’s groupID.
5.2 Unicast command isolation (enforced at IoT policy layer)
Unicast topics have the form nodes/<nodeID>/user/*. The second policy
statement (existing) uses ${iot:Connection.Thing.ThingName}, which resolves
only to the device’s own nodeID. The third statement (new) is scoped under
nodes/groups/<groupID>/. Because real nodeIDs cannot contain /, the
namespaces nodes/<nodeID>/ and nodes/groups/<groupID>/ are disjoint. A
device cannot subscribe to another device’s unicast topic.
5.3 Cross-subgroup isolation within the same home (enforced at firmware layer)
All devices in the same group share the same group_id attribute value. The
third policy statement therefore grants all of them subscribe permission to
nodes/groups/<groupID>/*control, which encompasses all subgroup control
topics within that group. A device could in principle subscribe to a sibling
subgroup’s control topic.
This is accepted by design for the following reasons:
Subgroup IDs are randomly generated and not discoverable by sibling devices through any platform mechanism.
All devices in the same group are in the same physical deployment (e.g., one home) and are within the same trust boundary.
Exploiting this requires firmware modification, which implies physical access to the device — a threat that exists independently through other attack vectors.
The user-facing security boundary (which phone app users can publish to which group control topics) is enforced at the IAM session policy layer and is unaffected.
5.4 User publish scoping (enforced at IAM session policy layer)
The IAM session policy generated at assume-role time restricts which group control topics a user can publish to. With the new IAM resource patterns (Section 3.2):
A user with full group access to
grp1gets resources covering bothtopic/rainmaker/nodes/groups/grp1/controlandtopic/rainmaker/nodes/groups/grp1/subgroups/*/control. They can publish to the group broadcast and any subgroup.A user with subgroup-only access to
sgXwithingrp1gets onlytopic/rainmaker/nodes/groups/grp1/subgroups/sgX/control. They cannot publish on the group-level broadcast topic, and they cannot publish on a sibling subgroup’s topic such asnodes/groups/grp1/subgroups/sgY/control.
5.5 Cross-device-type filtering (enforced at firmware layer)
Because group control payloads are addressed by device type, a single broadcast may carry settings for multiple device types. Each receiving node applies the payload only to its own devices whose device type matches a top-level key. Top-level keys that don’t match any local device are silently ignored. There is no platform mechanism by which a node could coerce another node into accepting a message whose top-level key does not match the target’s device type, because the filtering is done locally on each node from its own device-type registry.
6. What Does Not Change
The following components require no modification:
Component |
Why |
|---|---|
|
Already contains all information the firmware needs |
The group-add notification path |
Already pushes |
The subgroup-update notification path |
Already pushes |
Shadow naming convention ( |
Unchanged; only applies to unicast topics and device shadows |
Individual device unicast topic structure and payload shape |
Unchanged |
Assume-role authentication and credential flow |
Unchanged |
|
Unchanged |
The user-accessible-groups lookup |
Unchanged |
7. Where This Lives
Concern |
Where it is enforced |
|---|---|
|
The node add / remove side-effect path, run synchronously with the membership change |
Add / remove node, group and subgroup lifecycle |
The group and node control-plane APIs |
Device group-control permissions |
The device IoT policy, |
User publish scoping |
The MQTT session policy minted by |
|
The to-cloud event handler |
8. Topic Reference Summary
# Unicast (individual device command — existing, unchanged; device-name keyed payload)
rainmaker/nodes/<nodeID>/user/params-<groupID>[-<sg1>-<sg2>-<sg3>]/params
└─ subgroups sorted alphabetically, all subgroups combined in one topic
# Group broadcast (all devices in the group; device-type keyed payload)
rainmaker/nodes/groups/<groupID>/control
# Subgroup command (all devices in one specific subgroup; device-type keyed payload)
rainmaker/nodes/groups/<groupID>/subgroups/<sgID>/control
└─ one topic per subgroup, NOT a combined topic
# Cloud-to-device notification channel (existing, unchanged)
rainmaker/nodes/<nodeID>/from_cloud
A device with pgrp=grp1 and subgrps=[sgX, sgY] subscribes to:
rainmaker/nodes/node-abc/user/params-grp1-sgX-sgY/params ← unicast
rainmaker/nodes/groups/grp1/control ← group broadcast
rainmaker/nodes/groups/grp1/subgroups/sgX/control ← subgroup sgX
rainmaker/nodes/groups/grp1/subgroups/sgY/control ← subgroup sgY