Compare commits

...

8 Commits

Author SHA1 Message Date
dusan ae28821707 Add edge PRD
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-08-07 18:31:25 +02:00
dusan e8cf13c7f3 NOISSUE - Update FMQ version
Continuous Delivery / lint-and-build (push) Has been cancelled
Deploy GitHub Pages / swagger-ui (push) Has been cancelled
CI Pipeline / Lint Proto (push) Has been cancelled
CI Pipeline / Detect Changes (push) Has been cancelled
Continuous Delivery / Build and Push Docker Images (push) Has been cancelled
CI Pipeline / lint-and-build (push) Has been cancelled
CI Pipeline / Test ${{ matrix.module }} (push) Has been cancelled
CI Pipeline / Upload Coverage (push) Has been cancelled
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-08-05 17:59:48 +02:00
Dušan Borovčanin 10791c1e28 NOISSUE - Preserve message origin (#3565) 2026-08-05 16:53:52 +02:00
Filip Bugarski d63bbe1f93 NOISSUE - Mark Rules Engine, Alarms, and Audit Logs as Enterprise Edition in README (#3562)
Deploy GitHub Pages / swagger-ui (push) Has been cancelled
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:26:54 +02:00
dependabot[bot] f8fab301de NOISSUE - Bump the go-dependency group across 1 directory with 2 updates (#3560)
Continuous Delivery / lint-and-build (push) Has been cancelled
Continuous Delivery / Build and Push Docker Images (push) Has been cancelled
CI Pipeline / Lint Proto (push) Has been cancelled
CI Pipeline / lint-and-build (push) Has been cancelled
CI Pipeline / Detect Changes (push) Has been cancelled
CI Pipeline / Test ${{ matrix.module }} (push) Has been cancelled
CI Pipeline / Upload Coverage (push) Has been cancelled
Deploy GitHub Pages / swagger-ui (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 19:27:35 +02:00
Dušan Borovčanin 135490f680 NOISSUE - Use internal principals for services communication (#3564)
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-08-04 19:20:44 +02:00
dusan dcd6e85afd Update compose and deployments
Continuous Delivery / lint-and-build (push) Has been cancelled
Deploy GitHub Pages / swagger-ui (push) Has been cancelled
CI Pipeline / Lint Proto (push) Has been cancelled
CI Pipeline / Detect Changes (push) Has been cancelled
Continuous Delivery / Build and Push Docker Images (push) Has been cancelled
CI Pipeline / lint-and-build (push) Has been cancelled
CI Pipeline / Test ${{ matrix.module }} (push) Has been cancelled
CI Pipeline / Upload Coverage (push) Has been cancelled
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-08-03 15:40:26 +02:00
dusan 0ba7994db9 Update FMQ version
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-08-01 14:00:45 +02:00
48 changed files with 6956 additions and 115 deletions
+44 -3
View File
@@ -197,7 +197,14 @@ FILTERED_SERVICES = $(filter-out $(RUN_ADDON_ARGS), $(SERVICES))
all: $(SERVICES)
.PHONY: all $(SERVICES) dockers dockers_dev latest release provision_atom_tokens provision-atom-tokens migrate_atom run_latest run_latest_ci run_tls run_stable run_addons grpc_mtls_certs check_mtls check_certs test_api mocks
.PHONY: all help $(SERVICES) dockers dockers_dev latest release provision_atom_tokens provision-atom-tokens migrate_atom run_latest run_latest_ci run_tls run_stable run_addons grpc_mtls_certs check_mtls check_certs check_fluxmq_service_certs check_re_trace_key test_api mocks
help:
@printf 'Usage:\n make <target> [VARIABLE=value ...]\n\nAvailable targets:\n'
@$(MAKE) -qpRr : 2>/dev/null | \
awk -F: '/^[[:alnum:]_][^$$#\/\t=]*:([^=]|$$)/ { split($$1, targets, /[[:space:]]+/); for (i in targets) if (targets[i] != "") print targets[i] }' | \
LC_ALL=C sort -u | \
awk '$$0 != "Makefile" { printf " make %s\n", $$0 }'
clean:
rm -rf ${BUILD_DIR}
@@ -349,7 +356,41 @@ else
$(eval GRPC_MTLS :=)
endif
check_certs: check_mtls check_tls
# Internal services reach FluxMQ on the mTLS local listener, and Compose mounts
# both sides of those connections. Certificates and principal secrets are
# generated rather than committed, so make them before anything binds them.
check_fluxmq_service_certs:
ifeq ("$(wildcard docker/ssl/certs/re-fluxmq-client.crt)","")
$(MAKE) -C docker/ssl fluxmq_service_certs
endif
ifeq ("$(wildcard docker/ssl/certs/timescale-writer-fluxmq-client.crt)","")
$(MAKE) -C docker/ssl timescale_writer_fluxmq_client_cert
endif
ifeq ("$(wildcard docker/ssl/certs/postgres-writer-fluxmq-client.crt)","")
$(MAKE) -C docker/ssl postgres_writer_fluxmq_client_cert
endif
ifeq ("$(wildcard docker/ssl/certs/fluxmq-auth-fluxmq-client.crt)","")
$(MAKE) -C docker/ssl fluxmq_auth_fluxmq_client_cert
endif
ifeq ("$(wildcard docker/fluxmq/secrets/re-current)","")
$(MAKE) -C docker/ssl fluxmq_service_secret
endif
ifeq ("$(wildcard docker/fluxmq/secrets/timescale-writer-current)","")
$(MAKE) -C docker/ssl timescale_writer_fluxmq_service_secret
endif
ifeq ("$(wildcard docker/fluxmq/secrets/postgres-writer-current)","")
$(MAKE) -C docker/ssl postgres_writer_fluxmq_service_secret
endif
ifeq ("$(wildcard docker/fluxmq/secrets/fluxmq-auth-current)","")
$(MAKE) -C docker/ssl fluxmq_auth_fluxmq_service_secret
endif
check_re_trace_key:
ifeq ("$(wildcard docker/re/secrets/trace.key)","")
$(MAKE) -C docker/ssl re_trace_key
endif
check_certs: check_mtls check_tls check_fluxmq_service_certs check_re_trace_key
ifeq ($(GRPC_MTLS_CERT_FILES_EXISTS),0)
ifeq ($(filter true,$(GRPC_MTLS) $(GRPC_TLS)),true)
ifeq ($(filter $(DEFAULT_DOCKER_COMPOSE_COMMAND),$(DOCKER_COMPOSE_COMMAND)),$(DEFAULT_DOCKER_COMPOSE_COMMAND))
@@ -368,7 +409,7 @@ run_latest_ci: check_certs
$(SED_INPLACE) 's/^MG_RELEASE_TAG=.*/MG_RELEASE_TAG=latest/' docker/.env
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml -f docker/docker-compose-ci.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
run_tls:
run_tls: check_certs
@test -n "$(host)" || (echo "Usage: make run_tls host=example.com [email=admin@example.com] [letsencrypt=false] [staging=true] [force=true]" && exit 2)
@if [ "$(or $(letsencrypt),true)" != "false" ] && [ -z "$(email)" ]; then echo "Usage: make run_tls host=example.com email=admin@example.com [letsencrypt=false] [staging=true] [force=true]"; exit 2; fi
MG_PUBLIC_HOST="$(host)" \
+137 -24
View File
@@ -116,14 +116,14 @@ Magistrala provides a complete set of building blocks for IoT systems — from d
### ⚙️ Processing & Automation
- Rules engine for message processing and routing
- Alarms and triggers for reacting to events
- Rules engine for message processing and routing (Enterprise Edition)
- Alarms and triggers for reacting to events (Enterprise Edition)
- Scheduled actions for time-based workflows
- Event-driven architecture as the foundation
### 📊 Observability
- Audit logs for tracking system activity
- Audit logs for tracking system activity (Enterprise Edition)
- Metrics and tracing via Prometheus and OpenTelemetry
- Built-in visibility into system behavior and data flows
@@ -160,13 +160,13 @@ Current Docker deployments use the Atom image configured by `ATOM_IMAGE` in `doc
### Core Entity Mapping
| Magistrala concept | Atom concept | Meaning |
|--------------------|--------------|---------|
| Domain | Tenant | Isolation boundary for one organization, project, or environment |
| User | Entity with kind `human` | A person who logs in and uses the UI/API |
| Client | Entity with kind `device` | A device or application that sends/receives data |
| Channel | Resource with kind `channel` | A messaging/data path that clients can publish or subscribe to |
| Group | Group | A collection of users, clients, channels, or other grouped objects |
| Magistrala concept | Atom concept | Meaning |
| ------------------ | ---------------------------- | ------------------------------------------------------------------ |
| Domain | Tenant | Isolation boundary for one organization, project, or environment |
| User | Entity with kind `human` | A person who logs in and uses the UI/API |
| Client | Entity with kind `device` | A device or application that sends/receives data |
| Channel | Resource with kind `channel` | A messaging/data path that clients can publish or subscribe to |
| Group | Group | A collection of users, clients, channels, or other grouped objects |
In simple terms:
@@ -182,12 +182,12 @@ MG Group = Atom Group
Atom access control has these basic parts:
| Atom word | Simple meaning | Example |
|-----------|----------------|---------|
| Action | One permission verb | `read`, `write`, `delete`, `role.manage`, `policy.manage` |
| Permission Block | Where actions apply | all channels in domain `d1` can `read`, `publish` |
| Role | A bundle of permission blocks | `tenant-admin` bundles domain, role, and member access |
| Role Assignment | Who gets a role | give `user1` the `tenant-admin` role |
| Atom word | Simple meaning | Example |
| ---------------- | ----------------------------- | --------------------------------------------------------- |
| Action | One permission verb | `read`, `write`, `delete`, `role.manage`, `policy.manage` |
| Permission Block | Where actions apply | all channels in domain `d1` can `read`, `publish` |
| Role | A bundle of permission blocks | `tenant-admin` bundles domain, role, and member access |
| Role Assignment | Who gets a role | give `user1` the `tenant-admin` role |
Read an assignment like this:
@@ -221,15 +221,15 @@ MG UI shows actions such as:
These are mapped to Atom actions:
| MG action | Atom action |
|-----------|-----------------|
| view/read | `read` |
| create/update/edit/connect | `write` |
| delete/remove | `delete` |
| manage roles | `role.manage` |
| MG action | Atom action |
| ---------------------------- | --------------- |
| view/read | `read` |
| create/update/edit/connect | `write` |
| delete/remove | `delete` |
| manage roles | `role.manage` |
| add/remove members or access | `policy.manage` |
| channel publish | `publish` |
| channel subscribe | `subscribe` |
| channel publish | `publish` |
| channel subscribe | `subscribe` |
So when MG UI checks:
@@ -280,9 +280,122 @@ Then user2 can read only that channel, not the whole domain.
```bash
git clone https://github.com/absmach/magistrala.git
cd magistrala
make provision_atom_tokens
make run_latest
```
A fresh clone carries no generated secrets. Two sets have to exist before the
stack can start — certificates and keys the internal services authenticate
with, and the Atom service tokens each service presents to Atom. `make
run_latest` produces the first set itself but expects the second to be there
already, which is why the token step comes first above.
### Certificates, broker secret and trace key
Generated by `make run_latest`, or on demand:
```bash
make check_certs
```
This creates whatever is missing and leaves anything already present alone:
| Path | What it is |
| ----------------------------------------------------------- | ------------------------------------------------------------------------- |
| `docker/ssl/certs/fluxmq-service-server.{crt,key}` | Server certificate for FluxMQ's mTLS service listener |
| `docker/ssl/certs/re-fluxmq-client.{crt,key}` | Client certificate whose URI SAN identifies the Rules Engine |
| `docker/ssl/certs/timescale-writer-fluxmq-client.{crt,key}` | Client certificate whose URI SAN identifies the Timescale writer |
| `docker/ssl/certs/postgres-writer-fluxmq-client.{crt,key}` | Client certificate whose URI SAN identifies the Postgres writer |
| `docker/ssl/certs/fluxmq-auth-fluxmq-client.{crt,key}` | Client certificate whose URI SAN identifies the publish proxy |
| `docker/fluxmq/secrets/re-current` | Rules Engine principal secret, from `MG_RE_BROKER_SECRET` |
| `docker/fluxmq/secrets/timescale-writer-current` | Timescale writer secret, from `MG_TIMESCALE_WRITER_BROKER_SECRET` |
| `docker/fluxmq/secrets/postgres-writer-current` | Postgres writer secret, from `MG_POSTGRES_WRITER_BROKER_SECRET` |
| `docker/fluxmq/secrets/fluxmq-auth-current` | Publish proxy secret, from `MG_FLUXMQ_BROKER_SECRET` |
| `docker/re/secrets/trace.key` | HMAC key the Rules Engine signs its loop-detection traces with |
Internal services reach the broker as *local principals* rather than as ordinary
clients: each presents a client certificate whose URI SAN names it, plus a SASL
secret, and the broker grants it only what it needs — the Rules Engine consumes
`m`, republishes under it, and feeds the `writers` and `alarms` streams; the
writers only subscribe to `writers`; the publish proxy that serves the UI's
HTTP publish endpoint only publishes under `m.`. The principals are declared in
`docker/fluxmq/node{1,2,3}.yaml`, and adding a service means adding an entry
there alongside its certificate and secret.
Being a local principal is also what preserves a message's origin. The broker
stamps its own transport protocol and identity on anything published over a
connection it does not trust, so a message relayed to the writers over the plain
AMQP listener would be stored as `protocol: amqp` with the relaying service as
its publisher. A `service`-role principal on the mTLS listener may state the
origin instead, and the protocol the device actually published with survives to
the database.
The certificates are issued by the development CA committed at
`docker/ssl/certs/ca.crt`, so no extra setup is needed for a local run. The
generated material is gitignored.
The server certificate is issued for `fluxmq` and `fluxmq-node{1,2,3}`, which
covers both this Compose stack and a single-node deployment. Point any
`MG_*_BROKER_URL` at a host outside that set and the service fails its TLS
verification with `certificate is valid for ...`; add the name to
`FLUXMQ_SERVICE_SERVER_CERT_CONFIG` in `docker/ssl/Makefile` and reissue:
```bash
rm -f docker/ssl/certs/fluxmq-service-server.* \
docker/ssl/certs/re-fluxmq-client.* \
docker/ssl/certs/timescale-writer-fluxmq-client.* \
docker/ssl/certs/postgres-writer-fluxmq-client.*
make -C docker/ssl fluxmq_service_certs
```
`make check_certs` skips certificates that already exist, so stale certificates
have to be removed rather than merely re-running the target.
Each local-principal secret must stay equal to the corresponding value in
`docker/.env`; a mismatch fails that service's broker authentication. After
changing one, re-run its target:
| Variable | Target |
| ----------------------------------- | ----------------------------------------- |
| `MG_RE_BROKER_SECRET` | `fluxmq_service_secret` |
| `MG_TIMESCALE_WRITER_BROKER_SECRET` | `timescale_writer_fluxmq_service_secret` |
| `MG_POSTGRES_WRITER_BROKER_SECRET` | `postgres_writer_fluxmq_service_secret` |
| `MG_FLUXMQ_BROKER_SECRET` | `fluxmq_auth_fluxmq_service_secret` |
`trace.key` is created once and preserved on later runs — replacing it while
messages are in flight would invalidate the rule traces they already carry, so
delete it only deliberately. Every Rules Engine replica must read the same key.
Start the stack through `make run_latest` rather than calling `docker compose
up` directly. Compose creates a missing bind-mount source as an empty
*directory*, so bringing up `re` or `fluxmq` before these files exist leaves the
containers failing against a directory where they expect a key.
### Atom service tokens
Not generated automatically, because provisioning them starts Atom and runs a
bootstrap job against it:
```bash
make provision_atom_tokens
```
This brings up Atom, runs `atom-bootstrap`, and writes the gitignored
`docker/.env.tokens` with one service token per consumer —
`MG_ATOM_TOKEN_FLUXMQ_AUTH`, `MG_ATOM_TOKEN_FLUXMQ_NODE{1,2,3}`,
`MG_ATOM_TOKEN_RE`, `MG_ATOM_TOKEN_ALARMS`, `MG_ATOM_TOKEN_REPORTS`,
`MG_ATOM_TOKEN_TIMESCALE_READER`, and `MG_ATOM_TOKEN_POSTGRES_READER`.
`make run_latest` refuses to start when that file is absent or short of any of
those variables, and names what is missing. To fold the step into the run:
```bash
make run_latest PROVISION_ATOM_TOKENS=true
```
Re-run `provision_atom_tokens` after anything that resets Atom's database; the
old tokens do not survive it.
---
## Usage
+24 -4
View File
@@ -51,6 +51,16 @@ type config struct {
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
InstanceID string `env:"MG_FLUXMQ_INSTANCE_ID" envDefault:""`
// The publish proxy states the origin of the message it relays: the
// protocol the user published with and the client it was published as.
// A broker stamps its own transport and identity on a publication from a
// connection it does not trust, which would store every message from this
// service as having arrived over AMQP. These point the publisher at the
// listener that accepts a relayed origin instead.
MsgBrokerURL string `env:"MG_FLUXMQ_BROKER_URL" envDefault:""`
BrokerClientCert string `env:"MG_FLUXMQ_BROKER_CLIENT_CERT" envDefault:""`
BrokerClientKey string `env:"MG_FLUXMQ_BROKER_CLIENT_KEY" envDefault:""`
BrokerCACerts string `env:"MG_FLUXMQ_BROKER_CA_CERTS" envDefault:""`
}
func main() {
@@ -154,11 +164,21 @@ func main() {
MaxHeaderBytes: grpcServerConfig.MaxHeaderBytes,
}
messagePublisher, err := fluxmqbroker.NewUndeclaredPublisher(
ctx,
cfg.BrokerURL,
// The mTLS client identity is optional: brokers that expose an
// unauthenticated listener need none. A partial configuration still fails,
// since half an identity is not a usable one.
publisherOpts := []messaging.Option{
fluxmqbroker.ConnectionName("fluxmq-ui-message-publish-proxy"),
)
}
msgBrokerURL := cfg.BrokerURL
if cfg.MsgBrokerURL != "" {
msgBrokerURL = cfg.MsgBrokerURL
}
if cfg.BrokerClientCert != "" || cfg.BrokerClientKey != "" || cfg.BrokerCACerts != "" {
publisherOpts = append(publisherOpts, fluxmqbroker.InternalMetadata(cfg.BrokerClientCert, cfg.BrokerClientKey, cfg.BrokerCACerts))
}
messagePublisher, err := fluxmqbroker.NewUndeclaredPublisher(ctx, msgBrokerURL, publisherOpts...)
if err != nil {
logger.Error(fmt.Sprintf("failed to create publish proxy message publisher: %s", err))
exitCode = 1
+23 -10
View File
@@ -20,8 +20,9 @@ import (
writerpg "github.com/absmach/magistrala/consumers/writers/postgres"
mglog "github.com/absmach/magistrala/logger"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
"github.com/absmach/magistrala/pkg/messaging"
brokerstracing "github.com/absmach/magistrala/pkg/messaging/brokers/tracing"
brokers "github.com/absmach/magistrala/pkg/messaging/writers"
"github.com/absmach/magistrala/pkg/messaging/writers"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
@@ -41,13 +42,16 @@ const (
)
type config struct {
LogLevel string `env:"MG_POSTGRES_WRITER_LOG_LEVEL" envDefault:"info"`
ConfigPath string `env:"MG_POSTGRES_WRITER_CONFIG_PATH" envDefault:"/config.toml"`
BrokerURL string `env:"MG_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"MG_POSTGRES_WRITER_INSTANCE_ID" envDefault:""`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
LogLevel string `env:"MG_POSTGRES_WRITER_LOG_LEVEL" envDefault:"info"`
ConfigPath string `env:"MG_POSTGRES_WRITER_CONFIG_PATH" envDefault:"/config.toml"`
BrokerURL string `env:"MG_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
BrokerClientCert string `env:"MG_POSTGRES_WRITER_BROKER_CLIENT_CERT" envDefault:""`
BrokerClientKey string `env:"MG_POSTGRES_WRITER_BROKER_CLIENT_KEY" envDefault:""`
BrokerCACerts string `env:"MG_POSTGRES_WRITER_BROKER_CA_CERTS" envDefault:""`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"MG_POSTGRES_WRITER_INSTANCE_ID" envDefault:""`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
}
func main() {
@@ -107,7 +111,16 @@ func main() {
}()
tracer := tp.Tracer(svcName)
pubSub, err := brokers.NewPubSub(ctx, cfg.BrokerURL, logger)
// The mTLS client identity is optional: brokers that expose an unauthenticated
// listener need none. Passing the option with empty paths would fail the
// connection outright, so only ask for it once one of them is configured. A
// partial configuration still fails, since half an identity is not a usable one.
var brokerOpts []messaging.Option
if cfg.BrokerClientCert != "" || cfg.BrokerClientKey != "" || cfg.BrokerCACerts != "" {
brokerOpts = append(brokerOpts, writers.InternalMetadata(cfg.BrokerClientCert, cfg.BrokerClientKey, cfg.BrokerCACerts))
}
pubSub, err := writers.NewPubSub(ctx, cfg.BrokerURL, logger, brokerOpts...)
if err != nil {
logger.Error(fmt.Sprintf("failed to connect to message broker: %s", err))
exitCode = 1
@@ -119,7 +132,7 @@ func main() {
repo := newService(db, logger)
repo = consumertracing.NewBlocking(tracer, repo, httpServerConfig)
if err = consumers.Start(ctx, svcName, pubSub, repo, cfg.ConfigPath, brokers.AllTopic, logger); err != nil {
if err = consumers.Start(ctx, svcName, pubSub, repo, cfg.ConfigPath, writers.AllTopic, logger); err != nil {
logger.Error(fmt.Sprintf("failed to create Postgres writer: %s", err))
exitCode = 1
return
+23 -10
View File
@@ -20,8 +20,9 @@ import (
"github.com/absmach/magistrala/consumers/writers/timescale"
mglog "github.com/absmach/magistrala/logger"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
"github.com/absmach/magistrala/pkg/messaging"
brokerstracing "github.com/absmach/magistrala/pkg/messaging/brokers/tracing"
brokers "github.com/absmach/magistrala/pkg/messaging/writers"
"github.com/absmach/magistrala/pkg/messaging/writers"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
@@ -41,13 +42,16 @@ const (
)
type config struct {
LogLevel string `env:"MG_TIMESCALE_WRITER_LOG_LEVEL" envDefault:"info"`
ConfigPath string `env:"MG_TIMESCALE_WRITER_CONFIG_PATH" envDefault:"/config.toml"`
BrokerURL string `env:"MG_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"MG_TIMESCALE_WRITER_INSTANCE_ID" envDefault:""`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
LogLevel string `env:"MG_TIMESCALE_WRITER_LOG_LEVEL" envDefault:"info"`
ConfigPath string `env:"MG_TIMESCALE_WRITER_CONFIG_PATH" envDefault:"/config.toml"`
BrokerURL string `env:"MG_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
BrokerClientCert string `env:"MG_TIMESCALE_WRITER_BROKER_CLIENT_CERT" envDefault:""`
BrokerClientKey string `env:"MG_TIMESCALE_WRITER_BROKER_CLIENT_KEY" envDefault:""`
BrokerCACerts string `env:"MG_TIMESCALE_WRITER_BROKER_CA_CERTS" envDefault:""`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"MG_TIMESCALE_WRITER_INSTANCE_ID" envDefault:""`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
}
func main() {
@@ -112,7 +116,16 @@ func main() {
repo := newService(db, logger)
repo = consumertracing.NewBlocking(tracer, repo, httpServerConfig)
pubSub, err := brokers.NewPubSub(ctx, cfg.BrokerURL, logger)
// The mTLS client identity is optional: brokers that expose an unauthenticated
// listener need none. Passing the option with empty paths would fail the
// connection outright, so only ask for it once one of them is configured. A
// partial configuration still fails, since half an identity is not a usable one.
var brokerOpts []messaging.Option
if cfg.BrokerClientCert != "" || cfg.BrokerClientKey != "" || cfg.BrokerCACerts != "" {
brokerOpts = append(brokerOpts, writers.InternalMetadata(cfg.BrokerClientCert, cfg.BrokerClientKey, cfg.BrokerCACerts))
}
pubSub, err := writers.NewPubSub(ctx, cfg.BrokerURL, logger, brokerOpts...)
if err != nil {
logger.Error(fmt.Sprintf("failed to connect to message broker: %s", err))
exitCode = 1
@@ -121,7 +134,7 @@ func main() {
defer pubSub.Close()
pubSub = brokerstracing.NewPubSub(httpServerConfig, tracer, pubSub)
if err = consumers.Start(ctx, svcName, pubSub, repo, cfg.ConfigPath, brokers.AllTopic, logger); err != nil {
if err = consumers.Start(ctx, svcName, pubSub, repo, cfg.ConfigPath, writers.AllTopic, logger); err != nil {
logger.Error(fmt.Sprintf("failed to create Timescale writer: %s", err))
exitCode = 1
return
+49
View File
@@ -388,6 +388,16 @@ MG_FLUXMQ_INSTANCE_ID=
MG_FLUXMQ_CACHE_NUM_COUNTERS=200000
MG_FLUXMQ_CACHE_MAX_COST=1048576
MG_FLUXMQ_CACHE_BUFFER_ITEMS=64
# The publish proxy relays a user's message, so it publishes on the mTLS service
# listener rather than the plain one: only there may it state the origin
# protocol and publisher instead of having the broker stamp its own. The
# certificate identifies the local principal and its SASL secret must match
# docker/fluxmq/secrets/fluxmq-auth-current.
MG_FLUXMQ_BROKER_SECRET=fluxmq-auth-fluxmq-local-principal-secret-change-me
MG_FLUXMQ_BROKER_URL=amqps://fluxmq-auth:${MG_FLUXMQ_BROKER_SECRET}@fluxmq-node1:5685/
MG_FLUXMQ_BROKER_CLIENT_CERT=/etc/ssl/certs/fluxmq-auth-fluxmq-client.crt
MG_FLUXMQ_BROKER_CLIENT_KEY=/etc/ssl/private/fluxmq-auth-fluxmq-client.key
MG_FLUXMQ_BROKER_CA_CERTS=/etc/ssl/certs/ca.crt
### CoAP
MG_COAP_PORT=5683
@@ -502,6 +512,14 @@ MG_POSTGRES_WRITER_HTTP_PORT=9007
MG_POSTGRES_WRITER_HTTP_SERVER_CERT=
MG_POSTGRES_WRITER_HTTP_SERVER_KEY=
MG_POSTGRES_WRITER_INSTANCE_ID=
# The writer is a passive consumer on FluxMQ's mTLS local listener. Its client
# certificate identifies the local principal and its SASL secret must match
# docker/fluxmq/secrets/postgres-writer-current.
MG_POSTGRES_WRITER_BROKER_SECRET=postgres-writer-fluxmq-local-principal-secret-change-me
MG_POSTGRES_WRITER_BROKER_URL=amqps://postgres-writer:${MG_POSTGRES_WRITER_BROKER_SECRET}@fluxmq-node1:5685/
MG_POSTGRES_WRITER_BROKER_CLIENT_CERT=/etc/ssl/certs/postgres-writer-fluxmq-client.crt
MG_POSTGRES_WRITER_BROKER_CLIENT_KEY=/etc/ssl/private/postgres-writer-fluxmq-client.key
MG_POSTGRES_WRITER_BROKER_CA_CERTS=/etc/ssl/certs/ca.crt
### Postgres Reader
MG_POSTGRES_READER_LOG_LEVEL=debug
@@ -529,6 +547,14 @@ MG_TIMESCALE_WRITER_HTTP_PORT=9012
MG_TIMESCALE_WRITER_HTTP_SERVER_CERT=
MG_TIMESCALE_WRITER_HTTP_SERVER_KEY=
MG_TIMESCALE_WRITER_INSTANCE_ID=
# The writer is a passive consumer on FluxMQ's mTLS local listener. Its client
# certificate identifies the local principal and its SASL secret must match
# docker/fluxmq/secrets/timescale-writer-current.
MG_TIMESCALE_WRITER_BROKER_SECRET=timescale-writer-fluxmq-local-principal-secret-change-me
MG_TIMESCALE_WRITER_BROKER_URL=amqps://timescale-writer:${MG_TIMESCALE_WRITER_BROKER_SECRET}@fluxmq-node1:5685/
MG_TIMESCALE_WRITER_BROKER_CLIENT_CERT=/etc/ssl/certs/timescale-writer-fluxmq-client.crt
MG_TIMESCALE_WRITER_BROKER_CLIENT_KEY=/etc/ssl/private/timescale-writer-fluxmq-client.key
MG_TIMESCALE_WRITER_BROKER_CA_CERTS=/etc/ssl/certs/ca.crt
### Timescale Reader
MG_TIMESCALE_READER_LOG_LEVEL=debug
@@ -569,6 +595,29 @@ MG_RE_DB_SSL_CERT=
MG_RE_DB_SSL_KEY=
MG_RE_DB_SSL_ROOT_CERT=
MG_RE_INSTANCE_ID=
MG_RE_MAX_RULE_HOPS=32
MG_RE_TRACE_KEY=
MG_RE_TRACE_KEY_FILE=/run/secrets/magistrala-re-trace-key
# Rule loop detection needs the broker to carry the "_flux." message metadata
# that holds the rule execution trace. On FluxMQ only the mTLS local listener
# does, so the Rules Engine must reach the broker there. A plain FluxMQ
# connection cannot provide loop protection and is rejected at startup.
#
# The certificates, broker secret, and persistent trace key come from the
# corresponding targets in docker/ssl, all of which run as part of check_certs.
# MG_RE_BROKER_SECRET is the SASL secret the Rules Engine presents alongside
# its certificate; it is the single source for
# docker/fluxmq/secrets/re-current and must be at least 32 characters.
# MG_RE_TRACE_KEY_FILE names the mounted trace key. MG_RE_TRACE_KEY remains an
# inline alternative; set exactly one, and use at least 32 bytes.
#
# The URL names one node directly because mTLS cannot pass through the nginx
# AMQP proxy as configured.
MG_RE_BROKER_SECRET=re-fluxmq-local-principal-secret-change-me
MG_RE_BROKER_URL=amqps://rules-engine:${MG_RE_BROKER_SECRET}@fluxmq-node1:5685/
MG_RE_BROKER_CLIENT_CERT=/etc/ssl/certs/re-fluxmq-client.crt
MG_RE_BROKER_CLIENT_KEY=/etc/ssl/private/re-fluxmq-client.key
MG_RE_BROKER_CA_CERTS=/etc/ssl/certs/ca.crt
MG_RE_EMAIL_TEMPLATE=re.tmpl
MG_RE_CALLOUT_URLS=""
MG_RE_CALLOUT_METHOD="POST"
@@ -35,7 +35,10 @@ services:
image: ghcr.io/absmach/magistrala/postgres-writer:${MG_RELEASE_TAG}
container_name: magistrala-postgres-writer
depends_on:
- postgres
postgres:
condition: service_started
fluxmq-node1:
condition: service_healthy
restart: on-failure
environment:
MG_POSTGRES_WRITER_LOG_LEVEL: ${MG_POSTGRES_WRITER_LOG_LEVEL}
@@ -53,7 +56,10 @@ services:
MG_POSTGRES_SSL_CERT: ${MG_POSTGRES_SSL_CERT}
MG_POSTGRES_SSL_KEY: ${MG_POSTGRES_SSL_KEY}
MG_POSTGRES_SSL_ROOT_CERT: ${MG_POSTGRES_SSL_ROOT_CERT}
MG_MESSAGE_BROKER_URL: ${MG_MESSAGE_BROKER_URL}
MG_MESSAGE_BROKER_URL: ${MG_POSTGRES_WRITER_BROKER_URL}
MG_POSTGRES_WRITER_BROKER_CLIENT_CERT: ${MG_POSTGRES_WRITER_BROKER_CLIENT_CERT}
MG_POSTGRES_WRITER_BROKER_CLIENT_KEY: ${MG_POSTGRES_WRITER_BROKER_CLIENT_KEY}
MG_POSTGRES_WRITER_BROKER_CA_CERTS: ${MG_POSTGRES_WRITER_BROKER_CA_CERTS}
MG_JAEGER_URL: ${MG_JAEGER_URL}
MG_JAEGER_TRACE_RATIO: ${MG_JAEGER_TRACE_RATIO}
MG_SEND_TELEMETRY: ${MG_SEND_TELEMETRY}
@@ -63,4 +69,11 @@ services:
networks:
- magistrala-base-net
volumes:
- ./config.toml:/config.toml
# Relative paths in an addon file resolve against the project directory,
# which is the base compose file's directory rather than this one.
- ./addons/postgres-writer/config.toml:${MG_POSTGRES_WRITER_CONFIG_PATH}
# The URI SAN in this client certificate identifies the Postgres writer
# to the FluxMQ local listener; the broker ACL only permits `writers`.
- ./ssl/certs/postgres-writer-fluxmq-client.crt:${MG_POSTGRES_WRITER_BROKER_CLIENT_CERT}:ro
- ./ssl/certs/postgres-writer-fluxmq-client.key:${MG_POSTGRES_WRITER_BROKER_CLIENT_KEY}:ro
- ./ssl/certs/ca.crt:${MG_POSTGRES_WRITER_BROKER_CA_CERTS}:ro
+111 -20
View File
@@ -255,6 +255,15 @@ services:
ipv4_address: 172.30.0.201
volumes:
- ./fluxmq/node1.yaml:/etc/fluxmq/config.yaml:ro
# Server certificate and CA for the mTLS service listener, plus each
# local principal's SASL secret.
- ./ssl/certs/fluxmq-service-server.crt:/etc/fluxmq/certs/fluxmq-service-server.crt:ro
- ./ssl/certs/fluxmq-service-server.key:/etc/fluxmq/certs/fluxmq-service-server.key:ro
- ./ssl/certs/ca.crt:/etc/fluxmq/certs/ca.crt:ro
- ./fluxmq/secrets/re-current:/etc/fluxmq/secrets/re-current:ro
- ./fluxmq/secrets/timescale-writer-current:/etc/fluxmq/secrets/timescale-writer-current:ro
- ./fluxmq/secrets/postgres-writer-current:/etc/fluxmq/secrets/postgres-writer-current:ro
- ./fluxmq/secrets/fluxmq-auth-current:/etc/fluxmq/secrets/fluxmq-auth-current:ro
- magistrala-fluxmq-node1-volume:/tmp/fluxmq
fluxmq-node2:
@@ -275,6 +284,15 @@ services:
ipv4_address: 172.30.0.202
volumes:
- ./fluxmq/node2.yaml:/etc/fluxmq/config.yaml:ro
# Server certificate and CA for the mTLS service listener, plus each
# local principal's SASL secret.
- ./ssl/certs/fluxmq-service-server.crt:/etc/fluxmq/certs/fluxmq-service-server.crt:ro
- ./ssl/certs/fluxmq-service-server.key:/etc/fluxmq/certs/fluxmq-service-server.key:ro
- ./ssl/certs/ca.crt:/etc/fluxmq/certs/ca.crt:ro
- ./fluxmq/secrets/re-current:/etc/fluxmq/secrets/re-current:ro
- ./fluxmq/secrets/timescale-writer-current:/etc/fluxmq/secrets/timescale-writer-current:ro
- ./fluxmq/secrets/postgres-writer-current:/etc/fluxmq/secrets/postgres-writer-current:ro
- ./fluxmq/secrets/fluxmq-auth-current:/etc/fluxmq/secrets/fluxmq-auth-current:ro
- magistrala-fluxmq-node2-volume:/tmp/fluxmq
fluxmq-node3:
@@ -295,6 +313,15 @@ services:
ipv4_address: 172.30.0.203
volumes:
- ./fluxmq/node3.yaml:/etc/fluxmq/config.yaml:ro
# Server certificate and CA for the mTLS service listener, plus each
# local principal's SASL secret.
- ./ssl/certs/fluxmq-service-server.crt:/etc/fluxmq/certs/fluxmq-service-server.crt:ro
- ./ssl/certs/fluxmq-service-server.key:/etc/fluxmq/certs/fluxmq-service-server.key:ro
- ./ssl/certs/ca.crt:/etc/fluxmq/certs/ca.crt:ro
- ./fluxmq/secrets/re-current:/etc/fluxmq/secrets/re-current:ro
- ./fluxmq/secrets/timescale-writer-current:/etc/fluxmq/secrets/timescale-writer-current:ro
- ./fluxmq/secrets/postgres-writer-current:/etc/fluxmq/secrets/postgres-writer-current:ro
- ./fluxmq/secrets/fluxmq-auth-current:/etc/fluxmq/secrets/fluxmq-auth-current:ro
- magistrala-fluxmq-node3-volume:/tmp/fluxmq
fluxmq-auth:
@@ -328,6 +355,17 @@ services:
ATOM_TIMEOUT: ${ATOM_TIMEOUT}
MG_JAEGER_URL: ${MG_JAEGER_URL}
MG_JAEGER_TRACE_RATIO: ${MG_JAEGER_TRACE_RATIO}
MG_FLUXMQ_BROKER_URL: ${MG_FLUXMQ_BROKER_URL}
MG_FLUXMQ_BROKER_CLIENT_CERT: ${MG_FLUXMQ_BROKER_CLIENT_CERT}
MG_FLUXMQ_BROKER_CLIENT_KEY: ${MG_FLUXMQ_BROKER_CLIENT_KEY}
MG_FLUXMQ_BROKER_CA_CERTS: ${MG_FLUXMQ_BROKER_CA_CERTS}
volumes:
# The URI SAN in this client certificate identifies the publish proxy to
# the FluxMQ local listener; the broker ACL only permits publishing to the
# message topic namespace.
- ./ssl/certs/fluxmq-auth-fluxmq-client.crt:${MG_FLUXMQ_BROKER_CLIENT_CERT}:ro
- ./ssl/certs/fluxmq-auth-fluxmq-client.key:${MG_FLUXMQ_BROKER_CLIENT_KEY}:ro
- ./ssl/certs/ca.crt:${MG_FLUXMQ_BROKER_CA_CERTS}:ro
networks:
- magistrala-base-net
@@ -539,8 +577,12 @@ services:
networks:
- magistrala-base-net
# Creates the image bucket with the broker's own image rather than pulling a
# second one. An aws-cli here cost a 130 MB image for a single call, and it
# was the only untagged reference in this file, so what it resolved to
# depended on the day it was pulled.
seaweedfs-init:
image: amazon/aws-cli
image: chrislusf/seaweedfs:4.40
container_name: magistrala-seaweedfs-init
entrypoint: /bin/sh
depends_on:
@@ -548,26 +590,46 @@ services:
command:
- -c
- |
echo "[INIT] Waiting 20s for SeaweedFS S3 to be ready...";
sleep 20;
OUT=$(aws --endpoint-url http://seaweedfs-s3:8333 s3api create-bucket --bucket $${BUCKET} 2>&1);
EXIT=$$?;
if [ $$EXIT -eq 0 ]; then
echo "[INIT] Bucket $${BUCKET} created successfully.";
elif echo "$$OUT" | grep -q 'BucketAlreadyOwnedByYou\|BucketAlreadyExists'; then
echo "[INIT] Bucket $${BUCKET} already exists, skipping.";
else
echo "[INIT] Failed to create bucket $${BUCKET}: $$OUT" >&2;
exit 1;
fi
set -eu
# weed shell blocks indefinitely against an unreachable master, so the
# wait is a port check and every weed call is bounded; left to itself it
# would hang this container rather than fail it.
#
# 60 ticks is ~2 minutes while the store is merely slow to boot, and
# ~3 minutes to give up when it never appears, since a failing lookup
# costs about a second on top of the sleep.
tick=0;
while ! nc -z seaweedfs-s3 9333 2>/dev/null; do
tick=$$((tick + 1));
if [ $$tick -ge 60 ]; then
echo "[INIT] SeaweedFS master did not come up" >&2;
exit 1;
fi;
sleep 2;
done;
printf 's3.bucket.create -name %s\n' "$${BUCKET}" > /tmp/create.cmd;
printf 's3.bucket.list\n' > /tmp/list.cmd;
try=0;
while [ $$try -lt 3 ]; do
try=$$((try + 1));
timeout 20 weed shell -master seaweedfs-s3:9333 < /tmp/create.cmd > /tmp/create.out 2>&1 || true;
timeout 20 weed shell -master seaweedfs-s3:9333 < /tmp/list.cmd > /tmp/list.out 2>/dev/null || true;
# s3.bucket.create reports success even for an existing bucket, and
# weed shell exits 0 whatever the command did, so the listing is the
# only trustworthy signal that the bucket is really there.
if awk -v b="$${BUCKET}" '{sub(/^[ \t]+/,""); split($$0,f,/[ \t]/); if (f[1]==b) found=1} END{exit !found}' /tmp/list.out; then
echo "[INIT] Bucket $${BUCKET} present.";
exit 0;
fi;
sleep 3;
done;
echo "[INIT] Bucket $${BUCKET} was not created after $$try attempts" >&2;
cat /tmp/create.out >&2;
exit 1
networks:
- magistrala-base-net
environment:
BUCKET: ${MG_BACKEND_OBJECT_STORAGE_BUCKET}
AWS_ACCESS_KEY_ID: ${MG_BACKEND_OBJECT_STORAGE_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${MG_BACKEND_OBJECT_STORAGE_SECRET_KEY}
AWS_DEFAULT_REGION: ${MG_BACKEND_OBJECT_STORAGE_REGION}
AWS_EC2_METADATA_DISABLED: "true"
timescale:
image: timescale/timescaledb:2.29.0-pg16-oss
@@ -583,13 +645,18 @@ services:
- magistrala-base-net
volumes:
- magistrala-timescale-writer-volume:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 60
timescale-reader:
image: ghcr.io/absmach/magistrala/timescale-reader:${MG_RELEASE_TAG}
container_name: magistrala-timescale-reader
depends_on:
timescale:
condition: service_started
condition: service_healthy
atom-bootstrap:
condition: service_completed_successfully
restart: on-failure
@@ -668,7 +735,10 @@ services:
image: ghcr.io/absmach/magistrala/timescale-writer:${MG_RELEASE_TAG}
container_name: magistrala-timescale-writer
depends_on:
- timescale
timescale:
condition: service_healthy
fluxmq-node1:
condition: service_healthy
restart: on-failure
environment:
MG_TIMESCALE_WRITER_LOG_LEVEL: ${MG_TIMESCALE_WRITER_LOG_LEVEL}
@@ -686,7 +756,10 @@ services:
MG_TIMESCALE_SSL_CERT: ${MG_TIMESCALE_SSL_CERT}
MG_TIMESCALE_SSL_KEY: ${MG_TIMESCALE_SSL_KEY}
MG_TIMESCALE_SSL_ROOT_CERT: ${MG_TIMESCALE_SSL_ROOT_CERT}
MG_MESSAGE_BROKER_URL: ${MG_MESSAGE_BROKER_URL}
MG_MESSAGE_BROKER_URL: ${MG_TIMESCALE_WRITER_BROKER_URL}
MG_TIMESCALE_WRITER_BROKER_CLIENT_CERT: ${MG_TIMESCALE_WRITER_BROKER_CLIENT_CERT}
MG_TIMESCALE_WRITER_BROKER_CLIENT_KEY: ${MG_TIMESCALE_WRITER_BROKER_CLIENT_KEY}
MG_TIMESCALE_WRITER_BROKER_CA_CERTS: ${MG_TIMESCALE_WRITER_BROKER_CA_CERTS}
MG_JAEGER_URL: ${MG_JAEGER_URL}
MG_JAEGER_TRACE_RATIO: ${MG_JAEGER_TRACE_RATIO}
MG_SEND_TELEMETRY: ${MG_SEND_TELEMETRY}
@@ -697,6 +770,11 @@ services:
- magistrala-base-net
volumes:
- ./addons/timescale-writer/config.toml:${MG_TIMESCALE_WRITER_CONFIG_PATH}
# The URI SAN in this client certificate identifies the Timescale writer
# to the FluxMQ local listener; the broker ACL only permits `writers`.
- ./ssl/certs/timescale-writer-fluxmq-client.crt:${MG_TIMESCALE_WRITER_BROKER_CLIENT_CERT}:ro
- ./ssl/certs/timescale-writer-fluxmq-client.key:${MG_TIMESCALE_WRITER_BROKER_CLIENT_KEY}:ro
- ./ssl/certs/ca.crt:${MG_TIMESCALE_WRITER_BROKER_CA_CERTS}:ro
re-db:
image: docker.io/postgres:18.4-alpine3.24
container_name: magistrala-re-db
@@ -761,6 +839,13 @@ services:
MG_SEND_TELEMETRY: ${MG_SEND_TELEMETRY}
MG_PERMISSIONS_FILE: ${MG_PERMISSIONS_FILE}
MG_RE_INSTANCE_ID: ${MG_RE_INSTANCE_ID}
MG_RE_MAX_RULE_HOPS: ${MG_RE_MAX_RULE_HOPS}
MG_RE_TRACE_KEY: ${MG_RE_TRACE_KEY}
MG_RE_TRACE_KEY_FILE: ${MG_RE_TRACE_KEY_FILE}
MG_RE_BROKER_URL: ${MG_RE_BROKER_URL}
MG_RE_BROKER_CLIENT_CERT: ${MG_RE_BROKER_CLIENT_CERT}
MG_RE_BROKER_CLIENT_KEY: ${MG_RE_BROKER_CLIENT_KEY}
MG_RE_BROKER_CA_CERTS: ${MG_RE_BROKER_CA_CERTS}
MG_EMAIL_HOST: ${MG_EMAIL_HOST}
MG_EMAIL_PORT: ${MG_EMAIL_PORT}
MG_EMAIL_USERNAME: ${MG_EMAIL_USERNAME}
@@ -781,6 +866,12 @@ services:
volumes:
- ./permission.yaml:${MG_PERMISSIONS_FILE}
- ./templates/${MG_RE_EMAIL_TEMPLATE}:/email.tmpl
# Client certificate for the FluxMQ service listener. Its URI SAN is what
# the broker matches against the rules-engine local principal.
- ./ssl/certs/re-fluxmq-client.crt:${MG_RE_BROKER_CLIENT_CERT}:ro
- ./ssl/certs/re-fluxmq-client.key:${MG_RE_BROKER_CLIENT_KEY}:ro
- ./ssl/certs/ca.crt:${MG_RE_BROKER_CA_CERTS}:ro
- ./re/secrets/trace.key:/run/secrets/magistrala-re-trace-key:ro
alarms-db:
image: docker.io/postgres:18.4-alpine3.24
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) Abstract Machines
# SPDX-License-Identifier: Apache-2.0
# Derived from MG_RE_BROKER_SECRET by "make fluxmq_service_secret" in docker/ssl.
secrets/
+69
View File
@@ -0,0 +1,69 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package fluxmq_test
import (
"os"
"slices"
"testing"
"github.com/absmach/fluxmq/topics"
"gopkg.in/yaml.v3"
)
type brokerConfig struct {
Queues []struct {
Name string `yaml:"name"`
Topics []string `yaml:"topics"`
} `yaml:"queues"`
}
func TestQueueBindingsDoNotOverlap(t *testing.T) {
testCases := []struct {
topic string
want []string
}{
{topic: "$queue/mqtt/client", want: []string{"mqtt"}},
{topic: "$queue/events/domain/client", want: []string{"events"}},
{topic: "$queue/writers/domain/channel", want: []string{"writers"}},
{topic: "$queue/alarms/domain/channel", want: []string{"alarms"}},
// A queue addressed with no trailing path still has to land in its own
// stream: pkg/events/fluxmq addresses exactly "$queue/events" when the
// stream name resolves to an empty path, and a binding that stopped
// matching its own parent level would drop those publications with no
// error, since an unmatched topic capture is not a failure.
{topic: "$queue/events", want: []string{"events"}},
// Channel messages reach stream "m" through its own "m/#" binding, so
// nothing addresses the queue directly. Were that to change, the
// publication would match no queue at all rather than fall through to
// the reserved one.
{topic: "$queue/m/domain/channel", want: nil},
}
for _, configFile := range []string{"node1.yaml", "node2.yaml", "node3.yaml"} {
data, err := os.ReadFile(configFile)
if err != nil {
t.Fatalf("read %s: %v", configFile, err)
}
var cfg brokerConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("parse %s: %v", configFile, err)
}
for _, tc := range testCases {
var got []string
for _, queue := range cfg.Queues {
for _, pattern := range queue.Topics {
if topics.TopicMatch(pattern, tc.topic) {
got = append(got, queue.Name)
break
}
}
}
if !slices.Equal(got, tc.want) {
t.Errorf("%s: queues matching %q = %v, want %v", configFile, tc.topic, got, tc.want)
}
}
}
}
+84 -1
View File
@@ -37,6 +37,20 @@ server:
amqp091:
plain:
addr: "0.0.0.0:5682"
# The local listener is how a first-party service reaches the broker. It
# requires mTLS: FluxMQ matches the URI SAN of the presented certificate
# against a principal under auth.local_principals, and only a connection
# admitted here may exchange "_flux." message metadata. The Rules Engine
# needs that metadata to carry its rule execution trace, so it connects
# here rather than on the plain listener.
local:
addr: "0.0.0.0:5685"
max_connections: 32
cert_file: "/etc/fluxmq/certs/fluxmq-service-server.crt"
key_file: "/etc/fluxmq/certs/fluxmq-service-server.key"
ca_file: "/etc/fluxmq/certs/ca.crt"
client_auth: "require"
min_version: "TLS1.2"
health_addr: "0.0.0.0:8081"
health_enabled: true
shutdown_timeout: 30s
@@ -94,8 +108,13 @@ queue_manager:
queues:
- name: "mqtt"
# "mqtt" is this reserved queue's name, not an MQTT-only transport filter;
# every protocol adapter uses the same topic-based queue routing. A local-
# principal prefix grant is captured by every matching queue, so keep this
# binding inside the mqtt namespace rather than also persisting service
# streams such as writers and alarms here.
topics:
- "$queue/#"
- "$queue/mqtt/#"
reserved: true
- name: "events"
topics:
@@ -127,6 +146,70 @@ queues:
max_length_bytes: 1073741824
auth:
# Principals admitted on the service listener. The certificate_uri_san must
# match the URI SAN issued by docker/ssl/Makefile (fluxmq_service_certs), and
# each secret file must hold the value configured by its service.
local_principals:
- name: "rules-engine"
certificate_uri_san: "spiffe://absmach/magistrala/rules-engine"
# A service may consume and may relay the origin of messages it did not
# author, both of which the Rules Engine needs when it republishes a
# device message. The capability is the principal's, not the listener's.
role: "service"
current_secret_file: "/etc/fluxmq/secrets/re-current"
permissions:
# Rule outputs are republished to m.<domain>.c.<channel>.<subtopic>,
# whose tenant and channel identifiers cannot be enumerated here, so the
# grant is the topic namespace rather than a list of exact keys. Being a
# prefix it is also an ordinary topic publish, which the cluster
# forwards; an exact target would be durable on one node and could not
# run alongside cluster.enabled.
publish:
- routing_key_prefix: "m."
# The rule output streams. These grants have to live here: a
# publication from a connection the broker does not trust has the
# broker's own transport protocol and identity stamped on it, so a
# saved message would read as "amqp" rather than the protocol the
# device published with, and an alarm would name the engine's broker
# connection rather than the client. Same prefix reasoning as above --
# each queue's own "$queue/<name>/#" binding is what captures it.
- routing_key_prefix: "$queue/writers/"
- routing_key_prefix: "$queue/alarms/"
# The message stream the Rules Engine consumes.
subscribe:
- "m"
- name: "timescale-writer"
certificate_uri_san: "spiffe://absmach/magistrala/timescale-writer"
role: "service"
current_secret_file: "/etc/fluxmq/secrets/timescale-writer-current"
permissions:
# The writer is a passive consumer of the broker-provisioned stream. It
# neither publishes messages nor manages queues.
subscribe:
- "writers"
- name: "postgres-writer"
certificate_uri_san: "spiffe://absmach/magistrala/postgres-writer"
role: "service"
current_secret_file: "/etc/fluxmq/secrets/postgres-writer-current"
permissions:
# The writer is a passive consumer of the broker-provisioned stream. It
# neither publishes messages nor manages queues.
subscribe:
- "writers"
- name: "fluxmq-auth"
certificate_uri_san: "spiffe://absmach/magistrala/fluxmq-auth"
# The publish proxy relays messages a user published over HTTP. Stating
# their origin protocol and publisher is what the "service" role grants;
# on an untrusted connection the broker would stamp its own instead and
# every such message would be stored as having arrived over AMQP.
role: "service"
current_secret_file: "/etc/fluxmq/secrets/fluxmq-auth-current"
permissions:
# Messages are published to m.<domain>.c.<channel>.<subtopic>, whose
# tenant and channel identifiers cannot be enumerated here. It runs no
# consumer, so it is granted no subscribe.
publish:
- routing_key_prefix: "m."
external:
url: "http://fluxmq-auth:7016"
transport: "grpc"
+84 -1
View File
@@ -34,6 +34,20 @@ server:
amqp091:
plain:
addr: "0.0.0.0:5682"
# The local listener is how a first-party service reaches the broker. It
# requires mTLS: FluxMQ matches the URI SAN of the presented certificate
# against a principal under auth.local_principals, and only a connection
# admitted here may exchange "_flux." message metadata. The Rules Engine
# needs that metadata to carry its rule execution trace, so it connects
# here rather than on the plain listener.
local:
addr: "0.0.0.0:5685"
max_connections: 32
cert_file: "/etc/fluxmq/certs/fluxmq-service-server.crt"
key_file: "/etc/fluxmq/certs/fluxmq-service-server.key"
ca_file: "/etc/fluxmq/certs/ca.crt"
client_auth: "require"
min_version: "TLS1.2"
health_addr: "0.0.0.0:8081"
health_enabled: true
shutdown_timeout: 30s
@@ -91,8 +105,13 @@ queue_manager:
queues:
- name: "mqtt"
# "mqtt" is this reserved queue's name, not an MQTT-only transport filter;
# every protocol adapter uses the same topic-based queue routing. A local-
# principal prefix grant is captured by every matching queue, so keep this
# binding inside the mqtt namespace rather than also persisting service
# streams such as writers and alarms here.
topics:
- "$queue/#"
- "$queue/mqtt/#"
reserved: true
- name: "events"
topics:
@@ -124,6 +143,70 @@ queues:
max_length_bytes: 1073741824
auth:
# Principals admitted on the service listener. The certificate_uri_san must
# match the URI SAN issued by docker/ssl/Makefile (fluxmq_service_certs), and
# each secret file must hold the value configured by its service.
local_principals:
- name: "rules-engine"
certificate_uri_san: "spiffe://absmach/magistrala/rules-engine"
# A service may consume and may relay the origin of messages it did not
# author, both of which the Rules Engine needs when it republishes a
# device message. The capability is the principal's, not the listener's.
role: "service"
current_secret_file: "/etc/fluxmq/secrets/re-current"
permissions:
# Rule outputs are republished to m.<domain>.c.<channel>.<subtopic>,
# whose tenant and channel identifiers cannot be enumerated here, so the
# grant is the topic namespace rather than a list of exact keys. Being a
# prefix it is also an ordinary topic publish, which the cluster
# forwards; an exact target would be durable on one node and could not
# run alongside cluster.enabled.
publish:
- routing_key_prefix: "m."
# The rule output streams. These grants have to live here: a
# publication from a connection the broker does not trust has the
# broker's own transport protocol and identity stamped on it, so a
# saved message would read as "amqp" rather than the protocol the
# device published with, and an alarm would name the engine's broker
# connection rather than the client. Same prefix reasoning as above --
# each queue's own "$queue/<name>/#" binding is what captures it.
- routing_key_prefix: "$queue/writers/"
- routing_key_prefix: "$queue/alarms/"
# The message stream the Rules Engine consumes.
subscribe:
- "m"
- name: "timescale-writer"
certificate_uri_san: "spiffe://absmach/magistrala/timescale-writer"
role: "service"
current_secret_file: "/etc/fluxmq/secrets/timescale-writer-current"
permissions:
# The writer is a passive consumer of the broker-provisioned stream. It
# neither publishes messages nor manages queues.
subscribe:
- "writers"
- name: "postgres-writer"
certificate_uri_san: "spiffe://absmach/magistrala/postgres-writer"
role: "service"
current_secret_file: "/etc/fluxmq/secrets/postgres-writer-current"
permissions:
# The writer is a passive consumer of the broker-provisioned stream. It
# neither publishes messages nor manages queues.
subscribe:
- "writers"
- name: "fluxmq-auth"
certificate_uri_san: "spiffe://absmach/magistrala/fluxmq-auth"
# The publish proxy relays messages a user published over HTTP. Stating
# their origin protocol and publisher is what the "service" role grants;
# on an untrusted connection the broker would stamp its own instead and
# every such message would be stored as having arrived over AMQP.
role: "service"
current_secret_file: "/etc/fluxmq/secrets/fluxmq-auth-current"
permissions:
# Messages are published to m.<domain>.c.<channel>.<subtopic>, whose
# tenant and channel identifiers cannot be enumerated here. It runs no
# consumer, so it is granted no subscribe.
publish:
- routing_key_prefix: "m."
external:
url: "http://fluxmq-auth:7016"
transport: "grpc"
+84 -1
View File
@@ -34,6 +34,20 @@ server:
amqp091:
plain:
addr: "0.0.0.0:5682"
# The local listener is how a first-party service reaches the broker. It
# requires mTLS: FluxMQ matches the URI SAN of the presented certificate
# against a principal under auth.local_principals, and only a connection
# admitted here may exchange "_flux." message metadata. The Rules Engine
# needs that metadata to carry its rule execution trace, so it connects
# here rather than on the plain listener.
local:
addr: "0.0.0.0:5685"
max_connections: 32
cert_file: "/etc/fluxmq/certs/fluxmq-service-server.crt"
key_file: "/etc/fluxmq/certs/fluxmq-service-server.key"
ca_file: "/etc/fluxmq/certs/ca.crt"
client_auth: "require"
min_version: "TLS1.2"
health_addr: "0.0.0.0:8081"
health_enabled: true
shutdown_timeout: 30s
@@ -91,8 +105,13 @@ queue_manager:
queues:
- name: "mqtt"
# "mqtt" is this reserved queue's name, not an MQTT-only transport filter;
# every protocol adapter uses the same topic-based queue routing. A local-
# principal prefix grant is captured by every matching queue, so keep this
# binding inside the mqtt namespace rather than also persisting service
# streams such as writers and alarms here.
topics:
- "$queue/#"
- "$queue/mqtt/#"
reserved: true
- name: "events"
topics:
@@ -124,6 +143,70 @@ queues:
max_length_bytes: 1073741824
auth:
# Principals admitted on the service listener. The certificate_uri_san must
# match the URI SAN issued by docker/ssl/Makefile (fluxmq_service_certs), and
# each secret file must hold the value configured by its service.
local_principals:
- name: "rules-engine"
certificate_uri_san: "spiffe://absmach/magistrala/rules-engine"
# A service may consume and may relay the origin of messages it did not
# author, both of which the Rules Engine needs when it republishes a
# device message. The capability is the principal's, not the listener's.
role: "service"
current_secret_file: "/etc/fluxmq/secrets/re-current"
permissions:
# Rule outputs are republished to m.<domain>.c.<channel>.<subtopic>,
# whose tenant and channel identifiers cannot be enumerated here, so the
# grant is the topic namespace rather than a list of exact keys. Being a
# prefix it is also an ordinary topic publish, which the cluster
# forwards; an exact target would be durable on one node and could not
# run alongside cluster.enabled.
publish:
- routing_key_prefix: "m."
# The rule output streams. These grants have to live here: a
# publication from a connection the broker does not trust has the
# broker's own transport protocol and identity stamped on it, so a
# saved message would read as "amqp" rather than the protocol the
# device published with, and an alarm would name the engine's broker
# connection rather than the client. Same prefix reasoning as above --
# each queue's own "$queue/<name>/#" binding is what captures it.
- routing_key_prefix: "$queue/writers/"
- routing_key_prefix: "$queue/alarms/"
# The message stream the Rules Engine consumes.
subscribe:
- "m"
- name: "timescale-writer"
certificate_uri_san: "spiffe://absmach/magistrala/timescale-writer"
role: "service"
current_secret_file: "/etc/fluxmq/secrets/timescale-writer-current"
permissions:
# The writer is a passive consumer of the broker-provisioned stream. It
# neither publishes messages nor manages queues.
subscribe:
- "writers"
- name: "postgres-writer"
certificate_uri_san: "spiffe://absmach/magistrala/postgres-writer"
role: "service"
current_secret_file: "/etc/fluxmq/secrets/postgres-writer-current"
permissions:
# The writer is a passive consumer of the broker-provisioned stream. It
# neither publishes messages nor manages queues.
subscribe:
- "writers"
- name: "fluxmq-auth"
certificate_uri_san: "spiffe://absmach/magistrala/fluxmq-auth"
# The publish proxy relays messages a user published over HTTP. Stating
# their origin protocol and publisher is what the "service" role grants;
# on an untrusted connection the broker would stamp its own instead and
# every such message would be stored as having arrived over AMQP.
role: "service"
current_secret_file: "/etc/fluxmq/secrets/fluxmq-auth-current"
permissions:
# Messages are published to m.<domain>.c.<channel>.<subtopic>, whose
# tenant and channel identifiers cannot be enumerated here. It runs no
# consumer, so it is granted no subscribe.
publish:
- routing_key_prefix: "m."
external:
url: "http://fluxmq-auth:7016"
transport: "grpc"
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) Abstract Machines
# SPDX-License-Identifier: Apache-2.0
# Persistent Rules Engine HMAC keys generated by docker/ssl/Makefile.
secrets/
+15 -4
View File
@@ -1,10 +1,21 @@
# Copyright (c) Abstract Machines
# SPDX-License-Identifier: Apache-2.0
*grpc-server*
*grpc-client*
*srl
*conf
# Everything under certs/ is generated by this directory's Makefile, except the
# development CA and the default server certificate that ship with the
# repository.
#
# This is a denylist by intent: listing generated certificates by name meant
# each new one had to remember to add itself here, and forgetting silently left
# a private key committable. Ignore first, then re-include what is deliberately
# tracked.
certs/*
!certs/ca.crt
!certs/ca.key
!certs/magistrala-server.crt
!certs/magistrala-server.key
# Paths outside certs/.
client.crt
client.key
certbot-www/
+183 -1
View File
@@ -43,6 +43,23 @@ CHANNELS_GRPC_CLIENT_CRT_FILE_NAME=channels-grpc-client
COAP_DTLS_SERVER_CONF_FILE_NAME=coap-server.conf
COAP_DTLS_SERVER_CN=coap
COAP_DTLS_SERVER_CRT_FILE_NAME=coap-server
# FluxMQ service listener. The broker identifies a first-party service by the
# URI SAN of the certificate it presents, matching it against a principal
# declared in the broker's own configuration, so the SAN below must stay in
# step with docker/fluxmq/node*.yaml.
FLUXMQ_SERVICE_SERVER_CRT_FILE_NAME=fluxmq-service-server
RE_FLUXMQ_CLIENT_CN=rules-engine
RE_FLUXMQ_CLIENT_CRT_FILE_NAME=re-fluxmq-client
RE_FLUXMQ_CLIENT_URI_SAN=spiffe://absmach/magistrala/rules-engine
TIMESCALE_WRITER_FLUXMQ_CLIENT_CN=timescale-writer
TIMESCALE_WRITER_FLUXMQ_CLIENT_CRT_FILE_NAME=timescale-writer-fluxmq-client
TIMESCALE_WRITER_FLUXMQ_CLIENT_URI_SAN=spiffe://absmach/magistrala/timescale-writer
POSTGRES_WRITER_FLUXMQ_CLIENT_CN=postgres-writer
POSTGRES_WRITER_FLUXMQ_CLIENT_CRT_FILE_NAME=postgres-writer-fluxmq-client
POSTGRES_WRITER_FLUXMQ_CLIENT_URI_SAN=spiffe://absmach/magistrala/postgres-writer
FLUXMQ_AUTH_FLUXMQ_CLIENT_CN=fluxmq-auth
FLUXMQ_AUTH_FLUXMQ_CLIENT_CRT_FILE_NAME=fluxmq-auth-fluxmq-client
FLUXMQ_AUTH_FLUXMQ_CLIENT_URI_SAN=spiffe://absmach/magistrala/fluxmq-auth
define GRPC_CERT_CONFIG
[req]
@@ -65,6 +82,57 @@ subjectAltName = @alt_names
DNS.1 = <<SERVICE_NAME>>
endef
define FLUXMQ_SERVICE_SERVER_CERT_CONFIG
[req]
req_extensions = v3_req
distinguished_name = dn
prompt = no
[dn]
CN = mg.svc
C = RS
ST = RS
L = BELGRADE
O = SUPERMQ
OU = SUPERMQ
[v3_req]
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
# The Rules Engine verifies this certificate against the host in
# MG_RE_BROKER_URL, so every name a deployment may dial the service listener by
# has to appear here: the per-node names this Compose stack uses, and the plain
# "fluxmq" of a single-node deployment.
[alt_names]
DNS.1 = fluxmq
DNS.2 = fluxmq-node1
DNS.3 = fluxmq-node2
DNS.4 = fluxmq-node3
endef
define URI_SAN_CERT_CONFIG
[req]
req_extensions = v3_req
distinguished_name = dn
prompt = no
[dn]
CN = <<SERVICE_NAME>>
C = RS
ST = RS
L = BELGRADE
O = SUPERMQ
OU = SUPERMQ
[v3_req]
extendedKeyUsage = clientAuth
subjectAltName = @alt_names
[alt_names]
URI.1 = <<URI_SAN>>
endef
define ANNOUNCE_BODY
Version $(VERSION) of $(PACKAGE_NAME) has been released.
@@ -72,7 +140,7 @@ It can be downloaded from $(DOWNLOAD_URL).
etc, etc.
endef
all: clean_certs ca server_cert auth_grpc_certs domains_grpc_certs groups_grpc_certs clients_grpc_certs channels_grpc_certs coap_dtls_certs
all: clean_certs ca server_cert auth_grpc_certs domains_grpc_certs groups_grpc_certs clients_grpc_certs channels_grpc_certs coap_dtls_certs fluxmq_service_certs fluxmq_service_secret timescale_writer_fluxmq_service_secret postgres_writer_fluxmq_service_secret fluxmq_auth_fluxmq_service_secret re_trace_key
# CA name and key is "ca".
ca:
@@ -139,6 +207,120 @@ define gen_grpc_cert_pair
$(call gen_grpc_cert,$(3),$(4))
endef
# Usage: $(call gen_fluxmq_server_cert,cert_file_name)
define gen_fluxmq_server_cert
$(file > $(CRT_LOCATION)/$(1).conf,$(FLUXMQ_SERVICE_SERVER_CERT_CONFIG))
openssl req -new -sha256 -newkey rsa:4096 -nodes \
-keyout $(CRT_LOCATION)/$(1).key \
-out $(CRT_LOCATION)/$(1).csr \
-config $(CRT_LOCATION)/$(1).conf \
-extensions v3_req
openssl x509 -req -sha256 \
-in $(CRT_LOCATION)/$(1).csr \
-CA $(CRT_LOCATION)/ca.crt \
-CAkey $(CRT_LOCATION)/ca.key \
-CAcreateserial \
-out $(CRT_LOCATION)/$(1).crt \
-days 365 \
-extfile $(CRT_LOCATION)/$(1).conf \
-extensions v3_req
rm -rf $(CRT_LOCATION)/$(1).csr $(CRT_LOCATION)/$(1).conf
endef
# Usage: $(call gen_uri_san_cert,cert_file_name,common_name,uri_san)
define gen_uri_san_cert
$(file > $(CRT_LOCATION)/$(1).conf,$(subst <<URI_SAN>>,$(3),$(subst <<SERVICE_NAME>>,$(2),$(URI_SAN_CERT_CONFIG))))
openssl req -new -sha256 -newkey rsa:4096 -nodes \
-keyout $(CRT_LOCATION)/$(1).key \
-out $(CRT_LOCATION)/$(1).csr \
-config $(CRT_LOCATION)/$(1).conf \
-extensions v3_req
openssl x509 -req -sha256 \
-in $(CRT_LOCATION)/$(1).csr \
-CA $(CRT_LOCATION)/ca.crt \
-CAkey $(CRT_LOCATION)/ca.key \
-CAcreateserial \
-out $(CRT_LOCATION)/$(1).crt \
-days 365 \
-extfile $(CRT_LOCATION)/$(1).conf \
-extensions v3_req
rm -rf $(CRT_LOCATION)/$(1).csr $(CRT_LOCATION)/$(1).conf
endef
# Certificates for the FluxMQ service listener: a server certificate for the
# broker and a client certificate carrying each local principal's URI SAN. The
# listener requires mTLS, so both sides are issued by the same CA.
fluxmq_service_certs:
$(call gen_fluxmq_server_cert,$(FLUXMQ_SERVICE_SERVER_CRT_FILE_NAME))
$(call gen_uri_san_cert,$(RE_FLUXMQ_CLIENT_CRT_FILE_NAME),$(RE_FLUXMQ_CLIENT_CN),$(RE_FLUXMQ_CLIENT_URI_SAN))
$(call gen_uri_san_cert,$(TIMESCALE_WRITER_FLUXMQ_CLIENT_CRT_FILE_NAME),$(TIMESCALE_WRITER_FLUXMQ_CLIENT_CN),$(TIMESCALE_WRITER_FLUXMQ_CLIENT_URI_SAN))
$(call gen_uri_san_cert,$(POSTGRES_WRITER_FLUXMQ_CLIENT_CRT_FILE_NAME),$(POSTGRES_WRITER_FLUXMQ_CLIENT_CN),$(POSTGRES_WRITER_FLUXMQ_CLIENT_URI_SAN))
$(call gen_uri_san_cert,$(FLUXMQ_AUTH_FLUXMQ_CLIENT_CRT_FILE_NAME),$(FLUXMQ_AUTH_FLUXMQ_CLIENT_CN),$(FLUXMQ_AUTH_FLUXMQ_CLIENT_URI_SAN))
# Generate a single principal's certificate when adding it to an existing
# deployment, without rotating the broker certificate or the other principals.
timescale_writer_fluxmq_client_cert:
$(call gen_uri_san_cert,$(TIMESCALE_WRITER_FLUXMQ_CLIENT_CRT_FILE_NAME),$(TIMESCALE_WRITER_FLUXMQ_CLIENT_CN),$(TIMESCALE_WRITER_FLUXMQ_CLIENT_URI_SAN))
postgres_writer_fluxmq_client_cert:
$(call gen_uri_san_cert,$(POSTGRES_WRITER_FLUXMQ_CLIENT_CRT_FILE_NAME),$(POSTGRES_WRITER_FLUXMQ_CLIENT_CN),$(POSTGRES_WRITER_FLUXMQ_CLIENT_URI_SAN))
fluxmq_auth_fluxmq_client_cert:
$(call gen_uri_san_cert,$(FLUXMQ_AUTH_FLUXMQ_CLIENT_CRT_FILE_NAME),$(FLUXMQ_AUTH_FLUXMQ_CLIENT_CN),$(FLUXMQ_AUTH_FLUXMQ_CLIENT_URI_SAN))
# The SASL secret a local principal presents alongside its certificate. FluxMQ
# reads it from a file and the service sends it in its broker URL, so both sides
# must see the same value: .env is the single source and these targets derive
# the file from it. FluxMQ requires at least 32 bytes.
#
# Usage: $(call gen_fluxmq_service_secret,env_variable_name,secret_file_name)
define gen_fluxmq_service_secret
@mkdir -p ../fluxmq/secrets
@secret="$${$(1)}"; \
if [ -z "$$secret" ]; then \
secret=$$(grep -E "^$(1)=" ../.env | cut -d= -f2-); \
fi; \
if [ -z "$$secret" ]; then \
echo "$(1) is not set in docker/.env"; exit 1; \
fi; \
if [ $${#secret} -lt 32 ]; then \
echo "$(1) must be at least 32 characters"; exit 1; \
fi; \
printf '%s' "$$secret" > ../fluxmq/secrets/$(2); \
chmod 600 ../fluxmq/secrets/$(2); \
echo "wrote docker/fluxmq/secrets/$(2)"
endef
fluxmq_service_secret:
$(call gen_fluxmq_service_secret,MG_RE_BROKER_SECRET,re-current)
timescale_writer_fluxmq_service_secret:
$(call gen_fluxmq_service_secret,MG_TIMESCALE_WRITER_BROKER_SECRET,timescale-writer-current)
postgres_writer_fluxmq_service_secret:
$(call gen_fluxmq_service_secret,MG_POSTGRES_WRITER_BROKER_SECRET,postgres-writer-current)
fluxmq_auth_fluxmq_service_secret:
$(call gen_fluxmq_service_secret,MG_FLUXMQ_BROKER_SECRET,fluxmq-auth-current)
# Persistent HMAC key shared by Rules Engine replicas. Do not replace an
# existing key: doing so while old messages are in flight would invalidate
# their loop traces. The containing directory is excluded from git.
re_trace_key:
@mkdir -p ../re/secrets
@if [ ! -s ../re/secrets/trace.key ]; then \
umask 077; openssl rand -hex 32 > ../re/secrets/trace.key; \
echo "wrote docker/re/secrets/trace.key"; \
else \
echo "preserving docker/re/secrets/trace.key"; \
fi
auth_grpc_certs:
$(call gen_grpc_cert_pair,$(AUTH_GRPC_SERVER_CRT_FILE_NAME),$(AUTH_GRPC_SERVER_CN),$(AUTH_GRPC_CLIENT_CRT_FILE_NAME),$(AUTH_GRPC_CLIENT_CN))
+2214
View File
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
# ATOM-01 — Expose `attributesContains` on entity and group queries
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-09, UI-02 · MG-15 (phase 2) |
| **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: ["gw-a", "gw-b"] } // phase 1: IDs
```
"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. **Array containment works**: `entities(attributesContains: {gateways: ["gw-a"]})`
returns devices whose `gateways` array contains `gw-a`. This is the
gateway-view query and the reason for P0.
1b. ⏸ *Phase 2* — the same works when entries become objects
(`{gateways: [{"id": "gw-a"}]}`), matching on `id` and ignoring other keys.
1c. **Containment is per-element** (phase 2, once entries carry addresses). 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,129 @@
# ATOM-02 — Expose scoping filters on `authorizedObjectIds`
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-08 (reader authorization) |
| **Status** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> Justified by MG-08 **part B** device-set scale, which is phase 2. Part A checks a handful of publishers, for which per-publisher `authzCheck` is sufficient.
>
> The design below is unchanged and remains the target.
## Problem
`authorizedObjectIds` answers "which objects may this subject act on". Its query
struct and repository implementation support attribute, profile, status, group
and descendant filtering — but the GraphQL resolver hardcodes all of them to
`None`/`false`.
Callers must therefore page the subject's **entire** authorized set into memory
and filter client-side. For a consumer that wants "the meters this customer may
read, within this group" that means materialising every authorized ID first,
which does not scale and makes `total` meaningless for the caller's real query.
## Why this is generic
The listing queries (`entities()`, `resources()`) already accept these filters.
This makes the authorization query accept the same ones, so "what can I see"
and "what can I see, narrowed" are the same question with the same vocabulary.
Nothing caller-specific is introduced.
## Current state
`AuthorizedObjectIdsQuery` (`src/models/access.rs`) carries:
```rust
subject_id, action, object_kind, object_type, tenant_id, q,
attributes_contains, profile_id, entity_status, group_type,
parent_group_id, include_descendants, limit, offset
```
The resolver (`src/graphql/authz.rs:47-61`) passes only the first six and pins
the rest:
```rust
attributes_contains: None,
profile_id: None,
entity_status: None,
group_type: None,
parent_group_id: None,
include_descendants: false,
```
The repository implements all of them (`src/authz/repo.rs:155,189,221`).
## Scope
**In scope**
- Extend `AuthorizedObjectIdsInput` with: `attributesContains`, `profileId`,
`entityStatus`, `parentGroupId`, `includeDescendants`.
- Thread them through the resolver (`src/graphql/authz.rs:47-61`).
- Preserve the existing capability check
(`access::require_authz_check_access`, `authz.rs:40`) unchanged — filters
narrow a result set, they must never widen it.
> **Coordinate with [ATOM-06](./ATOM-06-entity-external-id.md).** Both add
> parameters to the *same* `authorizedObjectIds` resolver
> (`src/graphql/authz.rs:47-61`) — this one adds the existing repository filters,
> ATOM-06 adds `externalId`. Independent in design, guaranteed to conflict in the
> diff. Sequence them or land them together.
**Out of scope**
- `group_type` — no consumer yet. Leave pinned to `None` rather than exposing an
unused knob.
- Changes to the scoped-token ceiling semantics described at `authz.rs:43-46`.
Filters apply **after** ceiling filtering; adding them must not alter that
order.
- Repository or SQL changes.
## Design
Direct plumbing. Parameter naming and types mirror `entities()` after ATOM-01 so
the two queries read the same way.
The critical invariant: **filters are conjunctive with the authorization result,
never disjunctive.** A caller supplying `parentGroupId` for a group they cannot
read must receive an empty set, not an error and not the unfiltered set.
## Acceptance criteria
1. `authorizedObjectIds(input: {subjectId, action: "read", objectKind: "entity",
objectType: "entity:device", parentGroupId: <group>})` returns only device IDs
that are both authorized for the subject and in that group.
2. `attributesContains` narrows the same way.
3. `includeDescendants: true` walks the group tree; `false` (and omitted) does
not.
4. `total` reflects the filtered count.
5. A subject with no grants receives an empty list for every filter combination —
filters cannot grant access.
6. Scoped-token ceiling filtering still applies, and applies first.
7. Omitting all new arguments produces results identical to today.
## Test plan
- Unit: each new parameter reaches `AuthorizedObjectIdsQuery` unchanged;
omitted parameters retain today's defaults.
- Integration:
- subject with a `group_direct_objects` grant + `parentGroupId` filter →
intersection only;
- subject with **no** grant + any filter → empty;
- scoped token whose ceiling excludes an object the direct policy allows →
object absent regardless of filters;
- pagination: `limit`/`offset` over a filtered set returns each ID exactly once
across pages.
- Regression: existing `authorizedObjectIds` tests pass untouched.
## Risks
- **Widening by accident** is the failure mode that matters. A filter applied as
an `OR` branch, or applied before the ceiling filter, becomes privilege
escalation. Acceptance criteria 5 and 6 are the guards; both need explicit
tests, not incidental coverage.
- Query-plan regression on the larger `WHERE` clause — check the plan for the
common case (`objectKind: entity` + `parentGroupId`) before merging.
+102
View File
@@ -0,0 +1,102 @@
# ATOM-03 — Reverse policy lookup: `directPolicies(objectId:)`
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P3 |
| **Depends on** | — |
| **Blocks** | Sharing UI; correct revocation |
| **Status** | Draft |
## Problem
`directPolicies` filters by subject only — `tenantId`, `subjectKind`,
`subjectId`, `permissionBlockId` (`src/graphql/policies.rs:285-294`). There is no
way to ask **"who has access to this object?"**
Two consequences:
1. A sharing UI cannot show who a resource is shared with without enumerating
every subject in the tenant and querying each.
2. Revocation is unsafe. Magistrala's `DeletePolicyFilter`
(`pkg/atom/policy_service.go:78-105`) works around the gap by listing a
subject's policies and matching client-side, capped at 100 — so revoking
access on a widely-shared object silently misses policies past that cap.
## Why this is generic
"Who can access X" is the inverse of "what can this subject access", which Atom
already answers. Every system with a sharing model needs both directions. No
domain semantics are introduced.
## Scope
**In scope**
- Add `object_id: Option<ID>` to the `directPolicies` query, returning every
direct policy whose permission block targets that object.
- Object matching must cover the scope modes that can reference a specific
object:
- `object``permission_blocks.object_id = $1`
- `group_direct_objects` / `group_descendant_objects` — blocks whose
`group_id` contains the object, direct or transitive respectively
- Add `object_kind` / `object_type` as optional co-filters, since an ID alone is
ambiguous across kinds.
**Out of scope**
- Tenant-, platform-, `object_kind`- and `object_type`-scoped blocks. These grant
access to an object without naming it; including them would make the result a
full effective-access computation rather than a policy lookup. If effective
access is wanted, that is a separate `effectiveAccess(objectId:)` query with
different semantics — do not conflate them.
- Any change to `authzCheck` or the evaluation engine.
## Design
Extend `ListDirectPolicies` (`src/authz/repo.rs`) with the optional object
filter, joining `permission_blocks` and — for the group scope modes — the
membership tables `object_group_entities` / `object_group_resources`
(`migrations/001_initial.sql:525-544`) and, for descendants,
`object_group_hierarchy` (`:446-454`).
Authorization: reuse `require_policy_read` (`policies.rs:298`) unchanged. Reading
who can access an object is a policy-read operation on the tenant, as today.
**Result semantics must be documented on the field**, because the out-of-scope
exclusion above is surprising if undocumented: this returns *direct policies
naming this object*, not *everyone who can reach it*.
## Acceptance criteria
1. `directPolicies(objectId: <device>)` returns policies whose block is
`scope_mode: "object"` with that `object_id`.
2. It also returns policies whose block is `group_direct_objects` over a group
the object is a direct member of.
3. `group_descendant_objects` matches transitively through
`object_group_hierarchy`; `group_direct_objects` does not.
4. Tenant-, platform- and kind-scoped blocks are **not** returned, and the field
documentation says so.
5. `objectKind` / `objectType` co-filters narrow correctly.
6. Combining `objectId` with `subjectId` intersects.
7. Callers without policy-read on the tenant are refused.
8. Omitting `objectId` produces results identical to today.
## Test plan
- Integration: build one device reachable four ways — direct object block, direct
group membership, descendant group membership, and a tenant-scoped block —
then assert exactly the first three are returned.
- Hierarchy depth: 3-level group tree, confirm direct vs descendant behaviour at
each level.
- Authorization: caller without policy-read is refused.
- Pagination over a widely-shared object.
## Risks
- **Recursive hierarchy traversal cost.** Descendant matching needs a recursive
CTE. Bound it by tenant and check the plan on a deep tree.
- **Misreading the result as effective access.** The exclusion in Scope is
deliberate and load-bearing; if a consumer treats this as "everyone who can
see X", they will under-report. Documentation on the field is part of the
deliverable, not a nicety.
@@ -0,0 +1,207 @@
# ATOM-04 — Many-to-many object group membership
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-03, MG-04 |
| **Status** | Draft |
| **Decision** | [spec A1](../architecture.md#8-decision-record) |
> **Cost correction.** When recommending this change I described it as
> "near-trivial — a `PRIMARY KEY` change". That was based on the migration tool's
> `ON CONFLICT` clauses. Having read Atom's repository layer, it is **moderate,
> not trivial**: single membership is assumed in the upsert semantics, the delete
> path, the public API shape, the list queries, and — most importantly — the
> authorization evaluation path. The decision may still be right; the estimate
> was wrong. See [Scale](#scale-of-the-change).
## Problem
`object_group_entities` has `PRIMARY KEY (entity_id)`
(`migrations/001_initial.sql:525-532`) — an entity belongs to **at most one**
object group. Same for resources, `PRIMARY KEY (resource_id)` (`:537-544`).
This forbids overlapping object sets:
```
"Customer A meters" → granted to Customer A
"Building 5 meters" → granted to a maintenance contractor
Meter 7 ∈ both, and neither set contains the other
```
A hierarchy cannot express it, because the sets intersect without nesting.
## Why this is generic
Group membership being many-to-many is the ordinary case for a grouping
primitive — tags, labels, collections, teams. Atom's own API already reads as
though it were: `entityGroups(entityId)` returns a **list**
(`src/graphql/groups.rs:104`), and the mutation is named `addGroupMember`, not
`setGroupParent`. No product-specific semantics are introduced; a constraint is
removed.
## Scale of the change
Single membership is assumed in five places, in increasing order of risk.
### 1. Schema — trivial
```sql
ALTER TABLE object_group_entities
DROP CONSTRAINT object_group_entities_pkey,
ADD PRIMARY KEY (group_id, entity_id);
```
Data-preserving. Same for `object_group_resources` if included (see Scope).
### 2. Upsert semantics — small
`src/identity/repo.rs:511-523` currently **moves** an entity between groups:
```sql
INSERT INTO object_group_entities (group_id, entity_id, tenant_id)
VALUES ($1, $2, $3)
ON CONFLICT (entity_id) DO UPDATE
SET group_id = EXCLUDED.group_id, ...
```
Becomes `ON CONFLICT (group_id, entity_id) DO NOTHING` — additive rather than
replacing. This also settles spec §8 **E3**: today's behaviour is a silent
move; after this it is a genuine add.
### 3. Removal — small, but an API change
`src/identity/repo.rs:556` deletes by entity alone:
```sql
DELETE FROM object_group_entities WHERE entity_id = $1
```
That now means "remove from *all* groups". `removeGroupMember` already takes a
`group_id` (`groups.rs:699`), so the mutation is fine — but
`clear_entity_parent_group_in_tx` and any caller expecting "clear the parent"
need explicit semantics: remove-from-one versus remove-from-all.
### 4. The `parent_group_id` attribute API — moderate, semantic
Membership is currently settable through an **attribute** on create/update —
`parent_group_id_from_attrs` (`src/authz/repo.rs:59,237-241`). A scalar attribute
cannot express a set.
Decide one:
- **A.** Keep `parent_group_id` as a convenience meaning "sole membership"
(replaces all), with `addGroupMember` / `removeGroupMember` as the set API.
Backwards compatible, but two mechanisms with different semantics on one
relation is exactly the kind of thing that later confuses everyone.
- **B.** Deprecate the attribute path; membership is only mutated through the
explicit mutations. Cleaner; breaks existing callers, including Magistrala's
projection (`pkg/atom/mapping.go:46` writes `parent_group_id`).
**Recommend B**, consistent with the "avoid technical debt" ruling in
[spec §8 C1](../architecture.md#8-decision-record).
### 5. Queries joining membership — moderate, correctness-critical
This is the part that matters.
**Row multiplication in listings.** `src/authz/repo.rs:4839-4851`:
```sql
candidates AS (
SELECT e.id, ..., gep.group_id AS parent_group_id
FROM entities e
LEFT JOIN group_entity_parents gep ON gep.entity_id = e.id
...
AND ($8::uuid IS NULL OR gep.group_id IN (SELECT id FROM target_groups))
```
With M:N this yields one row **per (entity, group) pair** — duplicate entities in
every listing and an inflated `total`. Needs `EXISTS`-style filtering rather than
a join that projects the group, or explicit de-duplication.
**Missed grants in authorization.** `src/authz/repo.rs:5687-5692`:
```sql
SELECT e.id, ..., gep.group_id AS parent_group_id
FROM entities e
LEFT JOIN group_entity_parents gep ON gep.entity_id = e.id
WHERE e.id = $1 ...
```
`fetch_optional` over a now-multi-row result takes one arbitrary group. Since
this record feeds group-scoped policy evaluation, **an entity in two groups would
have grants through one of them silently ignored** — non-deterministically, since
which row wins is unspecified.
This is the single most important change in the PRD: `AuthzObjectRecord`'s
`parent_group_id: Option<Uuid>` must become a set, and every `group_direct_objects`
/ `group_descendant_objects` evaluation must consider all of them.
**Views.** `group_entity_parents` / `group_resource_parents`
(`001_initial.sql:549-555`) keep working but their names become misleading —
they are membership, not parentage.
## Scope
**In scope**
- Schema change for `object_group_entities`.
- Additive upsert; explicit removal semantics.
- De-duplicated list queries with correct `total`.
- Set-based membership in the authorization evaluation path.
- Decision and implementation of the `parent_group_id` attribute question.
**Open — decide before starting**
- **Include `object_group_resources`?** Symmetry argues yes; no requirement
exists yet (channels in multiple groups). Splitting them leaves an asymmetry
that will confuse; doing both roughly doubles the query work.
**Out of scope**
- Group hierarchy — `object_group_hierarchy PRIMARY KEY (child_id)`
(`:446-454`) stays a tree. A group has one parent; only *membership* becomes
many-to-many.
- Principal group membership, unless it shares the same tables.
## Acceptance criteria
1. An entity can be added to two groups and appears in both `groupMembers`
listings.
2. `entityGroups` returns both.
3. `entities(parentGroupId:)` returns the entity **once**, with `total` counting
it once, for either group.
4. A grant via `group_direct_objects` on **either** group authorizes the entity —
verified for both, not just the first.
5. Removing from one group leaves the other membership and its grants intact.
6. `includeDescendants` traversal is correct when an entity is in two groups in
different subtrees.
7. Adding an entity to a group it is already in is idempotent.
8. Cross-tenant membership is still rejected (`identity/repo.rs:505-509`).
9. Existing single-membership data behaves identically after migration.
## Test plan
- Migration against seeded data; assert every existing membership survives.
- **Criterion 4 is the security-relevant one** — entity in groups G1 and G2, grant
only via G2, assert allowed. This fails today's evaluation path and is the
reason for the change.
- Criterion 3 with an entity in three groups, asserting no duplicates and correct
`total` under pagination.
- Descendant traversal across two subtrees.
- Regression: full existing group and authz suites.
## Risks
- **Silent grant loss** if the evaluation path is not fully converted to sets.
Fails open or closed non-deterministically depending on row order — the worst
possible failure mode. Criterion 4 is the guard.
- **Duplicate rows** in listings are cosmetic in the UI but corrupt `total` and
therefore pagination.
- **Query plans** change once the join can fan out. Check plans for
`authorized_object_ids` with and without a group filter.
- **Scope creep into resources.** Decide up front; discovering halfway that
resources need it too doubles the work mid-flight.
+58
View File
@@ -0,0 +1,58 @@
# ATOM-05 — Add `gateway` entity kind
| | |
|---|---|
| **Repo** | `absmach/atom` (Rust) |
| **Priority** | — |
| **Status** | **WITHDRAWN** |
| **Superseded by** | [spec §8 A12](../architecture.md#8-decision-record) |
---
## Withdrawn
This PRD added `gateway` to Atom's `entities.kind` enum. **Gateway is a
capability, not a type**, so no new kind is needed — gateways stay
`entity_kind: device` with an `is_gateway` attribute.
### Why
Types are exclusive; roles compose. The model defines a gateway as *a Device with
a proxy role* ([spec §2.1](../architecture.md#21-a-gateway-is-a-path-not-a-container)),
and a distinct kind contradicts that on a real and common device: a smart
electricity meter that also concentrates wM-Bus water meters produces its own
readings **and** relays others. Under exclusive kinds it must be one or the
other, and either answer is wrong.
Surveying comparable platforms found the same conclusion everywhere:
| Platform | Gateway is |
|---|---|
| ThingsBoard | Device + `Is gateway` **boolean** |
| AWS Greengrass v2 | Core device — an IoT `thing`, **same type as clients** |
| Azure IoT Edge | Device identity with edge capability |
| ChirpStack | Separate — but its gateways are dumb radio infrastructure producing no data |
### What happened to its parts
| Was | Now |
|---|---|
| `Gateway` variant on `EntityKind` + two CHECK relaxations | Not needed |
| `entity:gateway` object type | `attributesContains: {is_gateway: true}` — ATOM-01 |
| Separate gateway profile namespace | One namespace. A device that both measures and relays needs *one* type, which is more correct |
| Gateway-specific `ActionAssignmentRule`s | Not needed — the existing `{entity_kind: device, publish, resource:channel}` rule keeps applying |
### The trap it carried, now gone
`pkg/atom/bootstrap.go:72-87` installs `{entity_kind: device, publish,
resource:channel}` as the only publish guardrail. Introducing a `gateway` kind
would have **silently stripped every gateway's right to publish** until matching
rules were added — surfacing as a bare authorization denial with nothing pointing
at the cause. Withdrawing this PRD removes that failure mode entirely.
### If this needs revisiting
The trigger would be a requirement to grant over *all gateways* as a first-class
object type, in a deployment where attribute filtering does not scale. The
migration stays permissive and additive, so it can be done later at the same cost
— with the publish-guardrail trap as the thing to plan for.
+155
View File
@@ -0,0 +1,155 @@
# 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) |
> **Two decisions block this PRD** ([spec §7](../architecture.md#7-open-questions)).
> Decide before writing the migration — both are one-way doors, and a wrong
> choice silently merges two devices into one with no migration back:
>
> | | Question | Recommendation |
> |---|---|---|
> | **Case** | Are `ABC123` and `abc123` one device or two? | **Case-sensitive.** Tightenable later; the reverse is not |
> | **Whitespace** | Is `"ABC123 "` the same as `"ABC123"`? | Trim — but decide it, do not inherit it from whichever client writes first |
## 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.
- ⏸ *Phase 2* — the same filter on `authorizedObjectIds`, landing with ATOM-02
which touches that resolver anyway. Nothing in phase 1 consumes it.
- `external_id` on the `Entity` GraphQL type and in `entity.*` domain events.
> **Phase 1 touches only the `entities` query.** The `authorizedObjectIds` half is
> deferred to phase 2 and lands with
> [ATOM-02](./ATOM-02-authorized-object-ids-filters.md), which edits that resolver
> anyway — so the diff conflict the two would otherwise have is avoided entirely.
**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. ⏸ *Phase 2* — `authorizedObjectIds(externalId: "…")` narrows correctly and
cannot widen access.
7. `external_id` can be changed, and cleared to NULL.
8. Soft-deleting an entity frees its `external_id` for reuse; restoring one whose
identifier was reused fails with a comprehensible error.
9. `external_id` appears in `entity.create` / `entity.update` events.
10. Existing entities and queries are unaffected.
## Test plan
- Migration on seeded data; assert all existing rows survive with NULL.
- Uniqueness: same tenant (reject), different tenants (allow), NULLs (allow many).
- Round-trip of hostile strings — unicode, embedded quotes, 1KB length, leading
and trailing whitespace. Decide and pin whether whitespace is trimmed.
- Case sensitivity, asserting the documented behaviour explicitly.
- Delete → reuse → restore conflict path (criterion 8).
- Query plan for the `externalId` filter.
## Risks
- **Case-sensitivity is a one-way door.** Choosing case-insensitive merges
devices that differ only in case, and no migration un-merges them. Pin it with
a test.
- **Whitespace and unicode normalisation.** `"ABC123 "` and `"ABC123"` will be
two devices unless trimmed. Trimming is probably right, but it must be a
decision written down rather than an accident of the client.
- **Length.** `TEXT` is unbounded; an index on a multi-kilobyte value is
pathological. Consider a sanity cap (e.g. 255) even though the format is
otherwise unconstrained.
- **Consumers treating it as immutable.** It is mutable, and Magistrala stores it
denormalised on every message row. Changing a device's `external_id` orphans
its historical data under the new value. Magistrala must either forbid the
change or accept the break — flagged in MG-09.
+125
View File
@@ -0,0 +1,125 @@
# MG-01 — Fix Atom policy client defects
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P0 |
| **Depends on** | — |
| **Blocks** | MG-04, MG-08 |
| **Status** | Draft |
## Problem
Four defects in `pkg/atom` sit directly beneath the access-control work. Each is
latent today — mostly because `PolicyService` is not wired into any running
binary — and each becomes a live correctness or security bug the moment it is.
### 1. `objectType` is sent unnamespaced
`policy_service.go:139` sends `ObjectType: entityKind(KindClient)``"device"`.
The write path sends the namespaced form `"entity:device"`
(`policy_service.go:225`).
Atom requires the namespaced form and says so explicitly:
> `object_type must be the full namespaced value matching object_kind, e.g. 'entity:device'`
> — `src/identity/access_tokens.rs:275`; construction at `src/graphql/entities.rs:157-165`
The filter therefore never matches what the writer stored. Confirmed against
Atom source, not inferred.
### 2. `DeletePolicyFilter` silently truncates
`policy_service.go:78-105` lists a subject's direct policies with
`Limit: policyPageLimit` (100, `:13`) and deletes matches from that single page.
A subject with more than 100 policies keeps access to everything past the cap.
Revocation reporting success while leaving access in place is a security defect,
not a pagination nit.
### 3. `CapabilityID` cannot see past 100 actions
`client.go:272-287` linear-scans `actions(limit: 100)`. Action 101 is
unresolvable, and the failure is a confusing "not found" far from the cause.
### 4. No applicability registered for `objectKind: entity`
`bootstrap.go:29-70` registers applicability for `tenant`, `group`,
`resource:channel`, `resource:rule` and `resource:report` — but nothing for
`entity`. Meanwhile `fluxmq/api/http/publish.go:184` already checks `read` on an
entity, and every device-level grant this project introduces will target
`entity:device`.
## Scope
**In scope**
- Fix the `objectType` mismatch. Introduce a single helper that produces the
namespaced object type and use it on **both** the read and write paths, so the
two cannot drift again.
- Make `DeletePolicyFilter` paginate to exhaustion, deleting across all pages.
- Make `CapabilityID` resolve reliably — paginate, or look up by name if Atom
supports it. Cache resolved IDs; they are immutable.
- Register applicability for `entity` / `entity:device`: `read`, `write`,
`delete`, `manage`.
- Widen `isSupportedObjectList` (`policy_service.go:182-187`) beyond
`user + client + view`, which is required by MG-08.
**Out of scope**
- Wiring `PolicyService` into services — MG-08 does that for readers.
- Group-scoped blocks — MG-04.
- Any behaviour change to `AddPolicy`'s block-per-call shape. Block reuse is a
legitimate optimisation but belongs with MG-04, which changes that path anyway.
## Design notes
The `objectType` fix should not be a literal-string edit in two places. Add:
```go
// atomObjectType returns the namespaced object type Atom requires,
// e.g. "entity:device", "resource:channel".
func atomObjectType(objectKind, kind string) string
```
and route both `policyGrantObjectType` (`policy_service.go:220-241`) and the
`ListAllObjects` query (`:135-143`) through it. The bug exists because the two
paths independently construct the same string; the fix is to remove that
independence.
For deletion, the loop must be resilient to the page shifting underneath it as
items are removed — page by offset and re-query, or collect all IDs first and
then delete. Collecting first is simpler and correct; the sets are small.
## Acceptance criteria
1. Read and write paths produce byte-identical `object_type` values for the same
logical object, enforced by a test that compares them directly.
2. `ListAllObjects` against a subject with an object-scoped `read` grant on a
device returns that device's ID. (This returns nothing today.)
3. Revoking a permission on a subject holding 250 policies removes **all**
matching policies. Verified by re-querying after deletion, not by return value.
4. `CapabilityID` resolves an action registered beyond the first 100.
5. `atom-bootstrap` registers `entity` applicability; re-running it is idempotent.
6. `isSupportedObjectList` admits the `read`-on-`entity:device` case MG-08 needs
and still rejects genuinely unsupported combinations.
## Test plan
- Unit: object-type helper across every kind; `isSupportedObjectList` truth table.
- Unit: `DeletePolicyFilter` against a mock returning 250 policies across
3 pages — assert every matching ID was passed to delete.
- Integration (Atom in Docker): grant `read` on a device, assert
`ListAllObjects` returns it; revoke, assert it is gone.
- Bootstrap idempotency: run `atom-bootstrap` twice, assert no duplicates and no
errors.
## Risks
- **Changing `objectType` invalidates existing stored blocks.** Any block written
with the unnamespaced form still holds it.
**Resolved — [spec §8 C1](../architecture.md#8-decision-record): no backwards compatibility.**
Fix the read path via the shared helper and leave it there. No dual-form
matching, no compatibility shim. Blocks holding the old value stop matching and
must be rewritten; that belongs in release notes rather than being discovered
in the field. With this ruling the PRD is purely additive plus fixes.
+127
View File
@@ -0,0 +1,127 @@
# MG-02 — Device Type (Atom Profile) client API
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 |
| **Depends on** | — |
| **Blocks** | MG-10 |
| **Status** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> Device types are a separate feature from the device/gateway relation and are not in the phase 1 cut.
>
> The design below is unchanged and remains the target.
## Problem
Magistrala has no concept of a device type. A watermeter and a temperature probe
are both an opaque `Client` with a free-form metadata map, so the UI cannot
render per-type views, nothing validates payload shape, and there is no way to
express "this model reports volume and battery, and accepts `set_interval`".
Atom already implements exactly this and Magistrala does not call it.
## What Atom already provides
| Capability | Location |
|---|---|
| Named type, tenant-scoped or global, unique on `(tenant, object_kind, kind, key)` | `migrations/001_initial.sql:48-76` |
| Versioned `json_schema` + `ui_schema` with `draft/active/deprecated/disabled` | `:77-90` |
| Binding on the entity: `profile_id`, `profile_version_id` | `:98-99` |
| **Schema enforcement on entity write** | `src/identity/repo.rs:641` |
| CRUD: `profiles`, `profile`, `profileVersions`, `createProfile`, `createProfileVersion`, `updateProfile` | `src/graphql/profiles.rs:24-209` |
| List entities of a type: `entities(profileId:)` | `src/graphql/entities.rs:79` |
No Atom change is required.
## Scope
**In scope**
- `DeviceType` and `DeviceTypeVersion` types in `pkg/atom`, mapping to Atom
Profile / ProfileVersion with `object_kind = "entity"`, `kind = "device"`.
- Client methods: create, get, list, update, create-version, list-versions.
- Entity create/update carrying `profile_id` and `profile_version_id`
(`src/graphql/entities.rs:205-206,275-276`).
- `ListEntities` gaining the `profileId` filter.
- Capability document helpers: build a JSON Schema from a measurement/command
declaration, and read it back, so callers do not hand-write JSON Schema.
**Out of scope**
- HTTP/SDK/CLI surface — MG-10.
- Command dispatch. The type *declares* commands; routing them is unspecified
(see [architecture.md §7](../architecture.md#7-open-questions)).
- Migrating existing clients onto types.
## Design
### Naming
Atom `Profile` and Bootstrap `Profile` (PR #3555) are unrelated concepts sharing
a word. In `pkg/atom` and every Magistrala-facing surface this is **Device Type**.
Never expose "profile" for this concept — the collision is a real hazard once
both are in play.
### Capability document
The declaration Magistrala cares about, expressed as JSON Schema so Atom enforces
it for free:
```go
type Measurement struct {
Name string // "volume"
Unit string // "m3"
Access string // "r" | "rw"
}
type Command struct {
Name string // "set_interval"
Params map[string]string // {"seconds": "int"}
}
```
Rendered to `json_schema` for validation and `ui_schema` for rendering hints.
Keep the mapping in one place and round-trip it — a helper that generates schema
but cannot parse it back leaves the UI hand-parsing JSON Schema.
### Versioning
Device types are versioned and entities bind to a specific version. Changing a
type must not retroactively invalidate deployed devices, so:
- `createDeviceTypeVersion` creates a new version; it never mutates an existing one.
- Devices stay on their bound version until explicitly moved.
- Version status governs whether *new* bindings are allowed, not whether existing
ones keep working.
## Acceptance criteria
1. Create a device type with a capability document; read it back with the
declaration intact through the round-trip.
2. Create a device bound to that type. Attributes satisfying the schema succeed.
3. Attributes **violating** the schema are rejected by Atom, and the client
surfaces a usable error naming the offending field — not a bare GraphQL error.
4. `ListDeviceTypes` returns both tenant-scoped and global types.
5. `ListEntities(profileID:)` returns exactly the devices bound to that type.
6. Adding version 2 leaves devices bound to version 1 working unchanged.
7. Deprecating a version blocks new bindings and leaves existing ones intact.
## Test plan
- Unit: capability document → JSON Schema → capability document round-trip,
including edge cases (no commands, no units, `rw` access).
- Integration (Atom in Docker): full lifecycle — create type, version, bind
device, valid write, invalid write, add version, deprecate.
- Error mapping: assert a schema violation produces a typed error carrying the
field path.
## Risks
- **JSON Schema error legibility.** Atom returns whatever the `jsonschema` crate
produces. If that surfaces raw to an operator creating a device, it will be
unusable. Error translation is part of this PRD's work, not a follow-up.
- **Global vs tenant-scoped types.** Uniqueness differs between the two
(`001_initial.sql:66-72`). Decide whether Magistrala exposes global types at
all; if not, always send `tenant_id` and say so explicitly.
+153
View File
@@ -0,0 +1,153 @@
# MG-03 — Group membership, hierarchy and group kinds
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 |
| **Depends on** | ATOM-04 |
| **Blocks** | MG-04, MG-09 |
| **Status** | Draft |
## Scope note — groups are for sharing only
Object groups in this model have **exactly one purpose: granting access**
([spec §3.5](../architecture.md#35-groups-and-sharing)). They are not used for
device↔gateway topology — that is a `gateways []` attribute on the device
([spec §8 A10](../architecture.md#8-decision-record)) — and not for any other
grouping.
An intermediate design put gateway fleets in this same namespace; it was reversed
because a group-scoped grant on a fleet group would silently hand over an entire
gateway's devices. Keep the namespace single-purpose.
## Problem
`pkg/atom` can create, read, update, delete and list groups — and nothing else.
It cannot add a member, remove a member, list members, or create a nested group.
The only code in the tree that writes group membership is the one-shot migration
tool, via raw SQL (`tools/atom-migration/migrator.go:598,619`).
Without membership there is no sharing model: the customer story in
[architecture.md §5.2](../architecture.md#35-groups-and-sharing) depends on a group
holding a device set.
## What Atom already provides
| Operation | Location |
|---|---|
| `addGroupMember`, `removeGroupMember` | `src/graphql/groups.rs:645,699` |
| `groupMembers(groupId)`, `entityGroups(entityId)` | `:90,104` |
| `createObjectGroup`, `createPrincipalGroup` | `:346,355` |
| `objectGroups`, `principalGroups`, `childGroups` | `:154,184,121` |
| `setGroupParent`, `removeGroupParent` | `:429,478` |
| Object-side variants `setObjectGroupParent`, `removeObjectGroupParent` | `:468,517` |
No Atom change is required.
## Two things the current client gets wrong
### Object groups vs principal groups
Atom has two distinct group namespaces backed by separate tables —
`object_groups` (`001_initial.sql:427-441`) and `principal_groups`
(`:386-400`), unioned only by a compatibility view (`:461-466`). `pkg/atom` uses
the generic `createGroup`, so which table a group lands in is implicit.
Magistrala's usage divides cleanly:
- **Object group** — a set of devices or channels. What customer sharing needs.
- **Principal group** — a set of users. A subject in a policy.
These must be explicit in the client. Creating the wrong kind produces a group
that silently cannot be used as intended.
### `parentId` is dropped
`groupCreateInput` (`client.go:751-760`) builds the create input without
`parentId` even though `Group` carries it (`types.go:39`). Nested groups are
therefore uncreatable from Go. The hierarchy is needed for
Customer → Site → meters roll-ups via `includeDescendants`.
## Scope
**In scope**
- Membership: `AddGroupMember`, `RemoveGroupMember`, `GroupMembers`,
`EntityGroups`.
- Typed creation: `CreateObjectGroup`, `CreatePrincipalGroup`. Retain the generic
`CreateGroup` only if a caller genuinely needs kind-agnostic behaviour;
otherwise remove it so the choice is always explicit.
- Hierarchy: `SetGroupParent`, `RemoveGroupParent`, `ChildGroups`; fix
`groupCreateInput` to send `parentId`.
- Listing: `ObjectGroups`, `PrincipalGroups`, and `includeDescendants` where Atom
accepts it.
**Out of scope**
- Group-scoped permission blocks — MG-04.
- Resource membership (`object_group_resources`). Add when a channel-grouping
requirement appears; there is none yet.
## Membership is many-to-many (after ATOM-04)
Originally Atom enforced `PRIMARY KEY (entity_id)` on `object_group_entities`,
meaning an entity belonged to at most one object group — and
`set_entity_parent_group_in_tx` (`src/identity/repo.rs:511-523`) **silently
moved** it between groups on re-add.
[ATOM-04](./ATOM-04-many-to-many-group-membership.md) removes that constraint per
[spec A1](../architecture.md#8-decision-record), so:
- `AddGroupMember` is **additive**. An entity in group A added to group B is in
both.
- Re-adding to a group it already belongs to is idempotent.
- `RemoveGroupMember` takes a group ID and removes only that membership.
- `EntityGroups` returning a list (`groups.rs:104`) is now literally correct.
**Do not start this PRD before ATOM-04 lands.** Building against the old
move-on-conflict semantics produces a client whose documented behaviour inverts
under it.
### Consequence for grants
A device can now reach a permission block through more than one group. Nothing in
the client changes for that, but it means removing a device from one sharing
group does **not** necessarily revoke access — another group may still grant it.
Any UI showing "who can see this device" must ask
[ATOM-03](./ATOM-03-reverse-policy-lookup.md) rather than inferring from a single
group.
## Acceptance criteria
1. Create an object group, add three devices, list members — all three returned.
2. Remove one; the remaining two are returned.
3. `EntityGroups` for a member returns every group it belongs to.
4. A device added to two groups appears in both member listings, and
`RemoveGroupMember` on one leaves the other intact.
5. Create a nested group by passing `parentId` at creation; `ChildGroups` on the
parent returns it.
6. `SetGroupParent` / `RemoveGroupParent` reparent an existing group.
7. Object and principal groups are created in the correct namespace, verified by
listing each kind separately.
8. Members from another tenant are rejected.
## Test plan
- Integration (Atom in Docker) for all of the above — membership semantics cannot
be established against a mock, since they live in Atom's schema and repository
layer.
- Explicit test for criterion 4; multi-group membership is the whole point of
ATOM-04 and the behaviour most likely to regress.
- Hierarchy: 3-level tree, assert direct vs descendant listing at each level.
- Authorization: a caller without `manage` on the group is refused
(`groups.rs:660-668`).
## Risks
- **Sequencing.** Built against pre-ATOM-04 Atom, `AddGroupMember` moves rather
than adds. Every acceptance criterion around multi-group membership would pass
vacuously or invert. Gate on ATOM-04.
- **Revocation is no longer "remove from the group".** With multi-group
membership, a device may retain access through another group. Anything that
presents removal as revocation will be wrong; this needs to be explicit in the
method documentation and in whatever UI consumes it.
+145
View File
@@ -0,0 +1,145 @@
# MG-04 — Group-scoped permission blocks
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 |
| **Depends on** | MG-01, MG-03 |
| **Blocks** | MG-08 |
| **Status** | Draft |
## Problem
`pkg/atom` writes only three of Atom's ten scope modes — `object`, `tenant` and
`platform` (`policy_service.go:196-205`). Everything that is not a domain or the
platform collapses to a single-instance `object` block (`:203`).
Granting a customer read access to 500 meters therefore means 500 permission
blocks and 500 direct policies, because `AddPolicy` (`:38-67`) creates a fresh
block per call with no reuse. That is slow to write, expensive to evaluate, and
walks straight into the truncating revocation path fixed in MG-01.
Atom supports exactly what is needed and it is unreachable from Go.
## What Atom supports
`scope_mode` accepts (`migrations/001_initial.sql:607`):
```
platform, tenant, object_kind, object_type, object,
group, group_direct_objects, group_descendant_objects,
group_child_groups, group_descendant_groups
```
Constraints per mode at `:619-627`. For `group_direct_objects` /
`group_descendant_objects`: `tenant_id` and `group_id` required, `object_kind ∈
{entity, resource}`, `object_id` must be null (`:625`).
Atom computes the scope reference as `{group_id}:{object_type}`
`src/authz/engine.rs:1334` produces `"{group_id}:entity:device"`, matching
`001_initial.sql:729`.
## Scope
**In scope**
- Extend `policyGrantScopeMode` (`policy_service.go:196-205`) and the companion
`policyGrantObjectKind` / `ObjectType` / `ObjectID` functions (`:207-248`) to
emit group-scoped blocks.
- A direct API for the customer-sharing case rather than squeezing it through the
legacy `policies.Policy` shape:
```go
GrantGroupAccess(ctx, GroupGrant{
TenantID, GroupID, SubjectKind, SubjectID,
ObjectKind, ObjectType, Actions []string,
IncludeDescendants bool,
}) error
RevokeGroupAccess(ctx, GroupGrant) error
ListGroupGrants(ctx, groupID) ([]GroupGrant, error)
```
- `directPolicyMatches` (`:250-266`) must compare `GroupID` too, or revocation
will not match group-scoped blocks.
**Out of scope**
- `object_kind` / `object_type` scope modes ("every device in the tenant"). No
requirement yet; adding unused grant shapes is how permission models rot.
- `group_child_groups` / `group_descendant_groups` (grants over *groups*, not
their contents). Add when group-management delegation is actually needed.
- Block reuse for `object`-scoped grants. Group scoping removes the case that
made it urgent.
## Design
### Grant shape
One block, one policy, per customer:
```
PermissionBlock {
scope_mode: "group_direct_objects",
tenant_id: <domain>,
group_id: <customer group>,
object_kind: "entity",
object_type: "entity:device",
effect: "allow",
actions: [read]
}
DirectPolicy { subject_kind: "entity", subject_id: <customer user>, … }
```
Adding a meter to the customer's group grants access. Removing it revokes.
**Membership becomes the sharing operation** — no policy write per device.
### Direct vs descendant
`group_direct_objects` covers immediate members only; `group_descendant_objects`
walks the tree. Customer → Site → meters requires descendant scoping if the grant
is made at the customer level. Expose it as `IncludeDescendants` and make the
default explicit rather than implied.
### Interaction with MG-01
Uses the namespaced object-type helper from MG-01. Do not reconstruct
`"entity:device"` here — that duplication is the exact cause of the MG-01 bug.
## Acceptance criteria
1. Granting a subject `read` over a group produces **one** block and **one**
direct policy, regardless of member count.
2. `authzCheck(subject, "read", entity, <member device>)` returns allowed.
3. A device **not** in the group is denied.
4. Adding a device to the group grants access with no policy write.
5. Removing it revokes access.
6. `IncludeDescendants: true` reaches devices in child groups;
`false` does not.
7. `RevokeGroupAccess` removes the block and policy; access is denied afterwards
for all members.
8. A grant over a 500-member group is a constant number of writes — asserted, not
assumed.
9. Existing object- and tenant-scoped grants are unchanged.
## Test plan
- Unit: scope-mode selection across every input; `directPolicyMatches` including
the `GroupID` comparison.
- Integration (Atom in Docker): the full customer scenario from
[architecture.md §5.2](../architecture.md#35-groups-and-sharing) — two customers,
three meters, one gateway; assert each customer sees only their own.
- Descendant scoping over a 3-level tree.
- Write-count assertion for criterion 8.
- Regression: existing connection grants (`grpc_compat.go:112-129`) still work.
## Risks
- **Revocation must match on `group_id`.** If `directPolicyMatches` is not
extended, `RevokeGroupAccess` silently no-ops and access persists. This is the
single highest-risk line in the PRD and needs a dedicated test.
- **Multi-group membership changes what revocation means.** Following
[spec A1](../architecture.md#8-decision-record) and ATOM-04, a device can reach grants
through several groups. `RevokeGroupAccess` on one group is therefore *not*
"this subject can no longer see this device" — another group may still grant
it. Acceptance criterion 7 must be read as "denied **for members reachable only
through this group**", and any UI phrasing revocation as absolute will be
wrong. Use [ATOM-03](./ATOM-03-reverse-policy-lookup.md) to answer "who can
still see this?"
+153
View File
@@ -0,0 +1,153 @@
# MG-05 — Topic grammar: device segment and `device_id`
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P2 |
| **Depends on** | — |
| **Blocks** | MG-06 |
| **Status** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> Topics are application-level in phase 1 — no `d` segment is added.
>
> The design below is unchanged and remains the target — this is a scope call,
> not a reversal.
## 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.
+145
View File
@@ -0,0 +1,145 @@
# MG-06 — Persist and filter `device_id`
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P2 |
| **Depends on** | MG-05 |
| **Blocks** | MG-08 |
| **Status** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> Nothing to store: `device_id` arrives only with MG-05.
>
> The design below is unchanged and remains the target — this is a scope call,
> not a reversal.
## 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--phase-2--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.
+213
View File
@@ -0,0 +1,213 @@
# MG-08 — Reader authorization: enforce per-device access
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P1 (phase 1 half) |
| **Depends on** | MG-01 (phase 1) · MG-06, ATOM-02, ATOM-06 (phase 2) |
| **Blocks** | — |
| **Status** | Draft |
> **This PRD splits across phases** ([spec §8 A14](../architecture.md#8-decision-record)).
>
> | | Scope | Phase |
> |---|---|---|
> | **A** | Enforce the *existing* `publishers` filter against authorization | **1 — now** |
> | **B** | Add device-level filtering and the UUID → `external_id` translation | 2 |
>
> **Part A is a live defect on `main` and needs none of the device work.** Today
> the `publishers` query filter is applied with no authorization check at all, so
> any user who can read a channel reads every publisher on it by changing a
> parameter. Fixing that requires only MG-01.
>
> Part B waits on MG-06. Acceptance criteria below are marked accordingly.
>
> Part A needs **only MG-01**. ATOM-02 was its scale story, and that belongs to
> part B — a handful of publishers is fine with a per-publisher `authzCheck`.
## 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
**Phase 1 (part A)**
A1. A user without a grant covering publisher X receives no rows for X, whatever
`publishers` they request. This fails on today's `main` — it is the defect.
A2. A user requesting `publishers` they *are* entitled to gets exactly those.
A3. A domain admin is unaffected.
A4. A user with no publisher grants receives empty, **not everything** — the
empty-set inversion is the failure mode that matters.
**Phase 2 (part B)**
1. A user granted `read` on meters 1 and 3, querying a channel carrying all
three, receives data for 1 and 3 only.
2. The same user requesting `device_ids=2` receives **empty**, not meter 2's data.
3. Requesting `device_ids=1,2` returns meter 1 only.
4. A user with **no** device grants receives empty, not everything.
5. A domain admin receives all three.
6. Equivalent behaviour for `publishers`.
7. Unauthorized IDs are dropped silently — the response does not reveal whether
they exist.
8. Behaviour is identical across HTTP, gRPC and SDK.
9. The channel-level check still rejects users without `subscribe`.
10. A customer granted a device sees its data, proving the UUID → `external_id`
translation works end to end. This is the criterion that catches a missing
translation, which otherwise looks like an authorization failure.
11. Orphan data — `device_id` with no entity — is visible to a channel-level
reader and invisible to a device-scoped customer.
12. **Revoking a customer's grant ends their access within the stated TTL**, and
immediately if MG-14 is present. The TTL *is* the revocation SLA, so it needs
a criterion rather than living only in the test plan.
## Test plan
- **Regression test first.** Criteria 14 written against current `main` must
fail. If they pass, the test is wrong.
- Integration (`ory/dockertest` + Atom): the full matrix, both backends.
- Cache: grant, query, revoke, query again — access ends within the stated TTL.
- Admin bypass: explicitly assert it is capability-driven by testing a
non-admin with a large grant set and an admin with none.
- Performance: query latency with an authorized set of 1, 100 and 10 000 devices.
## Risks
- **Empty-set inversion** — treating "no authorized devices" as "no filter" turns
this control into a full disclosure. Criterion 4 is the guard and must be an
explicit test, not incidental coverage.
- **Stale cache after revocation** leaves a window where a revoked customer still
reads data. Atom publishes `direct_policy.delete`, `group_member.remove` and
`entity.parent_group.clear` (`src/events/publisher.rs`), so
[MG-14](./MG-14-atom-event-consumer.md) makes invalidation deterministic rather
than TTL-bounded. Keep the TTL as the correctness floor: with the broker down,
the revocation SLA falls back to it, so it must stay short enough to be
defensible on its own.
- **Existing deployments may rely on the current permissive behaviour.** A user
who reads a channel today and sees everything will see less. That is the point,
but it is a behavioural break and belongs in release notes.
- Depends on ATOM-02 for the scalable path. Without it, ship the materialised
version and accept the ceiling — but do not silently skip enforcement.
+267
View File
@@ -0,0 +1,267 @@
# 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-11, UI-01 · MG-10, MG-12, MG-15 (phase 2) |
| **Status** | Draft |
## Problem
`Client` fuses connectivity identity with data-producing identity. Everything in
P1P3 works around that fusion internally; this PRD makes the split visible in
the API.
**This is not a rename for readability.** `Device` differs from `Client` in
substance: credentials are optional, it carries a type and a serial, and it can
be attached to a gateway. If the only change were the label, it would not be
worth the churn.
Widest blast radius in the programme — this freezes the public API shape.
## Scope
**In scope**
- `Device` replacing `Client` in `pkg/sdk`, with the new fields.
- `Gateway` as a first-class API surface over the same underlying entity.
- The device→gateways reachability relation.
- Proto rename and reshaping.
- Clean break: no `Client` type, no `/clients` route, no alias.
**Out of scope**
- Device types — MG-10.
- CLI, PAT scopes, permissions, OpenAPI — MG-11.
- Provisioning flows — MG-12/13 (phase 2).
- 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`~~ | ⏸ phase 2 — device types are deferred (MG-02) |
| `ProvisioningState` | `pending` / `provisioned` / `rejected` — an **attribute**, per spec §8 A2. Lifecycle only; nothing on the publish path reads it |
**No scalar `GatewayID`.** One device can be reached through several gateways, so
the link is a **list**`Gateways []string`, 0..N
([spec §8 A10](../architecture.md#8-decision-record)). A gateway's device list is
the reverse of it, one `attributesContains` query (ATOM-01).
`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 []string `json:"gateways,omitempty"` // phase 1: gateway IDs
}
// ⏸ Phase 2 promotes each entry to carry an opaque bus address
// (spec §8 A13) — present only for bus-addressed protocols such as
// Modbus, and stored, rendered and compared but never parsed:
//
// type GatewayLink struct {
// ID string
// Address map[string]any
// }
```
**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, and in phase 1 it is the whole of the gateway
view: `GatewayDevices` plus an entity read is everything the UI needs. The
*observed* half — what a gateway actually relayed for — needs `device_id` in
storage and is deferred with [MG-15](./MG-15-gateway-device-view.md).
**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.
### ⏸ Phase 2 — Address conflicts
*Deferred with the address itself. Retained because the reasoning holds.*
#### 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 — none
`Serial` is an arbitrary string. **Magistrala imposes no format constraint**
([spec §8 A14](../architecture.md#8-decision-record)): no `/` rejection, no
length rule, no normalisation. Atom stores it unconstrained (ATOM-06) and
enforces per-tenant uniqueness; that is the whole of it.
An earlier draft rejected `/` to protect the topic grammar. Phase 1 introduces no
topic grammar, so there is nothing to protect — an application putting a serial
in a topic owns its own encoding.
Still open before this API freezes: case sensitivity and whitespace trimming for
uniqueness (ATOM-06), and whether `Serial` may change after creation. Mutability
is cheap in phase 1 and becomes expensive in phase 2, when the serial is
denormalised onto every message row.
### `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 `/`, spaces or unicode is **accepted** — no format
validation exists.
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.
+138
View File
@@ -0,0 +1,138 @@
# MG-10 — Device Type API surface
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P4 |
| **Depends on** | MG-02, MG-09 |
| **Blocks** | MG-11 |
| **Status** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> Surface for MG-02, deferred with it.
>
> The design below is unchanged and remains the target.
## Problem
MG-02 gives `pkg/atom` the ability to manage device types. Nothing external can
reach it — no HTTP route, no SDK method. Operators cannot define a watermeter
type, and the UI has nothing to render from.
## Scope
**In scope**
- `DeviceType` and `DeviceTypeVersion` in `pkg/sdk`, with the capability document
as structured fields rather than raw JSON Schema.
- CRUD + versioning over HTTP and SDK.
- Binding a device to a type (extends the MG-09 device surface).
- Listing devices by type.
**Out of scope**
- CLI and OpenAPI — MG-11.
- Command dispatch. The type *declares* commands; routing is unspecified.
- Ingest-time payload validation against the type (see Design).
## Design
### Public shape
Operators should not hand-write JSON Schema. The SDK exposes the declaration;
`pkg/atom` (MG-02) renders it to `json_schema` + `ui_schema`:
```go
type DeviceType struct {
ID, Name, Key, Description, DomainID string
Version int
Measurements []Measurement
Commands []Command
Status string // active | deprecated | disabled
}
```
The raw schema should remain readable for advanced use, but the structured form
is the documented path. If callers must drop to JSON Schema for ordinary work,
the abstraction has failed.
### Routes
```
POST /{domainID}/device-types
GET /{domainID}/device-types
GET /{domainID}/device-types/{id}
PATCH /{domainID}/device-types/{id}
POST /{domainID}/device-types/{id}/versions
GET /{domainID}/device-types/{id}/versions
GET /{domainID}/devices?device_type_id={id}
```
Hyphenated to match existing multi-word resource conventions
(`resource:bootstrap-config` in PR #3555).
### Validation semantics — state these explicitly
Atom validates entity **attributes** against the bound schema on write
(`src/identity/repo.rs:641`). It does **not** validate message payloads at
ingest — that path never touches Atom.
So a device type constrains device *metadata*, not telemetry, unless ingest-time
validation is built separately. This is a genuinely surprising distinction and
must be documented on the API, or users will assume readings are validated when
they are not.
Whether telemetry validation is wanted at all is an open question — it puts a
schema lookup on the hot path. Out of scope here; do not imply it.
### Naming
Atom `Profile` and Bootstrap `Profile` (PR #3555) are unrelated. This surface is
**Device Type** everywhere. `profile` must not appear in routes, SDK names or
docs for this concept.
### Gateway types are device types
There is no separate gateway-type surface. A gateway is a device with
`is_gateway` ([spec §8 A12](../architecture.md#8-decision-record)), so its type is
an ordinary device type in the same namespace — and a device that both measures
and relays needs *one* type declaring both, not two.
Anything presenting gateway types as a distinct catalogue would be wrong, and
would break the concentrator-meter case the capability model exists to allow.
## Acceptance criteria
1. Create a device type with measurements and commands; read it back with the
declaration intact.
2. Create a device bound to it; conforming attributes succeed.
3. Non-conforming attributes are rejected with an error naming the offending
field.
4. Create version 2; devices on version 1 continue working unchanged.
5. Deprecate version 1; new bindings are refused, existing ones unaffected.
6. `GET /devices?device_type_id=` returns exactly the bound devices.
7. Global (tenant-less) types are listed alongside domain types, if exposed at all
— per the MG-02 decision.
8. Error responses are actionable: field path, expected constraint.
9. No route, field or doc string calls this a "profile".
## Test plan
- Unit: SDK type ↔ capability document mapping; error translation from Atom's
`jsonschema` output to a field-level API error.
- Integration: full lifecycle including versioning and deprecation.
- Criterion 3 with several violation shapes — wrong type, missing required,
out-of-range — asserting each produces a usable message.
- API-shape test for criterion 9.
## Risks
- **Schema-error legibility.** Atom surfaces raw `jsonschema` crate output.
Untranslated, an operator sees a JSON pointer and a validator name. Translation
is core to this PRD, not polish.
- **Attribute-vs-telemetry validation confusion** is the most likely user
misunderstanding of the whole feature. Documentation is a deliverable here.
- **Capability model expressiveness.** Measurements and commands cover the
watermeter case. Multi-channel devices, nested structures and enumerated
states may not fit. Validate the model against two or three additional real
device types before freezing it — the API is hard to widen after 1.0.
+152
View File
@@ -0,0 +1,152 @@
# MG-11 — CLI, PAT scopes, permissions and OpenAPI
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P4 |
| **Depends on** | MG-09 |
| **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 and gateways. (Device types are phase 2, with MG-10.)
- 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`. (`DeviceTypesType` waits for MG-10, phase 2.) **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:`.
(`device_types:` waits for MG-10, phase 2.) **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`. (`device-types.yaml` is phase 2.)
`/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.*Phase 2* — CLI can manage device types and versions.
4. PATs scoped to `devices` authorize device operations and nothing else — and
cover gateways, since a gateway is a device.
5. A PAT issued before the change, carrying a `bootstrap` or `domains` scope,
still authorizes the same operations after the enum is renumbered — proving
ordering is not load-bearing.
6. `permission.yaml` validates at startup (`pkg/permissions` rejects unknown
entity types).
7. `set_gateways` is independently grantable from ordinary device update.
8. OpenAPI specs validate and match implemented routes.
9. No surface refers to `clients`.
## Test plan
- CLI: command-level tests following the existing `cli/` patterns.
- PAT: round-trip every `EntityType` through `String()` / `ParseEntityType` /
JSON / text marshalling — the enum has four representations
(`auth/pat.go:155-164`, `:257-320`) and they must agree.
- **Token-compatibility test**: decode a token fixture issued before the change
and assert the documented behaviour.
- Permissions: service startup with the new file; assert an unknown entity type
is rejected.
- OpenAPI: spec linting plus a route-coverage check against the router.
## Risks
- **Stale `pat_scopes` rows.** Not the enum ordering — that concern was
unfounded — but rows holding `entity_type = 'clients'` will fail to parse.
Migrate or drop them as part of this PR, not afterwards.
- **The CLI has no binary.** `cmd/cli/main.go` was deleted in `16ba29cf4`, so
`cli` is an unimported library. These commands ship unreachable unless the
binary is restored — out of scope here, but it makes acceptance criteria 13
testable only at package level. Flag it; do not silently expand scope.
- **Channels have no roles** while devices, groups and domains do
(`pkg/sdk/channels.go` has no role methods). Pre-existing and out of scope, but
1.0 freezes it. Worth a decision before release.
+202
View File
@@ -0,0 +1,202 @@
# 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** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> No bus `address` to deliver — A13 is deferred with the messaging track.
>
> The design below is unchanged and remains the target — this is a scope call,
> not a reversal.
## 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,189 @@
# MG-13 — Gateway-announced device discovery
| | |
|---|---|
| **Repo** | `absmach/magistrala` (Go) |
| **Priority** | P5 |
| **Depends on** | MG-12 (MG-07 withdrawn — see banner) |
| **Blocks** | — |
| **Status** | **⏸ Phase 2 — deferred** |
> **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.
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> Predates A7/A8 and needs revising; discovery is phase 2 at the earliest.
>
> The design below is unchanged and remains the target — this is a scope call,
> not a reversal.
## 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.
+153
View File
@@ -0,0 +1,153 @@
# 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** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> Invalidates MG-08 part B's authorized-device cache. Phase 1 barely has that cache — invalidation for something not yet built.
>
> The design below is unchanged and remains the target.
## 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.
+157
View File
@@ -0,0 +1,157 @@
# 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** | **⏸ Phase 2 — deferred** |
> **⏸ Deferred to phase 2** ([spec §8 A14](../architecture.md#8-decision-record)).
> The observed half needs `device_id` in storage. The **declared** half is UI composition over ATOM-01 and needs no PRD.
>
> The design below is unchanged and remains the target — this is a scope call,
> not a reversal.
## 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.
+278
View File
@@ -0,0 +1,278 @@
# 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 — phase 1
The deliverable is the **entity model**: Device, `is_gateway`, `gateways[]`,
device types, groups and sharing of device *records*, plus `external_id` on Atom
entities.
**Messaging is untouched.** No `d` topic segment, no `device_id` on messages, no
per-device storage or filters. Topics are application-level
([spec §0](../architecture.md#0-phasing--read-this-first)).
| Track | PRDs |
|---|---|
| **Phase 1 — platform** | ATOM-01, ATOM-03, ATOM-04, ATOM-06 · MG-01, MG-03, MG-04, MG-08 *(part A)*, MG-09, MG-11 |
| **Phase 1 — UI** | UI-01, UI-02 *(panels 12)*, UI-03 |
| **⏸ Phase 2** | ATOM-02 · MG-02, MG-05, MG-06, MG-08 *(part B)*, MG-10, MG-12, MG-13, MG-14, MG-15 |
| **Withdrawn** | ATOM-05, MG-07 |
**UI is in scope and tracked here.** The model deliberately pushes gateway-ness to
the UI — `is_gateway` and `gateways[]` are attributes and a query filter, nothing
more — so without UI-01/02/03 "gateway" ships specified and unbuilt. They execute
in `absmach/magistrala-ui` (`apps/mg` frontend, `backend/` BFF), which is the only
layer holding both an Atom client and a reader client.
**Device types are phase 2.** MG-02 and MG-10 wire up Atom's existing profile
machinery; nothing in phase 1 needs them.
**Accepted trade:** per-device *data* sharing does not work in phase 1. A customer
can be granted a device record, but message data cannot be filtered by device —
that needs `device_id`, which is phase 2.
**Do not defer MG-08 part A.** The `publishers` query filter is applied today with
no authorization check (`readers/api/http/transport.go:251-266`), so any user who
can read a channel reads every publisher on it. It is a live defect, it needs none
of the device work, and it is phase 1.
## Build order — phase 1
```
P0 ATOM-01 ATOM-04 ATOM-06 MG-01
│ │ │ │
P1 │ MG-03 │ ├──► MG-08 (part A)
│ │ │ │
│ MG-04 │ │
P4 └─────────┴──────► MG-09 ──► MG-11
│ │
P5 └──► UI-01 ─┴──► UI-02
└────────► UI-03 ◄── MG-04, ATOM-03
```
**Critical path:** ATOM-06 → MG-09 → MG-11 → UI-01.
**Orderings that must not be swapped:**
- **ATOM-01 before MG-09** — the gateway→devices reverse lookup is an
`attributesContains` query; without it, listing a gateway's devices means
fetching every device in the domain.
- **ATOM-03 before UI-03 claims to revoke** — with multi-group membership,
removing a device from one group does not necessarily end access. Without the
reverse lookup the UI can only honestly say "removed from this group".
*(ATOM-02/ATOM-06 resolver conflict returns in phase 2, when ATOM-02 lands.)*
## Repositories
| Prefix | Repo | Language |
| -------- | -------------------- | -------- |
| `ATOM-*` | `absmach/atom` | Rust |
| `MG-*` | `absmach/magistrala` | Go |
## Priority order
### P0 — Correctness foundations
Nothing else is safe to build on until these land. Two are pure parameter
plumbing in Atom; one fixes defects that the new access model would otherwise
inherit.
| PRD | Repo | Title | Depends on | Status |
| ----------------------------------------------------- | ---- | ------------------------------------------------------- | ---------- | ------ |
| [ATOM-01](./ATOM-01-entity-attribute-filter.md) | Atom | Expose `attributesContains` on entity and group queries | — | Draft |
| [ATOM-02](./ATOM-02-authorized-object-ids-filters.md) | Atom | Expose scoping filters on `authorizedObjectIds` | — | Draft |
| [ATOM-04](./ATOM-04-many-to-many-group-membership.md) | Atom | Many-to-many object group membership | — | Draft |
| [ATOM-06](./ATOM-06-entity-external-id.md) | Atom | Entity `external_id`, unique per tenant | — | Draft |
| [MG-01](./MG-01-atom-policy-client-fixes.md) | MG | Fix Atom policy client defects | — | Draft |
ATOM-04 is the largest of the Atom items and the only one touching the
authorization evaluation path. ATOM-01 and ATOM-02 are parameter plumbing —
ATOM-01 backs the gateway→devices reverse lookup and is **required**, not
optional. ATOM-05 and ATOM-06 are permissive migrations.
**ATOM-05 is withdrawn.** Gateway is a capability (`is_gateway`), not an entity
kind — see [spec §8 A12](../architecture.md#8-decision-record). That removes an
Atom migration and the silent trap it carried, where a new kind would have
stripped every gateway's right to publish.
### P1 — Device model in the Atom client
Magistrala's Go client exposes a small subset of what Atom supports. These add
the primitives the model needs. All three are additive.
| PRD | Repo | Title | Depends on | Status |
| --------------------------------------- | ---- | ------------------------------------------- | ------------ | ------ |
| [MG-02](./MG-02-device-type-client.md) | MG | Device Type (Atom Profile) client API | — | Draft |
| [MG-03](./MG-03-group-client.md) | MG | Group membership, hierarchy and group kinds | ATOM-04 | Draft |
| [MG-04](./MG-04-group-scoped-grants.md) | MG | Group-scoped permission blocks | MG-01, MG-03 | Draft |
| [MG-14](./MG-14-atom-event-consumer.md) | MG | Consume Atom domain events | — | Draft |
MG-14 is independent of the rest of P1 and can start immediately. MG-08 ships
TTL-only without it, so land it first if you want its authorized-set cache
event-invalidated rather than retrofitted. (Its other original consumer, MG-07's
attachment cache, no longer exists.)
### P2 — Message attribution
Splits "who sent it" from "whose data it is". The core new capability.
| PRD | Repo | Title | Depends on | Status |
| -------------------------------------------------- | ---- | --------------------------------------------- | ---------- | ------ |
| [MG-05](./MG-05-topic-device-segment.md) | MG | Topic grammar: device segment and `device_id` | — | Draft |
| [MG-06](./MG-06-device-id-storage-filters.md) | MG | Persist and filter `device_id` | MG-05 | Draft |
| ~~[MG-07](./MG-07-gateway-attachment-enforcement.md)~~ | MG | ~~Gateway publish-on-behalf-of enforcement~~ | — | **Withdrawn** |
MG-07 is withdrawn: the `gateway_id` attachment it enforced no longer exists, and
the channel is now the publish boundary ([spec §8 A7](../architecture.md#8-decision-record)).
That removes the attachment cache and its invalidation entirely.
### P3 — Access enforcement
Closes a live security hole. See [architecture.md §5.6](../architecture.md#the-security-fix-is-not-optional).
| PRD | Repo | Title | Depends on | Status |
| --------------------------------------------- | ---- | -------------------------------------------------- | --------------------- | ------ |
| [MG-08](./MG-08-reader-authorization.md) | MG | Reader authorization: enforce per-device access | MG-01, MG-06, ATOM-02, ATOM-06 | Draft |
| [ATOM-03](./ATOM-03-reverse-policy-lookup.md) | Atom | Reverse policy lookup: `directPolicies(objectId:)` | — | Draft |
### P4 — API surface
The breaking rename. Clean break — `Client` is removed, not aliased.
| PRD | Repo | Title | Depends on | Status |
| -------------------------------------- | ---- | ---------------------------------------- | ------------ | ------ |
| [MG-09](./MG-09-device-gateway-api.md) | MG | Device, Gateway and the reachability relation | MG-03, ATOM-01, ATOM-06 | Draft |
| [MG-10](./MG-10-device-type-api.md) | MG | Device Type API surface | MG-02, MG-09 | Draft |
| [MG-11](./MG-11-surface-plumbing.md) | MG | CLI, PAT scopes, permissions, OpenAPI | MG-09, MG-10 | Draft |
| [MG-15](./MG-15-gateway-device-view.md) | MG | Gateway device view — declared observed | MG-06, MG-08, MG-09 | Draft |
MG-15 sits here rather than with the other access work because it needs MG-09's
relation. **It must not ship before MG-08:** without the authorized-set
narrowing, a gateway roster leaks every device a gateway serves to anyone with
channel read.
No separate `gateways` PAT scope or permission block — a gateway is a device.
`/gateways` is a filtered view, not a distinct resource.
### Withdrawn
| PRD | Why |
|---|---|
| [ATOM-05](./ATOM-05-gateway-entity-kind.md) | Gateway is a capability, not an entity kind (spec §8 A12) |
| [MG-07](./MG-07-gateway-attachment-enforcement.md) | The `gateway_id` attachment it enforced no longer exists; the channel is the publish boundary (A7) |
### P5 — Bootstrap delivery
Back in scope as of [spec §8 A13](../architecture.md#8-decision-record): the cloud
holds the serial → bus-address map, so bootstrap is the mechanism that delivers it.
Requires PR #3555 merged and rebased onto `pkg/atom`.
| PRD | Repo | Title | Depends on | Status |
| --------------------------------------------- | ---- | ----------------------------------------------------- | ------------ | ------ |
| [MG-12](./MG-12-bootstrap-device-bindings.md) | MG | Bootstrap bindings, fleet and address rendering | #3555, MG-09 | Draft |
### Deferred
| PRD | Repo | Title | Status |
| ----------------------------------------------- | ---- | ---------------------------------- | ------ |
| [MG-13](./MG-13-gateway-announced-discovery.md) | MG | Gateway-announced device discovery | Deferred |
MG-13 predates A7 and A8 and needs revising before it is picked up: late binding
removes the pending-device ingest check, and there is no attachment to re-home.
Its scenarios remain valid, and A13 makes it more attractive — agent-discovered
addresses would populate the edge automatically.
## Dependency graph
```mermaid
graph TD
ATOM01[ATOM-01<br/>attribute filter]
ATOM02[ATOM-02<br/>authz filters]
ATOM03[ATOM-03<br/>reverse lookup]
ATOM04[ATOM-04<br/>M:N membership]
ATOM06[ATOM-06<br/>external_id]
MG01[MG-01<br/>policy fixes]
MG02[MG-02<br/>device types]
MG03[MG-03<br/>groups]
MG04[MG-04<br/>group grants]
MG05[MG-05<br/>topic segment]
MG06[MG-06<br/>storage + filters]
MG08[MG-08<br/>reader authz]
MG09[MG-09<br/>device + gateway API]
MG10[MG-10<br/>device type API]
MG11[MG-11<br/>surface plumbing]
MG12[MG-12<br/>bootstrap delivery]
MG14[MG-14<br/>Atom events]
MG15[MG-15<br/>gateway view]
PR3555[PR #3555<br/>bootstrap service]
ATOM04 --> MG03
MG01 --> MG04
MG03 --> MG04
MG05 --> MG06
MG01 --> MG08
MG06 --> MG08
ATOM02 --> MG08
ATOM06 --> MG08
MG14 -. improves .-> MG08
ATOM01 ==> MG09
ATOM06 ==> MG09
MG03 --> MG09
MG02 --> MG10
MG09 --> MG10
MG09 --> MG11
MG10 --> MG11
MG09 --> MG15
MG08 --> MG15
MG06 --> MG15
MG09 --> MG12
PR3555 --> MG12
ATOM02 -. same resolver .- ATOM06
```
ATOM-03 has no hard dependents — it backs the "who can still see this device"
question that multi-group membership makes non-obvious. ATOM-05, MG-07 (withdrawn)
and MG-13 (deferred) are omitted.
## Parallelisation
Three tracks run concurrently and converge on MG-09:
- **Atom track** — ATOM-01, -02, -03, -04, -06 are independent *in design*.
ATOM-04 is on the critical path for the model track, so start it first.
ATOM-02 and ATOM-06 edit the same resolver and will conflict in the diff —
sequence them or land them together.
- **Attribution track** — MG-05 → MG-06 → MG-08. Touches messaging and storage,
independent of Atom except MG-08's dependency on ATOM-02 and ATOM-06.
- **Model track** — MG-02, MG-03, MG-04 touch only `pkg/atom`. MG-02 is
independent; MG-03 waits on ATOM-04.
- **MG-14** is gated on nothing and can start immediately.
MG-09 has the widest blast radius and should not start until the model track is
settled, since it freezes the public API shape. MG-12 is last — it needs both
MG-09 and PR #3555 rebased onto `pkg/atom`.
## Decisions
The spec is [../architecture.md](../architecture.md) — a single source of truth.
Its **§11 Decision record** holds every question, the options weighed, the ruling
and its consequences. Sections 110 are normative; §11 is the rationale.
All code-blocking questions are resolved. Several 🟡 items still gate individual
PRDs and are named in each PRD's Risks section.
## Conventions used in these PRDs
- **Scope** sections are binding. Anything under "Out of scope" belongs to
another PRD; if it turns out to be unavoidable, amend both PRDs rather than
widening silently.
- File references are `path:line` against `main` at the time of writing
(`e8cf13c7f` for Magistrala). Verify before editing — lines drift.
- Every PRD states acceptance criteria as observable behaviour, not as
"code written".
@@ -0,0 +1,93 @@
# UI-01 — Device and gateway management
| | |
|---|---|
| **Repo** | `absmach/magistrala-ui``apps/mg` (frontend), `backend/` (BFF) |
| **Priority** | P5 — after MG-09/MG-11 |
| **Depends on** | MG-09, MG-11 |
| **Blocks** | UI-02, UI-03 |
| **Status** | Draft |
## Problem
The platform ships Device, `is_gateway` and `gateways[]` as attributes and a
query filter, and **deliberately nothing more**
([spec §2.2](../architecture.md#22-what-follows-from-that--normative)). Gateway-ness
is a UI concept by design. Without this PRD, "gateway" is specified and unbuilt.
## Scope
**In scope**
- Device list and detail: create, edit, enable/disable, delete.
- `serial` (Atom `external_id`) as a first-class, searchable field.
- **`is_gateway` toggle** on any device, at creation or later.
- **Setting a device's gateways** — the `gateways[]` list, 0..N.
- A `/gateways` view: devices where `is_gateway` is true.
**Out of scope**
- The gateway detail view — UI-02.
- Sharing and grants — UI-03.
- Device types — deferred with MG-02/MG-10.
- Anything requiring message data — phase 2.
## Design
### Gateway is a filter, not a section
A gateway is a device with a flag. The `/gateways` view is
`GET /devices?is_gateway=true`, and a gateway's detail page **is** a device detail
page with an extra panel. Do not build a parallel entity surface: a device may be
both a gateway and a reporting device, and a UI that treats them as disjoint
populations breaks that case.
### Setting gateways
The relation lives on the **device** and is replace-the-list, so the natural
control is a multi-select on the device form.
Operators will also want the inverse — "add these 40 meters to this gateway" —
from the gateway page. That is a convenience that fans out to N device writes;
the storage does not change.
> **Concurrency.** Atom has no optimistic concurrency
> (`src/identity/repo.rs:335-341` is last-write-wins), so two operators editing
> one device's gateways will silently lose an edit. Until that is addressed, the
> bulk path should write sequentially and re-read, and the UI should not present
> the result as atomic.
### Deleting a gateway
**Must not cascade.** Deleting a gateway leaves its devices intact, now with a
stale reference that is dropped on read
([spec §2.2](../architecture.md#22-what-follows-from-that--normative), consequence 2).
The confirmation dialog must say what happens — "N devices will no longer list
this gateway" — not imply the devices go with it.
## Acceptance criteria
1. Create a device with a serial containing uppercase, `.`, `-` and `/`; all are
accepted — there is no format validation.
2. Toggle `is_gateway` on an existing device; it appears under `/gateways` and
remains under `/devices`.
3. Set a device's gateways to 0, 1 and 3 entries; each round-trips.
4. A device that is both a gateway and a reporter renders correctly in both views.
5. Deleting a gateway leaves its devices intact, and the confirmation says so.
6. Two domains may each hold a device with the same serial.
7. Search by serial finds the device.
## Test plan
- Component tests for the device form, `is_gateway` toggle and gateway
multi-select.
- E2E: the criteria above against a live stack.
- Explicitly test criterion 4 — the composite device is what the capability model
exists to allow, and the easiest thing for a UI to get wrong.
## Risks
- **Rebuilding gateways as a separate entity.** The most likely design error, and
it breaks the composite device. Criterion 4 is the guard.
- **Bulk assignment feels atomic and is not.** N sequential writes with no
transaction; partial failure must be visible rather than silently partial.
+71
View File
@@ -0,0 +1,71 @@
# UI-02 — Gateway view
| | |
|---|---|
| **Repo** | `absmach/magistrala-ui``apps/mg`, `backend/` |
| **Priority** | P5 |
| **Depends on** | UI-01 |
| **Blocks** | — |
| **Status** | Draft |
## Problem
Click a gateway, see what it is and what it serves. This is the concrete test of
the model: if the three panels cannot be assembled from platform primitives, the
model is wrong.
## Scope
**In scope**
- **Panel 1 — gateway information.** One entity read: name, serial, status,
credentials and certificate metadata.
- **Panel 2 — declared devices.** `GET /devices?gateways=<id>`, backed by
`attributesContains` (ATOM-01).
- Navigation from a device to the gateways that reach it, and back.
**Out of scope**
- **Panel 3 — bootstrap configuration.** Needs the `gateway_id` reference on the
Bootstrap projection (MG-12), which is phase 2.
- **Observed devices** and the healthy/silent/undeclared status. Needs
`device_id` on messages — phase 2, and the half that makes the view
*diagnostic* rather than a list.
- Fault correlation.
## Design
Phase 1 shows **declared only**. State that in the UI: a device appears because
someone commissioned it here, not because it has been heard from. An operator who
reads it as liveness will misdiagnose.
The panel is the reverse of a relation stored on the device, so it is one filtered
query — not a stored fleet the gateway owns.
*Phase 2* adds the observed half and merges the two into a per-device status,
which is where the view earns its keep: **declared but never heard** is a fault
for a wired link, and no other view surfaces it.
## Acceptance criteria
1. A gateway page shows its own attributes and its declared devices.
2. A gateway with no declared devices renders an empty state, not an error.
3. A device declared on three gateways appears on all three pages.
4. From a device, its gateways are listed and navigable.
5. The list is explicitly labelled as declared, not observed.
6. A non-admin sees only devices they may read — the list is authorization-filtered.
## Test plan
- E2E against a live stack for each criterion.
- Criterion 3 with a genuinely multi-gateway device — the case a containment
query gets right and a naive implementation gets wrong.
- Criterion 6 with a scoped user.
## Risks
- **Reading declared as observed.** An operator seeing a device listed assumes it
is alive. Until phase 2 there is no liveness here at all; labelling is the only
mitigation and it is a real one.
- **Fleet size.** A gateway with thousands of devices needs pagination from the
start.
+96
View File
@@ -0,0 +1,96 @@
# UI-03 — Sharing management
| | |
|---|---|
| **Repo** | `absmach/magistrala-ui``apps/mg`, `backend/` |
| **Priority** | P5 |
| **Depends on** | UI-01, MG-03, MG-04 · ATOM-03 (for "who can see this") |
| **Blocks** | — |
| **Status** | Draft |
## Problem
"A customer sees only their meters" was the requirement that started this design.
The platform delivers it as object groups plus group-scoped grants
([spec §3.5](../architecture.md#35-groups-and-sharing)) — but a group and a
permission block are not things an operator should ever see. Without a UI, the
feature exists and is unusable.
## Scope
**In scope**
- Create and manage **device groups** — named sets, with membership editing.
- Grant a **user** read access over a group; revoke it.
- From a device: which groups it belongs to, and who can see it.
- From a user: which devices they can see.
**Out of scope**
- **Per-device *data* sharing.** Phase 1 shares device *records*; filtering
message data by device needs `device_id` and is phase 2. **The UI must not
imply otherwise** — see Risks.
- Group hierarchy beyond a single level, unless MG-03 exposes it.
- Roles beyond read.
## Design
### Speak in the operator's language
The operator thinks "give this customer these meters". The platform does that as
one object group, one permission block scoped `group_direct_objects`, and one
direct policy per subject. **None of those words should appear in the UI.**
### Membership is the sharing operation
Adding a device to a customer's group grants access; removing it revokes. One
grant per customer, not one per device — so the primary interaction is editing
membership, not editing permissions.
### Removal is not revocation
With many-to-many membership (ATOM-04) a device can be in several groups, so
removing it from one **does not necessarily** end access — another group may
still grant it. A UI that says "access revoked" after one removal will be wrong.
This is what ATOM-03 is for: "who can still see this device" must be *asked*, not
inferred from the group just edited.
### Fleet groups do not exist
Groups mean sharing and nothing else. There is no gateway-fleet group to confuse
this with — the device↔gateway relation is an attribute (UI-01), deliberately
kept out of this namespace.
## Acceptance criteria
1. Create a group, add devices, grant a user read; that user sees exactly those
devices.
2. Removing a device from the group ends that user's access to it.
3. A device in two groups granted to two different users is visible to both.
4. Removing it from one group leaves the other user's access intact, **and the UI
reflects that** rather than reporting access revoked.
5. "Who can see this device" lists every subject with access, however granted.
6. A user with no grants sees no devices — not all of them.
7. Adding 200 devices to a group is one operator action.
## Test plan
- E2E for each criterion.
- Criteria 35 together: the multi-group case is where a naive UI reports
revocation that did not happen.
- Criterion 6 explicitly — the empty-set inversion is the security-relevant
failure and must be tested, not assumed.
## Risks
- **Implying data-level sharing.** In phase 1 a granted customer can see the
device *record*, not its readings. If the UI presents this as "customer can see
their meter data", it is lying until phase 2. Label it, and do not build a data
view that silently shows everything on the channel.
- **Reporting revocation that did not occur** — criterion 4. Needs ATOM-03;
without it the honest UI can only say "removed from this group", not "access
revoked".
- **Groups drifting into other uses.** The namespace is single-purpose by design;
anything that adds an organisational or topological group here re-creates the
problem A10 removed.
+8 -8
View File
@@ -7,7 +7,7 @@ require (
connectrpc.com/otelconnect v0.9.0
github.com/0x6flab/namegenerator v1.4.0
github.com/absmach/callhome v0.18.2
github.com/absmach/fluxmq v0.40.0
github.com/absmach/fluxmq v0.51.0
github.com/absmach/senml v1.0.8
github.com/caarlos0/env/v10 v10.0.0
github.com/caarlos0/env/v11 v11.4.1
@@ -17,7 +17,7 @@ require (
github.com/fiorix/go-smpp v0.0.0-20210403173735-2894b96e70ba
github.com/go-chi/chi/v5 v5.3.1
github.com/go-kit/kit v0.13.0
github.com/gofrs/uuid/v5 v5.4.0
github.com/gofrs/uuid/v5 v5.5.0
github.com/google/uuid v1.6.0
github.com/gookit/color v1.6.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
@@ -54,7 +54,7 @@ require (
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
gonum.org/v1/gonum v0.17.0
google.golang.org/grpc v1.82.1
google.golang.org/grpc v1.83.0
google.golang.org/protobuf v1.36.11
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
@@ -114,7 +114,7 @@ require (
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mattn/go-sqlite3 v1.14.45 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.54.2 // indirect
@@ -128,7 +128,7 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runc v1.3.6 // indirect
github.com/pelletier/go-toml/v2 v2.4.1 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/dtls/v3 v3.1.5 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -156,11 +156,11 @@ require (
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
)
+16 -16
View File
@@ -20,8 +20,8 @@ github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrd
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/absmach/callhome v0.18.2 h1:dmopRHm2qTheHN1hdUKRRYpKwRrj7X9d8AWCFrb+K6s=
github.com/absmach/callhome v0.18.2/go.mod h1:LEXKhES9JJtj3tBgTZv7VPNjOi5ukJQB0mFic0QP60Q=
github.com/absmach/fluxmq v0.40.0 h1:J7s6PHXliWRfwpQpt/umRNnrUegj9xYsVyK9BV8Azhk=
github.com/absmach/fluxmq v0.40.0/go.mod h1:oVbq3VlkD0vPKc45gkcxXpD2tbweyF+CDw1x72AS+PA=
github.com/absmach/fluxmq v0.51.0 h1:bts5xmgTpUFFmcya0YyaIgl3/Hb7/rzAlkrirLd5Dk0=
github.com/absmach/fluxmq v0.51.0/go.mod h1:S/4pWXGHmGAwa8pZ4Doql9H1cr5jaSC1lFvs0JGmUVM=
github.com/absmach/senml v1.0.8 h1:+opem/r4g6c6eA/JLyCIuksyEhj7eBdysY3pEmy1mqo=
github.com/absmach/senml v1.0.8/go.mod h1:DRhzHLgvQoIUHroBgpFrSWso+bJZO9E96RlHAHy+VRI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -114,8 +114,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gofrs/uuid/v5 v5.5.0 h1:FkPv6jYQRbZtH3bD8yC7106u+CedTCLF8+t7CLHSZNo=
github.com/gofrs/uuid/v5 v5.5.0/go.mod h1:bbAA98EoIlxyRHIVg6ektCSsZ5n8mSbwgEhvhMYlZgg=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -204,8 +204,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
@@ -246,8 +246,8 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.4.1 h1:j5OMOImsH+j2k7GJ5YO+RxfWwohNiH6t5zB/+h3bagc=
github.com/pelletier/go-toml/v2 v2.4.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
@@ -362,8 +362,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A=
golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -383,12 +383,12 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4=
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d h1:Jkpk39hlTZOIp3RbfvNX9R8Hv+Sw0X89nlU/xFOErsc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+8
View File
@@ -24,6 +24,8 @@ Publish routing depends on the topic and the publisher prefix.
The `$queue/` prefix lets any publisher force delivery into the durable stream queue regardless of its own prefix. This is used internally (e.g. by `writers`, `alarms`) to guarantee at-least-once delivery through the broker's stream.
Addressing a queue is not the same as one existing. Each stream is captured by its own `$queue/<name>/#` binding in the broker configuration, and a publication matching no binding is dropped without an error — a failed or absent capture never fails the publish. A new `$queue/<name>` namespace therefore needs its queue declared in `docker/fluxmq/node{1,2,3}.yaml` before anything is published to it. The bindings are deliberately disjoint, so that a message lands in exactly one stream rather than also accumulating in the reserved `mqtt` queue; `docker/fluxmq/config_test.go` holds that invariant.
### Stream queues
On startup, publishers and pubsub clients normally declare a durable stream queue named after their prefix. Stream subscribers use consumer groups, so each group receives every message exactly once. The default stream queue is named `m`. `InternalMetadata` instead requires that stream to be pre-provisioned by the broker and never attempts to create or modify it.
@@ -32,6 +34,12 @@ On startup, publishers and pubsub clients normally declare a durable stream queu
`Subscribe` attaches to the durable stream queue via a consumer group filtered by topic. Optionally (when `DirectTopicIngress` is enabled), it also subscribes to the raw MQTT topic so that messages published directly by MQTT clients — bypassing the queue — are also received. A deployment using `InternalMetadata` must authorize the requested subscriptions explicitly; the Rules Engine local principal authorizes only pre-provisioned stream `m`.
### Message origin
A message carries the protocol it was published with (`mqtt`, `http`, `coap`, …) and the identity of its publisher. Both are broker-controlled: on a publication from an untrusted connection the broker overwrites them with the transport and identity of that connection, so a service that consumes a device message and republishes it — into the `writers` stream, for instance — turns every one of them into `protocol: amqp` published by that service.
`InternalMetadata` is what avoids this. A connection authenticated as a `service`-role local principal on the mTLS listener may relay the origin protocol, publisher, `created` timestamp and metadata it received rather than having its own stamped on. Any service that republishes messages someone else authored has to use it, and its principal needs a `permissions.publish` entry for the destination.
### Options
| Option | Description |
+7 -1
View File
@@ -124,7 +124,13 @@ func (ps *pubsub) Subscribe(_ context.Context, cfg messaging.SubscriberConfig) e
sub.streamTopic = queueFilter(ps.prefix, cfg.Topic)
}
if ps.directTopicIngress {
// A preprovisioned connection is a local principal on the mTLS service
// listener, and that listener only serves queue addresses: FluxMQ resolves
// a bare topic filter to a pub/sub route and refuses it, because no
// subscribe ACL entry can name one. The direct subscription is also
// redundant there -- the broker-provisioned stream binds the same topic
// patterns, so a direct publish reaches the stream consumer anyway.
if ps.directTopicIngress && !ps.preprovisioned {
// Subscribe to regular MQTT topics so that messages published directly
// by MQTT clients (not through the stream queue) are also received.
sub.mqttTopic = topicFilter(ps.prefix, cfg.Topic)
+30 -4
View File
@@ -34,8 +34,23 @@ var cfg = jetstream.StreamConfig{
Storage: jetstream.FileStorage,
}
func NewPubSub(ctx context.Context, url string, logger *slog.Logger) (messaging.PubSub, error) {
pb, err := broker.NewPubSub(ctx, url, logger, broker.Prefix(prefix), broker.JSStreamConfig(cfg), broker.ConnectionName("writers-msg-pubsub"))
// InternalMetadata returns an option for a trusted local-service connection
// that carries internal metadata over mTLS and consumes the broker-provisioned
// writers stream. It lives here rather than being taken from
// pkg/messaging/brokers because that package selects its backend on a different
// build tag: an untagged build would pair a FluxMQ option with a NATS PubSub.
func InternalMetadata(certFile, keyFile, caFile string) messaging.Option {
return broker.InternalMetadata(certFile, keyFile, caFile)
}
func NewPubSub(ctx context.Context, url string, logger *slog.Logger, opts ...messaging.Option) (messaging.PubSub, error) {
brokerOpts := []messaging.Option{
broker.Prefix(prefix),
broker.JSStreamConfig(cfg),
broker.ConnectionName("writers-msg-pubsub"),
}
brokerOpts = append(brokerOpts, opts...)
pb, err := broker.NewPubSub(ctx, url, logger, brokerOpts...)
if err != nil {
return nil, err
}
@@ -43,8 +58,19 @@ func NewPubSub(ctx context.Context, url string, logger *slog.Logger) (messaging.
return pb, nil
}
func NewPublisher(ctx context.Context, url string) (messaging.Publisher, error) {
pb, err := broker.NewPublisher(ctx, url, broker.Prefix(prefix), broker.JSStreamConfig(cfg), broker.ConnectionName("writers-msg-pub"))
// NewPublisher creates the publisher that feeds the writers stream. Pass
// InternalMetadata so it connects as a trusted local principal: the broker
// stamps its own transport protocol and identity on a publication from an
// untrusted connection, which would leave every stored message recorded as
// having arrived over AMQP instead of the protocol its device spoke.
func NewPublisher(ctx context.Context, url string, opts ...messaging.Option) (messaging.Publisher, error) {
brokerOpts := []messaging.Option{
broker.Prefix(prefix),
broker.JSStreamConfig(cfg),
broker.ConnectionName("writers-msg-pub"),
}
brokerOpts = append(brokerOpts, opts...)
pb, err := broker.NewPublisher(ctx, url, brokerOpts...)
if err != nil {
return nil, err
}
+21 -4
View File
@@ -34,8 +34,20 @@ var cfg = jetstream.StreamConfig{
Storage: jetstream.FileStorage,
}
func NewPubSub(ctx context.Context, url string, logger *slog.Logger) (messaging.PubSub, error) {
pb, err := broker.NewPubSub(ctx, url, logger, broker.Prefix(prefix), broker.JSStreamConfig(cfg))
// InternalMetadata is a no-op for the NATS backend. It exists for compile-time
// compatibility with the FluxMQ variant; NATS carries metadata in the protobuf
// message.
func InternalMetadata(_, _, _ string) messaging.Option {
return func(_ any) error { return nil }
}
func NewPubSub(ctx context.Context, url string, logger *slog.Logger, opts ...messaging.Option) (messaging.PubSub, error) {
brokerOpts := []messaging.Option{
broker.Prefix(prefix),
broker.JSStreamConfig(cfg),
}
brokerOpts = append(brokerOpts, opts...)
pb, err := broker.NewPubSub(ctx, url, logger, brokerOpts...)
if err != nil {
return nil, err
}
@@ -43,8 +55,13 @@ func NewPubSub(ctx context.Context, url string, logger *slog.Logger) (messaging.
return pb, nil
}
func NewPublisher(ctx context.Context, url string) (messaging.Publisher, error) {
pb, err := broker.NewPublisher(ctx, url, broker.Prefix(prefix), broker.JSStreamConfig(cfg))
func NewPublisher(ctx context.Context, url string, opts ...messaging.Option) (messaging.Publisher, error) {
brokerOpts := []messaging.Option{
broker.Prefix(prefix),
broker.JSStreamConfig(cfg),
}
brokerOpts = append(brokerOpts, opts...)
pb, err := broker.NewPublisher(ctx, url, brokerOpts...)
if err != nil {
return nil, err
}