Compare commits

..

1 Commits

Author SHA1 Message Date
dusan 7f372910dc Add edge PRD
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-08-07 15:36:40 +02:00
23 changed files with 5413 additions and 0 deletions
+2096
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
# ATOM-01 — Expose `attributesContains` on entity and group queries
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-09, MG-15 (gateway → declared devices) |
| **Status** | Draft |
## Why this is P0
The device→gateway relation is stored as an attribute on the device
([spec §8 A10](../architecture.md#8-decision-record)):
```
Device meter-7 { gateways: [ {"id": "gw-a", "address": {...}}, {"id": "gw-b"} ] }
```
"Which devices are declared on this gateway" is therefore exactly an
`attributesContains` query with JSONB array containment. Without it, the gateway
view has no declared-device list and the only fallback is fetching every device
in the domain and filtering client-side — which paginates incorrectly.
> This PRD was briefly demoted to P2 when an intermediate design stored the
> relation as a group. A10 reversed that; the justification is restored.
## Problem
Callers cannot filter entities or groups by attribute. `resources()` already
supports this; `entities()` and `groups()` do not, purely because the parameter
is hardcoded to `None` at the resolver.
Any product storing domain-specific state in `attributes` — which is what
`attributes` is for — currently has to fetch and filter client-side, which does
not paginate correctly and does not compose with authorization filtering.
## Why this is generic
This is a symmetry fix, not a feature. `Resource` and `Entity` both carry a
JSONB `attributes` column and both have list queries; only one can filter on it.
No caller-specific semantics are introduced — the filter is a containment check
over opaque JSON.
## Current state
| Layer | Status | Location |
|---|---|---|
| Query model | Field exists | `src/models/access.rs:98`, `src/models/resource.rs:51` |
| Repository | Implemented and bound | `src/authz/repo.rs:155,189,221` and `:5023,5139` |
| GraphQL — resources | **Exposed** | `src/graphql/resources.rs:51,75,108` |
| GraphQL — entities | Hardcoded `None` | `src/graphql/entities.rs:134` |
| GraphQL — groups | Hardcoded `None` | `src/graphql/groups.rs:263` |
## Scope
**In scope**
- Add `attributes_contains: Option<Value>` to the `entities()` query resolver
(`src/graphql/entities.rs:74`), threading it through both the deleted-filter
branch (`entities.rs:98`) and the live authorization-filtered branch
(`entities.rs:123`).
- Same for `groups()` (`src/graphql/groups.rs:35`), threading through
`authorized_group_list` (`groups.rs:214`).
- Mirror the parameter position and naming used by `resources()`
(`src/graphql/resources.rs:44-56`) so the three queries stay consistent.
**Out of scope**
- Any change to the repository layer or SQL — the implementation already exists.
- New index work. Consider `GIN` on `entities.attributes` a follow-up, driven by
measurement (see Risks).
- `authorizedObjectIds` — that is ATOM-02.
## Design
Follow `resources()` exactly. The parameter is `Option<Value>` (a JSON object),
passed to `ListEntities` / `AuthorizedObjectIdsQuery` unchanged. The repository
already filters out null values (`repo.rs:155`), so no resolver-side validation
is needed.
Ordering of parameters in the resolver signature should place
`attributes_contains` after `tenant_id`, matching `resources.rs:51`.
## Acceptance criteria
1. `entities(attributesContains: {provisioning_state: "pending"})` returns only
entities whose attributes contain that pair.
1a. **Object-array containment works**:
`entities(attributesContains: {gateways: [{"id": "gw-a"}]})` returns devices
whose `gateways` array contains an element with that `id`, ignoring the
element's other keys. This is the gateway-view query and the reason for P0.
1b. **Containment is per-element.** Given a device where `gw-a` has
`{modbus_unit: 7}` and `gw-b` has `{modbus_unit: 9}`, a filter for
`{"id":"gw-a","address":{"modbus_unit":9}}` must **not** match. This is what
makes address-conflict detection sound; it is a Postgres guarantee, and the
test exists to catch a regression in how the filter is threaded, not in
Postgres.
2. The filter composes with `kind`, `profileId`, `tenantId`, `parentGroupId`,
`includeDescendants` and `status`.
3. The filter composes with authorization: a subject sees only entities they may
read **and** that match the filter. Verify on the live branch
(`entities.rs:123`), not just the platform-manage branch.
4. `total` in the returned `EntityList` reflects the filtered count, not the
unfiltered one.
5. `groups(attributesContains: …)` behaves equivalently.
6. Omitting the argument produces byte-identical results to today.
## Test plan
- Unit: resolver passes the parameter through unchanged, including the `None`
case.
- Integration: seed entities with differing attributes across two tenants; assert
filtering, tenant isolation, pagination correctness (`total` and `offset`), and
that an unauthorized subject sees nothing.
- Regression: existing `entities()` and `groups()` tests must pass untouched.
## Risks
- ~~**Unindexed JSONB containment.**~~ **Not a risk — the index already exists.**
`CREATE INDEX idx_entities_attrs ON entities USING GIN(attributes)`
(`001_initial.sql:119`), present since the initial migration. Verified against
Postgres with 60k rows: the containment query plans as a Bitmap Heap Scan.
An earlier draft of this PRD claimed the opposite; do not carry it into
implementation as a known problem.
- **Filter composition with authorization** is the subtle part — the live branch
routes through `authorized_object_ids`, so the filter must be applied inside
that query rather than after it, or pagination silently breaks.
@@ -0,0 +1,124 @@
# ATOM-02 — Expose scoping filters on `authorizedObjectIds`
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-08 (reader authorization) |
| **Status** | Draft |
## Problem
`authorizedObjectIds` answers "which objects may this subject act on". Its query
struct and repository implementation support attribute, profile, status, group
and descendant filtering — but the GraphQL resolver hardcodes all of them to
`None`/`false`.
Callers must therefore page the subject's **entire** authorized set into memory
and filter client-side. For a consumer that wants "the meters this customer may
read, within this group" that means materialising every authorized ID first,
which does not scale and makes `total` meaningless for the caller's real query.
## Why this is generic
The listing queries (`entities()`, `resources()`) already accept these filters.
This makes the authorization query accept the same ones, so "what can I see"
and "what can I see, narrowed" are the same question with the same vocabulary.
Nothing caller-specific is introduced.
## Current state
`AuthorizedObjectIdsQuery` (`src/models/access.rs`) carries:
```rust
subject_id, action, object_kind, object_type, tenant_id, q,
attributes_contains, profile_id, entity_status, group_type,
parent_group_id, include_descendants, limit, offset
```
The resolver (`src/graphql/authz.rs:47-61`) passes only the first six and pins
the rest:
```rust
attributes_contains: None,
profile_id: None,
entity_status: None,
group_type: None,
parent_group_id: None,
include_descendants: false,
```
The repository implements all of them (`src/authz/repo.rs:155,189,221`).
## Scope
**In scope**
- Extend `AuthorizedObjectIdsInput` with: `attributesContains`, `profileId`,
`entityStatus`, `parentGroupId`, `includeDescendants`.
- Thread them through the resolver (`src/graphql/authz.rs:47-61`).
- Preserve the existing capability check
(`access::require_authz_check_access`, `authz.rs:40`) unchanged — filters
narrow a result set, they must never widen it.
> **Coordinate with [ATOM-06](./ATOM-06-entity-external-id.md).** Both add
> parameters to the *same* `authorizedObjectIds` resolver
> (`src/graphql/authz.rs:47-61`) — this one adds the existing repository filters,
> ATOM-06 adds `externalId`. Independent in design, guaranteed to conflict in the
> diff. Sequence them or land them together.
**Out of scope**
- `group_type` — no consumer yet. Leave pinned to `None` rather than exposing an
unused knob.
- Changes to the scoped-token ceiling semantics described at `authz.rs:43-46`.
Filters apply **after** ceiling filtering; adding them must not alter that
order.
- Repository or SQL changes.
## Design
Direct plumbing. Parameter naming and types mirror `entities()` after ATOM-01 so
the two queries read the same way.
The critical invariant: **filters are conjunctive with the authorization result,
never disjunctive.** A caller supplying `parentGroupId` for a group they cannot
read must receive an empty set, not an error and not the unfiltered set.
## Acceptance criteria
1. `authorizedObjectIds(input: {subjectId, action: "read", objectKind: "entity",
objectType: "entity:device", parentGroupId: <group>})` returns only device IDs
that are both authorized for the subject and in that group.
2. `attributesContains` narrows the same way.
3. `includeDescendants: true` walks the group tree; `false` (and omitted) does
not.
4. `total` reflects the filtered count.
5. A subject with no grants receives an empty list for every filter combination —
filters cannot grant access.
6. Scoped-token ceiling filtering still applies, and applies first.
7. Omitting all new arguments produces results identical to today.
## Test plan
- Unit: each new parameter reaches `AuthorizedObjectIdsQuery` unchanged;
omitted parameters retain today's defaults.
- Integration:
- subject with a `group_direct_objects` grant + `parentGroupId` filter →
intersection only;
- subject with **no** grant + any filter → empty;
- scoped token whose ceiling excludes an object the direct policy allows →
object absent regardless of filters;
- pagination: `limit`/`offset` over a filtered set returns each ID exactly once
across pages.
- Regression: existing `authorizedObjectIds` tests pass untouched.
## Risks
- **Widening by accident** is the failure mode that matters. A filter applied as
an `OR` branch, or applied before the ceiling filter, becomes privilege
escalation. Acceptance criteria 5 and 6 are the guards; both need explicit
tests, not incidental coverage.
- Query-plan regression on the larger `WHERE` clause — check the plan for the
common case (`objectKind: entity` + `parentGroupId`) before merging.
+102
View File
@@ -0,0 +1,102 @@
# ATOM-03 — Reverse policy lookup: `directPolicies(objectId:)`
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P3 |
| **Depends on** | — |
| **Blocks** | Sharing UI; correct revocation |
| **Status** | Draft |
## Problem
`directPolicies` filters by subject only — `tenantId`, `subjectKind`,
`subjectId`, `permissionBlockId` (`src/graphql/policies.rs:285-294`). There is no
way to ask **"who has access to this object?"**
Two consequences:
1. A sharing UI cannot show who a resource is shared with without enumerating
every subject in the tenant and querying each.
2. Revocation is unsafe. Magistrala's `DeletePolicyFilter`
(`pkg/atom/policy_service.go:78-105`) works around the gap by listing a
subject's policies and matching client-side, capped at 100 — so revoking
access on a widely-shared object silently misses policies past that cap.
## Why this is generic
"Who can access X" is the inverse of "what can this subject access", which Atom
already answers. Every system with a sharing model needs both directions. No
domain semantics are introduced.
## Scope
**In scope**
- Add `object_id: Option<ID>` to the `directPolicies` query, returning every
direct policy whose permission block targets that object.
- Object matching must cover the scope modes that can reference a specific
object:
- `object``permission_blocks.object_id = $1`
- `group_direct_objects` / `group_descendant_objects` — blocks whose
`group_id` contains the object, direct or transitive respectively
- Add `object_kind` / `object_type` as optional co-filters, since an ID alone is
ambiguous across kinds.
**Out of scope**
- Tenant-, platform-, `object_kind`- and `object_type`-scoped blocks. These grant
access to an object without naming it; including them would make the result a
full effective-access computation rather than a policy lookup. If effective
access is wanted, that is a separate `effectiveAccess(objectId:)` query with
different semantics — do not conflate them.
- Any change to `authzCheck` or the evaluation engine.
## Design
Extend `ListDirectPolicies` (`src/authz/repo.rs`) with the optional object
filter, joining `permission_blocks` and — for the group scope modes — the
membership tables `object_group_entities` / `object_group_resources`
(`migrations/001_initial.sql:525-544`) and, for descendants,
`object_group_hierarchy` (`:446-454`).
Authorization: reuse `require_policy_read` (`policies.rs:298`) unchanged. Reading
who can access an object is a policy-read operation on the tenant, as today.
**Result semantics must be documented on the field**, because the out-of-scope
exclusion above is surprising if undocumented: this returns *direct policies
naming this object*, not *everyone who can reach it*.
## Acceptance criteria
1. `directPolicies(objectId: <device>)` returns policies whose block is
`scope_mode: "object"` with that `object_id`.
2. It also returns policies whose block is `group_direct_objects` over a group
the object is a direct member of.
3. `group_descendant_objects` matches transitively through
`object_group_hierarchy`; `group_direct_objects` does not.
4. Tenant-, platform- and kind-scoped blocks are **not** returned, and the field
documentation says so.
5. `objectKind` / `objectType` co-filters narrow correctly.
6. Combining `objectId` with `subjectId` intersects.
7. Callers without policy-read on the tenant are refused.
8. Omitting `objectId` produces results identical to today.
## Test plan
- Integration: build one device reachable four ways — direct object block, direct
group membership, descendant group membership, and a tenant-scoped block —
then assert exactly the first three are returned.
- Hierarchy depth: 3-level group tree, confirm direct vs descendant behaviour at
each level.
- Authorization: caller without policy-read is refused.
- Pagination over a widely-shared object.
## Risks
- **Recursive hierarchy traversal cost.** Descendant matching needs a recursive
CTE. Bound it by tenant and check the plan on a deep tree.
- **Misreading the result as effective access.** The exclusion in Scope is
deliberate and load-bearing; if a consumer treats this as "everyone who can
see X", they will under-report. Documentation on the field is part of the
deliverable, not a nicety.
@@ -0,0 +1,207 @@
# ATOM-04 — Many-to-many object group membership
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-03, MG-04 |
| **Status** | Draft |
| **Decision** | [spec A1](../architecture.md#8-decision-record) |
> **Cost correction.** When recommending this change I described it as
> "near-trivial — a `PRIMARY KEY` change". That was based on the migration tool's
> `ON CONFLICT` clauses. Having read Atom's repository layer, it is **moderate,
> not trivial**: single membership is assumed in the upsert semantics, the delete
> path, the public API shape, the list queries, and — most importantly — the
> authorization evaluation path. The decision may still be right; the estimate
> was wrong. See [Scale](#scale-of-the-change).
## Problem
`object_group_entities` has `PRIMARY KEY (entity_id)`
(`migrations/001_initial.sql:525-532`) — an entity belongs to **at most one**
object group. Same for resources, `PRIMARY KEY (resource_id)` (`:537-544`).
This forbids overlapping object sets:
```
"Customer A meters" → granted to Customer A
"Building 5 meters" → granted to a maintenance contractor
Meter 7 ∈ both, and neither set contains the other
```
A hierarchy cannot express it, because the sets intersect without nesting.
## Why this is generic
Group membership being many-to-many is the ordinary case for a grouping
primitive — tags, labels, collections, teams. Atom's own API already reads as
though it were: `entityGroups(entityId)` returns a **list**
(`src/graphql/groups.rs:104`), and the mutation is named `addGroupMember`, not
`setGroupParent`. No product-specific semantics are introduced; a constraint is
removed.
## Scale of the change
Single membership is assumed in five places, in increasing order of risk.
### 1. Schema — trivial
```sql
ALTER TABLE object_group_entities
DROP CONSTRAINT object_group_entities_pkey,
ADD PRIMARY KEY (group_id, entity_id);
```
Data-preserving. Same for `object_group_resources` if included (see Scope).
### 2. Upsert semantics — small
`src/identity/repo.rs:511-523` currently **moves** an entity between groups:
```sql
INSERT INTO object_group_entities (group_id, entity_id, tenant_id)
VALUES ($1, $2, $3)
ON CONFLICT (entity_id) DO UPDATE
SET group_id = EXCLUDED.group_id, ...
```
Becomes `ON CONFLICT (group_id, entity_id) DO NOTHING` — additive rather than
replacing. This also settles spec §8 **E3**: today's behaviour is a silent
move; after this it is a genuine add.
### 3. Removal — small, but an API change
`src/identity/repo.rs:556` deletes by entity alone:
```sql
DELETE FROM object_group_entities WHERE entity_id = $1
```
That now means "remove from *all* groups". `removeGroupMember` already takes a
`group_id` (`groups.rs:699`), so the mutation is fine — but
`clear_entity_parent_group_in_tx` and any caller expecting "clear the parent"
need explicit semantics: remove-from-one versus remove-from-all.
### 4. The `parent_group_id` attribute API — moderate, semantic
Membership is currently settable through an **attribute** on create/update —
`parent_group_id_from_attrs` (`src/authz/repo.rs:59,237-241`). A scalar attribute
cannot express a set.
Decide one:
- **A.** Keep `parent_group_id` as a convenience meaning "sole membership"
(replaces all), with `addGroupMember` / `removeGroupMember` as the set API.
Backwards compatible, but two mechanisms with different semantics on one
relation is exactly the kind of thing that later confuses everyone.
- **B.** Deprecate the attribute path; membership is only mutated through the
explicit mutations. Cleaner; breaks existing callers, including Magistrala's
projection (`pkg/atom/mapping.go:46` writes `parent_group_id`).
**Recommend B**, consistent with the "avoid technical debt" ruling in
[spec §8 C1](../architecture.md#8-decision-record).
### 5. Queries joining membership — moderate, correctness-critical
This is the part that matters.
**Row multiplication in listings.** `src/authz/repo.rs:4839-4851`:
```sql
candidates AS (
SELECT e.id, ..., gep.group_id AS parent_group_id
FROM entities e
LEFT JOIN group_entity_parents gep ON gep.entity_id = e.id
...
AND ($8::uuid IS NULL OR gep.group_id IN (SELECT id FROM target_groups))
```
With M:N this yields one row **per (entity, group) pair** — duplicate entities in
every listing and an inflated `total`. Needs `EXISTS`-style filtering rather than
a join that projects the group, or explicit de-duplication.
**Missed grants in authorization.** `src/authz/repo.rs:5687-5692`:
```sql
SELECT e.id, ..., gep.group_id AS parent_group_id
FROM entities e
LEFT JOIN group_entity_parents gep ON gep.entity_id = e.id
WHERE e.id = $1 ...
```
`fetch_optional` over a now-multi-row result takes one arbitrary group. Since
this record feeds group-scoped policy evaluation, **an entity in two groups would
have grants through one of them silently ignored** — non-deterministically, since
which row wins is unspecified.
This is the single most important change in the PRD: `AuthzObjectRecord`'s
`parent_group_id: Option<Uuid>` must become a set, and every `group_direct_objects`
/ `group_descendant_objects` evaluation must consider all of them.
**Views.** `group_entity_parents` / `group_resource_parents`
(`001_initial.sql:549-555`) keep working but their names become misleading —
they are membership, not parentage.
## Scope
**In scope**
- Schema change for `object_group_entities`.
- Additive upsert; explicit removal semantics.
- De-duplicated list queries with correct `total`.
- Set-based membership in the authorization evaluation path.
- Decision and implementation of the `parent_group_id` attribute question.
**Open — decide before starting**
- **Include `object_group_resources`?** Symmetry argues yes; no requirement
exists yet (channels in multiple groups). Splitting them leaves an asymmetry
that will confuse; doing both roughly doubles the query work.
**Out of scope**
- Group hierarchy — `object_group_hierarchy PRIMARY KEY (child_id)`
(`:446-454`) stays a tree. A group has one parent; only *membership* becomes
many-to-many.
- Principal group membership, unless it shares the same tables.
## Acceptance criteria
1. An entity can be added to two groups and appears in both `groupMembers`
listings.
2. `entityGroups` returns both.
3. `entities(parentGroupId:)` returns the entity **once**, with `total` counting
it once, for either group.
4. A grant via `group_direct_objects` on **either** group authorizes the entity —
verified for both, not just the first.
5. Removing from one group leaves the other membership and its grants intact.
6. `includeDescendants` traversal is correct when an entity is in two groups in
different subtrees.
7. Adding an entity to a group it is already in is idempotent.
8. Cross-tenant membership is still rejected (`identity/repo.rs:505-509`).
9. Existing single-membership data behaves identically after migration.
## Test plan
- Migration against seeded data; assert every existing membership survives.
- **Criterion 4 is the security-relevant one** — entity in groups G1 and G2, grant
only via G2, assert allowed. This fails today's evaluation path and is the
reason for the change.
- Criterion 3 with an entity in three groups, asserting no duplicates and correct
`total` under pagination.
- Descendant traversal across two subtrees.
- Regression: full existing group and authz suites.
## Risks
- **Silent grant loss** if the evaluation path is not fully converted to sets.
Fails open or closed non-deterministically depending on row order — the worst
possible failure mode. Criterion 4 is the guard.
- **Duplicate rows** in listings are cosmetic in the UI but corrupt `total` and
therefore pagination.
- **Query plans** change once the join can fan out. Check plans for
`authorized_object_ids` with and without a group filter.
- **Scope creep into resources.** Decide up front; discovering halfway that
resources need it too doubles the work mid-flight.
+58
View File
@@ -0,0 +1,58 @@
# ATOM-05 — Add `gateway` entity kind
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | — |
| **Status** | **WITHDRAWN** |
| **Superseded by** | [spec §8 A12](../architecture.md#8-decision-record) |
---
## Withdrawn
This PRD added `gateway` to Atom's `entities.kind` enum. **Gateway is a
capability, not a type**, so no new kind is needed — gateways stay
`entity_kind: device` with an `is_gateway` attribute.
### Why
Types are exclusive; roles compose. The model defines a gateway as *a Device with
a proxy role* ([spec §2.1](../architecture.md#21-a-gateway-is-a-path-not-a-container)),
and a distinct kind contradicts that on a real and common device: a smart
electricity meter that also concentrates wM-Bus water meters produces its own
readings **and** relays others. Under exclusive kinds it must be one or the
other, and either answer is wrong.
Surveying comparable platforms found the same conclusion everywhere:
| Platform | Gateway is |
|---|---|
| ThingsBoard | Device + `Is gateway` **boolean** |
| AWS Greengrass v2 | Core device — an IoT `thing`, **same type as clients** |
| Azure IoT Edge | Device identity with edge capability |
| ChirpStack | Separate — but its gateways are dumb radio infrastructure producing no data |
### What happened to its parts
| Was | Now |
|---|---|
| `Gateway` variant on `EntityKind` + two CHECK relaxations | Not needed |
| `entity:gateway` object type | `attributesContains: {is_gateway: true}` — ATOM-01 |
| Separate gateway profile namespace | One namespace. A device that both measures and relays needs *one* type, which is more correct |
| Gateway-specific `ActionAssignmentRule`s | Not needed — the existing `{entity_kind: device, publish, resource:channel}` rule keeps applying |
### The trap it carried, now gone
`pkg/atom/bootstrap.go:72-87` installs `{entity_kind: device, publish,
resource:channel}` as the only publish guardrail. Introducing a `gateway` kind
would have **silently stripped every gateway's right to publish** until matching
rules were added — surfacing as a bare authorization denial with nothing pointing
at the cause. Withdrawing this PRD removes that failure mode entirely.
### If this needs revisiting
The trigger would be a requirement to grant over *all gateways* as a first-class
object type, in a deployment where attribute filtering does not scale. The
migration stays permissive and additive, so it can be done later at the same cost
— with the publish-guardrail trap as the thing to plan for.
+145
View File
@@ -0,0 +1,145 @@
# ATOM-06 — Entity `external_id`
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-08, MG-09 |
| **Status** | Draft |
| **Decision** | [spec §8 A8](../architecture.md#8-decision-record) |
## Problem
Entities carry identifiers assigned outside Atom — serial numbers, MAC
addresses, employee numbers, SKUs. Atom has no field for them.
`alias` is the closest thing and cannot serve: it is slug-constrained to
`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$` (`001_initial.sql:105-113`) and
additionally forbidden from looking like a UUID (`:107-113`). A real meter serial
such as `WM-2024-ABC.123` fails the slug pattern (uppercase, `.`) and any attempt
to normalise it without losing information.
The alternative — storing it in `attributes` — gives no uniqueness guarantee and
no index, so lookup is a JSONB containment scan and two devices can silently
claim the same serial.
## Why this is generic
External identifiers are a property of any system that mirrors things it did not
create. The field carries no semantics: Atom stores, indexes and enforces
uniqueness on an opaque string, and never interprets it.
`alias` and `external_id` are deliberately different: `alias` is a
*human-friendly, URL-safe* name Atom constrains; `external_id` is a
*foreign key into someone else's namespace* that Atom must not constrain.
## Scope
**In scope**
- `external_id TEXT NULL` on `entities`.
- Unique per tenant, ignoring soft-deleted rows:
```sql
CREATE UNIQUE INDEX idx_entities_external_id
ON entities (tenant_id, external_id)
WHERE external_id IS NOT NULL AND deleted_at IS NULL;
```
- `externalId` on `CreateEntityInput` and `UpdateEntityInput`.
- `externalId` as an exact-match filter on the `entities` query, and on
`authorizedObjectIds` alongside ATOM-02's filters.
- `external_id` on the `Entity` GraphQL type and in `entity.*` domain events.
> **Coordinate with [ATOM-02](./ATOM-02-authorized-object-ids-filters.md).** Both
> add parameters to the same `authorizedObjectIds` resolver
> (`src/graphql/authz.rs:47-61`). Independent in design, guaranteed to conflict in
> the diff.
**Out of scope**
- Any format validation. The value is opaque by design — that is the point.
Consumers may impose their own: Magistrala rejects `/` because the value travels
verbatim in a topic, but that is Magistrala's rule, not Atom's.
- Resources, groups, tenants. Add when a requirement appears; entities is what is
needed.
- Cross-tenant uniqueness. Two tenants may legitimately hold the same serial.
## Design
### Nullable, and unique only when present
Most entities have no external identifier. A partial unique index gives
uniqueness where the value exists and costs nothing where it does not.
### Case sensitivity — decide explicitly
`tenants.alias` is uniquely indexed on `lower(alias)` (`001_initial.sql:40-42`).
Serial numbers are a different case: `abc123` and `ABC123` may be genuinely
different part numbers in some vendor schemes, and treating them as one would
merge two devices.
**Recommend case-sensitive** (index the raw column), which is the conservative
choice: it can be tightened to case-insensitive later, whereas relaxing a
case-insensitive index after devices have merged is not recoverable.
State the decision in the schema comment either way — this is exactly the kind of
thing that is discovered the hard way.
### Soft-delete interaction
The index excludes `deleted_at IS NOT NULL`, so a deleted device's serial is
reusable. That is almost certainly wanted — replacing a meter with the same
serial should work — but it means `restore_entity` can now fail on a uniqueness
conflict if the serial was reused meanwhile. Handle it with a clear error rather
than a constraint-violation surfacing raw.
### Not a primary key
Consumers may store `external_id` in preference to the UUID — Magistrala's
message pipeline does exactly that (spec §8 A8), keeping the string on every
row so the publish path needs no lookup. That is a consumer choice; Atom's
identity remains the UUID, and `external_id` is mutable.
## Acceptance criteria
1. An entity can be created with an arbitrary-string `external_id` — including
uppercase, `/`, `.`, spaces and unicode — and read back byte-identical.
2. Two entities in one tenant cannot share an `external_id`; the conflict is a
clear error, not a raw constraint violation.
3. Two entities in *different* tenants may share one.
4. Multiple entities may have `external_id` NULL.
5. `entities(externalId: "…")` returns the match, scoped to tenant, and uses the
index — verified by query plan, not assumed.
6. `authorizedObjectIds(externalId: "…")` narrows correctly and cannot widen
access.
7. `external_id` can be changed, and cleared to NULL.
8. Soft-deleting an entity frees its `external_id` for reuse; restoring one whose
identifier was reused fails with a comprehensible error.
9. `external_id` appears in `entity.create` / `entity.update` events.
10. Existing entities and queries are unaffected.
## Test plan
- Migration on seeded data; assert all existing rows survive with NULL.
- Uniqueness: same tenant (reject), different tenants (allow), NULLs (allow many).
- Round-trip of hostile strings — unicode, embedded quotes, 1KB length, leading
and trailing whitespace. Decide and pin whether whitespace is trimmed.
- Case sensitivity, asserting the documented behaviour explicitly.
- Delete → reuse → restore conflict path (criterion 8).
- Query plan for the `externalId` filter.
## Risks
- **Case-sensitivity is a one-way door.** Choosing case-insensitive merges
devices that differ only in case, and no migration un-merges them. Pin it with
a test.
- **Whitespace and unicode normalisation.** `"ABC123 "` and `"ABC123"` will be
two devices unless trimmed. Trimming is probably right, but it must be a
decision written down rather than an accident of the client.
- **Length.** `TEXT` is unbounded; an index on a multi-kilobyte value is
pathological. Consider a sanity cap (e.g. 255) even though the format is
otherwise unconstrained.
- **Consumers treating it as immutable.** It is mutable, and Magistrala stores it
denormalised on every message row. Changing a device's `external_id` orphans
its historical data under the new value. Magistrala must either forbid the
change or accept the break — flagged in MG-09.
+125
View File
@@ -0,0 +1,125 @@
# MG-01 — Fix Atom policy client defects
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-04, MG-08 |
| **Status** | Draft |
## Problem
Four defects in `pkg/atom` sit directly beneath the access-control work. Each is
latent today — mostly because `PolicyService` is not wired into any running
binary — and each becomes a live correctness or security bug the moment it is.
### 1. `objectType` is sent unnamespaced
`policy_service.go:139` sends `ObjectType: entityKind(KindClient)``"device"`.
The write path sends the namespaced form `"entity:device"`
(`policy_service.go:225`).
Atom requires the namespaced form and says so explicitly:
> `object_type must be the full namespaced value matching object_kind, e.g. 'entity:device'`
> — `src/identity/access_tokens.rs:275`; construction at `src/graphql/entities.rs:157-165`
The filter therefore never matches what the writer stored. Confirmed against
Atom source, not inferred.
### 2. `DeletePolicyFilter` silently truncates
`policy_service.go:78-105` lists a subject's direct policies with
`Limit: policyPageLimit` (100, `:13`) and deletes matches from that single page.
A subject with more than 100 policies keeps access to everything past the cap.
Revocation reporting success while leaving access in place is a security defect,
not a pagination nit.
### 3. `CapabilityID` cannot see past 100 actions
`client.go:272-287` linear-scans `actions(limit: 100)`. Action 101 is
unresolvable, and the failure is a confusing "not found" far from the cause.
### 4. No applicability registered for `objectKind: entity`
`bootstrap.go:29-70` registers applicability for `tenant`, `group`,
`resource:channel`, `resource:rule` and `resource:report` — but nothing for
`entity`. Meanwhile `fluxmq/api/http/publish.go:184` already checks `read` on an
entity, and every device-level grant this project introduces will target
`entity:device`.
## Scope
**In scope**
- Fix the `objectType` mismatch. Introduce a single helper that produces the
namespaced object type and use it on **both** the read and write paths, so the
two cannot drift again.
- Make `DeletePolicyFilter` paginate to exhaustion, deleting across all pages.
- Make `CapabilityID` resolve reliably — paginate, or look up by name if Atom
supports it. Cache resolved IDs; they are immutable.
- Register applicability for `entity` / `entity:device`: `read`, `write`,
`delete`, `manage`.
- Widen `isSupportedObjectList` (`policy_service.go:182-187`) beyond
`user + client + view`, which is required by MG-08.
**Out of scope**
- Wiring `PolicyService` into services — MG-08 does that for readers.
- Group-scoped blocks — MG-04.
- Any behaviour change to `AddPolicy`'s block-per-call shape. Block reuse is a
legitimate optimisation but belongs with MG-04, which changes that path anyway.
## Design notes
The `objectType` fix should not be a literal-string edit in two places. Add:
```go
// atomObjectType returns the namespaced object type Atom requires,
// e.g. "entity:device", "resource:channel".
func atomObjectType(objectKind, kind string) string
```
and route both `policyGrantObjectType` (`policy_service.go:220-241`) and the
`ListAllObjects` query (`:135-143`) through it. The bug exists because the two
paths independently construct the same string; the fix is to remove that
independence.
For deletion, the loop must be resilient to the page shifting underneath it as
items are removed — page by offset and re-query, or collect all IDs first and
then delete. Collecting first is simpler and correct; the sets are small.
## Acceptance criteria
1. Read and write paths produce byte-identical `object_type` values for the same
logical object, enforced by a test that compares them directly.
2. `ListAllObjects` against a subject with an object-scoped `read` grant on a
device returns that device's ID. (This returns nothing today.)
3. Revoking a permission on a subject holding 250 policies removes **all**
matching policies. Verified by re-querying after deletion, not by return value.
4. `CapabilityID` resolves an action registered beyond the first 100.
5. `atom-bootstrap` registers `entity` applicability; re-running it is idempotent.
6. `isSupportedObjectList` admits the `read`-on-`entity:device` case MG-08 needs
and still rejects genuinely unsupported combinations.
## Test plan
- Unit: object-type helper across every kind; `isSupportedObjectList` truth table.
- Unit: `DeletePolicyFilter` against a mock returning 250 policies across
3 pages — assert every matching ID was passed to delete.
- Integration (Atom in Docker): grant `read` on a device, assert
`ListAllObjects` returns it; revoke, assert it is gone.
- Bootstrap idempotency: run `atom-bootstrap` twice, assert no duplicates and no
errors.
## Risks
- **Changing `objectType` invalidates existing stored blocks.** Any block written
with the unnamespaced form still holds it.
**Resolved — [spec §8 C1](../architecture.md#8-decision-record): no backwards compatibility.**
Fix the read path via the shared helper and leave it there. No dual-form
matching, no compatibility shim. Blocks holding the old value stop matching and
must be rewritten; that belongs in release notes rather than being discovered
in the field. With this ruling the PRD is purely additive plus fixes.
+122
View File
@@ -0,0 +1,122 @@
# MG-02 — Device Type (Atom Profile) client API
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 |
| **Depends on** | — |
| **Blocks** | MG-10 |
| **Status** | Draft |
## Problem
Magistrala has no concept of a device type. A watermeter and a temperature probe
are both an opaque `Client` with a free-form metadata map, so the UI cannot
render per-type views, nothing validates payload shape, and there is no way to
express "this model reports volume and battery, and accepts `set_interval`".
Atom already implements exactly this and Magistrala does not call it.
## What Atom already provides
| Capability | Location |
|---|---|
| Named type, tenant-scoped or global, unique on `(tenant, object_kind, kind, key)` | `migrations/001_initial.sql:48-76` |
| Versioned `json_schema` + `ui_schema` with `draft/active/deprecated/disabled` | `:77-90` |
| Binding on the entity: `profile_id`, `profile_version_id` | `:98-99` |
| **Schema enforcement on entity write** | `src/identity/repo.rs:641` |
| CRUD: `profiles`, `profile`, `profileVersions`, `createProfile`, `createProfileVersion`, `updateProfile` | `src/graphql/profiles.rs:24-209` |
| List entities of a type: `entities(profileId:)` | `src/graphql/entities.rs:79` |
No Atom change is required.
## Scope
**In scope**
- `DeviceType` and `DeviceTypeVersion` types in `pkg/atom`, mapping to Atom
Profile / ProfileVersion with `object_kind = "entity"`, `kind = "device"`.
- Client methods: create, get, list, update, create-version, list-versions.
- Entity create/update carrying `profile_id` and `profile_version_id`
(`src/graphql/entities.rs:205-206,275-276`).
- `ListEntities` gaining the `profileId` filter.
- Capability document helpers: build a JSON Schema from a measurement/command
declaration, and read it back, so callers do not hand-write JSON Schema.
**Out of scope**
- HTTP/SDK/CLI surface — MG-10.
- Command dispatch. The type *declares* commands; routing them is unspecified
(see [architecture.md §7](../architecture.md#7-open-questions)).
- Migrating existing clients onto types.
## Design
### Naming
Atom `Profile` and Bootstrap `Profile` (PR #3555) are unrelated concepts sharing
a word. In `pkg/atom` and every Magistrala-facing surface this is **Device Type**.
Never expose "profile" for this concept — the collision is a real hazard once
both are in play.
### Capability document
The declaration Magistrala cares about, expressed as JSON Schema so Atom enforces
it for free:
```go
type Measurement struct {
Name string // "volume"
Unit string // "m3"
Access string // "r" | "rw"
}
type Command struct {
Name string // "set_interval"
Params map[string]string // {"seconds": "int"}
}
```
Rendered to `json_schema` for validation and `ui_schema` for rendering hints.
Keep the mapping in one place and round-trip it — a helper that generates schema
but cannot parse it back leaves the UI hand-parsing JSON Schema.
### Versioning
Device types are versioned and entities bind to a specific version. Changing a
type must not retroactively invalidate deployed devices, so:
- `createDeviceTypeVersion` creates a new version; it never mutates an existing one.
- Devices stay on their bound version until explicitly moved.
- Version status governs whether *new* bindings are allowed, not whether existing
ones keep working.
## Acceptance criteria
1. Create a device type with a capability document; read it back with the
declaration intact through the round-trip.
2. Create a device bound to that type. Attributes satisfying the schema succeed.
3. Attributes **violating** the schema are rejected by Atom, and the client
surfaces a usable error naming the offending field — not a bare GraphQL error.
4. `ListDeviceTypes` returns both tenant-scoped and global types.
5. `ListEntities(profileID:)` returns exactly the devices bound to that type.
6. Adding version 2 leaves devices bound to version 1 working unchanged.
7. Deprecating a version blocks new bindings and leaves existing ones intact.
## Test plan
- Unit: capability document → JSON Schema → capability document round-trip,
including edge cases (no commands, no units, `rw` access).
- Integration (Atom in Docker): full lifecycle — create type, version, bind
device, valid write, invalid write, add version, deprecate.
- Error mapping: assert a schema violation produces a typed error carrying the
field path.
## Risks
- **JSON Schema error legibility.** Atom returns whatever the `jsonschema` crate
produces. If that surfaces raw to an operator creating a device, it will be
unusable. Error translation is part of this PRD's work, not a follow-up.
- **Global vs tenant-scoped types.** Uniqueness differs between the two
(`001_initial.sql:66-72`). Decide whether Magistrala exposes global types at
all; if not, always send `tenant_id` and say so explicitly.
+153
View File
@@ -0,0 +1,153 @@
# MG-03 — Group membership, hierarchy and group kinds
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 |
| **Depends on** | ATOM-04 |
| **Blocks** | MG-04, MG-09 |
| **Status** | Draft |
## Scope note — groups are for sharing only
Object groups in this model have **exactly one purpose: granting access**
([spec §3.5](../architecture.md#35-groups-and-sharing)). They are not used for
device↔gateway topology — that is a `gateways []` attribute on the device
([spec §8 A10](../architecture.md#8-decision-record)) — and not for any other
grouping.
An intermediate design put gateway fleets in this same namespace; it was reversed
because a group-scoped grant on a fleet group would silently hand over an entire
gateway's devices. Keep the namespace single-purpose.
## Problem
`pkg/atom` can create, read, update, delete and list groups — and nothing else.
It cannot add a member, remove a member, list members, or create a nested group.
The only code in the tree that writes group membership is the one-shot migration
tool, via raw SQL (`tools/atom-migration/migrator.go:598,619`).
Without membership there is no sharing model: the customer story in
[architecture.md §5.2](../architecture.md#35-groups-and-sharing) depends on a group
holding a device set.
## What Atom already provides
| Operation | Location |
|---|---|
| `addGroupMember`, `removeGroupMember` | `src/graphql/groups.rs:645,699` |
| `groupMembers(groupId)`, `entityGroups(entityId)` | `:90,104` |
| `createObjectGroup`, `createPrincipalGroup` | `:346,355` |
| `objectGroups`, `principalGroups`, `childGroups` | `:154,184,121` |
| `setGroupParent`, `removeGroupParent` | `:429,478` |
| Object-side variants `setObjectGroupParent`, `removeObjectGroupParent` | `:468,517` |
No Atom change is required.
## Two things the current client gets wrong
### Object groups vs principal groups
Atom has two distinct group namespaces backed by separate tables —
`object_groups` (`001_initial.sql:427-441`) and `principal_groups`
(`:386-400`), unioned only by a compatibility view (`:461-466`). `pkg/atom` uses
the generic `createGroup`, so which table a group lands in is implicit.
Magistrala's usage divides cleanly:
- **Object group** — a set of devices or channels. What customer sharing needs.
- **Principal group** — a set of users. A subject in a policy.
These must be explicit in the client. Creating the wrong kind produces a group
that silently cannot be used as intended.
### `parentId` is dropped
`groupCreateInput` (`client.go:751-760`) builds the create input without
`parentId` even though `Group` carries it (`types.go:39`). Nested groups are
therefore uncreatable from Go. The hierarchy is needed for
Customer → Site → meters roll-ups via `includeDescendants`.
## Scope
**In scope**
- Membership: `AddGroupMember`, `RemoveGroupMember`, `GroupMembers`,
`EntityGroups`.
- Typed creation: `CreateObjectGroup`, `CreatePrincipalGroup`. Retain the generic
`CreateGroup` only if a caller genuinely needs kind-agnostic behaviour;
otherwise remove it so the choice is always explicit.
- Hierarchy: `SetGroupParent`, `RemoveGroupParent`, `ChildGroups`; fix
`groupCreateInput` to send `parentId`.
- Listing: `ObjectGroups`, `PrincipalGroups`, and `includeDescendants` where Atom
accepts it.
**Out of scope**
- Group-scoped permission blocks — MG-04.
- Resource membership (`object_group_resources`). Add when a channel-grouping
requirement appears; there is none yet.
## Membership is many-to-many (after ATOM-04)
Originally Atom enforced `PRIMARY KEY (entity_id)` on `object_group_entities`,
meaning an entity belonged to at most one object group — and
`set_entity_parent_group_in_tx` (`src/identity/repo.rs:511-523`) **silently
moved** it between groups on re-add.
[ATOM-04](./ATOM-04-many-to-many-group-membership.md) removes that constraint per
[spec A1](../architecture.md#8-decision-record), so:
- `AddGroupMember` is **additive**. An entity in group A added to group B is in
both.
- Re-adding to a group it already belongs to is idempotent.
- `RemoveGroupMember` takes a group ID and removes only that membership.
- `EntityGroups` returning a list (`groups.rs:104`) is now literally correct.
**Do not start this PRD before ATOM-04 lands.** Building against the old
move-on-conflict semantics produces a client whose documented behaviour inverts
under it.
### Consequence for grants
A device can now reach a permission block through more than one group. Nothing in
the client changes for that, but it means removing a device from one sharing
group does **not** necessarily revoke access — another group may still grant it.
Any UI showing "who can see this device" must ask
[ATOM-03](./ATOM-03-reverse-policy-lookup.md) rather than inferring from a single
group.
## Acceptance criteria
1. Create an object group, add three devices, list members — all three returned.
2. Remove one; the remaining two are returned.
3. `EntityGroups` for a member returns every group it belongs to.
4. A device added to two groups appears in both member listings, and
`RemoveGroupMember` on one leaves the other intact.
5. Create a nested group by passing `parentId` at creation; `ChildGroups` on the
parent returns it.
6. `SetGroupParent` / `RemoveGroupParent` reparent an existing group.
7. Object and principal groups are created in the correct namespace, verified by
listing each kind separately.
8. Members from another tenant are rejected.
## Test plan
- Integration (Atom in Docker) for all of the above — membership semantics cannot
be established against a mock, since they live in Atom's schema and repository
layer.
- Explicit test for criterion 4; multi-group membership is the whole point of
ATOM-04 and the behaviour most likely to regress.
- Hierarchy: 3-level tree, assert direct vs descendant listing at each level.
- Authorization: a caller without `manage` on the group is refused
(`groups.rs:660-668`).
## Risks
- **Sequencing.** Built against pre-ATOM-04 Atom, `AddGroupMember` moves rather
than adds. Every acceptance criterion around multi-group membership would pass
vacuously or invert. Gate on ATOM-04.
- **Revocation is no longer "remove from the group".** With multi-group
membership, a device may retain access through another group. Anything that
presents removal as revocation will be wrong; this needs to be explicit in the
method documentation and in whatever UI consumes it.
+145
View File
@@ -0,0 +1,145 @@
# MG-04 — Group-scoped permission blocks
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 |
| **Depends on** | MG-01, MG-03 |
| **Blocks** | MG-08 |
| **Status** | Draft |
## Problem
`pkg/atom` writes only three of Atom's ten scope modes — `object`, `tenant` and
`platform` (`policy_service.go:196-205`). Everything that is not a domain or the
platform collapses to a single-instance `object` block (`:203`).
Granting a customer read access to 500 meters therefore means 500 permission
blocks and 500 direct policies, because `AddPolicy` (`:38-67`) creates a fresh
block per call with no reuse. That is slow to write, expensive to evaluate, and
walks straight into the truncating revocation path fixed in MG-01.
Atom supports exactly what is needed and it is unreachable from Go.
## What Atom supports
`scope_mode` accepts (`migrations/001_initial.sql:607`):
```
platform, tenant, object_kind, object_type, object,
group, group_direct_objects, group_descendant_objects,
group_child_groups, group_descendant_groups
```
Constraints per mode at `:619-627`. For `group_direct_objects` /
`group_descendant_objects`: `tenant_id` and `group_id` required, `object_kind ∈
{entity, resource}`, `object_id` must be null (`:625`).
Atom computes the scope reference as `{group_id}:{object_type}`
`src/authz/engine.rs:1334` produces `"{group_id}:entity:device"`, matching
`001_initial.sql:729`.
## Scope
**In scope**
- Extend `policyGrantScopeMode` (`policy_service.go:196-205`) and the companion
`policyGrantObjectKind` / `ObjectType` / `ObjectID` functions (`:207-248`) to
emit group-scoped blocks.
- A direct API for the customer-sharing case rather than squeezing it through the
legacy `policies.Policy` shape:
```go
GrantGroupAccess(ctx, GroupGrant{
TenantID, GroupID, SubjectKind, SubjectID,
ObjectKind, ObjectType, Actions []string,
IncludeDescendants bool,
}) error
RevokeGroupAccess(ctx, GroupGrant) error
ListGroupGrants(ctx, groupID) ([]GroupGrant, error)
```
- `directPolicyMatches` (`:250-266`) must compare `GroupID` too, or revocation
will not match group-scoped blocks.
**Out of scope**
- `object_kind` / `object_type` scope modes ("every device in the tenant"). No
requirement yet; adding unused grant shapes is how permission models rot.
- `group_child_groups` / `group_descendant_groups` (grants over *groups*, not
their contents). Add when group-management delegation is actually needed.
- Block reuse for `object`-scoped grants. Group scoping removes the case that
made it urgent.
## Design
### Grant shape
One block, one policy, per customer:
```
PermissionBlock {
scope_mode: "group_direct_objects",
tenant_id: <domain>,
group_id: <customer group>,
object_kind: "entity",
object_type: "entity:device",
effect: "allow",
actions: [read]
}
DirectPolicy { subject_kind: "entity", subject_id: <customer user>, … }
```
Adding a meter to the customer's group grants access. Removing it revokes.
**Membership becomes the sharing operation** — no policy write per device.
### Direct vs descendant
`group_direct_objects` covers immediate members only; `group_descendant_objects`
walks the tree. Customer → Site → meters requires descendant scoping if the grant
is made at the customer level. Expose it as `IncludeDescendants` and make the
default explicit rather than implied.
### Interaction with MG-01
Uses the namespaced object-type helper from MG-01. Do not reconstruct
`"entity:device"` here — that duplication is the exact cause of the MG-01 bug.
## Acceptance criteria
1. Granting a subject `read` over a group produces **one** block and **one**
direct policy, regardless of member count.
2. `authzCheck(subject, "read", entity, <member device>)` returns allowed.
3. A device **not** in the group is denied.
4. Adding a device to the group grants access with no policy write.
5. Removing it revokes access.
6. `IncludeDescendants: true` reaches devices in child groups;
`false` does not.
7. `RevokeGroupAccess` removes the block and policy; access is denied afterwards
for all members.
8. A grant over a 500-member group is a constant number of writes — asserted, not
assumed.
9. Existing object- and tenant-scoped grants are unchanged.
## Test plan
- Unit: scope-mode selection across every input; `directPolicyMatches` including
the `GroupID` comparison.
- Integration (Atom in Docker): the full customer scenario from
[architecture.md §5.2](../architecture.md#35-groups-and-sharing) — two customers,
three meters, one gateway; assert each customer sees only their own.
- Descendant scoping over a 3-level tree.
- Write-count assertion for criterion 8.
- Regression: existing connection grants (`grpc_compat.go:112-129`) still work.
## Risks
- **Revocation must match on `group_id`.** If `directPolicyMatches` is not
extended, `RevokeGroupAccess` silently no-ops and access persists. This is the
single highest-risk line in the PRD and needs a dedicated test.
- **Multi-group membership changes what revocation means.** Following
[spec A1](../architecture.md#8-decision-record) and ATOM-04, a device can reach grants
through several groups. `RevokeGroupAccess` on one group is therefore *not*
"this subject can no longer see this device" — another group may still grant
it. Acceptance criterion 7 must be read as "denied **for members reachable only
through this group**", and any UI phrasing revocation as absolute will be
wrong. Use [ATOM-03](./ATOM-03-reverse-policy-lookup.md) to answer "who can
still see this?"
+147
View File
@@ -0,0 +1,147 @@
# MG-05 — Topic grammar: device segment and `device_id`
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P2 |
| **Depends on** | — |
| **Blocks** | MG-06 |
| **Status** | Draft |
## Problem
A gateway publishing for 200 meters produces 200 streams of data attributed to
one publisher. `Message.publisher` is stamped by the broker from the
authenticated connection and cannot carry per-meter identity, so per-meter views
are impossible.
Two identities are needed, answering different questions:
| Field | Question | Set by | Spoofable |
|---|---|---|---|
| `publisher` | Who sent it | Broker, from the authenticated connection | No |
| `device_id` | Whose data it is | Taken verbatim from the topic | Yes, within the publisher's channels |
`publisher` semantics stay **exactly** as they are. This PRD only adds.
## Scope
**In scope**
- Topic grammar:
```
m/<domain>/c/<channel>[/d/<device>][/<subtopic>]
hc/<domain>
```
`<device>` is the device's **serial** — an arbitrary string, carried verbatim.
Serial *is* the device id in this model
([spec §8 A8](../architecture.md#8-decision-record)); there is no separate platform-assigned
identifier in the topic.
**Slash is the one constraint**, since it is the topic separator. Uppercase,
`.`, `-`, `:` and unicode pass through untouched. A serial containing `/` is
rejected at device creation (MG-09), never mangled or encoded here —
percent-encoding was rejected because it puts encode/decode on the publish path
and breaks "verbatim".
- `DeviceTopicPrefix = 'd'` alongside `MsgTopicPrefix` / `ChannelTopicPrefix`
(`pkg/messaging/topics.go:20-23`).
- Extend `ParseTopic` (`topics.go:373-452`) and every caller of the changed
signature: `ParsePublishTopic` (`:249`), `ParseSubscribeTopic` (`:283`),
encoders (`:333-364`), FluxMQ variant (`pkg/messaging/fluxmq/topic.go:82-97`).
- `string device_id = 10` on `pkg/messaging/message.proto:10-21`; regenerate.
- Populate in the inbound constructor (`pkg/messaging/fluxmq/pubsub.go:233-252`)
and mirror in `messageProperties` (`pkg/messaging/fluxmq/publisher.go:146-162`)
so republishes preserve it.
**Republish preservation is what makes aggregation attribute correctly.** Per
[spec §2.2](../architecture.md#22-what-follows-from-that--normative)
consequence 5, a value computed *about* meter-7 — a daily total, a rolling
average — belongs to meter-7 and must carry its `device_id`, not the identity of
whatever computed it. Dropping `device_id` on republish would silently
re-attribute every derived value.
**Out of scope**
- **Resolution.** The device segment is carried **verbatim** — it is the device's
external identifier (serial), not an Atom UUID, and is never looked up on the
publish path ([spec §8 A8](../architecture.md#8-decision-record)). Earlier drafts of this PRD
specified route→ID resolution through the ristretto cache; that is removed. A
per-message entity lookup is exactly what channel-as-boundary exists to avoid.
- **Attachment validation.** There is none — the channel is the boundary
([spec §8 A7](../architecture.md#8-decision-record)). [MG-07](./MG-07-gateway-attachment-enforcement.md)
is withdrawn.
- Persistence and query — MG-06.
- Command/downlink topics.
## Design
### Why a marked segment
Subtopic is already load-bearing: `pkg/transformers/json/transformer.go:69` uses
the **last** subtopic segment as the destination table name. Device IDs in the
subtopic would create a table per device. The `d` marker leaves subtopic
semantics untouched and makes the device position unambiguous.
### Parsing
After the channel ID, if the next segment is exactly `d`, the following segment
is the device and the remainder is subtopic. `ParseTopic` is a hand-rolled byte
scanner with no regex — keep it that way; it is on the hot path.
### The reserved segment
`m/dom/c/chan/d/x` is ambiguous: `x` could be a device, or `d/x` a subtopic.
Resolve by **reserving `d` as a first subtopic segment**. Publishing to a
subtopic beginning with a `d` segment is rejected at validation
(`ParsePublishSubtopic`, `topics.go:262-281`).
Breaking, deliberate, and cheap before 1.0. It must be documented in the topic
grammar comment (`topics.go:366-372`) and the messaging README, not left as a
parser quirk.
### Per-message, not per-record
Attribution comes from the topic, so it is per message — like `publisher`. One
publish carries one device's data. This is simpler than the SenML `bn` route
(no base-name tracking, no split after `Normalize`, no per-record resolution) at
the cost of no multi-sensor batching in a single pack.
## Acceptance criteria
1. `m/dom/c/chan/d/dev123/sub/topic` parses to domain, channel, device `dev123`,
subtopic `sub/topic`.
2. `m/dom/c/chan/d/dev123` parses with an empty subtopic.
3. `m/dom/c/chan/sub/topic` parses with an empty device — existing behaviour is
byte-identical.
4. Publishing to a subtopic whose first segment is `d` is rejected with a clear
error.
5. The device segment is carried **verbatim**, including uppercase, `.` and `-`.
No lookup occurs and no error is raised for an unknown identifier — the
publish path performs zero entity resolution, asserted by counting Atom calls.
6. `Message.DeviceId` is populated end to end from an MQTT publish.
7. Republishing through the writer path preserves `device_id`.
8. Existing topics without a device segment are unaffected everywhere —
subscribe, health, wildcards.
## Test plan
- Table-driven `ParseTopic` tests extending `pkg/messaging/topics_test.go`: with
and without device, with and without subtopic, leading slash, the reserved-`d`
case, malformed forms (`d` with no device, trailing `d/`), and wildcards in
subscribe topics.
- Zero-resolution: assert the publish path makes no Atom call, including for an
identifier no device holds.
- Integration: publish over MQTT with a device segment; assert `device_id` on the
consumed message.
- Regression: the full existing topic suite must pass untouched — this is the
primary guard for criterion 8.
## Risks
- **Signature change to `ParseTopic` fans out.** It is called from the broker hot
path and from FluxMQ's own topic handling. Missing a caller is a compile error
in-repo, but check for callers outside it.
- **Hot-path cost.** The scanner runs per publish. The device branch adds one
segment comparison; keep allocations at zero and benchmark before merging.
- **Reserved `d`** breaks any existing deployment publishing to such a subtopic.
Low likelihood, but it is a real break and belongs in release notes.
+139
View File
@@ -0,0 +1,139 @@
# MG-06 — Persist and filter `device_id`
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P2 |
| **Depends on** | MG-05 |
| **Blocks** | MG-08 |
| **Status** | Draft |
## Problem
MG-05 puts `device_id` on the message. Nothing persists it and nothing can query
by it, so per-meter history — the actual user-visible requirement — still does
not exist.
## Scope
**In scope**
- `device_id` column on the SenML tables in both backends:
`consumers/writers/postgres/init.go:15-43`,
`consumers/writers/timescale/init.go:15-69`.
- Same for the on-demand JSON tables
(`consumers/writers/postgres/consumer.go:158-173`).
- Carry `device_id` through both transformers into their message structs
(`pkg/transformers/senml/transformer.go:59-91`,
`pkg/transformers/json/transformer.go:48-58`) and the INSERTs
(`postgres/consumer.go:53-58`, `timescale/consumer.go:59-64`).
- `DeviceIDs []string` on `readers.PageMetadata` (`readers/messages.go:43-63`),
with the `= ANY(:device_ids)` condition in both backends.
- Expose on all three transports: gRPC proto
(`internal/proto/readers/v1/readers.proto`), HTTP query key
(`readers/api/http/transport.go:27-49`), SDK (`pkg/sdk/sdk.go:83`).
**Out of scope**
- Authorization. `DeviceIDs` here is a **convenience filter**, exactly like
`publishers` today. MG-08 makes it a boundary. Do not half-enforce it here —
partial enforcement is worse than none, because it reads as a guarantee.
- Backfill of historical rows.
## Design
### Follow the merged template
Commit `14d6db968` ("Add multi-publisher filter to readers PageMetadata", #3550)
is a complete worked example of this exact change: struct field with `omitempty`,
param bind, `= ANY(...)` condition in both backends, proto field, gRPC wiring.
Mirror it.
Note that commit added `publishers` to **gRPC only** — no HTTP query key, not in
the SDK. Close that gap for `publishers` while adding `device_ids`, so the two
filters are not asymmetric across transports.
### Filter mechanics
The WHERE builders JSON-marshal `PageMetadata` and iterate the resulting map, so
`omitempty` is what makes a filter "unset". Consequences to respect:
- `DeviceIDs: []string{}` is omitted entirely — an empty slice cannot mean "match
nothing". **MG-08 depends on this distinction**, because an authorized set that
is legitimately empty must return zero rows, not all rows. Either represent it
as `*[]string`, or have MG-08 short-circuit before reaching the query. Decide
here and write it down; discovering it in MG-08 means reworking both.
### What `device_id` contains — state this before writing any SQL
**The device's serial, verbatim.** Not a platform UUID, not a resolved entity
reference — the exact string that appeared in the publish topic
([spec §2.5](../architecture.md#25-message-attribution),
[§8 A8](../architecture.md#8-decision-record)).
Consequences that shape this PRD:
- The column is `TEXT`. There is no foreign key and can be none — the publish path
performs no lookup, by design.
- **Rows may reference devices that do not exist.** Late binding means data
arrives for serials with no device record, and is stored anyway. Registering the
device later makes its history queryable retroactively. Nothing here may reject
or quarantine such rows.
- **MG-08 must translate.** `authorizedObjectIds` returns Atom UUIDs; this column
holds serials. The authorized set is mapped UUID → `external_id` before it can
filter. Getting this wrong yields a filter matching nothing, which presents as a
permissions bug.
- Serials never contain `/` (rejected at device creation, MG-09), so no escaping
or encoding is involved at any layer.
### Schema
Primary keys currently include `publisher` — Postgres `(time, publisher,
subtopic, name)` (`postgres/init.go:37-43`), Timescale `(time, channel, subtopic,
protocol, publisher, name)` (`timescale/init.go`). Add `device_id` **alongside**;
do not replace `publisher`, which remains the audit identity.
Timescale indexes lead with `channel` and end with `name, time DESC`. Add
`(channel, device_id, name, time DESC)` to match that convention.
### Migration
Existing rows have no device. `device_id` must be nullable (or empty-string
default) and every query must treat "no device" as a first-class case — direct
publishers legitimately have none.
## Acceptance criteria
1. A message published with a device segment is stored with `device_id` set to
the **exact serial string** from the topic — byte-identical, including case
and `.`/`-`/`:` characters.
1a. A message for a serial with **no device record** is stored and queryable, and
becomes attributable once that device is created.
2. A message published without one is stored with `device_id` empty/null, and
every existing query returns identical results to before.
3. `DeviceIDs` filtering returns only matching rows, in both backends.
4. It composes with `publisher`, `publishers`, `subtopic`, `name`, time range and
aggregation.
5. Available over gRPC, HTTP and SDK, with consistent semantics.
6. `publishers` is reachable over HTTP and SDK, closing the #3550 gap.
7. A SenML pack fanned out to N rows carries the same `device_id` on every row.
8. Migration against a populated table succeeds and leaves existing rows queryable.
## Test plan
- Unit: WHERE-builder output for `DeviceIDs` set, unset, single and multiple;
and explicitly for the empty-slice case, asserting the decision above.
- Integration (`ory/dockertest`, as the reader/writer suites already use):
write messages with distinct `device_id`s to one channel, assert filtering in
both backends, assert `total` correctness under pagination.
- Migration: run against a table seeded with pre-change rows.
- Regression: full existing reader suite.
## Risks
- **Primary-key change on a hypertable.** Timescale PK changes on a populated
hypertable can be expensive or need a rewrite. Confirm the migration strategy
against a realistically sized table before merging — this is the highest-risk
operational item in the PRD.
- **The empty-slice semantics** are a genuine trap. Called out above precisely so
MG-08 does not inherit it as a surprise.
@@ -0,0 +1,59 @@
# MG-07 — Gateway publish-on-behalf-of enforcement
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | — |
| **Status** | **WITHDRAWN** |
| **Superseded by** | [spec §8 A7](../architecture.md#8-decision-record) |
---
## Withdrawn
This PRD existed to enforce a `gateway_id` attachment on the publish path:
```
allow if device.gateway_id == publisher
```
**The attachment it enforced no longer exists.** One device can broadcast to
several gateways — a BLE meter heard by three gateways in range is a normal
deployment, not an error — so a scalar `gateway_id` cannot represent the
relationship at all.
Per [spec §8 A7](../architecture.md#8-decision-record), **the channel is the boundary**: a
gateway connected to a channel may publish any `device_id` on it. There is no
per-device authorization on the publish path and no device lookup, which is the
point — the hot path stays lookup-free.
## What happened to its parts
| Was | Now |
|---|---|
| `gateway_id == publisher` check | Removed. Channel authorization, which already exists, is the whole control. |
| Attachment cache | Removed. Nothing to cache. |
| Cache invalidation on re-homing | Removed. Nothing to invalidate. There is no re-homing — a device is simply heard by different gateways over time. |
| `provisioning_state` deny clause (B1) | Removed. Superseded by late binding — see [spec §8 A8](../architecture.md#8-decision-record); data is stored whether or not a device entity exists. |
| "Which devices does this gateway serve?" | Derived, not stored: `DISTINCT device_id WHERE publisher = <gateway>` over the message store (MG-06). Handles many-gateways-per-device for free. |
## The accepted risk
A compromised gateway can fabricate readings for any `device_id` on channels it
holds — including impersonating another customer's meter if they share a channel.
This was accepted deliberately in exchange for a lookup-free publish path. The
mitigation is deployment-level: **segregate channels per site or customer** where
cross-fabrication matters. That guidance belongs in operator documentation, and
is recorded in A7 rather than lost here.
## If this needs revisiting
The trigger would be a requirement for per-device publish authorization — for
example a multi-tenant gateway estate where channels cannot be segregated. The
shape it would take is a group-scoped `publish_on_behalf_of` grant
(devices in an object group, gateways granted over that group via
`group_direct_objects`), which composes with ATOM-04's many-to-many membership
and would let several gateways serve one device without a scalar attachment.
That was the alternative considered and not taken.
+185
View File
@@ -0,0 +1,185 @@
# MG-08 — Reader authorization: enforce per-device access
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P3 |
| **Depends on** | MG-01, MG-06, ATOM-02, ATOM-06 |
| **Blocks** | — |
| **Status** | Draft |
## Problem
Reader authorization is not a security boundary.
`readers/api/http/transport.go:251-266` authorizes `subscribe` on the **channel**,
then applies the caller-supplied `publisher` / `publishers` query filters without
validating them (`readers/messages.go:49-50`, applied at `transport.go:175`).
**Any user who can read a channel can read every publisher's messages on it by
changing a query parameter.** The filter is a convenience, not a control.
This is a live issue today, independent of the device work — the `publisher`
filter has always behaved this way. MG-06 adds `device_ids` with the same
property. This PRD makes both boundaries.
Without it, the customer requirement — *"customer A sees meters 1 and 3, not
meter 2"* — cannot be satisfied, because meter 2's data is one query parameter
away.
## Scope
**In scope**
- After the existing channel check, resolve the caller's authorized device set
and intersect it with the requested filter.
- Same treatment for `publishers`.
- Wire `PolicyService` into the reader binaries — it is `nil` everywhere today
(`cmd/auth/main.go:182`; readers construct only `channels` at
`cmd/timescale-reader/main.go:109`, `cmd/postgres-reader/main.go:109`).
- Bypass for domain admins, who legitimately read the whole channel.
- Cache the resolved set per `(subject, domain)` with a short TTL.
**Out of scope**
- Changing the channel-level check. It stays; device scoping narrows within it.
- Per-message ACLs.
- Subscribe-side (live MQTT) device scoping — different path, different PRD.
## Design
### Resolution
Use `authorizedObjectIds` via `PolicyService.ListAllObjects`
(`pkg/atom/policy_service.go:128-156`):
```
subjectID: <caller>
action: "read"
objectKind: "entity"
objectType: "entity:device" // namespaced — see MG-01
tenantID: <domain>
```
Requires MG-01 (the `objectType` fix and the `isSupportedObjectList` widening) or
this returns nothing.
### The translation step — easy to miss, and it fails silently
`authorizedObjectIds` returns Atom entity **UUIDs**. Messages carry `device_id`
as the device's **external serial string** ([spec §8 A8](../architecture.md#8-decision-record)),
because the publish path performs no lookup. The authorized set must therefore be
mapped UUID → `external_id` before it can filter anything.
Skipping this produces a filter that matches no rows and presents as a
permissions bug — the caller sees an empty result and concludes they have no
access. Requires ATOM-06.
The mapping is one indexed query over a small set, cacheable alongside the
authorized set itself.
### Orphan data
Rows whose `device_id` has no device entity cannot be granted to anyone — there
is no object to grant. They stay readable through channel-level access only,
which is the correct default: unregistered data is visible to operators and never
to customers. No special handling is needed; it falls out of the intersection.
### Intersection rules
| Caller supplied | Behaviour |
|---|---|
| Nothing | Filter by the full authorized set |
| A subset of the authorized set | Use it as given |
| IDs outside the authorized set | Silently drop them — return the intersection, not an error |
| Only unauthorized IDs | Empty result |
Dropping rather than erroring avoids leaking which device IDs exist.
**The empty set must mean "no rows", never "no filter".** This is the sharpest
failure mode in the PRD: a subject with zero authorized devices must get zero
rows. MG-06 flags that `omitempty` erases an empty slice from the query — so
either use a pointer type or short-circuit before building the query. Confirm
which, and test it directly.
### Scale
Materialising every authorized ID into `= ANY(...)` degrades on large fleets.
Two mitigations, in order:
1. **Prefer server-side narrowing.** ATOM-02 exposes `parentGroupId`,
`includeDescendants` and `attributesContains` on `authorizedObjectIds`, so the
set can be narrowed in Atom rather than materialised in Go.
2. Cache per `(subject, domain)` with a short TTL. Group-scoped grants (MG-04)
keep the practical set small.
If fleets outgrow both, the filter has to move into Atom entirely. Worth knowing
before it bites rather than after.
### Admin bypass
Per [spec §8 B3](../architecture.md#8-decision-record): determine "may read all devices in this
domain" from an **explicit tenant-scoped capability check**.
Not by string-matching a role name, and specifically **not** by treating an empty
authorized set as "unrestricted" — two opposite situations produce an identical
empty list:
| Caller | Per-device grants | Should see |
|---|---|---|
| Domain admin | none — holds a *tenant-wide* grant | everything |
| User with no access at all | none | nothing |
Reading empty as unrestricted gives both everything, so the caller with the
fewest permissions receives the most data. Asking the capability question
directly makes empty unambiguously mean "no access".
## Acceptance criteria
1. A user granted `read` on meters 1 and 3, querying a channel carrying all
three, receives data for 1 and 3 only.
2. The same user requesting `device_ids=2` receives **empty**, not meter 2's data.
3. Requesting `device_ids=1,2` returns meter 1 only.
4. A user with **no** device grants receives empty, not everything.
5. A domain admin receives all three.
6. Equivalent behaviour for `publishers`.
7. Unauthorized IDs are dropped silently — the response does not reveal whether
they exist.
8. Behaviour is identical across HTTP, gRPC and SDK.
9. The channel-level check still rejects users without `subscribe`.
10. A customer granted a device sees its data, proving the UUID → `external_id`
translation works end to end. This is the criterion that catches a missing
translation, which otherwise looks like an authorization failure.
11. Orphan data — `device_id` with no entity — is visible to a channel-level
reader and invisible to a device-scoped customer.
12. **Revoking a customer's grant ends their access within the stated TTL**, and
immediately if MG-14 is present. The TTL *is* the revocation SLA, so it needs
a criterion rather than living only in the test plan.
## Test plan
- **Regression test first.** Criteria 14 written against current `main` must
fail. If they pass, the test is wrong.
- Integration (`ory/dockertest` + Atom): the full matrix, both backends.
- Cache: grant, query, revoke, query again — access ends within the stated TTL.
- Admin bypass: explicitly assert it is capability-driven by testing a
non-admin with a large grant set and an admin with none.
- Performance: query latency with an authorized set of 1, 100 and 10 000 devices.
## Risks
- **Empty-set inversion** — treating "no authorized devices" as "no filter" turns
this control into a full disclosure. Criterion 4 is the guard and must be an
explicit test, not incidental coverage.
- **Stale cache after revocation** leaves a window where a revoked customer still
reads data. Atom publishes `direct_policy.delete`, `group_member.remove` and
`entity.parent_group.clear` (`src/events/publisher.rs`), so
[MG-14](./MG-14-atom-event-consumer.md) makes invalidation deterministic rather
than TTL-bounded. Keep the TTL as the correctness floor: with the broker down,
the revocation SLA falls back to it, so it must stay short enough to be
defensible on its own.
- **Existing deployments may rely on the current permissive behaviour.** A user
who reads a channel today and sees everything will see less. That is the point,
but it is a behavioural break and belongs in release notes.
- Depends on ATOM-02 for the scalable path. Without it, ship the materialised
version and accept the ceiling — but do not silently skip enforcement.
+257
View File
@@ -0,0 +1,257 @@
# MG-09 — Device and Gateway model, SDK and protos
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P4 |
| **Depends on** | MG-03, ATOM-01, ATOM-06 |
| **Blocks** | MG-10, MG-11, MG-12 |
| **Status** | Draft |
## Problem
`Client` fuses connectivity identity with data-producing identity. Everything in
P1P3 works around that fusion internally; this PRD makes the split visible in
the API.
**This is not a rename for readability.** `Device` differs from `Client` in
substance: credentials are optional, it carries a type and a serial, and it can
be attached to a gateway. If the only change were the label, it would not be
worth the churn.
Widest blast radius in the programme — this freezes the public API shape.
## Scope
**In scope**
- `Device` replacing `Client` in `pkg/sdk`, with the new fields.
- `Gateway` as a first-class API surface over the same underlying entity.
- The device→gateways reachability relation.
- Proto rename and reshaping.
- Clean break: no `Client` type, no `/clients` route, no alias.
**Out of scope**
- Device types — MG-10.
- CLI, PAT scopes, permissions, OpenAPI — MG-11.
- Provisioning flows — MG-12/13.
- Channel/connection model, which is unchanged.
## Design
### Model
`Device` gains, over today's `Client` (`pkg/sdk/clients.go:28`):
| Field | Meaning |
|---|---|
| `Serial` | **The device identifier.** Arbitrary string, Atom `external_id`, unique per domain (ATOM-06). Appears verbatim in publish topics. Independent of Bootstrap `ExternalID` (C3) |
| `DeviceTypeID` | Atom `profile_id` |
| `DeviceTypeVersionID` | Atom `profile_version_id` |
| `ProvisioningState` | `pending` / `provisioned` / `rejected` — an **attribute**, per spec §8 A2. Lifecycle only; nothing on the publish path reads it |
**No `GatewayID`.** There is no stored gateway↔device link
([spec §8 A7](../architecture.md#8-decision-record)) — one device can be heard by several
gateways, which a scalar field cannot express. A gateway's device list is derived
from message traffic ([MG-15](./MG-15-gateway-device-view.md)).
`Credentials` becomes **optional** — the single property that makes one model
serve both a BLE meter and an NB-IoT meter.
### Gateway
A Gateway is **a Device with `IsGateway` set** — a capability, not a type
([spec §8 A12](../architecture.md#8-decision-record)). Every device, gateway or
not, is Atom `entity_kind: device`.
```go
type Device struct {
...
IsGateway bool `json:"is_gateway,omitempty"`
Gateways []GatewayLink `json:"gateways,omitempty"`
}
// GatewayLink is one reachability edge. Address is present only for
// bus-addressed protocols (Modbus, M-Bus primary) and is **opaque** —
// Magistrala stores, renders and compares it, never parses it.
type GatewayLink struct {
ID string `json:"id"`
Address map[string]any `json:"address,omitempty"`
}
```
**The capability composes.** A device may be a gateway *and* report its own
measurements — a concentrator-meter is one device with one type doing both jobs.
Nothing may treat gateways as a disjoint population.
`/gateways` remains a first-class API surface; it lists devices where
`is_gateway` is true, via `attributesContains` (ATOM-01).
Because gateways stay `entity_kind: device`, the existing
`{entity_kind: device, publish, resource:channel}` guardrail
(`pkg/atom/bootstrap.go:72-87`) keeps applying — no new assignment rules, and
none of the silent publish-permission breakage a separate kind would have caused.
### The reachability relation
A device declares which gateways it is reachable through
([spec §2.3](../architecture.md#23-the-relation-is-a-relation--not-a-group)).
The relation is a property of the **device**, 0..N, and is *not* containment —
see [spec §2.1](../architecture.md#21-a-gateway-is-a-path-not-a-container).
```go
type Device struct {
...
Gateways []string `json:"gateways,omitempty"` // 0..N gateway IDs
}
SetDeviceGateways(ctx, deviceID, gatewayIDs []string, domainID, token) error
DeviceGateways(ctx, deviceID, domainID, token) ([]Gateway, error)
GatewayDevices(ctx, gatewayID, pm, domainID, token) (DevicesPage, error)
```
`SetDeviceGateways` replaces the whole list rather than offering attach/detach —
the list is short, and replace-semantics make the full-list write explicit.
**It needs optimistic concurrency, and Atom does not provide it.**
`update_entity` (`src/identity/repo.rs:335-341`) is `COALESCE($n, col)`: last
write wins, no version check. Two operators commissioning the same device
concurrently will silently lose one edit. Until Atom offers an `If-Match`, this
PRD must either serialise the update Magistrala-side or document last-write-wins
explicitly. **Do not leave it unstated** — the failure is silent.
`GatewayDevices` is the reverse lookup: devices whose `gateways` array contains
this gateway. Needs **ATOM-01** array containment; without it the only fallback
is fetching every device in the domain, which paginates incorrectly.
This is the **declared** relation only. What a gateway has *actually* relayed for
is observed from traffic and lives in
[MG-15](./MG-15-gateway-device-view.md), which merges the two.
**Why it exists beyond the UI.** The declared relation is the authoritative
source for **generating gateway config**. Self-identifying protocols need no
mapping — a wM-Bus telegram carries its serial, which flows through to the topic
and to `external_id` untouched. Bus-addressed protocols do: a Modbus unit ID is
an address, not a serial, and the gateway cannot derive one from the other. That
mapping lives in agent config, and generating it needs the authoritative serial
list for a gateway — which is exactly this query.
### Address conflicts — decide where this lives
Two devices declaring the **same bus address on the same gateway** is a
commissioning error: the agent would poll one unit and attribute it to two
devices. It is detectable without parsing the blob — byte-equality via containment
(`spec §3.3`) — so the "never interpret" rule survives either way.
Unassigned as yet:
- **Reject at write time**, here. Catches it at the point of the mistake, but
needs a containment query on every `SetDeviceGateways`.
- **Surface as a warning** in the gateway view (MG-15). Cheaper, and tolerates the
transient state during a bulk re-commission.
Recommend rejecting here, on the grounds that a silent mis-attribution is worse
than a rejected write. Decide before build; do not leave it to whoever notices.
### Deletion must not cascade
Per [spec §2.2](../architecture.md#22-what-follows-from-that--normative),
consequence 2: **deleting a gateway never deletes devices.** It is a path, not a
container. Deletion leaves stale IDs in the devices that named it; they are
resolved and dropped on read, and optionally swept.
Equally, deleting a *device* must not touch its gateways.
### Serial validation
Serial is the device identifier and appears verbatim in publish topics, so
**a serial containing `/` must be rejected at creation** — it would silently
change the topic's shape. That is the only format constraint Magistrala imposes;
everything else is Atom's business (ATOM-06 stores it unconstrained).
Open, and needed before this API freezes: case sensitivity, whitespace trimming,
and whether `Serial` may be changed after creation. Changing it orphans the
device's message history, which is denormalised on every row — so the likely
answer is that it is immutable in Magistrala even though Atom permits the update.
### `provisioning_state`
Atom's `entities.status` is constrained to `active/inactive/suspended`
(`001_initial.sql:96`), so this lives in attributes — confirmed by
[spec §8 A2](../architecture.md#8-decision-record).
It is **not** Bootstrap enrollment state and must not be presented as a synonym:
sensors never enroll, and MG-07 reads this on the publish hot path where a
Bootstrap call does not belong. Atom's `status` means something different and
stays untouched.
### Surfaces
- SDK: `pkg/sdk/clients.go``devices.go`; new `gateways.go`. Interface block at
`pkg/sdk/sdk.go:712-840`.
- Protos: `internal/proto/clients/v1/clients.proto``devices/v1/devices.proto`;
`ClientsService``DevicesService`. Regenerate into `api/grpc/devices/`.
- `internal/proto/common/v1/common.proto:52` `Connection` — assess whether it
needs a device field or whether channel connections stay gateway-level.
- `pkg/atom/mapping.go:6-12`: `KindClient``KindDevice`. No gateway kind.
- `is_gateway` and `gateways` carried as entity attributes.
## Acceptance criteria
1. Create a device with no credentials; it persists and is retrievable.
2. Create a device with credentials; it can authenticate and publish.
3. Create a gateway; it holds credentials and is listed under `/gateways`. It is
stored as Atom `entity_kind: device` with `is_gateway` set.
3a. A gateway can publish and subscribe on a connected channel, with **no new
assignment rules** — the existing device guardrail covers it.
3b. **A device can be both**: one device with `is_gateway` set that also reports
its own measurements appears in `/gateways` *and* `/devices`, and both its own
readings and its relayed traffic are attributed correctly.
4. A device can declare 0, 1 and 3 gateways in turn; `DeviceGateways` and
`GatewayDevices` agree in both directions each time.
4a. **Deleting a gateway leaves its devices intact**, with the stale reference
dropped on read. Deleting a device leaves its gateways intact.
5. A device's data published by two different gateways is attributed to that one
device — the model admits many gateways per device.
6. Group membership and grants are unaffected by which gateway published a
device's data, and by changes to the reachability relation.
6a. Access to a gateway grants **no** access to the devices reachable through it,
and vice versa (spec §2.2, consequence 3).
7. `Serial` is queryable and unique **within a domain**; two domains may each
hold the same serial, and a lookup in one never returns the other's device.
7a. A serial containing `/` is rejected at creation with a clear error.
8. No `Client` type or `/clients` route remains anywhere in the tree.
9. Existing channel connections and message flow are unaffected.
## Test plan
- Unit: mapping between the SDK type and Atom entity attributes, both directions.
- Integration: full lifecycle for a credential-less device, a credentialed
device, and a gateway; setting 0, 1 and many gateways; and one device that is
both a gateway and a reporter.
- Criterion 6 explicitly — re-homing is where topology and sharing are most
likely to get accidentally coupled.
- Grep-based check for criterion 8, as a test, so the break stays clean.
- Regression: messaging suite unaffected.
## Risks
- ~~**`Serial` versus Bootstrap `ExternalID`.**~~ **Resolved**
[spec §8 C3](../architecture.md#8-decision-record): independent, may coincide, nothing enforced.
This PRD is no longer blocked on MG-12. Operator documentation should recommend
using the same value; the code does not require it.
- **Clean break is unrecoverable once released.** Every SDK (TS, JS, Rust), the
UI, and the docs move together. Sequence the cutover across repos rather than
merging here and discovering the fan-out.
- **`Serial` uniqueness is per tenant** ([spec §8 C4](../architecture.md#8-decision-record)) —
two domains may each hold `ABC123`; within one domain it identifies exactly one
device. Every serial lookup must be tenant-scoped; one that is not
cross-attributes data between customers and fails silently.
**Mechanism still open:** Atom's `alias` (`001_initial.sql:104-113`) gives
uniqueness for free but constrains values to
`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`. Uppercase alphanumeric serials
normalise fine; serials containing `/`, `.` or spaces do not. **Collect real
meter serial formats before choosing** — otherwise it is a unique index on the
attribute instead.
+133
View File
@@ -0,0 +1,133 @@
# MG-10 — Device Type API surface
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P4 |
| **Depends on** | MG-02, MG-09 |
| **Blocks** | MG-11 |
| **Status** | Draft |
## Problem
MG-02 gives `pkg/atom` the ability to manage device types. Nothing external can
reach it — no HTTP route, no SDK method. Operators cannot define a watermeter
type, and the UI has nothing to render from.
## Scope
**In scope**
- `DeviceType` and `DeviceTypeVersion` in `pkg/sdk`, with the capability document
as structured fields rather than raw JSON Schema.
- CRUD + versioning over HTTP and SDK.
- Binding a device to a type (extends the MG-09 device surface).
- Listing devices by type.
**Out of scope**
- CLI and OpenAPI — MG-11.
- Command dispatch. The type *declares* commands; routing is unspecified.
- Ingest-time payload validation against the type (see Design).
## Design
### Public shape
Operators should not hand-write JSON Schema. The SDK exposes the declaration;
`pkg/atom` (MG-02) renders it to `json_schema` + `ui_schema`:
```go
type DeviceType struct {
ID, Name, Key, Description, DomainID string
Version int
Measurements []Measurement
Commands []Command
Status string // active | deprecated | disabled
}
```
The raw schema should remain readable for advanced use, but the structured form
is the documented path. If callers must drop to JSON Schema for ordinary work,
the abstraction has failed.
### Routes
```
POST /{domainID}/device-types
GET /{domainID}/device-types
GET /{domainID}/device-types/{id}
PATCH /{domainID}/device-types/{id}
POST /{domainID}/device-types/{id}/versions
GET /{domainID}/device-types/{id}/versions
GET /{domainID}/devices?device_type_id={id}
```
Hyphenated to match existing multi-word resource conventions
(`resource:bootstrap-config` in PR #3555).
### Validation semantics — state these explicitly
Atom validates entity **attributes** against the bound schema on write
(`src/identity/repo.rs:641`). It does **not** validate message payloads at
ingest — that path never touches Atom.
So a device type constrains device *metadata*, not telemetry, unless ingest-time
validation is built separately. This is a genuinely surprising distinction and
must be documented on the API, or users will assume readings are validated when
they are not.
Whether telemetry validation is wanted at all is an open question — it puts a
schema lookup on the hot path. Out of scope here; do not imply it.
### Naming
Atom `Profile` and Bootstrap `Profile` (PR #3555) are unrelated. This surface is
**Device Type** everywhere. `profile` must not appear in routes, SDK names or
docs for this concept.
### Gateway types are device types
There is no separate gateway-type surface. A gateway is a device with
`is_gateway` ([spec §8 A12](../architecture.md#8-decision-record)), so its type is
an ordinary device type in the same namespace — and a device that both measures
and relays needs *one* type declaring both, not two.
Anything presenting gateway types as a distinct catalogue would be wrong, and
would break the concentrator-meter case the capability model exists to allow.
## Acceptance criteria
1. Create a device type with measurements and commands; read it back with the
declaration intact.
2. Create a device bound to it; conforming attributes succeed.
3. Non-conforming attributes are rejected with an error naming the offending
field.
4. Create version 2; devices on version 1 continue working unchanged.
5. Deprecate version 1; new bindings are refused, existing ones unaffected.
6. `GET /devices?device_type_id=` returns exactly the bound devices.
7. Global (tenant-less) types are listed alongside domain types, if exposed at all
— per the MG-02 decision.
8. Error responses are actionable: field path, expected constraint.
9. No route, field or doc string calls this a "profile".
## Test plan
- Unit: SDK type ↔ capability document mapping; error translation from Atom's
`jsonschema` output to a field-level API error.
- Integration: full lifecycle including versioning and deprecation.
- Criterion 3 with several violation shapes — wrong type, missing required,
out-of-range — asserting each produces a usable message.
- API-shape test for criterion 9.
## Risks
- **Schema-error legibility.** Atom surfaces raw `jsonschema` crate output.
Untranslated, an operator sees a JSON pointer and a validator name. Translation
is core to this PRD, not polish.
- **Attribute-vs-telemetry validation confusion** is the most likely user
misunderstanding of the whole feature. Documentation is a deliverable here.
- **Capability model expressiveness.** Measurements and commands cover the
watermeter case. Multi-channel devices, nested structures and enumerated
states may not fit. Validate the model against two or three additional real
device types before freezing it — the API is hard to widen after 1.0.
+152
View File
@@ -0,0 +1,152 @@
# MG-11 — CLI, PAT scopes, permissions and OpenAPI
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P4 |
| **Depends on** | MG-09, MG-10 |
| **Blocks** | — |
| **Status** | Draft |
## Problem
MG-09 and MG-10 introduce Device, Gateway and Device Type. Four surfaces still
speak `client` and would leave the 1.0 API internally inconsistent: the CLI,
personal access token scopes, the permission matrix, and the published API docs.
Mechanical work, but two parts are easy to get wrong in ways that break
deployments.
## Scope
**In scope**
- CLI commands for devices, gateways and device types.
- PAT `EntityType` scopes.
- `docker/permission.yaml` entity blocks.
- OpenAPI specs.
**Out of scope**
- Model or SDK changes — MG-09/10.
- The missing CLI binary (see Risks).
- Channel role inconsistency (see Risks).
## Design
### CLI
Add `cli/devices.go`, `cli/gateways.go`, `cli/devicetypes.go`. This is **net-new,
not a rewrite** — `cli/clients.go` was deleted in `16ba29cf4` along with
`users.go`, `groups.go` and `domains.go`.
Follow the shape of the surviving `cli/channels.go:35`.
Gateway commands must include setting a device's gateways and listing a
gateway's declared devices — that is where the CLI earns its keep for field work.
`cli/gateways.go` is a thin view over devices filtered by `is_gateway`, not a
separate entity surface.
### PAT scopes
`auth/pat.go:73-99` declares `EntityType` as a positional `iota` enum. An earlier
draft of this PRD claimed renumbering would invalidate issued tokens.
**That was wrong — `EntityType` is never persisted or transmitted as a number:**
| Path | Representation | Evidence |
|---|---|---|
| Database | `entity_type VARCHAR(50)`, stores the name | `auth/postgres/init.go:97`; written `repo.go:426`, read `repo.go:554` |
| JSON | name | `auth/pat.go:155-164` |
| Text | name | `auth/pat.go:166-174` |
| gRPC | `string entity_type = 6` | `internal/proto/auth/v1/auth.proto:47` |
Per [spec §8 C2](../architecture.md#8-decision-record):
1. **Remove `ClientsType` outright**, along with `ClientsScopeStr`, its `String()`
case (`:101-126`) and its `ParseEntityType` case (`:128-153`). Renumbering is
safe.
2. Add `DevicesType` and `DeviceTypesType`. **No `GatewaysType`** — a gateway
*is* a device ([spec §8 A12](../architecture.md#8-decision-record)), so a
device-scoped PAT already covers it. A separate scope would imply a separate
population and be wrong the moment one device is both.
3. **Pin explicit values** instead of `iota`, so ordering never becomes
load-bearing by accident later.
4. Update `IsValidOperationForEntity` (`:176-181`), which enumerates
`ClientsType` today.
**Remaining work:** existing `pat_scopes` rows with `entity_type = 'clients'`
will fail `ParseEntityType` once the constant is gone. Migrate them or drop them
— a clients-scoped PAT *should* stop working once clients no longer exist.
### Permissions
`docker/permission.yaml:4-32` — replace the `clients:` block with `devices:` and
`device_types:`. **No `gateways:` block:** a gateway is a device, so device
permissions already govern it. Adding one would fragment permissions across a
population that is not disjoint.
The reachability relation needs one new operation on devices:
```yaml
devices:
operations:
- set_gateways: update_permission # or its own permission
```
Decide whether declaring a device's gateways is an ordinary update or warrants
its own permission. Its own is probably right — re-pointing a device's gateways
changes how its data reaches the platform, which is a different act from renaming
it, and least-privilege grants should be able to separate them.
`pkg/permissions/entities.go` is config-driven with string entity keys, so no
code change is needed for new entity types.
### OpenAPI
`apidocs/openapi/clients.yaml``devices.yaml`; add `device-types.yaml`.
`/gateways` routes are documented inside `devices.yaml`, since they return
devices filtered by `is_gateway` rather than a distinct resource. Update the
aggregate reference in
`apidocs/openapi/README.md`.
## Acceptance criteria
1. CLI can create, list, view, update, enable/disable and delete devices.
2. CLI can create a device with `is_gateway`, set a device's gateways, and list
the devices declared on a gateway.
3. CLI can manage device types and versions.
4. PATs scoped to `devices` authorize device operations and nothing else — and
cover gateways, since a gateway is a device.
5. A PAT issued before the change, carrying a `bootstrap` or `domains` scope,
still authorizes the same operations after the enum is renumbered — proving
ordering is not load-bearing.
6. `permission.yaml` validates at startup (`pkg/permissions` rejects unknown
entity types).
7. `set_gateways` is independently grantable from ordinary device update.
8. OpenAPI specs validate and match implemented routes.
9. No surface refers to `clients`.
## Test plan
- CLI: command-level tests following the existing `cli/` patterns.
- PAT: round-trip every `EntityType` through `String()` / `ParseEntityType` /
JSON / text marshalling — the enum has four representations
(`auth/pat.go:155-164`, `:257-320`) and they must agree.
- **Token-compatibility test**: decode a token fixture issued before the change
and assert the documented behaviour.
- Permissions: service startup with the new file; assert an unknown entity type
is rejected.
- OpenAPI: spec linting plus a route-coverage check against the router.
## Risks
- **Stale `pat_scopes` rows.** Not the enum ordering — that concern was
unfounded — but rows holding `entity_type = 'clients'` will fail to parse.
Migrate or drop them as part of this PR, not afterwards.
- **The CLI has no binary.** `cmd/cli/main.go` was deleted in `16ba29cf4`, so
`cli` is an unimported library. These commands ship unreachable unless the
binary is restored — out of scope here, but it makes acceptance criteria 13
testable only at package level. Flag it; do not silently expand scope.
- **Channels have no roles** while devices, groups and domains do
(`pkg/sdk/channels.go` has no role methods). Pre-existing and out of scope, but
1.0 freezes it. Worth a decision before release.
+196
View File
@@ -0,0 +1,196 @@
# MG-12 — Bootstrap device/gateway bindings and fleet rendering
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P5 |
| **Depends on** | PR #3555 merged and rebased, MG-09, ATOM-01 |
| **Blocks** | MG-13 |
| **Status** | Draft — **back in scope** (spec §8 A13) |
## Problem
A gateway running the Magistrala agent needs a rendered configuration: its
credentials, its channels, and — the part that does not exist — **which sensors
it fronts and how to reach them locally**. Without the fleet list the agent
cannot poll a BLE or Modbus meter, because it does not know the meter exists.
[PR #3555](https://github.com/absmach/magistrala/pull/3555) reintroduces the
Bootstrap service with profiles, binding slots, templated rendering and an
authenticated device-facing protocol. It predates the device model, so its
binding slots speak `client` and its render context knows only one device.
## Prerequisite
PR #3555 branches from `c09020a29`, where the Atom package was `internal/atom`.
It has since moved to `pkg/atom` and the PR reports `mergeable: false`. **Rebase
first.** Nothing here is actionable until it lands.
## Scope
**In scope**
- `BindingSlot.Type`: `client``device`, add `gateway`.
- Render context: gateway identity plus its attached-device fleet.
**In scope, added by [spec §8 A13](../architecture.md#8-decision-record)**
- **Render the serial → bus-address map** into gateway config, generated from the
declared relation. For each device where `gateways` contains this gateway, emit
its serial and the `address` blob on *that* edge.
- The blob is passed through **verbatim**. Magistrala does not parse it, and this
PRD must not introduce Modbus/BLE/M-Bus awareness to render it.
**Out of scope**
- **Reconciling `ExternalID` with `Serial`.** [spec §8 C3](../architecture.md#8-decision-record):
they are independent, may coincide, and nothing enforces it.
- Gateway-announced discovery — MG-13.
- The device-facing crypto protocol, which is complete and unchanged
(`bootstrap/device_bootstrap.go`).
- Bootstrap's own service architecture.
## Design
### Binding slots
`bootstrap/bindings.go` currently declares `Type ∈ "client","channel","cert"`.
Rename to `device` and add `gateway`. Since PR #3555 is unreleased, this is a
clean edit with no compatibility burden — provided it lands before release.
### Fleet in the render context
`RenderContext.Device` (`bootstrap/bindings.go`) becomes the gateway, plus:
```go
type RenderContext struct {
Gateway GatewayContext
Devices []DeviceContext // the attached fleet
Vars map[string]any
Bindings map[string]BindingContext
}
type DeviceContext struct {
ID string
Serial string
DeviceType string
Address map[string]any // opaque; the edge's address for *this* gateway
}
```
So a profile template can render the roster:
```
{{ range .Devices }}
- serial: {{ .Serial }}
type: {{ .DeviceType }}
{{- with .Address }}
address: {{ toJSON . }} {{/* opaque — rendered, never parsed */}}
{{- end }}
{{ end }}
```
Devices on self-identifying buses (wM-Bus, BLE) have no `address` and the block is
omitted.
### What the fleet list is for
Per [spec §8 A13](../architecture.md#8-decision-record), the roster tells the
agent **which meters are its own, and — for bus-addressed protocols — where to
find them.** Self-identifying buses (wM-Bus, BLE) need only the serial; the agent
matches its own scan results against the list. Modbus and M-Bus primary
addressing additionally carry the `address` blob, because the agent cannot derive
a serial from a unit ID.
So the contract is a clean split:
| | Knows |
|---|---|
| Cloud | which devices exist, their serials, their types, which gateway fronts them |
| Gateway | how to physically reach each serial on its local bus |
This keeps BLE/Modbus/M-Bus/LoRa addressing entirely out of Magistrala. The cost
is that a replacement gateway must rescan rather than inheriting a map, and
"which Modbus unit is meter ABC123 on?" is answerable only on the gateway.
**The agent's side of this contract is not designed** — see
[spec §8 E5](../architecture.md#8-decision-record). The gateway must persist the roster, its own
scan results, and the join between them, across restarts and cloud outages. That
is `absmach/agent` work outside this repo, but the *contract* — what this PRD
renders and what MG-13's announce accepts — must be settled here, or the cloud
side ships something the agent cannot consume.
One sub-question lands directly on this PRD: **does the gateway publish by serial
or by device ID?** MG-05's `d` segment accepts either. If serial resolves as a
route, the rendered roster need not carry cloud-assigned device IDs at all.
### Snapshot versus live
`BindingResolver` snapshots resources at bind time so the render path never calls
external services (`bootstrap/bindings.go`). The fleet is different: it changes
whenever a device is attached, detached or re-homed.
Options:
| | Behaviour | Cost |
|---|---|---|
| **Snapshot** | Consistent with existing design; fleet stale until refresh | Needs a refresh trigger on every attachment change |
| **Resolve at render** | Always current | Breaks the "render never calls out" invariant |
**Recommend snapshot**, reusing the existing `RefreshBootstrapBindings` path
(`pkg/sdk/bootstrap.go:452`), with attachment changes marking the config stale.
It preserves the architecture; the cost is an explicit refresh, which the agent
already has a mechanism to trigger.
### Identity: two independent values
Bootstrap `Config.ExternalID` ("a device MAC address is a good choice",
`bootstrap/README.md`) and Device `Serial` look like the same concept but answer
different questions: `Serial` identifies a physical device within a domain;
`ExternalID` identifies an enrollment used to fetch a config.
Per [spec §8 C3](../architecture.md#8-decision-record) they are **independent**. They may
coincide, and operator documentation should recommend using the same value, but
nothing enforces it and neither is resolvable from the other.
## Acceptance criteria
1. A profile template renders a gateway config including its channels and
credentials.
2. The rendered config lists all declared devices with serial and device type, and
the `address` blob for those that have one **on this gateway's edge** — not the
address from some other gateway's edge.
2a. A device with no address renders without the field; nothing in the render path
inspects the blob's contents.
3. Attaching a device and refreshing bindings updates the rendered fleet.
4. Detaching removes it from the fleet.
5. A gateway whose `ExternalID` differs from its Device `Serial` bootstraps
successfully — the two are not coupled.
6. `gateway` and `device` binding slot types resolve and validate.
7. The device-facing challenge/response flow is unchanged, verified by the
existing PR #3555 tests passing untouched.
8. Secrets remain in Bootstrap's PostgreSQL; only non-secret metadata is
projected to Atom (`bootstrap/atom.go`).
## Test plan
- Unit: render context construction; template rendering with 0, 1 and many
attached devices.
- Integration: create gateway + enrollment, attach devices, bootstrap, assert the
fleet in the decrypted config.
- Staleness: attach without refresh → old fleet; refresh → new fleet. Assert both
halves so the documented behaviour is pinned.
- Regression: full PR #3555 suite.
## Risks
- **Fleet size in rendered config.** A gateway with 500 meters produces a large
encrypted payload over a possibly constrained link. Serial + type + an optional
small address blob per device — measure before assuming it fits (spec §8 D1).
- **Staleness window.** Between attaching a device and refreshing, the agent does
not know it exists. Must be documented behaviour rather than a surprise, and it
interacts directly with MG-13's discovery flow.
- **Stale addresses.** The cloud now holds the wiring map (A13), so "which Modbus
unit is meter ABC123 on?" is answerable centrally and a replacement gateway
inherits it. The new risk is the inverse: if someone re-wires the bus without
updating the edge, the rendered config is confidently wrong. Agent-reported
discovery (A13) is the mitigation — the gateway can contradict the record.
@@ -0,0 +1,183 @@
# MG-13 — Gateway-announced device discovery
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P5 |
| **Depends on** | MG-12 (MG-07 withdrawn — see banner) |
| **Blocks** | — |
| **Status** | **Deferred — out of scope** |
> **Predates decisions A7 and A8 and is not currently in scope.** It still assumes
> a `gateway_id` attachment and a pending-state ingest check, both of which the
> model has since dropped. Retained because the *scenarios* it captures remain
> valid. Revise before picking it up.
## Problem
Cloud-first provisioning requires an operator to create every meter record before
installation — serial typed in by hand, per device. For a utility rolling out
thousands of meters that is the dominant cost of deployment and the main source
of data-entry error.
The gateway already discovers meters on its local bus. It knows their serials. It
should be able to say so.
Target flow:
```
1. Installer powers the meter on site
2. Gateway discovers it on BLE/Modbus — serial ABC123
3. Gateway announces it
4. Device appears as pending, attached to that gateway
5. Operator approves: assigns device type and customer group
6. Data flows, attributed to the meter
```
The installer needs no console and no credentials.
## Scope
**In scope**
- An authenticated announce endpoint for gateways.
- Pending device lifecycle: `pending``provisioned` / `rejected`.
- Operator approval, including bulk.
- Defined handling of data arriving from a pending device.
**Out of scope**
- Local discovery itself — agent-side.
- Auto-approval. Every announced device requires an explicit decision (see
Design).
- Auto device-type inference from serial patterns. Tempting, deferred.
## Design
### Authentication
The gateway already has an authenticated channel: PR #3555's challenge/response
gives proof of possession of the enrollment key without a device clock
(`bootstrap/device_bootstrap.go`). Reuse it. Announce is a Bootstrap-side
endpoint authenticated the same way.
The alternative — announcing over MQTT on a reserved topic — avoids a second
protocol but puts entity creation on the message path, where there is no
request/response and no useful error reporting. **Recommend the Bootstrap
endpoint.**
### Announce
```
POST /devices/announce/{externalID}
{ "devices": [ { "serial": "ABC123",
"hints": { "manufacturer": "…", "model": "…" } } ] }
```
**Revised by [spec §8 A13](../architecture.md#8-decision-record):** the announce
payload *should* carry the discovered address, since the cloud now holds it. Where
the agent can read a serial from a holding register — the device type says which
one — announce becomes the preferred way to populate the edge, turning "operator
types 500 mappings" into "operator confirms 500 discovered mappings".
Semantics:
- Unknown serial → create Device, `provisioning_state: pending`
(a device attribute, per [spec §8 A2](../architecture.md#8-decision-record)),
`gateway_id` = announcing gateway.
- Known serial, same gateway → update hints, no state change.
- Known serial, **different** gateway → this is a re-homing claim, not a
discovery. Do not silently re-home; flag for operator confirmation. Silent
re-homing would let any gateway steal any meter by announcing its serial.
- Idempotent: re-announcing an existing set is a no-op.
### Pending devices and their data
A pending device exists but is not approved. Its data must not silently enter the
system as though provisioned — that would make approval meaningless.
MG-07 denies publishes for devices not attached to the publisher. A pending
device **is** attached, so it would otherwise pass and approval would mean
nothing.
**DECIDED ([spec §8 B1](../architecture.md#8-decision-record)): reject.** MG-07 gains a clause
denying any device whose `provisioning_state` is not `provisioned`.
Consequences that belong to this PRD:
- **Readings between installation and approval are lost.** The approval window is
therefore an operational parameter, not a UX detail — every unapproved minute
costs data.
- Bulk approval (below) matters more under this decision than it would under
quarantine.
- Approval must invalidate MG-07's attachment cache, or an approved device stays
unable to publish until the TTL expires.
- Operators need visibility into how long devices have been pending, since that
duration is data loss.
Revisit only if the window turns out to be days rather than hours; quarantine
would then need a retention and access-control policy for data belonging to
devices that may ultimately be rejected.
### Approval
```
POST /{domainID}/devices/{id}/approve { device_type_id, group_id }
POST /{domainID}/devices/{id}/reject
POST /{domainID}/devices/approve (bulk)
```
Approval assigns the device type and optionally the sharing group — the two
things that make the device useful. Bulk matters: a gateway announcing 200 meters
should not require 200 operator actions.
Rejection should be sticky, so a rejected serial is not re-created on the next
announce cycle.
### Rate limiting
A misbehaving or compromised gateway can announce unbounded serials, each
creating an entity. Bound announcements per gateway per interval, and cap pending
devices per gateway. Without this, announce is an entity-creation amplifier
reachable with one gateway credential.
## Acceptance criteria
1. A gateway announcing an unknown serial creates a pending device attached to it.
2. Re-announcing the same serial is idempotent.
3. Announcing a serial owned by another gateway does **not** re-home it silently;
it is flagged.
4. A pending device's data is handled per the decided rule, with a test asserting
exactly that behaviour.
5. Approval assigns type and group, sets `provisioned`, and data flows.
6. Rejection marks the device; the next announce does not recreate it.
7. Bulk approval handles 200 devices in one call.
8. An unauthenticated announce is refused.
9. Exceeding the announce rate limit or pending cap is refused with a clear error.
10. Cloud-first provisioning still works unchanged — both paths coexist.
## Test plan
- Integration: full flow — announce, list pending, approve, publish, read back
attributed to the meter.
- Criterion 3 explicitly: two gateways, one serial. This is the security-relevant
case.
- Idempotency: announce the same set five times, assert one device.
- Rate limiting and pending cap.
- Rejection stickiness across announce cycles.
- Interaction with MG-07 for criterion 4.
## Risks
- **Serial uniqueness is per tenant** ([spec §8 C4](../architecture.md#8-decision-record)) — two
domains may each hold a meter with serial `ABC123`. The announce path must
resolve serials **within the announcing gateway's tenant**; a lookup that
forgets the tenant filter cross-attributes data between customers and fails
silently. Needs an explicit test, not care.
- **Announce as an amplification vector** — one gateway credential creating
unbounded entities. Rate limiting is a requirement, not a hardening extra.
- **Operator burden at scale.** 200 pending devices with no filtering or grouping
in the UI is unusable, and the feature's value evaporates. Bulk approval is
necessary but probably not sufficient; the UI needs a view designed for this.
- **The pending-data decision couples to MG-07.** Deciding it here without
changing MG-07 leaves the two inconsistent.
+148
View File
@@ -0,0 +1,148 @@
# MG-14 — Consume Atom domain events
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 |
| **Depends on** | — |
| **Improves** | MG-07, MG-08 (both ship TTL-only without it) |
| **Status** | Draft |
## Problem
Two caches in this programme are correctness-sensitive and, without event-driven
invalidation, both rely on a TTL:
| Cache | Consequence of staleness |
|---|---|
| MG-08 authorized device set | A revoked customer keeps reading data until expiry — the TTL *is* the revocation SLA |
| MG-08 UUID → `external_id` translation | A newly-granted device stays invisible until expiry |
> An earlier draft listed a second consumer, MG-07's attachment cache. That cache
> no longer exists — [spec §8 A7](../architecture.md#8-decision-record) removed the attachment and
> withdrew MG-07. MG-08 is now the only consumer, which narrows this PRD's value
> but does not remove it: the revocation SLA is customer-visible.
Earlier drafts treated this as unavoidable, on the basis that no entity lifecycle
events exist: all Magistrala event consumers were deleted in
`16ba29cf4`, leaving only messaging events.
**That reasoning was incomplete. Atom already publishes exactly these events.**
## What Atom provides
A transactional outbox (`migrations/004_event_outbox.sql`) with an AMQP publisher
(`src/events/publisher.rs`), emitting ~40 domain events. The ones that matter
here:
| Event | Invalidates |
|---|---|
| `entity.update` | translation cache — a changed `external_id` or a newly-created device |
| `entity.create`, `entity.delete` | translation cache — a new or removed device changes the UUID ↔ serial map |
| `group_member.add`, `group_member.remove` | authorized-set cache — the sharing operation under MG-04 |
| `direct_policy.create`, `direct_policy.delete` | authorized-set cache — grant changes |
| `entity.parent_group.set`, `entity.parent_group.clear` | authorized-set cache |
The outbox design is deliberately append-only, with unconstrained
`actor_entity_id` / `tenant_id` so that failure events and post-purge history
survive — see the rationale comment at `004_event_outbox.sql:6-28`. Delivery is
at-least-once with retry and an `unparseable` flag distinguishing permanent
deserialize failures from transient broker outages.
**It is dark by default.** Publishing is a no-op unless `ATOM_EVENTS_AMQP_URL` is
set (`atom/docker-compose.yml:51`), and Magistrala's compose does not set it at
all — no `ATOM_EVENTS*` variable appears anywhere under `docker/`.
## Scope
**In scope**
- Enable Atom event publishing in Magistrala's deployment: `ATOM_EVENTS_AMQP_URL`
and related settings in `docker/.env` and `docker/docker-compose.yaml`.
- A consumer in `pkg/atom/events` (or similar) subscribing to the Atom exchange.
- A cache-invalidation interface MG-08 registers against, and any later consumer
can reuse.
- At-least-once handling: idempotent invalidation, which is trivially safe since
invalidation is a delete.
**Out of scope**
- Magistrala-*emitted* entity lifecycle events. This consumes Atom's; it does not
reintroduce the producers deleted in `16ba29cf4`.
- Replacing TTL. Event invalidation is an optimisation **on top of** a TTL, never
a replacement — see Risks.
- Reacting to events beyond cache invalidation (audit, notifications, journal).
## Design
### Transport
Atom publishes AMQP 0.9.1. Magistrala already runs an AMQP broker — FluxMQ
exposes it (`docker/nginx/snippets/fluxmq-amqp-upstream.conf`,
`MG_NGINX_AMQP_PORT`) and services already consume from it via
`pkg/messaging/fluxmq`. Point Atom at that broker rather than introducing another.
The surviving generic event machinery in `pkg/events/` (`Subscriber`,
`SubscriberConfig`, backends for fluxmq/nats/redis) is the natural home for the
consumer, and is currently unused for anything but messaging.
### Invalidation, not synchronisation
The consumer must only **invalidate**, never populate. An event says "this fact
changed"; the next lookup re-reads from Atom. Populating caches from event
payloads reintroduces ordering and consistency problems that at-least-once
delivery does not solve.
Concretely: on `direct_policy.delete` for subject S, drop S's authorized set.
Do not attempt to remove just the affected device from the cached set by parsing
the payload — re-read it.
### Degradation
If the broker is unreachable or events stop flowing, behaviour must degrade to
today's TTL semantics — stale but bounded — not to indefinite staleness. This is
why the TTL stays.
## Acceptance criteria
1. Atom publishes events in the Magistrala compose stack; the exchange receives
them.
2. Creating a device with a serial that already has stored data invalidates the
translation cache, so its history becomes visible to grant-holders without
waiting for the TTL.
3. Changing a device's `external_id` invalidates the translation cache.
4. Revoking a customer's group grant invalidates the authorized-set cache, and
the next read reflects it.
5. Adding a device to a sharing group takes effect on the next read.
6. Duplicate delivery of the same event is harmless.
7. With the broker stopped, the caches still expire by TTL and the system stays
correct — slower, not wrong.
8. Events for other tenants do not invalidate unrelated cache entries.
## Test plan
- Integration (Atom + broker in Docker): each event type above, asserting
invalidation timing well inside the TTL.
- Duplicate delivery: replay the same event, assert no error and no incorrect
state.
- Broker-down: stop the broker mid-test, assert TTL fallback still produces
correct results (criterion 7).
- Ordering: apply `entity.update` events out of order, assert the final state is
read from Atom rather than reconstructed from payloads.
## Risks
- **Treating events as authoritative.** At-least-once delivery with no ordering
guarantee means payload-derived state will eventually be wrong. Invalidate
only; the design note above is the guard.
- **Losing the TTL.** Once event invalidation works, the TTL looks redundant and
someone will raise it to hours. Then a broker outage becomes a security
problem — revoked customers keep reading. Keep the TTL as the correctness
floor and document it as such.
- **Outbox lag.** `ATOM_EVENTS_OUTBOX_POLL_INTERVAL_SECS` defaults to 5s
(`atom/docker-compose.yml:57`), so invalidation is not instantaneous. Fine
here, but "immediately" in the acceptance criteria means seconds, not
milliseconds.
- **New infrastructure dependency** between Atom and the broker in a deployment
that currently has none. It is optional and degrades safely, but it is one more
thing to configure and monitor.
+151
View File
@@ -0,0 +1,151 @@
# MG-15 — Gateway device view
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P4 — gated by MG-09, which is P4 |
| **Depends on** | MG-06, MG-08, MG-09, ATOM-01 |
| **Blocks** | — |
| **Status** | Draft |
## Problem
An operator clicks a gateway and needs to see the devices reachable through it —
both what was *commissioned* onto it and what has *actually* published through it.
Neither half exists today. `publisher` is already a reader filter
(`readers/messages.go:49`), so "all messages from gateway G" works, but there is
no distinct-device aggregation and nothing merges it with the declared relation.
**The merge is the point.** Either list alone is misleading:
| Declared | Observed | Status | Why it matters |
|---|---|---|---|
| ✓ | ✓ | **Healthy** | |
| ✓ | ✗ | **Silent** | Commissioned, never heard. For a wired link this is the fault condition — and observed-only cannot see it |
| ✗ | ✓ | **Undeclared** | Undocumented device, or a neighbour's broadcast — and declared-only cannot see it |
See [spec §2.4](../architecture.md#24-declared-and-observed) and
[§3.7](../architecture.md#37-the-gateway-view).
## Scope
**In scope**
- Distinct `device_id` values for a given publisher, with last-seen timestamp and
message count — the **observed** half.
- The **merge** with the declared relation from MG-09, yielding a per-device
status of healthy / silent / undeclared.
- The inverse: distinct `publisher` values for a given `device_id` — which
gateways have relayed for this meter — alongside its declared counterpart.
- Time-bounded: "devices seen in the last 24h" is the operationally useful form.
- Exposed over HTTP, gRPC and SDK, subject to the same authorization as any other
read.
**Out of scope**
- Enriching the roster with device entity data (name, type, status). The reader
has no access to Atom; joining belongs in the UI backend or a composing layer,
which already talks to both.
- Live presence. This is "what has been published", not "what is connected".
- Any stored gateway↔device relation. The whole point is that it is derived.
## Design
### Shape
```
GET /{domainID}/gateways/{id}/devices?from=…&to=…
→ [ { serial, status, last_seen, message_count, device_id? }, … ]
status ∈ healthy | silent | undeclared
GET /{domainID}/devices/{id}/gateways
→ [ { gateway_id, declared, last_seen, message_count }, … ]
```
`device_id` is present only where a device record exists — an *undeclared* row
may be an orphan serial with no entity behind it.
Both are aggregations over the same table the readers already query, so they
belong in `readers/` beside `ReadAll` rather than in a new service.
### The query
```sql
SELECT device_id, MAX(time) AS last_seen, COUNT(*) AS message_count
FROM messages
WHERE channel = :channel AND publisher = :publisher
AND time >= :from AND time < :to
GROUP BY device_id
```
MG-06 adds the `(channel, device_id, name, time DESC)` index; this query wants
`(channel, publisher, device_id)` ordering to avoid a full scan of the channel's
partition. **Measure before assuming the existing indexes cover it** — a
`GROUP BY` over a hypertable without a supporting index is the kind of query that
looks fine on test data and melts on a year of production.
### Authorization
Same boundary as any other read: channel-level check, then — for non-admin
callers — narrowed to the caller's authorized device set (MG-08). A customer
querying a gateway's roster must see only *their* devices on it, not the
gateway's full fleet.
This means MG-15 inherits MG-08's UUID → `external_id` translation. Building it
before MG-08 lands would ship a roster endpoint that leaks the full device list
of every gateway to anyone with channel read.
### Where the merge happens
The declared side comes from Atom (via MG-09's `GatewayDevices`); the observed
side from the message store. **The reader cannot reach Atom**, so the join
belongs in the composing layer — the UI backend, or a thin endpoint that already
holds both clients. Readers expose the observed aggregation; they do not learn
about entities.
### Orphan devices are included
A `device_id` with no device entity appears in the roster, because the roster is
built from traffic. That is a feature — it is how an operator discovers devices
worth registering — but it means the response contains identifiers the entity
store knows nothing about. Consumers must not assume every `device_id` resolves.
## Acceptance criteria
1. A gateway publishing for three devices yields exactly those three, with
accurate `last_seen` and counts.
1a. A device declared on the gateway but never heard appears as **silent**; one
heard but not declared appears as **undeclared**; one both declared and heard
appears as **healthy**.
2. A device published by two gateways appears in both gateways' rosters, and the
inverse query returns both publishers.
3. Time bounds narrow correctly; a device silent in the window is absent.
4. Orphan `device_id`s — no matching entity — appear.
5. A non-admin caller sees only devices they are authorized for, on both queries.
6. A caller without channel access is refused.
7. Consistent across HTTP, gRPC and SDK.
8. Query plan uses an index; runtime is bounded on a realistically sized table.
## Test plan
- Integration (`ory/dockertest`, both backends): write messages from two
gateways with overlapping device sets, assert both directions.
- Authorization: customer with two of a gateway's five devices sees two
(criterion 5) — this fails without MG-08 and is the reason for the dependency.
- Orphan inclusion.
- Performance: roster over a channel with 10k devices and a year of data, with
the plan captured.
## Risks
- **Shipping before MG-08 leaks fleet composition.** The roster reveals every
`device_id` a gateway serves. Without the authorized-set narrowing, any user
with channel read learns the full device list — including other customers'
meters. Sequence accordingly.
- **Aggregation cost.** `GROUP BY device_id` over a large hypertable partition is
the expensive part. Consider a continuous aggregate if the live query does not
hold up; that is a real possibility, not a remote one.
- **Cardinality.** A channel with 100k devices returns a 100k-row roster. Needs
pagination from the start, and a default time bound so the unbounded form is
not the easy one to call.
+258
View File
@@ -0,0 +1,258 @@
# Edge Model PRDs
Work breakdown for [../architecture.md](../architecture.md). One PRD = one PR.
Atom and Magistrala work are tracked separately because they live in different
repositories and ship independently.
These are living documents — refine as work progresses. Update the **Status**
column here when a PRD's state changes.
## Scope
The deliverable is the **Magistrala model** — see
[../architecture.md](../architecture.md), which is closed and normative.
Bootstrap (PR #3555) and the edge agent are *sources of edge cases*, not work
items: the agent keeps publishing over MQTT as it does today, to a topic with one
extra segment. MG-12 and MG-13 are retained for reference but are **not part of
this programme**. The single thing the model asks of Bootstrap is one attribute
on its Atom projection (`gateway_id`), tracked in MG-12.
## Build order
Five Atom changes plus MG-01 unblock everything. Three tracks then run in
parallel and converge on MG-09.
```
P0 ATOM-01 ATOM-02 ATOM-04 ATOM-06 ATOM-03 MG-01
│ │ │ │ │
P1 │ │ MG-03 MG-02 MG-14 │
│ │ │ │ │
P2 │ │ │ │ MG-05 → MG-06
│ │ │ │ │
P3 │ └────────│────────│──────────────► MG-08
│ │ │ │
│ MG-04 │ │
P4 └──────────────► MG-09 ◄───┘ │
│ ├──► MG-10 ──► MG-11 │
│ └──► MG-15 ◄──────────────┘
P5 └──► MG-12 (needs PR #3555)
```
**Critical path:** ATOM-06 → MG-09 → MG-10 → MG-11.
**Three orderings that must not be swapped:**
- **MG-08 before MG-15** — a gateway roster without the authorized-set narrowing
leaks every device a gateway serves to anyone with channel read.
- **MG-05 before MG-06** — the column stores what the topic carried.
- **ATOM-02 and ATOM-06 together, or in sequence** — both edit the same
`authorizedObjectIds` resolver and will conflict in the diff.
## Repositories
| Prefix | Repo | Language |
| -------- | -------------------- | -------- |
| `ATOM-*` | `absmach/atom` | Rust |
| `MG-*` | `absmach/magistrala` | Go |
## Priority order
### P0 — Correctness foundations
Nothing else is safe to build on until these land. Two are pure parameter
plumbing in Atom; one fixes defects that the new access model would otherwise
inherit.
| PRD | Repo | Title | Depends on | Status |
| ----------------------------------------------------- | ---- | ------------------------------------------------------- | ---------- | ------ |
| [ATOM-01](./ATOM-01-entity-attribute-filter.md) | Atom | Expose `attributesContains` on entity and group queries | — | Draft |
| [ATOM-02](./ATOM-02-authorized-object-ids-filters.md) | Atom | Expose scoping filters on `authorizedObjectIds` | — | Draft |
| [ATOM-04](./ATOM-04-many-to-many-group-membership.md) | Atom | Many-to-many object group membership | — | Draft |
| [ATOM-06](./ATOM-06-entity-external-id.md) | Atom | Entity `external_id`, unique per tenant | — | Draft |
| [MG-01](./MG-01-atom-policy-client-fixes.md) | MG | Fix Atom policy client defects | — | Draft |
ATOM-04 is the largest of the Atom items and the only one touching the
authorization evaluation path. ATOM-01 and ATOM-02 are parameter plumbing —
ATOM-01 backs the gateway→devices reverse lookup and is **required**, not
optional. ATOM-05 and ATOM-06 are permissive migrations.
**ATOM-05 is withdrawn.** Gateway is a capability (`is_gateway`), not an entity
kind — see [spec §8 A12](../architecture.md#8-decision-record). That removes an
Atom migration and the silent trap it carried, where a new kind would have
stripped every gateway's right to publish.
### P1 — Device model in the Atom client
Magistrala's Go client exposes a small subset of what Atom supports. These add
the primitives the model needs. All three are additive.
| PRD | Repo | Title | Depends on | Status |
| --------------------------------------- | ---- | ------------------------------------------- | ------------ | ------ |
| [MG-02](./MG-02-device-type-client.md) | MG | Device Type (Atom Profile) client API | — | Draft |
| [MG-03](./MG-03-group-client.md) | MG | Group membership, hierarchy and group kinds | ATOM-04 | Draft |
| [MG-04](./MG-04-group-scoped-grants.md) | MG | Group-scoped permission blocks | MG-01, MG-03 | Draft |
| [MG-14](./MG-14-atom-event-consumer.md) | MG | Consume Atom domain events | — | Draft |
MG-14 is independent of the rest of P1 and can start immediately. MG-08 ships
TTL-only without it, so land it first if you want its authorized-set cache
event-invalidated rather than retrofitted. (Its other original consumer, MG-07's
attachment cache, no longer exists.)
### P2 — Message attribution
Splits "who sent it" from "whose data it is". The core new capability.
| PRD | Repo | Title | Depends on | Status |
| -------------------------------------------------- | ---- | --------------------------------------------- | ---------- | ------ |
| [MG-05](./MG-05-topic-device-segment.md) | MG | Topic grammar: device segment and `device_id` | — | Draft |
| [MG-06](./MG-06-device-id-storage-filters.md) | MG | Persist and filter `device_id` | MG-05 | Draft |
| ~~[MG-07](./MG-07-gateway-attachment-enforcement.md)~~ | MG | ~~Gateway publish-on-behalf-of enforcement~~ | — | **Withdrawn** |
MG-07 is withdrawn: the `gateway_id` attachment it enforced no longer exists, and
the channel is now the publish boundary ([spec §8 A7](../architecture.md#8-decision-record)).
That removes the attachment cache and its invalidation entirely.
### P3 — Access enforcement
Closes a live security hole. See [architecture.md §5.6](../architecture.md#the-security-fix-is-not-optional).
| PRD | Repo | Title | Depends on | Status |
| --------------------------------------------- | ---- | -------------------------------------------------- | --------------------- | ------ |
| [MG-08](./MG-08-reader-authorization.md) | MG | Reader authorization: enforce per-device access | MG-01, MG-06, ATOM-02, ATOM-06 | Draft |
| [ATOM-03](./ATOM-03-reverse-policy-lookup.md) | Atom | Reverse policy lookup: `directPolicies(objectId:)` | — | Draft |
### P4 — API surface
The breaking rename. Clean break — `Client` is removed, not aliased.
| PRD | Repo | Title | Depends on | Status |
| -------------------------------------- | ---- | ---------------------------------------- | ------------ | ------ |
| [MG-09](./MG-09-device-gateway-api.md) | MG | Device, Gateway and the reachability relation | MG-03, ATOM-01, ATOM-06 | Draft |
| [MG-10](./MG-10-device-type-api.md) | MG | Device Type API surface | MG-02, MG-09 | Draft |
| [MG-11](./MG-11-surface-plumbing.md) | MG | CLI, PAT scopes, permissions, OpenAPI | MG-09, MG-10 | Draft |
| [MG-15](./MG-15-gateway-device-view.md) | MG | Gateway device view — declared observed | MG-06, MG-08, MG-09 | Draft |
MG-15 sits here rather than with the other access work because it needs MG-09's
relation. **It must not ship before MG-08:** without the authorized-set
narrowing, a gateway roster leaks every device a gateway serves to anyone with
channel read.
No separate `gateways` PAT scope or permission block — a gateway is a device.
`/gateways` is a filtered view, not a distinct resource.
### Withdrawn
| PRD | Why |
|---|---|
| [ATOM-05](./ATOM-05-gateway-entity-kind.md) | Gateway is a capability, not an entity kind (spec §8 A12) |
| [MG-07](./MG-07-gateway-attachment-enforcement.md) | The `gateway_id` attachment it enforced no longer exists; the channel is the publish boundary (A7) |
### P5 — Bootstrap delivery
Back in scope as of [spec §8 A13](../architecture.md#8-decision-record): the cloud
holds the serial → bus-address map, so bootstrap is the mechanism that delivers it.
Requires PR #3555 merged and rebased onto `pkg/atom`.
| PRD | Repo | Title | Depends on | Status |
| --------------------------------------------- | ---- | ----------------------------------------------------- | ------------ | ------ |
| [MG-12](./MG-12-bootstrap-device-bindings.md) | MG | Bootstrap bindings, fleet and address rendering | #3555, MG-09 | Draft |
### Deferred
| PRD | Repo | Title | Status |
| ----------------------------------------------- | ---- | ---------------------------------- | ------ |
| [MG-13](./MG-13-gateway-announced-discovery.md) | MG | Gateway-announced device discovery | Deferred |
MG-13 predates A7 and A8 and needs revising before it is picked up: late binding
removes the pending-device ingest check, and there is no attachment to re-home.
Its scenarios remain valid, and A13 makes it more attractive — agent-discovered
addresses would populate the edge automatically.
## Dependency graph
```mermaid
graph TD
ATOM01[ATOM-01<br/>attribute filter]
ATOM02[ATOM-02<br/>authz filters]
ATOM03[ATOM-03<br/>reverse lookup]
ATOM04[ATOM-04<br/>M:N membership]
ATOM06[ATOM-06<br/>external_id]
MG01[MG-01<br/>policy fixes]
MG02[MG-02<br/>device types]
MG03[MG-03<br/>groups]
MG04[MG-04<br/>group grants]
MG05[MG-05<br/>topic segment]
MG06[MG-06<br/>storage + filters]
MG08[MG-08<br/>reader authz]
MG09[MG-09<br/>device + gateway API]
MG10[MG-10<br/>device type API]
MG11[MG-11<br/>surface plumbing]
MG12[MG-12<br/>bootstrap delivery]
MG14[MG-14<br/>Atom events]
MG15[MG-15<br/>gateway view]
PR3555[PR #3555<br/>bootstrap service]
ATOM04 --> MG03
MG01 --> MG04
MG03 --> MG04
MG05 --> MG06
MG01 --> MG08
MG06 --> MG08
ATOM02 --> MG08
ATOM06 --> MG08
MG14 -. improves .-> MG08
ATOM01 ==> MG09
ATOM06 ==> MG09
MG03 --> MG09
MG02 --> MG10
MG09 --> MG10
MG09 --> MG11
MG10 --> MG11
MG09 --> MG15
MG08 --> MG15
MG06 --> MG15
MG09 --> MG12
PR3555 --> MG12
ATOM02 -. same resolver .- ATOM06
```
ATOM-03 has no hard dependents — it backs the "who can still see this device"
question that multi-group membership makes non-obvious. ATOM-05, MG-07 (withdrawn)
and MG-13 (deferred) are omitted.
## Parallelisation
Three tracks run concurrently and converge on MG-09:
- **Atom track** — ATOM-01, -02, -03, -04, -06 are independent *in design*.
ATOM-04 is on the critical path for the model track, so start it first.
ATOM-02 and ATOM-06 edit the same resolver and will conflict in the diff —
sequence them or land them together.
- **Attribution track** — MG-05 → MG-06 → MG-08. Touches messaging and storage,
independent of Atom except MG-08's dependency on ATOM-02 and ATOM-06.
- **Model track** — MG-02, MG-03, MG-04 touch only `pkg/atom`. MG-02 is
independent; MG-03 waits on ATOM-04.
- **MG-14** is gated on nothing and can start immediately.
MG-09 has the widest blast radius and should not start until the model track is
settled, since it freezes the public API shape. MG-12 is last — it needs both
MG-09 and PR #3555 rebased onto `pkg/atom`.
## Decisions
The spec is [../architecture.md](../architecture.md) — a single source of truth.
Its **§11 Decision record** holds every question, the options weighed, the ruling
and its consequences. Sections 110 are normative; §11 is the rationale.
All code-blocking questions are resolved. Several 🟡 items still gate individual
PRDs and are named in each PRD's Risks section.
## Conventions used in these PRDs
- **Scope** sections are binding. Anything under "Out of scope" belongs to
another PRD; if it turns out to be unavoidable, amend both PRDs rather than
widening silently.
- File references are `path:line` against `main` at the time of writing
(`e8cf13c7f` for Magistrala). Verify before editing — lines drift.
- Every PRD states acceptance criteria as observable behaviour, not as
"code written".