mirror of
https://github.com/absmach/magistrala.git
synced 2026-08-07 23:32:14 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae28821707 | |||
| e8cf13c7f3 | |||
| 10791c1e28 | |||
| d63bbe1f93 | |||
| f8fab301de | |||
| 135490f680 | |||
| dcd6e85afd | |||
| 0ba7994db9 |
@@ -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)" \
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ATOM-03 — Reverse policy lookup: `directPolicies(objectId:)`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Repo** | `absmach/atom` (Rust) |
|
||||
| **Priority** | P3 |
|
||||
| **Depends on** | — |
|
||||
| **Blocks** | Sharing UI; correct revocation |
|
||||
| **Status** | Draft |
|
||||
|
||||
## Problem
|
||||
|
||||
`directPolicies` filters by subject only — `tenantId`, `subjectKind`,
|
||||
`subjectId`, `permissionBlockId` (`src/graphql/policies.rs:285-294`). There is no
|
||||
way to ask **"who has access to this object?"**
|
||||
|
||||
Two consequences:
|
||||
|
||||
1. A sharing UI cannot show who a resource is shared with without enumerating
|
||||
every subject in the tenant and querying each.
|
||||
2. Revocation is unsafe. Magistrala's `DeletePolicyFilter`
|
||||
(`pkg/atom/policy_service.go:78-105`) works around the gap by listing a
|
||||
subject's policies and matching client-side, capped at 100 — so revoking
|
||||
access on a widely-shared object silently misses policies past that cap.
|
||||
|
||||
## Why this is generic
|
||||
|
||||
"Who can access X" is the inverse of "what can this subject access", which Atom
|
||||
already answers. Every system with a sharing model needs both directions. No
|
||||
domain semantics are introduced.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Add `object_id: Option<ID>` to the `directPolicies` query, returning every
|
||||
direct policy whose permission block targets that object.
|
||||
- Object matching must cover the scope modes that can reference a specific
|
||||
object:
|
||||
- `object` — `permission_blocks.object_id = $1`
|
||||
- `group_direct_objects` / `group_descendant_objects` — blocks whose
|
||||
`group_id` contains the object, direct or transitive respectively
|
||||
- Add `object_kind` / `object_type` as optional co-filters, since an ID alone is
|
||||
ambiguous across kinds.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Tenant-, platform-, `object_kind`- and `object_type`-scoped blocks. These grant
|
||||
access to an object without naming it; including them would make the result a
|
||||
full effective-access computation rather than a policy lookup. If effective
|
||||
access is wanted, that is a separate `effectiveAccess(objectId:)` query with
|
||||
different semantics — do not conflate them.
|
||||
- Any change to `authzCheck` or the evaluation engine.
|
||||
|
||||
## Design
|
||||
|
||||
Extend `ListDirectPolicies` (`src/authz/repo.rs`) with the optional object
|
||||
filter, joining `permission_blocks` and — for the group scope modes — the
|
||||
membership tables `object_group_entities` / `object_group_resources`
|
||||
(`migrations/001_initial.sql:525-544`) and, for descendants,
|
||||
`object_group_hierarchy` (`:446-454`).
|
||||
|
||||
Authorization: reuse `require_policy_read` (`policies.rs:298`) unchanged. Reading
|
||||
who can access an object is a policy-read operation on the tenant, as today.
|
||||
|
||||
**Result semantics must be documented on the field**, because the out-of-scope
|
||||
exclusion above is surprising if undocumented: this returns *direct policies
|
||||
naming this object*, not *everyone who can reach it*.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. `directPolicies(objectId: <device>)` returns policies whose block is
|
||||
`scope_mode: "object"` with that `object_id`.
|
||||
2. It also returns policies whose block is `group_direct_objects` over a group
|
||||
the object is a direct member of.
|
||||
3. `group_descendant_objects` matches transitively through
|
||||
`object_group_hierarchy`; `group_direct_objects` does not.
|
||||
4. Tenant-, platform- and kind-scoped blocks are **not** returned, and the field
|
||||
documentation says so.
|
||||
5. `objectKind` / `objectType` co-filters narrow correctly.
|
||||
6. Combining `objectId` with `subjectId` intersects.
|
||||
7. Callers without policy-read on the tenant are refused.
|
||||
8. Omitting `objectId` produces results identical to today.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Integration: build one device reachable four ways — direct object block, direct
|
||||
group membership, descendant group membership, and a tenant-scoped block —
|
||||
then assert exactly the first three are returned.
|
||||
- Hierarchy depth: 3-level group tree, confirm direct vs descendant behaviour at
|
||||
each level.
|
||||
- Authorization: caller without policy-read is refused.
|
||||
- Pagination over a widely-shared object.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Recursive hierarchy traversal cost.** Descendant matching needs a recursive
|
||||
CTE. Bound it by tenant and check the plan on a deep tree.
|
||||
- **Misreading the result as effective access.** The exclusion in Scope is
|
||||
deliberate and load-bearing; if a consumer treats this as "everyone who can
|
||||
see X", they will under-report. Documentation on the field is part of the
|
||||
deliverable, not a nicety.
|
||||
@@ -0,0 +1,207 @@
|
||||
# ATOM-04 — Many-to-many object group membership
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Repo** | `absmach/atom` (Rust) |
|
||||
| **Priority** | P0 |
|
||||
| **Depends on** | — |
|
||||
| **Blocks** | MG-03, MG-04 |
|
||||
| **Status** | Draft |
|
||||
| **Decision** | [spec A1](../architecture.md#8-decision-record) |
|
||||
|
||||
> **Cost correction.** When recommending this change I described it as
|
||||
> "near-trivial — a `PRIMARY KEY` change". That was based on the migration tool's
|
||||
> `ON CONFLICT` clauses. Having read Atom's repository layer, it is **moderate,
|
||||
> not trivial**: single membership is assumed in the upsert semantics, the delete
|
||||
> path, the public API shape, the list queries, and — most importantly — the
|
||||
> authorization evaluation path. The decision may still be right; the estimate
|
||||
> was wrong. See [Scale](#scale-of-the-change).
|
||||
|
||||
## Problem
|
||||
|
||||
`object_group_entities` has `PRIMARY KEY (entity_id)`
|
||||
(`migrations/001_initial.sql:525-532`) — an entity belongs to **at most one**
|
||||
object group. Same for resources, `PRIMARY KEY (resource_id)` (`:537-544`).
|
||||
|
||||
This forbids overlapping object sets:
|
||||
|
||||
```
|
||||
"Customer A meters" → granted to Customer A
|
||||
"Building 5 meters" → granted to a maintenance contractor
|
||||
Meter 7 ∈ both, and neither set contains the other
|
||||
```
|
||||
|
||||
A hierarchy cannot express it, because the sets intersect without nesting.
|
||||
|
||||
## Why this is generic
|
||||
|
||||
Group membership being many-to-many is the ordinary case for a grouping
|
||||
primitive — tags, labels, collections, teams. Atom's own API already reads as
|
||||
though it were: `entityGroups(entityId)` returns a **list**
|
||||
(`src/graphql/groups.rs:104`), and the mutation is named `addGroupMember`, not
|
||||
`setGroupParent`. No product-specific semantics are introduced; a constraint is
|
||||
removed.
|
||||
|
||||
## Scale of the change
|
||||
|
||||
Single membership is assumed in five places, in increasing order of risk.
|
||||
|
||||
### 1. Schema — trivial
|
||||
|
||||
```sql
|
||||
ALTER TABLE object_group_entities
|
||||
DROP CONSTRAINT object_group_entities_pkey,
|
||||
ADD PRIMARY KEY (group_id, entity_id);
|
||||
```
|
||||
|
||||
Data-preserving. Same for `object_group_resources` if included (see Scope).
|
||||
|
||||
### 2. Upsert semantics — small
|
||||
|
||||
`src/identity/repo.rs:511-523` currently **moves** an entity between groups:
|
||||
|
||||
```sql
|
||||
INSERT INTO object_group_entities (group_id, entity_id, tenant_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (entity_id) DO UPDATE
|
||||
SET group_id = EXCLUDED.group_id, ...
|
||||
```
|
||||
|
||||
Becomes `ON CONFLICT (group_id, entity_id) DO NOTHING` — additive rather than
|
||||
replacing. This also settles spec §8 **E3**: today's behaviour is a silent
|
||||
move; after this it is a genuine add.
|
||||
|
||||
### 3. Removal — small, but an API change
|
||||
|
||||
`src/identity/repo.rs:556` deletes by entity alone:
|
||||
|
||||
```sql
|
||||
DELETE FROM object_group_entities WHERE entity_id = $1
|
||||
```
|
||||
|
||||
That now means "remove from *all* groups". `removeGroupMember` already takes a
|
||||
`group_id` (`groups.rs:699`), so the mutation is fine — but
|
||||
`clear_entity_parent_group_in_tx` and any caller expecting "clear the parent"
|
||||
need explicit semantics: remove-from-one versus remove-from-all.
|
||||
|
||||
### 4. The `parent_group_id` attribute API — moderate, semantic
|
||||
|
||||
Membership is currently settable through an **attribute** on create/update —
|
||||
`parent_group_id_from_attrs` (`src/authz/repo.rs:59,237-241`). A scalar attribute
|
||||
cannot express a set.
|
||||
|
||||
Decide one:
|
||||
|
||||
- **A.** Keep `parent_group_id` as a convenience meaning "sole membership"
|
||||
(replaces all), with `addGroupMember` / `removeGroupMember` as the set API.
|
||||
Backwards compatible, but two mechanisms with different semantics on one
|
||||
relation is exactly the kind of thing that later confuses everyone.
|
||||
- **B.** Deprecate the attribute path; membership is only mutated through the
|
||||
explicit mutations. Cleaner; breaks existing callers, including Magistrala's
|
||||
projection (`pkg/atom/mapping.go:46` writes `parent_group_id`).
|
||||
|
||||
**Recommend B**, consistent with the "avoid technical debt" ruling in
|
||||
[spec §8 C1](../architecture.md#8-decision-record).
|
||||
|
||||
### 5. Queries joining membership — moderate, correctness-critical
|
||||
|
||||
This is the part that matters.
|
||||
|
||||
**Row multiplication in listings.** `src/authz/repo.rs:4839-4851`:
|
||||
|
||||
```sql
|
||||
candidates AS (
|
||||
SELECT e.id, ..., gep.group_id AS parent_group_id
|
||||
FROM entities e
|
||||
LEFT JOIN group_entity_parents gep ON gep.entity_id = e.id
|
||||
...
|
||||
AND ($8::uuid IS NULL OR gep.group_id IN (SELECT id FROM target_groups))
|
||||
```
|
||||
|
||||
With M:N this yields one row **per (entity, group) pair** — duplicate entities in
|
||||
every listing and an inflated `total`. Needs `EXISTS`-style filtering rather than
|
||||
a join that projects the group, or explicit de-duplication.
|
||||
|
||||
**Missed grants in authorization.** `src/authz/repo.rs:5687-5692`:
|
||||
|
||||
```sql
|
||||
SELECT e.id, ..., gep.group_id AS parent_group_id
|
||||
FROM entities e
|
||||
LEFT JOIN group_entity_parents gep ON gep.entity_id = e.id
|
||||
WHERE e.id = $1 ...
|
||||
```
|
||||
|
||||
`fetch_optional` over a now-multi-row result takes one arbitrary group. Since
|
||||
this record feeds group-scoped policy evaluation, **an entity in two groups would
|
||||
have grants through one of them silently ignored** — non-deterministically, since
|
||||
which row wins is unspecified.
|
||||
|
||||
This is the single most important change in the PRD: `AuthzObjectRecord`'s
|
||||
`parent_group_id: Option<Uuid>` must become a set, and every `group_direct_objects`
|
||||
/ `group_descendant_objects` evaluation must consider all of them.
|
||||
|
||||
**Views.** `group_entity_parents` / `group_resource_parents`
|
||||
(`001_initial.sql:549-555`) keep working but their names become misleading —
|
||||
they are membership, not parentage.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Schema change for `object_group_entities`.
|
||||
- Additive upsert; explicit removal semantics.
|
||||
- De-duplicated list queries with correct `total`.
|
||||
- Set-based membership in the authorization evaluation path.
|
||||
- Decision and implementation of the `parent_group_id` attribute question.
|
||||
|
||||
**Open — decide before starting**
|
||||
|
||||
- **Include `object_group_resources`?** Symmetry argues yes; no requirement
|
||||
exists yet (channels in multiple groups). Splitting them leaves an asymmetry
|
||||
that will confuse; doing both roughly doubles the query work.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Group hierarchy — `object_group_hierarchy PRIMARY KEY (child_id)`
|
||||
(`:446-454`) stays a tree. A group has one parent; only *membership* becomes
|
||||
many-to-many.
|
||||
- Principal group membership, unless it shares the same tables.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. An entity can be added to two groups and appears in both `groupMembers`
|
||||
listings.
|
||||
2. `entityGroups` returns both.
|
||||
3. `entities(parentGroupId:)` returns the entity **once**, with `total` counting
|
||||
it once, for either group.
|
||||
4. A grant via `group_direct_objects` on **either** group authorizes the entity —
|
||||
verified for both, not just the first.
|
||||
5. Removing from one group leaves the other membership and its grants intact.
|
||||
6. `includeDescendants` traversal is correct when an entity is in two groups in
|
||||
different subtrees.
|
||||
7. Adding an entity to a group it is already in is idempotent.
|
||||
8. Cross-tenant membership is still rejected (`identity/repo.rs:505-509`).
|
||||
9. Existing single-membership data behaves identically after migration.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Migration against seeded data; assert every existing membership survives.
|
||||
- **Criterion 4 is the security-relevant one** — entity in groups G1 and G2, grant
|
||||
only via G2, assert allowed. This fails today's evaluation path and is the
|
||||
reason for the change.
|
||||
- Criterion 3 with an entity in three groups, asserting no duplicates and correct
|
||||
`total` under pagination.
|
||||
- Descendant traversal across two subtrees.
|
||||
- Regression: full existing group and authz suites.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Silent grant loss** if the evaluation path is not fully converted to sets.
|
||||
Fails open or closed non-deterministically depending on row order — the worst
|
||||
possible failure mode. Criterion 4 is the guard.
|
||||
- **Duplicate rows** in listings are cosmetic in the UI but corrupt `total` and
|
||||
therefore pagination.
|
||||
- **Query plans** change once the join can fan out. Check plans for
|
||||
`authorized_object_ids` with and without a group filter.
|
||||
- **Scope creep into resources.** Decide up front; discovering halfway that
|
||||
resources need it too doubles the work mid-flight.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ATOM-05 — Add `gateway` entity kind
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Repo** | `absmach/atom` (Rust) |
|
||||
| **Priority** | — |
|
||||
| **Status** | **WITHDRAWN** |
|
||||
| **Superseded by** | [spec §8 A12](../architecture.md#8-decision-record) |
|
||||
|
||||
---
|
||||
|
||||
## Withdrawn
|
||||
|
||||
This PRD added `gateway` to Atom's `entities.kind` enum. **Gateway is a
|
||||
capability, not a type**, so no new kind is needed — gateways stay
|
||||
`entity_kind: device` with an `is_gateway` attribute.
|
||||
|
||||
### Why
|
||||
|
||||
Types are exclusive; roles compose. The model defines a gateway as *a Device with
|
||||
a proxy role* ([spec §2.1](../architecture.md#21-a-gateway-is-a-path-not-a-container)),
|
||||
and a distinct kind contradicts that on a real and common device: a smart
|
||||
electricity meter that also concentrates wM-Bus water meters produces its own
|
||||
readings **and** relays others. Under exclusive kinds it must be one or the
|
||||
other, and either answer is wrong.
|
||||
|
||||
Surveying comparable platforms found the same conclusion everywhere:
|
||||
|
||||
| Platform | Gateway is |
|
||||
|---|---|
|
||||
| ThingsBoard | Device + `Is gateway` **boolean** |
|
||||
| AWS Greengrass v2 | Core device — an IoT `thing`, **same type as clients** |
|
||||
| Azure IoT Edge | Device identity with edge capability |
|
||||
| ChirpStack | Separate — but its gateways are dumb radio infrastructure producing no data |
|
||||
|
||||
### What happened to its parts
|
||||
|
||||
| Was | Now |
|
||||
|---|---|
|
||||
| `Gateway` variant on `EntityKind` + two CHECK relaxations | Not needed |
|
||||
| `entity:gateway` object type | `attributesContains: {is_gateway: true}` — ATOM-01 |
|
||||
| Separate gateway profile namespace | One namespace. A device that both measures and relays needs *one* type, which is more correct |
|
||||
| Gateway-specific `ActionAssignmentRule`s | Not needed — the existing `{entity_kind: device, publish, resource:channel}` rule keeps applying |
|
||||
|
||||
### The trap it carried, now gone
|
||||
|
||||
`pkg/atom/bootstrap.go:72-87` installs `{entity_kind: device, publish,
|
||||
resource:channel}` as the only publish guardrail. Introducing a `gateway` kind
|
||||
would have **silently stripped every gateway's right to publish** until matching
|
||||
rules were added — surfacing as a bare authorization denial with nothing pointing
|
||||
at the cause. Withdrawing this PRD removes that failure mode entirely.
|
||||
|
||||
### If this needs revisiting
|
||||
|
||||
The trigger would be a requirement to grant over *all gateways* as a first-class
|
||||
object type, in a deployment where attribute filtering does not scale. The
|
||||
migration stays permissive and additive, so it can be done later at the same cost
|
||||
— with the publish-guardrail trap as the thing to plan for.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,125 @@
|
||||
# MG-01 — Fix Atom policy client defects
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Repo** | `absmach/magistrala` (Go) |
|
||||
| **Priority** | P0 |
|
||||
| **Depends on** | — |
|
||||
| **Blocks** | MG-04, MG-08 |
|
||||
| **Status** | Draft |
|
||||
|
||||
## Problem
|
||||
|
||||
Four defects in `pkg/atom` sit directly beneath the access-control work. Each is
|
||||
latent today — mostly because `PolicyService` is not wired into any running
|
||||
binary — and each becomes a live correctness or security bug the moment it is.
|
||||
|
||||
### 1. `objectType` is sent unnamespaced
|
||||
|
||||
`policy_service.go:139` sends `ObjectType: entityKind(KindClient)` → `"device"`.
|
||||
The write path sends the namespaced form `"entity:device"`
|
||||
(`policy_service.go:225`).
|
||||
|
||||
Atom requires the namespaced form and says so explicitly:
|
||||
|
||||
> `object_type must be the full namespaced value matching object_kind, e.g. 'entity:device'`
|
||||
> — `src/identity/access_tokens.rs:275`; construction at `src/graphql/entities.rs:157-165`
|
||||
|
||||
The filter therefore never matches what the writer stored. Confirmed against
|
||||
Atom source, not inferred.
|
||||
|
||||
### 2. `DeletePolicyFilter` silently truncates
|
||||
|
||||
`policy_service.go:78-105` lists a subject's direct policies with
|
||||
`Limit: policyPageLimit` (100, `:13`) and deletes matches from that single page.
|
||||
A subject with more than 100 policies keeps access to everything past the cap.
|
||||
Revocation reporting success while leaving access in place is a security defect,
|
||||
not a pagination nit.
|
||||
|
||||
### 3. `CapabilityID` cannot see past 100 actions
|
||||
|
||||
`client.go:272-287` linear-scans `actions(limit: 100)`. Action 101 is
|
||||
unresolvable, and the failure is a confusing "not found" far from the cause.
|
||||
|
||||
### 4. No applicability registered for `objectKind: entity`
|
||||
|
||||
`bootstrap.go:29-70` registers applicability for `tenant`, `group`,
|
||||
`resource:channel`, `resource:rule` and `resource:report` — but nothing for
|
||||
`entity`. Meanwhile `fluxmq/api/http/publish.go:184` already checks `read` on an
|
||||
entity, and every device-level grant this project introduces will target
|
||||
`entity:device`.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Fix the `objectType` mismatch. Introduce a single helper that produces the
|
||||
namespaced object type and use it on **both** the read and write paths, so the
|
||||
two cannot drift again.
|
||||
- Make `DeletePolicyFilter` paginate to exhaustion, deleting across all pages.
|
||||
- Make `CapabilityID` resolve reliably — paginate, or look up by name if Atom
|
||||
supports it. Cache resolved IDs; they are immutable.
|
||||
- Register applicability for `entity` / `entity:device`: `read`, `write`,
|
||||
`delete`, `manage`.
|
||||
- Widen `isSupportedObjectList` (`policy_service.go:182-187`) beyond
|
||||
`user + client + view`, which is required by MG-08.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Wiring `PolicyService` into services — MG-08 does that for readers.
|
||||
- Group-scoped blocks — MG-04.
|
||||
- Any behaviour change to `AddPolicy`'s block-per-call shape. Block reuse is a
|
||||
legitimate optimisation but belongs with MG-04, which changes that path anyway.
|
||||
|
||||
## Design notes
|
||||
|
||||
The `objectType` fix should not be a literal-string edit in two places. Add:
|
||||
|
||||
```go
|
||||
// atomObjectType returns the namespaced object type Atom requires,
|
||||
// e.g. "entity:device", "resource:channel".
|
||||
func atomObjectType(objectKind, kind string) string
|
||||
```
|
||||
|
||||
and route both `policyGrantObjectType` (`policy_service.go:220-241`) and the
|
||||
`ListAllObjects` query (`:135-143`) through it. The bug exists because the two
|
||||
paths independently construct the same string; the fix is to remove that
|
||||
independence.
|
||||
|
||||
For deletion, the loop must be resilient to the page shifting underneath it as
|
||||
items are removed — page by offset and re-query, or collect all IDs first and
|
||||
then delete. Collecting first is simpler and correct; the sets are small.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. Read and write paths produce byte-identical `object_type` values for the same
|
||||
logical object, enforced by a test that compares them directly.
|
||||
2. `ListAllObjects` against a subject with an object-scoped `read` grant on a
|
||||
device returns that device's ID. (This returns nothing today.)
|
||||
3. Revoking a permission on a subject holding 250 policies removes **all**
|
||||
matching policies. Verified by re-querying after deletion, not by return value.
|
||||
4. `CapabilityID` resolves an action registered beyond the first 100.
|
||||
5. `atom-bootstrap` registers `entity` applicability; re-running it is idempotent.
|
||||
6. `isSupportedObjectList` admits the `read`-on-`entity:device` case MG-08 needs
|
||||
and still rejects genuinely unsupported combinations.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit: object-type helper across every kind; `isSupportedObjectList` truth table.
|
||||
- Unit: `DeletePolicyFilter` against a mock returning 250 policies across
|
||||
3 pages — assert every matching ID was passed to delete.
|
||||
- Integration (Atom in Docker): grant `read` on a device, assert
|
||||
`ListAllObjects` returns it; revoke, assert it is gone.
|
||||
- Bootstrap idempotency: run `atom-bootstrap` twice, assert no duplicates and no
|
||||
errors.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Changing `objectType` invalidates existing stored blocks.** Any block written
|
||||
with the unnamespaced form still holds it.
|
||||
|
||||
**Resolved — [spec §8 C1](../architecture.md#8-decision-record): no backwards compatibility.**
|
||||
Fix the read path via the shared helper and leave it there. No dual-form
|
||||
matching, no compatibility shim. Blocks holding the old value stop matching and
|
||||
must be rewritten; that belongs in release notes rather than being discovered
|
||||
in the field. With this ruling the PRD is purely additive plus fixes.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,153 @@
|
||||
# MG-03 — Group membership, hierarchy and group kinds
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Repo** | `absmach/magistrala` (Go) |
|
||||
| **Priority** | P1 |
|
||||
| **Depends on** | ATOM-04 |
|
||||
| **Blocks** | MG-04, MG-09 |
|
||||
| **Status** | Draft |
|
||||
|
||||
## Scope note — groups are for sharing only
|
||||
|
||||
Object groups in this model have **exactly one purpose: granting access**
|
||||
([spec §3.5](../architecture.md#35-groups-and-sharing)). They are not used for
|
||||
device↔gateway topology — that is a `gateways []` attribute on the device
|
||||
([spec §8 A10](../architecture.md#8-decision-record)) — and not for any other
|
||||
grouping.
|
||||
|
||||
An intermediate design put gateway fleets in this same namespace; it was reversed
|
||||
because a group-scoped grant on a fleet group would silently hand over an entire
|
||||
gateway's devices. Keep the namespace single-purpose.
|
||||
|
||||
## Problem
|
||||
|
||||
`pkg/atom` can create, read, update, delete and list groups — and nothing else.
|
||||
It cannot add a member, remove a member, list members, or create a nested group.
|
||||
The only code in the tree that writes group membership is the one-shot migration
|
||||
tool, via raw SQL (`tools/atom-migration/migrator.go:598,619`).
|
||||
|
||||
Without membership there is no sharing model: the customer story in
|
||||
[architecture.md §5.2](../architecture.md#35-groups-and-sharing) depends on a group
|
||||
holding a device set.
|
||||
|
||||
## What Atom already provides
|
||||
|
||||
| Operation | Location |
|
||||
|---|---|
|
||||
| `addGroupMember`, `removeGroupMember` | `src/graphql/groups.rs:645,699` |
|
||||
| `groupMembers(groupId)`, `entityGroups(entityId)` | `:90,104` |
|
||||
| `createObjectGroup`, `createPrincipalGroup` | `:346,355` |
|
||||
| `objectGroups`, `principalGroups`, `childGroups` | `:154,184,121` |
|
||||
| `setGroupParent`, `removeGroupParent` | `:429,478` |
|
||||
| Object-side variants `setObjectGroupParent`, `removeObjectGroupParent` | `:468,517` |
|
||||
|
||||
No Atom change is required.
|
||||
|
||||
## Two things the current client gets wrong
|
||||
|
||||
### Object groups vs principal groups
|
||||
|
||||
Atom has two distinct group namespaces backed by separate tables —
|
||||
`object_groups` (`001_initial.sql:427-441`) and `principal_groups`
|
||||
(`:386-400`), unioned only by a compatibility view (`:461-466`). `pkg/atom` uses
|
||||
the generic `createGroup`, so which table a group lands in is implicit.
|
||||
|
||||
Magistrala's usage divides cleanly:
|
||||
|
||||
- **Object group** — a set of devices or channels. What customer sharing needs.
|
||||
- **Principal group** — a set of users. A subject in a policy.
|
||||
|
||||
These must be explicit in the client. Creating the wrong kind produces a group
|
||||
that silently cannot be used as intended.
|
||||
|
||||
### `parentId` is dropped
|
||||
|
||||
`groupCreateInput` (`client.go:751-760`) builds the create input without
|
||||
`parentId` even though `Group` carries it (`types.go:39`). Nested groups are
|
||||
therefore uncreatable from Go. The hierarchy is needed for
|
||||
Customer → Site → meters roll-ups via `includeDescendants`.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Membership: `AddGroupMember`, `RemoveGroupMember`, `GroupMembers`,
|
||||
`EntityGroups`.
|
||||
- Typed creation: `CreateObjectGroup`, `CreatePrincipalGroup`. Retain the generic
|
||||
`CreateGroup` only if a caller genuinely needs kind-agnostic behaviour;
|
||||
otherwise remove it so the choice is always explicit.
|
||||
- Hierarchy: `SetGroupParent`, `RemoveGroupParent`, `ChildGroups`; fix
|
||||
`groupCreateInput` to send `parentId`.
|
||||
- Listing: `ObjectGroups`, `PrincipalGroups`, and `includeDescendants` where Atom
|
||||
accepts it.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Group-scoped permission blocks — MG-04.
|
||||
- Resource membership (`object_group_resources`). Add when a channel-grouping
|
||||
requirement appears; there is none yet.
|
||||
|
||||
## Membership is many-to-many (after ATOM-04)
|
||||
|
||||
Originally Atom enforced `PRIMARY KEY (entity_id)` on `object_group_entities`,
|
||||
meaning an entity belonged to at most one object group — and
|
||||
`set_entity_parent_group_in_tx` (`src/identity/repo.rs:511-523`) **silently
|
||||
moved** it between groups on re-add.
|
||||
|
||||
[ATOM-04](./ATOM-04-many-to-many-group-membership.md) removes that constraint per
|
||||
[spec A1](../architecture.md#8-decision-record), so:
|
||||
|
||||
- `AddGroupMember` is **additive**. An entity in group A added to group B is in
|
||||
both.
|
||||
- Re-adding to a group it already belongs to is idempotent.
|
||||
- `RemoveGroupMember` takes a group ID and removes only that membership.
|
||||
- `EntityGroups` returning a list (`groups.rs:104`) is now literally correct.
|
||||
|
||||
**Do not start this PRD before ATOM-04 lands.** Building against the old
|
||||
move-on-conflict semantics produces a client whose documented behaviour inverts
|
||||
under it.
|
||||
|
||||
### Consequence for grants
|
||||
|
||||
A device can now reach a permission block through more than one group. Nothing in
|
||||
the client changes for that, but it means removing a device from one sharing
|
||||
group does **not** necessarily revoke access — another group may still grant it.
|
||||
Any UI showing "who can see this device" must ask
|
||||
[ATOM-03](./ATOM-03-reverse-policy-lookup.md) rather than inferring from a single
|
||||
group.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. Create an object group, add three devices, list members — all three returned.
|
||||
2. Remove one; the remaining two are returned.
|
||||
3. `EntityGroups` for a member returns every group it belongs to.
|
||||
4. A device added to two groups appears in both member listings, and
|
||||
`RemoveGroupMember` on one leaves the other intact.
|
||||
5. Create a nested group by passing `parentId` at creation; `ChildGroups` on the
|
||||
parent returns it.
|
||||
6. `SetGroupParent` / `RemoveGroupParent` reparent an existing group.
|
||||
7. Object and principal groups are created in the correct namespace, verified by
|
||||
listing each kind separately.
|
||||
8. Members from another tenant are rejected.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Integration (Atom in Docker) for all of the above — membership semantics cannot
|
||||
be established against a mock, since they live in Atom's schema and repository
|
||||
layer.
|
||||
- Explicit test for criterion 4; multi-group membership is the whole point of
|
||||
ATOM-04 and the behaviour most likely to regress.
|
||||
- Hierarchy: 3-level tree, assert direct vs descendant listing at each level.
|
||||
- Authorization: a caller without `manage` on the group is refused
|
||||
(`groups.rs:660-668`).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Sequencing.** Built against pre-ATOM-04 Atom, `AddGroupMember` moves rather
|
||||
than adds. Every acceptance criterion around multi-group membership would pass
|
||||
vacuously or invert. Gate on ATOM-04.
|
||||
- **Revocation is no longer "remove from the group".** With multi-group
|
||||
membership, a device may retain access through another group. Anything that
|
||||
presents removal as revocation will be wrong; this needs to be explicit in the
|
||||
method documentation and in whatever UI consumes it.
|
||||
@@ -0,0 +1,145 @@
|
||||
# MG-04 — Group-scoped permission blocks
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Repo** | `absmach/magistrala` (Go) |
|
||||
| **Priority** | P1 |
|
||||
| **Depends on** | MG-01, MG-03 |
|
||||
| **Blocks** | MG-08 |
|
||||
| **Status** | Draft |
|
||||
|
||||
## Problem
|
||||
|
||||
`pkg/atom` writes only three of Atom's ten scope modes — `object`, `tenant` and
|
||||
`platform` (`policy_service.go:196-205`). Everything that is not a domain or the
|
||||
platform collapses to a single-instance `object` block (`:203`).
|
||||
|
||||
Granting a customer read access to 500 meters therefore means 500 permission
|
||||
blocks and 500 direct policies, because `AddPolicy` (`:38-67`) creates a fresh
|
||||
block per call with no reuse. That is slow to write, expensive to evaluate, and
|
||||
walks straight into the truncating revocation path fixed in MG-01.
|
||||
|
||||
Atom supports exactly what is needed and it is unreachable from Go.
|
||||
|
||||
## What Atom supports
|
||||
|
||||
`scope_mode` accepts (`migrations/001_initial.sql:607`):
|
||||
|
||||
```
|
||||
platform, tenant, object_kind, object_type, object,
|
||||
group, group_direct_objects, group_descendant_objects,
|
||||
group_child_groups, group_descendant_groups
|
||||
```
|
||||
|
||||
Constraints per mode at `:619-627`. For `group_direct_objects` /
|
||||
`group_descendant_objects`: `tenant_id` and `group_id` required, `object_kind ∈
|
||||
{entity, resource}`, `object_id` must be null (`:625`).
|
||||
|
||||
Atom computes the scope reference as `{group_id}:{object_type}` —
|
||||
`src/authz/engine.rs:1334` produces `"{group_id}:entity:device"`, matching
|
||||
`001_initial.sql:729`.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend `policyGrantScopeMode` (`policy_service.go:196-205`) and the companion
|
||||
`policyGrantObjectKind` / `ObjectType` / `ObjectID` functions (`:207-248`) to
|
||||
emit group-scoped blocks.
|
||||
- A direct API for the customer-sharing case rather than squeezing it through the
|
||||
legacy `policies.Policy` shape:
|
||||
```go
|
||||
GrantGroupAccess(ctx, GroupGrant{
|
||||
TenantID, GroupID, SubjectKind, SubjectID,
|
||||
ObjectKind, ObjectType, Actions []string,
|
||||
IncludeDescendants bool,
|
||||
}) error
|
||||
RevokeGroupAccess(ctx, GroupGrant) error
|
||||
ListGroupGrants(ctx, groupID) ([]GroupGrant, error)
|
||||
```
|
||||
- `directPolicyMatches` (`:250-266`) must compare `GroupID` too, or revocation
|
||||
will not match group-scoped blocks.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- `object_kind` / `object_type` scope modes ("every device in the tenant"). No
|
||||
requirement yet; adding unused grant shapes is how permission models rot.
|
||||
- `group_child_groups` / `group_descendant_groups` (grants over *groups*, not
|
||||
their contents). Add when group-management delegation is actually needed.
|
||||
- Block reuse for `object`-scoped grants. Group scoping removes the case that
|
||||
made it urgent.
|
||||
|
||||
## Design
|
||||
|
||||
### Grant shape
|
||||
|
||||
One block, one policy, per customer:
|
||||
|
||||
```
|
||||
PermissionBlock {
|
||||
scope_mode: "group_direct_objects",
|
||||
tenant_id: <domain>,
|
||||
group_id: <customer group>,
|
||||
object_kind: "entity",
|
||||
object_type: "entity:device",
|
||||
effect: "allow",
|
||||
actions: [read]
|
||||
}
|
||||
DirectPolicy { subject_kind: "entity", subject_id: <customer user>, … }
|
||||
```
|
||||
|
||||
Adding a meter to the customer's group grants access. Removing it revokes.
|
||||
**Membership becomes the sharing operation** — no policy write per device.
|
||||
|
||||
### Direct vs descendant
|
||||
|
||||
`group_direct_objects` covers immediate members only; `group_descendant_objects`
|
||||
walks the tree. Customer → Site → meters requires descendant scoping if the grant
|
||||
is made at the customer level. Expose it as `IncludeDescendants` and make the
|
||||
default explicit rather than implied.
|
||||
|
||||
### Interaction with MG-01
|
||||
|
||||
Uses the namespaced object-type helper from MG-01. Do not reconstruct
|
||||
`"entity:device"` here — that duplication is the exact cause of the MG-01 bug.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. Granting a subject `read` over a group produces **one** block and **one**
|
||||
direct policy, regardless of member count.
|
||||
2. `authzCheck(subject, "read", entity, <member device>)` returns allowed.
|
||||
3. A device **not** in the group is denied.
|
||||
4. Adding a device to the group grants access with no policy write.
|
||||
5. Removing it revokes access.
|
||||
6. `IncludeDescendants: true` reaches devices in child groups;
|
||||
`false` does not.
|
||||
7. `RevokeGroupAccess` removes the block and policy; access is denied afterwards
|
||||
for all members.
|
||||
8. A grant over a 500-member group is a constant number of writes — asserted, not
|
||||
assumed.
|
||||
9. Existing object- and tenant-scoped grants are unchanged.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit: scope-mode selection across every input; `directPolicyMatches` including
|
||||
the `GroupID` comparison.
|
||||
- Integration (Atom in Docker): the full customer scenario from
|
||||
[architecture.md §5.2](../architecture.md#35-groups-and-sharing) — two customers,
|
||||
three meters, one gateway; assert each customer sees only their own.
|
||||
- Descendant scoping over a 3-level tree.
|
||||
- Write-count assertion for criterion 8.
|
||||
- Regression: existing connection grants (`grpc_compat.go:112-129`) still work.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Revocation must match on `group_id`.** If `directPolicyMatches` is not
|
||||
extended, `RevokeGroupAccess` silently no-ops and access persists. This is the
|
||||
single highest-risk line in the PRD and needs a dedicated test.
|
||||
- **Multi-group membership changes what revocation means.** Following
|
||||
[spec A1](../architecture.md#8-decision-record) and ATOM-04, a device can reach grants
|
||||
through several groups. `RevokeGroupAccess` on one group is therefore *not*
|
||||
"this subject can no longer see this device" — another group may still grant
|
||||
it. Acceptance criterion 7 must be read as "denied **for members reachable only
|
||||
through this group**", and any UI phrasing revocation as absolute will be
|
||||
wrong. Use [ATOM-03](./ATOM-03-reverse-policy-lookup.md) to answer "who can
|
||||
still see this?"
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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 1–4 written against current `main` must
|
||||
fail. If they pass, the test is wrong.
|
||||
- Integration (`ory/dockertest` + Atom): the full matrix, both backends.
|
||||
- Cache: grant, query, revoke, query again — access ends within the stated TTL.
|
||||
- Admin bypass: explicitly assert it is capability-driven by testing a
|
||||
non-admin with a large grant set and an admin with none.
|
||||
- Performance: query latency with an authorized set of 1, 100 and 10 000 devices.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Empty-set inversion** — treating "no authorized devices" as "no filter" turns
|
||||
this control into a full disclosure. Criterion 4 is the guard and must be an
|
||||
explicit test, not incidental coverage.
|
||||
- **Stale cache after revocation** leaves a window where a revoked customer still
|
||||
reads data. Atom publishes `direct_policy.delete`, `group_member.remove` and
|
||||
`entity.parent_group.clear` (`src/events/publisher.rs`), so
|
||||
[MG-14](./MG-14-atom-event-consumer.md) makes invalidation deterministic rather
|
||||
than TTL-bounded. Keep the TTL as the correctness floor: with the broker down,
|
||||
the revocation SLA falls back to it, so it must stay short enough to be
|
||||
defensible on its own.
|
||||
- **Existing deployments may rely on the current permissive behaviour.** A user
|
||||
who reads a channel today and sees everything will see less. That is the point,
|
||||
but it is a behavioural break and belongs in release notes.
|
||||
- Depends on ATOM-02 for the scalable path. Without it, ship the materialised
|
||||
version and accept the ceiling — but do not silently skip enforcement.
|
||||
@@ -0,0 +1,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
|
||||
P1–P3 works around that fusion internally; this PRD makes the split visible in
|
||||
the API.
|
||||
|
||||
**This is not a rename for readability.** `Device` differs from `Client` in
|
||||
substance: credentials are optional, it carries a type and a serial, and it can
|
||||
be attached to a gateway. If the only change were the label, it would not be
|
||||
worth the churn.
|
||||
|
||||
Widest blast radius in the programme — this freezes the public API shape.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- `Device` replacing `Client` in `pkg/sdk`, with the new fields.
|
||||
- `Gateway` as a first-class API surface over the same underlying entity.
|
||||
- The device→gateways reachability relation.
|
||||
- Proto rename and reshaping.
|
||||
- Clean break: no `Client` type, no `/clients` route, no alias.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Device types — MG-10.
|
||||
- CLI, PAT scopes, permissions, OpenAPI — MG-11.
|
||||
- Provisioning flows — MG-12/13 (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.
|
||||
@@ -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.
|
||||
@@ -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 1–3
|
||||
testable only at package level. Flag it; do not silently expand scope.
|
||||
- **Channels have no roles** while devices, groups and domains do
|
||||
(`pkg/sdk/channels.go` has no role methods). Pre-existing and out of scope, but
|
||||
1.0 freezes it. Worth a decision before release.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–2)*, 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 1–10 are normative; §11 is the rationale.
|
||||
|
||||
All code-blocking questions are resolved. Several 🟡 items still gate individual
|
||||
PRDs and are named in each PRD's Risks section.
|
||||
|
||||
## Conventions used in these PRDs
|
||||
|
||||
- **Scope** sections are binding. Anything under "Out of scope" belongs to
|
||||
another PRD; if it turns out to be unavoidable, amend both PRDs rather than
|
||||
widening silently.
|
||||
- File references are `path:line` against `main` at the time of writing
|
||||
(`e8cf13c7f` for Magistrala). Verify before editing — lines drift.
|
||||
- Every PRD states acceptance criteria as observable behaviour, not as
|
||||
"code written".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 3–5 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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user