mirror of
https://github.com/absmach/magistrala.git
synced 2026-08-07 15:25:48 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f372910dc |
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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?"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–4 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.
|
||||
@@ -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
|
||||
P1–P3 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.
|
||||
@@ -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.
|
||||
@@ -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 1–3
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–10 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".
|
||||
Reference in New Issue
Block a user