Compare commits

..

28 Commits

Author SHA1 Message Date
dusan 05ce424453 Update migrations
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-06-26 20:52:01 +02:00
dusan afd852d7b4 Fix migrations
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-06-26 20:30:31 +02:00
Arvindh 3c7e81fe1f remove rule metadata from atom
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh db81b3033b fix alarms
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 710f18b95a fix the makefile
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 59f8cc9b1a auto provision before start compose
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh a6264fb5ae fix rules engine atom token
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 8408fac353 allow unverified user
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 369cc49874 fix atom api ci failures
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh d252655ab4 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 2dd5225288 add back fluxmq-auth
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 22ffcda201 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 3a13e81956 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh eed4d4aa00 auto provision of ATOM token
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 601bbacd17 remove fluxmq
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Rodney Osodo d7c49e068d chore(docker): update service credentials in .env
Signed-off-by: Rodney Osodo <socials@rodneyosodo.com>
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Dušan Borovčanin 9cc0991227 Add Magistrala v0.30.0 migration tool (#3536)
Signed-off-by: dusan <borovcanindusan1@gmail.com>
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 523305c120 fix(authz): enforce Atom list checks for rules reports and alarms
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 19bdd334b2 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 746086f1cd remove old services
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh b929d51b3f remove core
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 0bb8b0947a Remove stale bootstrap backfill artifacts after Atom rebase
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh dbbd8453e9 add atom bootstrap
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh aee8474e49 update nginx and env
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 36cfd989fe update compose
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh baf507e699 certficate integration with ATOM
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 4921cb4cf5 align with ATOM changes
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 9a186fd26f inital integration with ATOM
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:48 +02:00
75 changed files with 921 additions and 2676 deletions
-24
View File
@@ -193,8 +193,6 @@ jobs:
base-url: ${{ env.USERS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --exclude-operation-id=requestPasswordReset --phases=examples'
coverage-artifact-name: schema-coverage-users
coverage-pr-comment: false
- name: Run Groups API tests
if: (steps.changes.outputs.groups == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('groups/api/http/**') != ''
@@ -204,8 +202,6 @@ jobs:
base-url: ${{ env.GROUPS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-groups
coverage-pr-comment: false
- name: Run Clients API tests
if: (steps.changes.outputs.clients == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('clients/api/http/**') != ''
@@ -215,8 +211,6 @@ jobs:
base-url: ${{ env.CLIENTS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-clients
coverage-pr-comment: false
- name: Run Channels API tests
if: (steps.changes.outputs.channels == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('channels/api/http/**') != ''
@@ -226,8 +220,6 @@ jobs:
base-url: ${{ env.CHANNELS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-channels
coverage-pr-comment: false
- name: Run Domains API tests
if: (steps.changes.outputs.domains == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('domains/api/http/**') != ''
@@ -237,8 +229,6 @@ jobs:
base-url: ${{ env.DOMAIN_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-domains
coverage-pr-comment: false
- name: Run Journal API tests
if: steps.changes.outputs.journal == 'true' || steps.changes.outputs.workflow == 'true'
@@ -248,8 +238,6 @@ jobs:
base-url: ${{ env.JOURNAL_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-journal
coverage-pr-comment: false
- name: Run Bootstrap API tests
if: (steps.changes.outputs.bootstrap == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('bootstrap/api/**') != ''
@@ -259,8 +247,6 @@ jobs:
base-url: ${{ env.BOOTSTRAP_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-bootstrap
coverage-pr-comment: false
- name: Run Certs API tests
if: (steps.changes.outputs.certs == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('docker/addons/certs/docker-compose.yaml') != ''
@@ -270,8 +256,6 @@ jobs:
base-url: ${{ env.CERTS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-certs
coverage-pr-comment: false
- name: Run Readers API tests
if: steps.changes.outputs.readers == 'true' || steps.changes.outputs.workflow == 'true'
@@ -281,8 +265,6 @@ jobs:
base-url: ${{ env.READERS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-readers
coverage-pr-comment: false
- name: Run Rules Engine API tests
if: steps.changes.outputs.re == 'true' || steps.changes.outputs.workflow == 'true'
@@ -292,8 +274,6 @@ jobs:
base-url: ${{ env.RE_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-rules
coverage-pr-comment: false
- name: Run Alarms API tests
if: steps.changes.outputs.alarms == 'true' || steps.changes.outputs.workflow == 'true'
@@ -303,8 +283,6 @@ jobs:
base-url: ${{ env.ALARMS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-alarms
coverage-pr-comment: false
- name: Run Reports API tests
if: steps.changes.outputs.reports == 'true' || steps.changes.outputs.workflow == 'true'
@@ -314,8 +292,6 @@ jobs:
base-url: ${{ env.REPORTS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-reports
coverage-pr-comment: false
- name: Stop containers
if: always()
+1 -11
View File
@@ -197,7 +197,7 @@ 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 $(SERVICES) dockers dockers_dev latest release provision_atom_tokens provision-atom-tokens run_latest run_latest_ci run_tls run_stable run_addons grpc_mtls_certs check_mtls check_certs test_api mocks
clean:
rm -rf ${BUILD_DIR}
@@ -324,16 +324,6 @@ provision_atom_tokens:
provision-atom-tokens:
@:
# Migrate an old Magistrala (v0.30.0 / pre-Atom) deployment into Atom. Runs an
# isolated, collision-free stack, seeds the Atom schema into the run_latest Atom
# volume and loads the data. Default is a dry-run; pass args="--apply" to load,
# args="--verify" to reconcile afterwards.
# make migrate_atom # dry-run
# make migrate_atom args="--apply" # perform the migration
# make migrate_atom args="--apply --fresh-atom" # rebuild Atom schema first
migrate_atom:
DOCKER_PROJECT="$(DOCKER_PROJECT)" tools/atom-migration/migrate.sh $(args)
check_tls:
ifeq ($(GRPC_TLS),true)
@echo "gRPC TLS is enabled"
-2
View File
@@ -156,8 +156,6 @@ Atom is the source of truth for:
Magistrala services such as rules, alarms, and reports remain Magistrala services, but they use Atom for identity and authorization.
Current Docker deployments use the Atom image configured by `ATOM_IMAGE` in `docker/.env`. For compatibility with the current Magistrala integration, the generated `MG_ATOM_TOKEN_*` service credentials are unscoped Atom access tokens. Scoped Atom access tokens should not be used for these service env vars until Magistrala stops using owner-wide Atom listing APIs such as `authorizedObjectIds` in service policy paths.
### Core Entity Mapping
| Magistrala concept | Atom concept | Meaning |
+3 -4
View File
@@ -28,9 +28,8 @@ The service is configured using the following environment variables (values show
| `MG_JAEGER_TRACE_RATIO` | Trace sampling ratio | `1.0` |
| `ATOM_URL` | Atom HTTP endpoint | `http://atom:8080` |
| `ATOM_JWKS_URL` | Atom JWKS endpoint for JWT verification | `http://atom:8080/.well-known/jwks.json` |
| `ATOM_SERVICE_TOKEN` | Atom service token for authorization checks | "" |
| `ATOM_ADMIN_USERNAME` | Atom admin login fallback when no service token is configured | `atom-admin` |
| `ATOM_ADMIN_SECRET` | Atom admin secret fallback when no service token is configured | `change-me` |
| `ATOM_ADMIN_USERNAME` | Atom admin login for service projections | `atom-admin` |
| `ATOM_ADMIN_SECRET` | Atom admin secret for service projections | `change-me` |
| `ATOM_TIMEOUT` | Atom request timeout | `5s` |
| `MG_ALLOW_UNVERIFIED_USER` | Allow unverified users to access | `true` |
@@ -40,7 +39,7 @@ The service is configured using the following environment variables (values show
- **Stateful updates**: Updates assignee, acknowledgment, resolution, and metadata fields.
- **Filtering and paging**: Lists alarms by domain, rule, channel, client, subtopic, status, severity, and time range.
- **Observability**: `/metrics` Prometheus endpoint and Jaeger tracing support.
- **Auth and authorization**: Authn/authz enforced through Atom JWT verification and PDP checks while alarm records stay in PostgreSQL.
- **Auth and authorization**: Authn/authz enforced through Atom JWT verification and PDP checks.
## Architecture
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"time"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
)
type atomService struct {
Service
projector atom.Projector
}
func WithAtom(svc Service, projector atom.Projector) Service {
if projector == nil {
return svc
}
return atomService{Service: svc, projector: projector}
}
func (svc atomService) CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error) {
created, err := svc.Service.CreateAlarm(ctx, alarm)
if err != nil {
return created, err
}
if created.ID == "" {
return created, nil
}
if err := svc.projector.UpsertResource(ctx, alarmProjection(created)); err != nil {
return created, nil
}
return created, nil
}
func (svc atomService) UpdateAlarm(ctx context.Context, session authn.Session, alarm Alarm) (Alarm, error) {
updated, err := svc.Service.UpdateAlarm(ctx, session, alarm)
if err != nil {
return updated, err
}
if err := svc.projector.UpsertResource(ctx, alarmProjection(updated)); err != nil {
return updated, nil
}
return updated, nil
}
func (svc atomService) DeleteAlarm(ctx context.Context, session authn.Session, id string) error {
if err := svc.Service.DeleteAlarm(ctx, session, id); err != nil {
return err
}
_ = svc.projector.DeleteResource(ctx, id)
return nil
}
func alarmProjection(a Alarm) atom.Resource {
res := atom.ResourceFromFields(atom.ObjectFields{
ID: a.ID,
Kind: atom.KindAlarm,
Name: a.Cause,
TenantID: a.DomainID,
OwnerID: a.AssigneeID,
Status: a.Status.String(),
Metadata: map[string]any(a.Metadata),
UpdatedBy: a.UpdatedBy,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
res.Attributes["rule_id"] = a.RuleID
res.Attributes["channel_id"] = a.ChannelID
res.Attributes["client_id"] = a.ClientID
res.Attributes["subtopic"] = a.Subtopic
res.Attributes["severity"] = a.Severity
res.Attributes["measurement"] = a.Measurement
res.Attributes["value"] = a.Value
res.Attributes["unit"] = a.Unit
res.Attributes["threshold"] = a.Threshold
res.Attributes["cause"] = a.Cause
res.Attributes["assignee_id"] = a.AssigneeID
res.Attributes["assigned_at"] = alarmTimeString(a.AssignedAt)
res.Attributes["assigned_by"] = a.AssignedBy
res.Attributes["acknowledged_at"] = alarmTimeString(a.AcknowledgedAt)
res.Attributes["acknowledged_by"] = a.AcknowledgedBy
res.Attributes["resolved_at"] = alarmTimeString(a.ResolvedAt)
res.Attributes["resolved_by"] = a.ResolvedBy
return res
}
func alarmTimeString(ts time.Time) string {
if ts.IsZero() {
return ""
}
return ts.Format(time.RFC3339Nano)
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"testing"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
)
func TestAtomServiceCreateAlarmProjectsCreatedAlarm(t *testing.T) {
projector := &alarmProjector{}
svc := WithAtom(alarmService{
create: Alarm{
ID: "alarm-1",
RuleID: "rule-1",
DomainID: "domain-1",
ChannelID: "channel-1",
ClientID: "client-1",
Cause: "high temperature",
Measurement: "temperature",
Value: "92.4",
Unit: "C",
Threshold: "80",
Severity: 90,
Status: ActiveStatus,
},
}, projector)
created, err := svc.CreateAlarm(context.Background(), Alarm{RuleID: "rule-1"})
if err != nil {
t.Fatalf("create alarm: %v", err)
}
if created.ID != "alarm-1" {
t.Fatalf("unexpected created alarm: %#v", created)
}
if projector.resource.ID != "alarm-1" || projector.resource.Kind != atom.KindAlarm {
t.Fatalf("unexpected projection: %#v", projector.resource)
}
if projector.resource.Attributes["rule_id"] != "rule-1" {
t.Fatalf("missing rule projection: %#v", projector.resource.Attributes)
}
if projector.resource.Attributes["value"] != "92.4" || projector.resource.Attributes["threshold"] != "80" {
t.Fatalf("missing alarm value projection: %#v", projector.resource.Attributes)
}
}
type alarmService struct {
create Alarm
}
func (svc alarmService) CreateAlarm(context.Context, Alarm) (Alarm, error) {
return svc.create, nil
}
func (svc alarmService) UpdateAlarm(context.Context, authn.Session, Alarm) (Alarm, error) {
return Alarm{}, nil
}
func (svc alarmService) ViewAlarm(context.Context, authn.Session, string) (Alarm, error) {
return Alarm{}, nil
}
func (svc alarmService) ListAlarms(context.Context, authn.Session, PageMetadata) (AlarmsPage, error) {
return AlarmsPage{}, nil
}
func (svc alarmService) DeleteAlarm(context.Context, authn.Session, string) error {
return nil
}
type alarmProjector struct {
atom.Projector
resource atom.Resource
}
func (p *alarmProjector) UpsertResource(_ context.Context, resource atom.Resource) error {
p.resource = resource
return nil
}
+1 -1
View File
@@ -120,7 +120,7 @@ func (am *authorizationMiddleware) ListAlarms(ctx context.Context, session authn
case err == nil:
session.SuperAdmin = true
case errors.Contains(err, svcerr.ErrSuperAdminAction):
if err := am.authorize(ctx, operations.OpListAlarms, session, policies.DomainType, session.DomainID); err != nil {
if err := am.authorize(ctx, operations.OpListAlarms, session, operations.EntityType, auth.AnyIDs); err != nil {
return alarms.AlarmsPage{}, errors.Wrap(errDomainViewAlarms, err)
}
default:
+5 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/alarms/mocks"
"github.com/absmach/magistrala/alarms/operations"
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
pkgerrors "github.com/absmach/magistrala/pkg/errors"
@@ -48,12 +49,12 @@ func TestListAlarmsAuthorizesRegularUser(t *testing.T) {
assert.Equal(t, atom.AuthzRequest{
SubjectID: "user-1",
Action: "list",
ResourceID: "",
ObjectKind: "tenant",
ObjectID: "domain-1",
ResourceID: auth.AnyIDs,
ObjectKind: "resource",
ObjectID: auth.AnyIDs,
Context: map[string]any{
"domain_id": "domain-1",
"legacy_object_type": "domain",
"legacy_object_type": operations.EntityType,
},
}, authz.reqs[0])
}
+1
View File
@@ -117,6 +117,7 @@ func main() {
idp := uuid.New()
svc := alarms.NewService(idp, repo)
svc = alarms.WithAtom(svc, atom.NewClient(atomCfg))
permConfig, err := permissions.ParsePermissionsFile(cfg.PermissionsFile)
if err != nil {
+1 -1
View File
@@ -75,7 +75,7 @@ func runProvisionTokens(client *atom.Client, args []string) {
fs := flag.NewFlagSet("provision-tokens", flag.ExitOnError)
output := fs.String("output", envString("MG_ATOM_TOKENS_OUTPUT", "docker/.env.tokens"), "path to write generated token env file")
rotate := fs.String("rotate", "", "rotate one token by name/env var, or all")
entityID := fs.String("entity-id", envString("ATOM_SERVICE_ENTITY_ID", atom.DefaultServiceEntityID), "Atom service entity ID to receive access tokens")
entityID := fs.String("entity-id", envString("ATOM_SERVICE_ENTITY_ID", atom.DefaultServiceEntityID), "Atom service entity ID to receive API keys")
if err := fs.Parse(args); err != nil {
log.Fatal(err)
}
+75 -2
View File
@@ -9,6 +9,7 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
@@ -53,6 +54,40 @@ type config struct {
InstanceID string `env:"MG_FLUXMQ_INSTANCE_ID" envDefault:""`
}
type fanoutPublisher struct {
publishers []messaging.Publisher
}
func (fp fanoutPublisher) Publish(ctx context.Context, topic string, msg *messaging.Message) error {
for _, publisher := range fp.publishers {
if err := publisher.Publish(ctx, topic, msg); err != nil {
return err
}
}
return nil
}
func (fp fanoutPublisher) Close() error {
errs := make([]error, 0, len(fp.publishers))
for _, publisher := range fp.publishers {
errs = append(errs, publisher.Close())
}
return errors.Join(errs...)
}
type writerBridgeHandler struct {
ctx context.Context
publisher messaging.Publisher
}
func (h writerBridgeHandler) Handle(msg *messaging.Message) error {
return h.publisher.Publish(h.ctx, messaging.EncodeMessageTopic(msg), msg)
}
func (h writerBridgeHandler) Cancel() error {
return nil
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
g, ctx := errgroup.WithContext(ctx)
@@ -137,7 +172,6 @@ func main() {
connect.WithInterceptors(otelInterceptor),
)
mux.Handle(path, handler)
mux.Handle("/hooks", fluxmqhttp.MakeHooksHandler(parser))
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`)) //nolint:errcheck // HTTP response write; client disconnect is non-fatal.
@@ -166,6 +200,45 @@ func main() {
}
defer messagePublisher.Close()
writerPublisher, err := fluxmqbroker.NewUndeclaredPublisher(
ctx,
cfg.BrokerURL,
fluxmqbroker.Prefix("writers"),
fluxmqbroker.ConnectionName("fluxmq-ui-publish-proxy"),
)
if err != nil {
logger.Error(fmt.Sprintf("failed to create publish proxy writer publisher: %s", err))
exitCode = 1
return
}
defer writerPublisher.Close()
publisher := fanoutPublisher{publishers: []messaging.Publisher{messagePublisher, writerPublisher}}
writerBridge, err := fluxmqbroker.NewPubSub(
ctx,
cfg.BrokerURL,
logger,
fluxmqbroker.DirectTopicOnly(),
fluxmqbroker.ConnectionName("fluxmq-mqtt-writer-bridge"),
)
if err != nil {
logger.Error(fmt.Sprintf("failed to create MQTT writer bridge subscriber: %s", err))
exitCode = 1
return
}
defer writerBridge.Close()
if err := writerBridge.Subscribe(ctx, messaging.SubscriberConfig{
ID: cfg.InstanceID + "-mqtt-writer-bridge",
Topic: "m/#",
Handler: writerBridgeHandler{ctx: ctx, publisher: writerPublisher},
DeliveryPolicy: messaging.DeliverNewPolicy,
}); err != nil {
logger.Error(fmt.Sprintf("failed to subscribe MQTT writer bridge: %s", err))
exitCode = 1
return
}
logger.Info("FluxMQ MQTT writer bridge subscribed", "topic", "m/#")
httpServerConfig := server.Config{Port: "9026"}
if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil {
logger.Error(fmt.Sprintf("failed to load publish proxy HTTP server configuration: %s", err))
@@ -177,7 +250,7 @@ func main() {
cancel,
"fluxmq-publish",
httpServerConfig,
fluxmqhttp.MakePublishHandler(authn, atomAuthz, messagePublisher),
fluxmqhttp.MakePublishHandler(authn, atomAuthz, publisher),
logger,
)
-5
View File
@@ -141,7 +141,6 @@ MG_AUTH_GRPC_CLIENT_KEY=${GRPC_MTLS:+./ssl/certs/auth-grpc-client.key}
MG_AUTH_GRPC_CLIENT_CA_CERTS=${GRPC_MTLS:+./ssl/certs/ca.crt}
### Atom Integration
ATOM_IMAGE=ghcr.io/absmach/atom:latest
ATOM_URL=http://atom:8080
ATOM_PUBLIC_URL=http://nginx:80
ATOM_JWKS_URL=http://atom:8080/.well-known/jwks.json
@@ -740,7 +739,3 @@ MG_UI_CLI_WS_URL=ws://localhost:80/mqtt
MG_UI_CLI_COAP_HOST=0.0.0.0
MG_UI_CLI_COAP_PORT=5684
MG_UI_CLI_HTTP_URL=http://localhost:80/http
# Atom
ATOM_LOG_LEVEL=info
ATOM_LOG_FORMAT=text
+1 -5
View File
@@ -17,11 +17,7 @@ make provision_atom_tokens
make run_latest
```
`make provision_atom_tokens` starts Atom, creates per-service unscoped Atom access tokens, and writes them to the generated `docker/.env.tokens` file. That file is local-only and must not be committed.
The Atom runtime image is selected with `ATOM_IMAGE` in `docker/.env`. To test Magistrala against a local Atom checkout, build that checkout with a local tag and set `ATOM_IMAGE` to that tag before running Compose.
The generated `MG_ATOM_TOKEN_*` values are intentionally unscoped service credentials for the current integration. Do not replace them with scoped Atom access tokens yet: Atom rejects scoped tokens on owner-wide listing APIs such as `authorizedObjectIds`, and some Magistrala policy-listing paths still use that API.
`make provision_atom_tokens` starts Atom, creates per-service Atom API keys, and writes them to the generated `docker/.env.tokens` file. That file is local-only and must not be committed.
If you use `docker compose` directly instead of the Makefile, pass both env files:
+1 -1
View File
@@ -46,7 +46,7 @@ services:
retries: 5
atom:
image: ${ATOM_IMAGE:-ghcr.io/absmach/atom:latest}
image: ghcr.io/absmach/atom:latest
container_name: magistrala-atom
restart: on-failure
depends_on:
-16
View File
@@ -136,19 +136,3 @@ auth:
coap: true
amqp: true
amqp091: false
hooks:
url: "http://fluxmq-auth:7016"
transport: "http"
timeout: 500ms
fail_mode: "deny"
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: true
events:
auth_on_publish: true
auth_on_subscribe: true
auth_on_unsubscribe: true
-16
View File
@@ -133,19 +133,3 @@ auth:
coap: true
amqp: true
amqp091: false
hooks:
url: "http://fluxmq-auth:7016"
transport: "http"
timeout: 500ms
fail_mode: "deny"
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: true
events:
auth_on_publish: true
auth_on_subscribe: true
auth_on_unsubscribe: true
-16
View File
@@ -133,19 +133,3 @@ auth:
coap: true
amqp: true
amqp091: false
hooks:
url: "http://fluxmq-auth:7016"
transport: "http"
timeout: 500ms
fail_mode: "deny"
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: true
events:
auth_on_publish: true
auth_on_subscribe: true
auth_on_unsubscribe: true
+1 -6
View File
@@ -6,7 +6,6 @@ set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT_DIR/docker/.env"
TOKENS_ENV_FILE="$ROOT_DIR/docker/.env.tokens"
COMPOSE_FILE="$ROOT_DIR/docker/docker-compose.yaml"
HOST=${MG_PUBLIC_HOST:-}
@@ -125,11 +124,7 @@ comment_env_any() {
}
compose() {
if [ -f "$TOKENS_ENV_FILE" ]; then
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" --env-file "$TOKENS_ENV_FILE" -p "$PROJECT" "$@"
else
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT" "$@"
fi
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT" "$@"
}
write_ui_proxy() {
+3 -1
View File
@@ -145,7 +145,9 @@ func (s *connectServer) Authorize(ctx context.Context, req *connect.Request[auth
return nil, encodeError(err)
}
return connect.NewResponse(&authv1.AuthzRes{Authorized: res.GetAuthorized()}), nil
return connect.NewResponse(&authv1.AuthzRes{
Authorized: res.GetAuthorized(),
}), nil
}
func shouldTryDomainAuth(msg *authv1.AuthnReq, username, password string) bool {
-76
View File
@@ -1,76 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc
import (
"context"
"testing"
"connectrpc.com/connect"
authv1 "github.com/absmach/fluxmq/pkg/proto/auth/v1"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/messaging"
"github.com/stretchr/testify/require"
)
type fakeTopicParser struct {
domainID string
channelID string
subtopic string
topicType messaging.TopicType
err error
}
func (p fakeTopicParser) ParsePublishTopic(context.Context, string, bool) (string, string, string, messaging.TopicType, error) {
return p.domainID, p.channelID, p.subtopic, p.topicType, p.err
}
func (p fakeTopicParser) ParseSubscribeTopic(context.Context, string, bool) (string, string, string, messaging.TopicType, error) {
return p.domainID, p.channelID, p.subtopic, p.topicType, p.err
}
type fakeAtomAuthorizer struct {
resp atom.AuthzResponse
err error
}
func (a fakeAtomAuthorizer) CheckAuthz(context.Context, atom.AuthzRequest) (atom.AuthzResponse, error) {
return a.resp, a.err
}
func TestAuthorizeReturnsAuthorizedOnlyWhenAllowed(t *testing.T) {
srv := NewServer(nil, nil, fakeTopicParser{
domainID: "26ad5c3f-cd91-4ff0-9685-0c3115643174",
channelID: "cdc8f55f-0c54-4a9f-b4aa-8c69d4a8ce15",
subtopic: "messages",
topicType: messaging.MessageType,
}, fakeAtomAuthorizer{resp: atom.AuthzResponse{Allowed: true}}).(*connectServer)
res, err := srv.Authorize(context.Background(), connect.NewRequest(&authv1.AuthzReq{
ExternalId: "64d6bc95-b313-4412-9369-299543d9c63b",
Topic: "m/d1/c/ch1/messages",
Action: authv1.Action_Publish,
}))
require.NoError(t, err)
require.True(t, res.Msg.GetAuthorized())
require.Empty(t, res.Msg.ProtoReflect().GetUnknown())
}
func TestAuthorizeReturnsDeniedOnlyWhenDenied(t *testing.T) {
srv := NewServer(nil, nil, fakeTopicParser{
domainID: "26ad5c3f-cd91-4ff0-9685-0c3115643174",
channelID: "cdc8f55f-0c54-4a9f-b4aa-8c69d4a8ce15",
subtopic: "messages",
topicType: messaging.MessageType,
}, fakeAtomAuthorizer{resp: atom.AuthzResponse{Allowed: false}}).(*connectServer)
res, err := srv.Authorize(context.Background(), connect.NewRequest(&authv1.AuthzReq{
ExternalId: "64d6bc95-b313-4412-9369-299543d9c63b",
Topic: "m/d1/c/ch1/messages",
Action: authv1.Action_Subscribe,
}))
require.NoError(t, err)
require.False(t, res.Msg.GetAuthorized())
require.Empty(t, res.Msg.ProtoReflect().GetUnknown())
}
-145
View File
@@ -1,145 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/absmach/magistrala/pkg/messaging"
)
const (
hookResultOK = "ok"
hookResultDeny = "deny"
hookAuthOnPublish = "auth_on_publish"
hookAuthOnSubscribe = "auth_on_subscribe"
hookAuthOnUnsubscribe = "auth_on_unsubscribe"
)
type hookRequest struct {
Hook string `json:"hook"`
ClientID string `json:"client_id"`
ExternalID string `json:"external_id"`
Protocol string `json:"protocol"`
Topic string `json:"topic"`
Payload []byte `json:"payload,omitempty"`
QoS uint32 `json:"qos"`
Retain bool `json:"retain"`
Properties map[string]string `json:"properties,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
type hookResponse struct {
Result string `json:"result"`
Topic string `json:"topic,omitempty"`
Payload []byte `json:"payload,omitempty"`
PayloadSet bool `json:"payload_set,omitempty"`
QoS uint32 `json:"qos,omitempty"`
QoSSet bool `json:"qos_set,omitempty"`
Retain bool `json:"retain,omitempty"`
RetainSet bool `json:"retain_set,omitempty"`
Properties map[string]string `json:"properties,omitempty"`
ExternalID string `json:"external_id,omitempty"`
ReasonCode uint32 `json:"reason_code,omitempty"`
Reason string `json:"reason,omitempty"`
}
// MakeHooksHandler returns an HTTP handler for FluxMQ blocking hooks.
func MakeHooksHandler(parser messaging.TopicParser) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req hookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid hook request")
return
}
res := handleHook(r.Context(), parser, req)
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(res); err != nil {
return
}
})
}
func handleHook(ctx context.Context, parser messaging.TopicParser, req hookRequest) hookResponse {
topic, err := resolveHookTopic(ctx, parser, req)
if err != nil {
return hookResponse{Result: hookResultDeny, Reason: err.Error()}
}
return hookResponse{Result: hookResultOK, Topic: topic}
}
func resolveHookTopic(ctx context.Context, parser messaging.TopicParser, req hookRequest) (string, error) {
hook := strings.ToLower(strings.TrimSpace(req.Hook))
if isAMQP091MessageStreamConsume(req, hook) {
return strings.TrimPrefix(strings.TrimSpace(req.Topic), "/"), nil
}
if !isMessageTopic(req.Topic) {
return "", nil
}
if parser == nil {
return "", fmt.Errorf("topic parser is not configured")
}
var domainID, channelID, subtopic string
var topicType messaging.TopicType
var err error
switch hook {
case hookAuthOnPublish:
domainID, channelID, subtopic, topicType, err = parser.ParsePublishTopic(ctx, req.Topic, true)
case hookAuthOnSubscribe, hookAuthOnUnsubscribe:
domainID, channelID, subtopic, topicType, err = parser.ParseSubscribeTopic(ctx, req.Topic, true)
default:
return "", nil
}
if err != nil {
return "", err
}
if topicType != messaging.MessageType {
return "", nil
}
return messaging.EncodeTopic(domainID, channelID, subtopic), nil
}
func isMessageTopic(topic string) bool {
topic = strings.TrimSpace(topic)
topic = strings.TrimPrefix(topic, "/")
return strings.HasPrefix(topic, string(messaging.MsgTopicPrefix)+"/")
}
// isAMQP091MessageStreamConsume reports whether the request is an AMQP 0-9-1
// stream-queue consume of the full message firehose (m/#), which is passed
// through without parsing because the topic parser cannot resolve a
// channel-level wildcard.
//
// SECURITY: in the default deployment the auth callout is disabled for
// amqp091 (docker/fluxmq/node*.yaml), so this allow is the only gate for
// stream consume. The amqp091 listener must remain network-restricted until
// identity-gated authorization for m/# lands in the gRPC Authorize path.
func isAMQP091MessageStreamConsume(req hookRequest, hook string) bool {
if hook != hookAuthOnSubscribe && hook != hookAuthOnUnsubscribe {
return false
}
if strings.ToLower(strings.TrimSpace(req.Protocol)) != "amqp091" {
return false
}
topic := strings.TrimPrefix(strings.TrimSpace(req.Topic), "/")
return topic == string(messaging.MsgTopicPrefix)+"/#"
}
-157
View File
@@ -1,157 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"strings"
"testing"
"github.com/absmach/magistrala/pkg/messaging"
"github.com/stretchr/testify/require"
)
type fakeHookParser struct {
domainID string
channelID string
subtopic string
topicType messaging.TopicType
err error
publishCalled bool
subscribeCalled bool
}
func (p *fakeHookParser) ParsePublishTopic(context.Context, string, bool) (string, string, string, messaging.TopicType, error) {
p.publishCalled = true
return p.domainID, p.channelID, p.subtopic, p.topicType, p.err
}
func (p *fakeHookParser) ParseSubscribeTopic(context.Context, string, bool) (string, string, string, messaging.TopicType, error) {
p.subscribeCalled = true
return p.domainID, p.channelID, p.subtopic, p.topicType, p.err
}
func TestHooksHandlerReturnsCanonicalTopicModifier(t *testing.T) {
parser := &fakeHookParser{
domainID: "26ad5c3f-cd91-4ff0-9685-0c3115643174",
channelID: "cdc8f55f-0c54-4a9f-b4aa-8c69d4a8ce15",
subtopic: "messages",
topicType: messaging.MessageType,
}
req := httptest.NewRequest("POST", "/hooks", strings.NewReader(`{
"hook":"auth_on_publish",
"client_id":"cli1",
"external_id":"64d6bc95-b313-4412-9369-299543d9c63b",
"protocol":"mqtt",
"topic":"m/d1/c/ch1/messages"
}`))
w := httptest.NewRecorder()
MakeHooksHandler(parser).ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
require.True(t, parser.publishCalled)
require.False(t, parser.subscribeCalled)
var res hookResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
require.Equal(t, hookResultOK, res.Result)
require.Equal(t, "m/26ad5c3f-cd91-4ff0-9685-0c3115643174/c/cdc8f55f-0c54-4a9f-b4aa-8c69d4a8ce15/messages", res.Topic)
}
func TestHooksHandlerUsesSubscribeParserForSubscribeAndUnsubscribe(t *testing.T) {
parser := &fakeHookParser{
domainID: "26ad5c3f-cd91-4ff0-9685-0c3115643174",
channelID: "cdc8f55f-0c54-4a9f-b4aa-8c69d4a8ce15",
subtopic: "messages/+",
topicType: messaging.MessageType,
}
for _, hook := range []string{hookAuthOnSubscribe, hookAuthOnUnsubscribe} {
parser.publishCalled = false
parser.subscribeCalled = false
req := httptest.NewRequest("POST", "/hooks", strings.NewReader(`{"hook":"`+hook+`","topic":"m/d1/c/ch1/messages/+"}`))
w := httptest.NewRecorder()
MakeHooksHandler(parser).ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
require.False(t, parser.publishCalled)
require.True(t, parser.subscribeCalled)
}
}
func TestHooksHandlerAllowsAMQP091MessageStreamWildcard(t *testing.T) {
cases := []struct {
desc string
protocol string
topic string
}{
{desc: "plain topic", protocol: "amqp091", topic: "m/#"},
{desc: "leading slash topic", protocol: "amqp091", topic: "/m/#"},
{desc: "uppercase protocol", protocol: "AMQP091", topic: "m/#"},
}
for _, tc := range cases {
for _, hook := range []string{hookAuthOnSubscribe, hookAuthOnUnsubscribe} {
parser := &fakeHookParser{err: errors.New("must not parse stream queue wildcard")}
req := httptest.NewRequest("POST", "/hooks", strings.NewReader(`{"hook":"`+hook+`","protocol":"`+tc.protocol+`","topic":"`+tc.topic+`"}`))
w := httptest.NewRecorder()
MakeHooksHandler(parser).ServeHTTP(w, req)
require.Equal(t, 200, w.Code, tc.desc)
require.False(t, parser.publishCalled, tc.desc)
require.False(t, parser.subscribeCalled, tc.desc)
var res hookResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&res), tc.desc)
require.Equal(t, hookResultOK, res.Result, tc.desc)
require.Equal(t, "m/#", res.Topic, tc.desc)
}
}
}
func TestHooksHandlerStillParsesMQTTMessageWildcard(t *testing.T) {
parser := &fakeHookParser{err: errors.New("malformed topic")}
req := httptest.NewRequest("POST", "/hooks", strings.NewReader(`{"hook":"auth_on_subscribe","protocol":"mqtt","topic":"m/#"}`))
w := httptest.NewRecorder()
MakeHooksHandler(parser).ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
require.False(t, parser.publishCalled)
require.True(t, parser.subscribeCalled)
var res hookResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
require.Equal(t, hookResultDeny, res.Result)
}
func TestHooksHandlerReturnsOKForNonMGTopic(t *testing.T) {
req := httptest.NewRequest("POST", "/hooks", strings.NewReader(`{"hook":"auth_on_publish","topic":"$SYS/broker/uptime"}`))
w := httptest.NewRecorder()
MakeHooksHandler(nil).ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
var res hookResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
require.Equal(t, hookResultOK, res.Result)
require.Empty(t, res.Topic)
}
func TestHooksHandlerDeniesUnresolvedMGTopic(t *testing.T) {
parser := &fakeHookParser{err: errors.New("failed to resolve channel route")}
req := httptest.NewRequest("POST", "/hooks", strings.NewReader(`{"hook":"auth_on_publish","topic":"m/d1/c/ch1/messages"}`))
w := httptest.NewRecorder()
MakeHooksHandler(parser).ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
var res hookResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
require.Equal(t, hookResultDeny, res.Result)
}
+5 -6
View File
@@ -19,9 +19,8 @@ import (
)
const (
contentType = "application/json"
httpProto = "http"
domainIDContextKey = "domain_id"
contentType = "application/json"
httpProto = "http"
)
type publishRequest struct {
@@ -151,7 +150,7 @@ func (h publishHandler) ensureUserPublish(
ObjectKind: "resource",
ObjectID: channelID,
Context: map[string]any{
domainIDContextKey: domainID,
"domain_id": domainID,
"publisher_client_id": clientID,
},
})
@@ -188,7 +187,7 @@ func (h publishHandler) ensureClientPublisher(
ObjectKind: "entity",
ObjectID: clientID,
Context: map[string]any{
domainIDContextKey: domainID,
"domain_id": domainID,
},
})
if err != nil {
@@ -204,7 +203,7 @@ func (h publishHandler) ensureClientPublisher(
ObjectKind: "resource",
ObjectID: channelID,
Context: map[string]any{
domainIDContextKey: domainID,
"domain_id": domainID,
},
})
if err != nil {
+3 -3
View File
@@ -35,11 +35,11 @@ require (
github.com/openbao/openbao/api/v2 v2.6.0
github.com/ory/dockertest/v3 v3.12.0
github.com/pelletier/go-toml v1.9.5
github.com/plgd-dev/go-coap/v3 v3.5.4
github.com/plgd-dev/go-coap/v3 v3.5.3
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.21.0
github.com/rubenv/sql-migrate v1.8.1
github.com/slack-go/slack v0.27.0
github.com/slack-go/slack v0.26.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/sqids/sqids-go v0.4.1
@@ -147,7 +147,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rabbitmq/amqp091-go v1.12.0
github.com/rabbitmq/amqp091-go v1.11.0
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
+6 -6
View File
@@ -395,8 +395,8 @@ github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBP
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/plgd-dev/go-coap/v3 v3.5.4 h1:KtSx2upgpeMzjdYXsEZDgUYui9l08HfoO6DldCI7Msc=
github.com/plgd-dev/go-coap/v3 v3.5.4/go.mod h1:kgdxil4mi3Bi9s5av/NbQeVwRJ+8N6zGHFEPy7qTRWI=
github.com/plgd-dev/go-coap/v3 v3.5.3 h1:0MRTXwIasXmTwqUXJjUjHALl8hQxuLBMr/pr4NTBa6U=
github.com/plgd-dev/go-coap/v3 v3.5.3/go.mod h1:kgdxil4mi3Bi9s5av/NbQeVwRJ+8N6zGHFEPy7qTRWI=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -424,8 +424,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rabbitmq/amqp091-go v1.12.0 h1:V0v14Iqfs+MwHWihJt/nGS5Ulu0vw572b2Co3mwunkI=
github.com/rabbitmq/amqp091-go v1.12.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8=
github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -454,8 +454,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/slack-go/slack v0.27.0 h1:VWOpUzOK6UAPCCQlFxl79jhv8a/b+GOSJMnWziDJ8B8=
github.com/slack-go/slack v0.27.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
github.com/slack-go/slack v0.26.0 h1:hx5Iy1t89tSw2zLEHu5YFFTDDFGmvhYCUh73ptHQ2Ls=
github.com/slack-go/slack v0.26.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
+2 -2
View File
@@ -23,8 +23,8 @@ func Authorize(ctx context.Context, client Authorizer, session authn.Session, ac
ObjectKind: ObjectKind(legacyObjectType, resourceKind),
ObjectID: objectID,
Context: map[string]any{
atomContextDomainID: session.DomainID,
atomContextLegacyObjectType: legacyObjectType,
"domain_id": session.DomainID,
"legacy_object_type": legacyObjectType,
},
}
res, err := client.CheckAuthz(ctx, req)
+7 -7
View File
@@ -33,13 +33,13 @@ func (a AuthorizationCompat) Authorize(ctx context.Context, pr smqauthz.PolicyRe
ObjectKind: objectKind,
ObjectID: pr.Object,
Context: map[string]any{
atomContextDomainID: pr.Domain,
"legacy_object_kind": pr.ObjectKind,
atomContextLegacyObjectType: pr.ObjectType,
"legacy_permission": pr.Permission,
"legacy_relation": pr.Relation,
"legacy_subject_kind": pr.SubjectKind,
"legacy_subject_type": pr.SubjectType,
"domain_id": pr.Domain,
"legacy_object_kind": pr.ObjectKind,
"legacy_object_type": pr.ObjectType,
"legacy_permission": pr.Permission,
"legacy_relation": pr.Relation,
"legacy_subject_kind": pr.SubjectKind,
"legacy_subject_type": pr.SubjectType,
},
})
if err != nil {
+27 -27
View File
@@ -28,32 +28,32 @@ var magistralaActionApplicability = []CapabilityApplicabilitySpec{
{ActionName: atomActionManage, ObjectKind: atomObjectKindGroup},
{ActionName: atomActionList, ObjectKind: atomObjectKindGroup},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceChannel},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceChannel},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceChannel},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceChannel},
{ActionName: atomActionPublish, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceChannel},
{ActionName: atomActionSubscribe, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceChannel},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: "resource:channel"},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: "resource:channel"},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: "resource:channel"},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: "resource:channel"},
{ActionName: atomActionPublish, ObjectKind: atomObjectKindResource, ObjectType: "resource:channel"},
{ActionName: atomActionSubscribe, ObjectKind: atomObjectKindResource, ObjectType: "resource:channel"},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceRule},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceRule},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceRule},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceRule},
{ActionName: atomActionExecute, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceRule},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceRule},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionExecute, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceReport},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceReport},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceReport},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceReport},
{ActionName: atomActionExecute, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceReport},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceReport},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionExecute, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceAlarm},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceAlarm},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceAlarm},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceAlarm},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: atomObjectTypeResourceAlarm},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
}
var magistralaActionAssignmentRules = []ActionAssignmentRuleSpec{
@@ -61,15 +61,15 @@ var magistralaActionAssignmentRules = []ActionAssignmentRuleSpec{
EntityKind: atomKindDevice,
ActionName: atomActionPublish,
ObjectKind: atomObjectKindResource,
ObjectType: atomObjectTypeResourceChannel,
Decision: atomDecisionAllow,
ObjectType: "resource:channel",
Decision: "allow",
},
{
EntityKind: atomKindDevice,
ActionName: atomActionSubscribe,
ObjectKind: atomObjectKindResource,
ObjectType: atomObjectTypeResourceChannel,
Decision: atomDecisionAllow,
ObjectType: "resource:channel",
Decision: "allow",
},
}
+78 -148
View File
@@ -56,8 +56,8 @@ func (c *Client) CreateTenant(ctx context.Context, tenant Tenant) (Tenant, error
CreateTenant Tenant `json:"createTenant"`
}
err := c.graphQL(ctx, `mutation CreateTenant($input: CreateTenantInput!) {
createTenant(input: $input) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
}`, map[string]any{atomInputKeyInput: tenantCreateInput(tenant)}, &out)
createTenant(input: $input) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
}`, map[string]any{"input": tenantCreateInput(tenant)}, &out)
return out.CreateTenant, err
}
@@ -66,7 +66,7 @@ func (c *Client) GetTenant(ctx context.Context, id string) (Tenant, error) {
Tenant Tenant `json:"tenant"`
}
err := c.graphQL(ctx, `query Tenant($id: ID!) {
tenant(id: $id) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
tenant(id: $id) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
}`, map[string]any{"id": id}, &out)
return out.Tenant, err
}
@@ -76,8 +76,8 @@ func (c *Client) UpdateTenant(ctx context.Context, id string, tenant Tenant) (Te
UpdateTenant Tenant `json:"updateTenant"`
}
err := c.graphQL(ctx, `mutation UpdateTenant($id: ID!, $input: UpdateTenantInput!) {
updateTenant(id: $id, input: $input) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
}`, map[string]any{"id": id, atomInputKeyInput: tenantUpdateInput(tenant)}, &out)
updateTenant(id: $id, input: $input) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
}`, map[string]any{"id": id, "input": tenantUpdateInput(tenant)}, &out)
return out.UpdateTenant, err
}
@@ -92,7 +92,7 @@ func (c *Client) ChangeTenantStatus(ctx context.Context, id, action string) (Ten
}
var out map[string]Tenant
err := c.graphQL(ctx, fmt.Sprintf(`mutation ChangeTenantStatus($id: ID!) {
%s(id: $id) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
%s(id: $id) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
}`, field), map[string]any{"id": id}, &out)
if err != nil {
return Tenant{}, err
@@ -118,7 +118,7 @@ func (c *Client) CreateEntity(ctx context.Context, entity Entity) (Entity, error
}
err := c.graphQL(ctx, `mutation CreateEntity($input: CreateEntityInput!) {
createEntity(input: $input) { id kind name tenant_id: tenantId status attributes created_at: createdAt updated_at: updatedAt }
}`, map[string]any{atomInputKeyInput: entityCreateInput(entity)}, &out)
}`, map[string]any{"input": entityCreateInput(entity)}, &out)
return out.CreateEntity, err
}
@@ -138,7 +138,7 @@ func (c *Client) UpdateEntity(ctx context.Context, id string, entity Entity) (En
}
err := c.graphQL(ctx, `mutation UpdateEntity($id: ID!, $input: UpdateEntityInput!) {
updateEntity(id: $id, input: $input) { id kind name tenant_id: tenantId status attributes created_at: createdAt updated_at: updatedAt }
}`, map[string]any{"id": id, atomInputKeyInput: entityUpdateInput(entity)}, &out)
}`, map[string]any{"id": id, "input": entityUpdateInput(entity)}, &out)
return out.UpdateEntity, err
}
@@ -160,7 +160,7 @@ func (c *Client) CreateGroup(ctx context.Context, group Group) (Group, error) {
}
err := c.graphQL(ctx, `mutation CreateGroup($input: CreateGroupInput!) {
createGroup(input: $input) { id name tenant_id: tenantId description parent_id: parentId status attributes created_at: createdAt updated_at: updatedAt }
}`, map[string]any{atomInputKeyInput: groupCreateInput(group)}, &out)
}`, map[string]any{"input": groupCreateInput(group)}, &out)
return out.CreateGroup, err
}
@@ -180,7 +180,7 @@ func (c *Client) UpdateGroup(ctx context.Context, id string, group Group) (Group
}
err := c.graphQL(ctx, `mutation UpdateGroup($id: ID!, $input: UpdateGroupInput!) {
updateGroup(id: $id, input: $input) { id name tenant_id: tenantId description parent_id: parentId status attributes created_at: createdAt updated_at: updatedAt }
}`, map[string]any{"id": id, atomInputKeyInput: groupUpdateInput(group)}, &out)
}`, map[string]any{"id": id, "input": groupUpdateInput(group)}, &out)
return out.UpdateGroup, err
}
@@ -202,7 +202,7 @@ func (c *Client) CreateResource(ctx context.Context, resource Resource) (Resourc
}
err := c.graphQL(ctx, `mutation CreateResource($input: CreateResourceInput!) {
createResource(input: $input) { id kind name tenant_id: tenantId owner_id: ownerId attributes created_at: createdAt updated_at: updatedAt }
}`, map[string]any{atomInputKeyInput: resourceCreateInput(resource)}, &out)
}`, map[string]any{"input": resourceCreateInput(resource)}, &out)
return out.CreateResource, err
}
@@ -222,7 +222,7 @@ func (c *Client) UpdateResource(ctx context.Context, id string, resource Resourc
}
err := c.graphQL(ctx, `mutation UpdateResource($id: ID!, $input: UpdateResourceInput!) {
updateResource(id: $id, input: $input) { id kind name tenant_id: tenantId owner_id: ownerId attributes created_at: createdAt updated_at: updatedAt }
}`, map[string]any{"id": id, atomInputKeyInput: resourceUpdateInput(resource)}, &out)
}`, map[string]any{"id": id, "input": resourceUpdateInput(resource)}, &out)
return out.UpdateResource, err
}
@@ -234,10 +234,10 @@ func (c *Client) ListTenants(ctx context.Context, q Query) (TenantList, error) {
var out struct {
Tenants TenantList `json:"tenants"`
}
err := c.graphQL(ctx, `query Tenants($q: String, $name: String, $alias: String, $status: TenantStatus, $limit: Int, $offset: Int) {
tenants(q: $q, name: $name, alias: $alias, status: $status, limit: $limit, offset: $offset) {
err := c.graphQL(ctx, `query Tenants($q: String, $name: String, $route: String, $status: TenantStatus, $limit: Int, $offset: Int) {
tenants(q: $q, name: $name, route: $route, status: $status, limit: $limit, offset: $offset) {
total
items { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
items { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
}
}`, queryVariables(q), &out)
return out.Tenants, err
@@ -249,7 +249,7 @@ func (c *Client) CheckAuthz(ctx context.Context, req AuthzRequest) (AuthzRespons
}
err := c.graphQL(ctx, `mutation AuthzCheck($input: AuthzCheckInput!) {
authzCheck(input: $input) { allowed reason }
}`, map[string]any{atomInputKeyInput: authzInput(req)}, &out)
}`, map[string]any{"input": authzInput(req)}, &out)
return out.AuthzCheck, err
}
@@ -259,7 +259,7 @@ func (c *Client) CheckAuthzWithToken(ctx context.Context, token string, req Auth
}
err := c.graphQLWithToken(ctx, `mutation AuthzCheck($input: AuthzCheckInput!) {
authzCheck(input: $input) { allowed reason }
}`, map[string]any{atomInputKeyInput: authzInput(req)}, &out, token)
}`, map[string]any{"input": authzInput(req)}, &out, token)
return out.AuthzCheck, err
}
@@ -290,11 +290,11 @@ func (c *Client) CreateCapability(ctx context.Context, name, description string)
var out struct {
CreateAction Capability `json:"createAction"`
}
input := map[string]any{atomInputKeyName: name}
input := map[string]any{"name": name}
setIfNotEmpty(input, "description", description)
err := c.graphQL(ctx, `mutation CreateAction($input: CreateActionInput!) {
createAction(input: $input) { id name description }
}`, map[string]any{atomInputKeyInput: input}, &out)
}`, map[string]any{"input": input}, &out)
return out.CreateAction, err
}
@@ -303,8 +303,8 @@ func (c *Client) AddCapabilityApplicability(ctx context.Context, actionID, objec
AddActionApplicability CapabilityApplicability `json:"addActionApplicability"`
}
input := map[string]any{
"actionId": actionID,
atomInputKeyObjectKind: objectKind,
"actionId": actionID,
"objectKind": objectKind,
}
setIfNotEmpty(input, "objectType", objectType)
err := c.graphQL(ctx, `mutation AddActionApplicability($input: AddActionApplicabilityInput!) {
@@ -315,7 +315,7 @@ func (c *Client) AddCapabilityApplicability(ctx context.Context, actionID, objec
object_kind: objectKind
object_type: objectType
}
}`, map[string]any{atomInputKeyInput: input}, &out)
}`, map[string]any{"input": input}, &out)
return out.AddActionApplicability, err
}
@@ -327,7 +327,7 @@ func (c *Client) ListActionAssignmentRules(ctx context.Context, spec ActionAssig
setIfNotEmpty(vars, "tenantId", spec.TenantID)
setIfNotEmpty(vars, "entityKind", spec.EntityKind)
setIfNotEmpty(vars, "actionName", spec.ActionName)
setIfNotEmpty(vars, atomInputKeyObjectKind, spec.ObjectKind)
setIfNotEmpty(vars, "objectKind", spec.ObjectKind)
setIfNotEmpty(vars, "objectType", spec.ObjectType)
setIfNotEmpty(vars, "decision", spec.Decision)
err := c.graphQL(ctx, `query ActionAssignmentRules(
@@ -372,11 +372,11 @@ func (c *Client) CreateActionAssignmentRule(ctx context.Context, spec ActionAssi
CreateActionAssignmentRule ActionAssignmentRule `json:"createActionAssignmentRule"`
}
input := map[string]any{
"entityKind": spec.EntityKind,
"actionName": spec.ActionName,
atomInputKeyObjectKind: spec.ObjectKind,
"decision": spec.Decision,
"isAbsolute": spec.IsAbsolute,
"entityKind": spec.EntityKind,
"actionName": spec.ActionName,
"objectKind": spec.ObjectKind,
"decision": spec.Decision,
"isAbsolute": spec.IsAbsolute,
}
setIfNotEmpty(input, "tenantId", spec.TenantID)
setIfNotEmpty(input, "objectType", spec.ObjectType)
@@ -392,7 +392,7 @@ func (c *Client) CreateActionAssignmentRule(ctx context.Context, spec ActionAssi
is_absolute: isAbsolute
created_at: createdAt
}
}`, map[string]any{atomInputKeyInput: input}, &out)
}`, map[string]any{"input": input}, &out)
return out.CreateActionAssignmentRule, err
}
@@ -405,7 +405,7 @@ func (c *Client) CreatePermissionBlock(ctx context.Context, block CreatePermissi
id tenant_id: tenantId scope_mode: scopeMode object_kind: objectKind object_type: objectType object_id: objectId group_id: groupId effect conditions
actions { id name description }
}
}`, map[string]any{atomInputKeyInput: permissionBlockInput(block)}, &out)
}`, map[string]any{"input": permissionBlockInput(block)}, &out)
return out.CreatePermissionBlock, err
}
@@ -421,7 +421,7 @@ func (c *Client) CreateDirectPolicy(ctx context.Context, policy CreateDirectPoli
actions { id name description }
}
}
}`, map[string]any{atomInputKeyInput: directPolicyInput(policy)}, &out)
}`, map[string]any{"input": directPolicyInput(policy)}, &out)
return out.CreateDirectPolicy, err
}
@@ -462,26 +462,18 @@ func (c *Client) AuthorizedObjectIDs(ctx context.Context, q AuthorizedObjectIDsQ
}
func (c *Client) LoginPassword(ctx context.Context, identifier, secret string) (LoginResponse, error) {
return c.LoginCredential(ctx, identifier, secret, "password")
}
func (c *Client) LoginSharedKey(ctx context.Context, identifier, secret string) (LoginResponse, error) {
return c.LoginCredential(ctx, identifier, secret, "shared_key")
}
func (c *Client) LoginCredential(ctx context.Context, identifier, secret, kind string) (LoginResponse, error) {
var out LoginResponse
err := c.doWithToken(ctx, http.MethodPost, "/auth/login", LoginRequest{
Identifier: identifier,
Secret: secret,
Kind: kind,
Kind: "password",
}, &out, "")
return out, err
}
func (c *Client) Introspect(ctx context.Context, token string) (IntrospectionResponse, error) {
var out IntrospectionResponse
err := c.doWithToken(ctx, http.MethodGet, atomAuthIntrospectPath, nil, &out, token)
err := c.doWithToken(ctx, http.MethodGet, "/auth/introspect", nil, &out, token)
return out, err
}
@@ -492,94 +484,32 @@ func (c *Client) DeleteEntity(ctx context.Context, id string) error {
func (c *Client) CreatePassword(ctx context.Context, entityID, password string) error {
return c.graphQL(ctx, `mutation CreatePassword($entityId: ID!, $password: String!) {
createPassword(entityId: $entityId, password: $password)
}`, map[string]any{atomInputKeyEntityID: entityID, "password": password}, nil)
}`, map[string]any{"entityId": entityID, "password": password}, nil)
}
func (c *Client) CreateUnscopedAccessToken(ctx context.Context, entityID, name, description string) (AccessTokenResponse, error) {
func (c *Client) CreateAPIKey(ctx context.Context, entityID, description string) (APIKeyResponse, error) {
var out struct {
CreateAccessToken AccessTokenResponse `json:"createAccessToken"`
CreateAPIKey APIKeyResponse `json:"createApiKey"`
}
err := c.graphQL(ctx, `mutation CreateAccessToken($input: CreateAccessTokenInput!) {
createAccessToken(input: $input) {
err := c.graphQL(ctx, `mutation CreateAPIKey($entityId: ID!, $input: CreateApiKeyInput!) {
createApiKey(entityId: $entityId, input: $input) {
credentialId
token
name
key
expiresAt
}
}`, map[string]any{
atomInputKeyInput: map[string]any{
atomInputKeyName: name,
"description": description,
atomInputKeySubjectID: entityID,
"scoped": false,
"permissions": []any{},
"entityId": entityID,
"input": map[string]any{
"description": description,
},
}, &out)
return out.CreateAccessToken, err
}
func (c *Client) CreateSharedKey(ctx context.Context, entityID, key, description string) (SharedKeyResponse, error) {
var out struct {
CreateSharedKey SharedKeyResponse `json:"createSharedKey"`
}
input := map[string]any{}
setIfNotEmpty(input, "key", key)
setIfNotEmpty(input, "description", description)
err := c.graphQL(ctx, `mutation CreateSharedKey($entityId: ID!, $input: CreateSharedKeyInput!) {
createSharedKey(entityId: $entityId, input: $input) {
credentialId
key
expiresAt
}
}`, map[string]any{
atomInputKeyEntityID: entityID,
atomInputKeyInput: input,
}, &out)
return out.CreateSharedKey, err
}
func (c *Client) RevealSharedKey(ctx context.Context, entityID, credentialID string) (SharedKeyResponse, error) {
var out struct {
RevealSharedKey SharedKeyResponse `json:"revealSharedKey"`
}
err := c.graphQL(ctx, `mutation RevealSharedKey($entityId: ID!, $credentialId: ID!) {
revealSharedKey(entityId: $entityId, credentialId: $credentialId) {
credentialId
key
expiresAt
}
}`, map[string]any{
atomInputKeyEntityID: entityID,
atomInputKeyCredentialID: credentialID,
}, &out)
return out.RevealSharedKey, err
}
func (c *Client) ListCredentials(ctx context.Context, entityID string) (CredentialList, error) {
var out struct {
Credentials CredentialList `json:"credentials"`
}
err := c.graphQL(ctx, `query Credentials($entityId: ID!) {
credentials(entityId: $entityId) {
total
items {
id
entity_id: entityId
kind
identifier
status
expires_at: expiresAt
created_at: createdAt
}
}
}`, map[string]any{atomInputKeyEntityID: entityID}, &out)
return out.Credentials, err
return out.CreateAPIKey, err
}
func (c *Client) RevokeCredential(ctx context.Context, entityID, credentialID string) error {
return c.graphQL(ctx, `mutation RevokeCredential($entityId: ID!, $credentialId: ID!) {
revokeCredential(entityId: $entityId, credentialId: $credentialId)
}`, map[string]any{atomInputKeyEntityID: entityID, atomInputKeyCredentialID: credentialID}, nil)
}`, map[string]any{"entityId": entityID, "credentialId": credentialID}, nil)
}
func (c *Client) ListEntities(ctx context.Context, q Query) (EntityList, error) {
@@ -700,11 +630,11 @@ func graphQLErr(errors []graphQLErrorItem) error {
}
func tenantCreateInput(tenant Tenant) map[string]any {
input := map[string]any{atomInputKeyName: tenant.Name}
input := map[string]any{"name": tenant.Name}
setIfNotEmpty(input, "id", tenant.ID)
setIfNotEmpty(input, "alias", tenant.Route)
setIfNotEmpty(input, "route", tenant.Route)
if tenant.Tags != nil {
input[atomAttributeTags] = tenant.Tags
input["tags"] = tenant.Tags
}
if tenant.Attributes != nil {
input["attributes"] = tenant.Attributes
@@ -714,10 +644,10 @@ func tenantCreateInput(tenant Tenant) map[string]any {
func tenantUpdateInput(tenant Tenant) map[string]any {
input := map[string]any{}
setIfNotEmpty(input, atomInputKeyName, tenant.Name)
setIfNotEmpty(input, "alias", tenant.Route)
setIfNotEmpty(input, "name", tenant.Name)
setIfNotEmpty(input, "route", tenant.Route)
if tenant.Tags != nil {
input[atomAttributeTags] = tenant.Tags
input["tags"] = tenant.Tags
}
if tenant.Attributes != nil {
input["attributes"] = tenant.Attributes
@@ -726,9 +656,9 @@ func tenantUpdateInput(tenant Tenant) map[string]any {
}
func entityCreateInput(entity Entity) map[string]any {
input := map[string]any{atomInputKeyName: entity.Name}
input := map[string]any{"name": entity.Name}
setIfNotEmpty(input, "id", entity.ID)
setIfNotEmpty(input, atomInputKeyKind, entity.Kind)
setIfNotEmpty(input, "kind", entity.Kind)
setIfNotEmpty(input, "tenantId", entity.TenantID)
if entity.Attributes != nil {
input["attributes"] = entity.Attributes
@@ -740,8 +670,8 @@ func entityCreateInput(entity Entity) map[string]any {
func entityUpdateInput(entity Entity) map[string]any {
input := map[string]any{}
setIfNotEmpty(input, atomInputKeyName, entity.Name)
setIfNotEmpty(input, atomAttributeStatus, entity.Status)
setIfNotEmpty(input, "name", entity.Name)
setIfNotEmpty(input, "status", entity.Status)
if entity.Attributes != nil {
input["attributes"] = entity.Attributes
}
@@ -749,7 +679,7 @@ func entityUpdateInput(entity Entity) map[string]any {
}
func groupCreateInput(group Group) map[string]any {
input := map[string]any{atomInputKeyName: group.Name}
input := map[string]any{"name": group.Name}
setIfNotEmpty(input, "id", group.ID)
setIfNotEmpty(input, "tenantId", group.TenantID)
setIfNotEmpty(input, "description", group.Description)
@@ -761,9 +691,9 @@ func groupCreateInput(group Group) map[string]any {
func groupUpdateInput(group Group) map[string]any {
input := map[string]any{}
setIfNotEmpty(input, atomInputKeyName, group.Name)
setIfNotEmpty(input, "name", group.Name)
setIfNotEmpty(input, "description", group.Description)
setIfNotEmpty(input, atomAttributeStatus, group.Status)
setIfNotEmpty(input, "status", group.Status)
if group.Attributes != nil {
input["attributes"] = group.Attributes
}
@@ -771,9 +701,9 @@ func groupUpdateInput(group Group) map[string]any {
}
func resourceCreateInput(resource Resource) map[string]any {
input := map[string]any{atomInputKeyKind: resource.Kind}
input := map[string]any{"kind": resource.Kind}
setIfNotEmpty(input, "id", resource.ID)
setIfNotEmpty(input, atomInputKeyName, resource.Name)
setIfNotEmpty(input, "name", resource.Name)
setIfNotEmpty(input, "tenantId", resource.TenantID)
setIfNotEmpty(input, "ownerId", resource.OwnerID)
if resource.Attributes != nil {
@@ -784,7 +714,7 @@ func resourceCreateInput(resource Resource) map[string]any {
func resourceUpdateInput(resource Resource) map[string]any {
input := map[string]any{}
setIfNotEmpty(input, atomInputKeyName, resource.Name)
setIfNotEmpty(input, "name", resource.Name)
if resource.Attributes != nil {
input["attributes"] = resource.Attributes
}
@@ -793,11 +723,11 @@ func resourceUpdateInput(resource Resource) map[string]any {
func authzInput(req AuthzRequest) map[string]any {
input := map[string]any{
atomInputKeySubjectID: req.SubjectID,
atomInputKeyAction: req.Action,
"subjectId": req.SubjectID,
"action": req.Action,
}
setIfNotEmpty(input, "resourceId", req.ResourceID)
setIfNotEmpty(input, atomInputKeyObjectKind, req.ObjectKind)
setIfNotEmpty(input, "objectKind", req.ObjectKind)
setIfNotEmpty(input, "objectId", req.ObjectID)
if req.Context != nil {
input["context"] = req.Context
@@ -811,7 +741,7 @@ func permissionBlockInput(block CreatePermissionBlock) map[string]any {
"actionIds": block.ActionIDs,
}
setIfNotEmpty(input, "tenantId", block.TenantID)
setIfNotEmpty(input, atomInputKeyObjectKind, block.ObjectKind)
setIfNotEmpty(input, "objectKind", block.ObjectKind)
setIfNotEmpty(input, "objectType", block.ObjectType)
setIfNotEmpty(input, "objectId", block.ObjectID)
setIfNotEmpty(input, "groupId", block.GroupID)
@@ -824,9 +754,9 @@ func permissionBlockInput(block CreatePermissionBlock) map[string]any {
func directPolicyInput(policy CreateDirectPolicy) map[string]any {
input := map[string]any{
"subjectKind": policy.SubjectKind,
atomInputKeySubjectID: policy.SubjectID,
"permissionBlockId": policy.PermissionBlockID,
"subjectKind": policy.SubjectKind,
"subjectId": policy.SubjectID,
"permissionBlockId": policy.PermissionBlockID,
}
setIfNotEmpty(input, "tenantId", policy.TenantID)
return input
@@ -836,7 +766,7 @@ func directPolicyQueryVariables(q DirectPolicyQuery) map[string]any {
vars := map[string]any{}
setIfNotEmpty(vars, "tenantId", q.TenantID)
setIfNotEmpty(vars, "subjectKind", q.SubjectKind)
setIfNotEmpty(vars, atomInputKeySubjectID, q.SubjectID)
setIfNotEmpty(vars, "subjectId", q.SubjectID)
if q.Limit > 0 {
vars["limit"] = int(q.Limit)
}
@@ -848,9 +778,9 @@ func directPolicyQueryVariables(q DirectPolicyQuery) map[string]any {
func authorizedObjectIDVariables(q AuthorizedObjectIDsQuery) map[string]any {
input := map[string]any{
atomInputKeySubjectID: q.SubjectID,
atomInputKeyAction: q.Action,
atomInputKeyObjectKind: q.ObjectKind,
"subjectId": q.SubjectID,
"action": q.Action,
"objectKind": q.ObjectKind,
}
setIfNotEmpty(input, "objectType", q.ObjectType)
setIfNotEmpty(input, "tenantId", q.TenantID)
@@ -861,17 +791,17 @@ func authorizedObjectIDVariables(q AuthorizedObjectIDsQuery) map[string]any {
if q.Offset > 0 {
input["offset"] = int(q.Offset)
}
return map[string]any{atomInputKeyInput: input}
return map[string]any{"input": input}
}
func queryVariables(q Query) map[string]any {
vars := map[string]any{}
setIfNotEmpty(vars, "q", q.Q)
setIfNotEmpty(vars, atomInputKeyName, q.Name)
setIfNotEmpty(vars, "alias", q.Route)
setIfNotEmpty(vars, atomInputKeyKind, q.Kind)
setIfNotEmpty(vars, "name", q.Name)
setIfNotEmpty(vars, "route", q.Route)
setIfNotEmpty(vars, "kind", q.Kind)
setIfNotEmpty(vars, "tenantId", q.TenantID)
setIfNotEmpty(vars, atomAttributeStatus, q.Status)
setIfNotEmpty(vars, "status", q.Status)
if q.Limit > 0 {
vars["limit"] = int(q.Limit)
}
@@ -884,9 +814,9 @@ func queryVariables(q Query) map[string]any {
func objectQueryVariables(q Query) map[string]any {
vars := map[string]any{}
setIfNotEmpty(vars, "q", q.Q)
setIfNotEmpty(vars, atomInputKeyKind, q.Kind)
setIfNotEmpty(vars, "kind", q.Kind)
setIfNotEmpty(vars, "tenantId", q.TenantID)
setIfNotEmpty(vars, atomAttributeStatus, q.Status)
setIfNotEmpty(vars, "status", q.Status)
if q.Limit > 0 {
vars["limit"] = int(q.Limit)
}
-435
View File
@@ -94,441 +94,6 @@ func TestListResources(t *testing.T) {
}
}
func TestCurrentAtomCompatibilitySurface(t *testing.T) {
const (
serviceToken = "service-token"
runtimeToken = "runtime-token"
)
seen := map[string]bool{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case atomGraphQLPath:
if r.Method != http.MethodPost {
t.Fatalf("unexpected GraphQL method: %s", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+serviceToken {
t.Fatalf("unexpected GraphQL authorization header: %q", got)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
switch {
case strings.Contains(payload.Query, "authzCheck"):
seen["authzCheck"] = true
input, ok := payload.Variables["input"].(map[string]any)
if !ok {
t.Fatalf("unexpected authz input: %+v", payload.Variables["input"])
}
if input["subjectId"] != testEntityID || input["action"] != atomActionPublish || input["resourceId"] != "channel-1" {
t.Fatalf("unexpected authz input: %+v", input)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"authzCheck": map[string]any{"allowed": true, "reason": "allowed"},
},
})
case strings.Contains(payload.Query, "authorizedObjectIds"):
seen["authorizedObjectIds"] = true
input, ok := payload.Variables["input"].(map[string]any)
if !ok {
t.Fatalf("unexpected authorized objects input: %+v", payload.Variables["input"])
}
if input["subjectId"] != testEntityID ||
input["action"] != atomActionRead ||
input["objectKind"] != atomObjectKindEntity ||
input["objectType"] != atomKindDevice ||
input["tenantId"] != testDomainID {
t.Fatalf("unexpected authorized objects input: %+v", input)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"authorizedObjectIds": map[string]any{"ids": []string{testDeviceID}, "total": 1},
},
})
case strings.Contains(payload.Query, "createAccessToken"):
seen["createAccessToken"] = true
input, ok := payload.Variables["input"].(map[string]any)
if !ok ||
input["name"] != "magistrala-service" ||
input["description"] != "Magistrala service token" ||
input["subjectId"] != testEntityID ||
input["scoped"] != false ||
len(input["permissions"].([]any)) != 0 {
t.Fatalf("unexpected createAccessToken input: %+v", payload.Variables["input"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"createAccessToken": map[string]any{
"credentialId": testCredentialID,
"token": "atom_00000000000000000000000000000000_0000000000000000000000000000000000000000000000000000000000000000",
"name": "magistrala-service",
},
},
})
default:
t.Fatalf("unexpected GraphQL payload: %s", payload.Query)
}
case atomAuthIntrospectPath:
seen["introspect"] = true
if r.Method != http.MethodGet {
t.Fatalf("unexpected introspection method: %s", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+runtimeToken {
t.Fatalf("unexpected introspection authorization header: %q", got)
}
_ = json.NewEncoder(w).Encode(IntrospectionResponse{
Active: true,
EntityID: testEntityID,
TenantID: testDomainID,
SessionID: "session-1",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
client := NewClient(Config{URL: srv.URL, Token: serviceToken, Timeout: time.Second})
authz, err := client.CheckAuthz(context.Background(), AuthzRequest{
SubjectID: testEntityID,
Action: atomActionPublish,
ResourceID: "channel-1",
})
if err != nil {
t.Fatalf("authz check failed: %v", err)
}
if !authz.Allowed {
t.Fatalf("unexpected authz response: %+v", authz)
}
objects, err := client.AuthorizedObjectIDs(context.Background(), AuthorizedObjectIDsQuery{
SubjectID: testEntityID,
Action: atomActionRead,
ObjectKind: atomObjectKindEntity,
ObjectType: atomKindDevice,
TenantID: testDomainID,
Limit: 10,
})
if err != nil {
t.Fatalf("authorized object listing failed: %v", err)
}
if objects.Total != 1 || len(objects.IDs) != 1 || objects.IDs[0] != testDeviceID {
t.Fatalf("unexpected authorized object listing: %+v", objects)
}
created, err := client.CreateUnscopedAccessToken(context.Background(), testEntityID, "magistrala-service", "Magistrala service token")
if err != nil {
t.Fatalf("create access token failed: %v", err)
}
if created.CredentialID != testCredentialID || created.Token == "" {
t.Fatalf("unexpected access token response: %+v", created)
}
introspection, err := client.Introspect(context.Background(), runtimeToken)
if err != nil {
t.Fatalf("introspection failed: %v", err)
}
if !introspection.Active || introspection.EntityID != testEntityID || introspection.TenantID != testDomainID {
t.Fatalf("unexpected introspection response: %+v", introspection)
}
for _, operation := range []string{"authzCheck", "authorizedObjectIds", "createAccessToken", "introspect"} {
if !seen[operation] {
t.Fatalf("operation %q was not exercised", operation)
}
}
}
func TestCreateTenantMapsRouteToAlias(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if !strings.Contains(payload.Query, "createTenant") || !strings.Contains(payload.Query, "route: alias") {
t.Fatalf("query does not map tenant alias to route: %s", payload.Query)
}
input, ok := payload.Variables["input"].(map[string]any)
if !ok {
t.Fatalf("unexpected input: %+v", payload.Variables["input"])
}
if input["alias"] != "d1" {
t.Fatalf("expected alias input from route, got: %+v", input)
}
if _, ok := input["route"]; ok {
t.Fatalf("input must not use Atom route field: %+v", input)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"createTenant": map[string]any{
"id": testTenantID,
"name": "D1",
"route": "d1",
"status": "active",
},
},
})
}))
defer srv.Close()
client := NewClient(Config{URL: srv.URL, Timeout: time.Second})
got, err := client.CreateTenant(context.Background(), Tenant{Name: "D1", Route: "d1"})
if err != nil {
t.Fatalf("create tenant failed: %v", err)
}
if got.ID != testTenantID || got.Route != "d1" {
t.Fatalf("unexpected tenant: %+v", got)
}
}
func TestUpdateTenantMapsRouteToAlias(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if !strings.Contains(payload.Query, "updateTenant") || !strings.Contains(payload.Query, "route: alias") {
t.Fatalf("query does not map tenant alias to route: %s", payload.Query)
}
if payload.Variables["id"] != testTenantID {
t.Fatalf("unexpected variables: %+v", payload.Variables)
}
input, ok := payload.Variables["input"].(map[string]any)
if !ok {
t.Fatalf("unexpected input: %+v", payload.Variables["input"])
}
if input["alias"] != "d1" {
t.Fatalf("expected alias input from route, got: %+v", input)
}
if _, ok := input["route"]; ok {
t.Fatalf("input must not use Atom route field: %+v", input)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"updateTenant": map[string]any{
"id": testTenantID,
"name": "D1",
"route": "d1",
"status": "active",
},
},
})
}))
defer srv.Close()
client := NewClient(Config{URL: srv.URL, Timeout: time.Second})
got, err := client.UpdateTenant(context.Background(), testTenantID, Tenant{Name: "D1", Route: "d1"})
if err != nil {
t.Fatalf("update tenant failed: %v", err)
}
if got.ID != testTenantID || got.Route != "d1" {
t.Fatalf("unexpected tenant: %+v", got)
}
}
func TestListTenantsMapsRouteToAlias(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if !strings.Contains(payload.Query, "$alias: String") ||
!strings.Contains(payload.Query, "alias: $alias") ||
!strings.Contains(payload.Query, "route: alias") {
t.Fatalf("query does not use Atom alias for tenant route lookup: %s", payload.Query)
}
if strings.Contains(payload.Query, "$route") || strings.Contains(payload.Query, "route: $route") {
t.Fatalf("query must not use removed Atom route field/filter: %s", payload.Query)
}
if payload.Variables["alias"] != "d1" {
t.Fatalf("expected alias variable from route, got: %+v", payload.Variables)
}
if _, ok := payload.Variables["route"]; ok {
t.Fatalf("variables must not use Atom route field: %+v", payload.Variables)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"tenants": map[string]any{
"items": []Tenant{{ID: testTenantID, Name: "D1", Route: "d1", Status: "active"}},
"total": 1,
},
},
})
}))
defer srv.Close()
client := NewClient(Config{URL: srv.URL, Timeout: time.Second})
got, err := client.ListTenants(context.Background(), Query{Route: "d1", Limit: 1})
if err != nil {
t.Fatalf("list tenants failed: %v", err)
}
if got.Total != 1 || len(got.Items) != 1 || got.Items[0].Route != "d1" {
t.Fatalf("unexpected tenants: %+v", got)
}
}
func TestCreateSharedKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if !strings.Contains(payload.Query, "createSharedKey") {
t.Fatalf("query does not create shared key: %s", payload.Query)
}
if payload.Variables["entityId"] != testDeviceID {
t.Fatalf("unexpected entity id: %+v", payload.Variables)
}
input, ok := payload.Variables["input"].(map[string]any)
if !ok {
t.Fatalf("unexpected input: %+v", payload.Variables["input"])
}
if input["key"] != testClientKey || input["description"] != "provisioned from mg" {
t.Fatalf("unexpected input: %+v", input)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"createSharedKey": map[string]any{
"credentialId": testCredentialID,
"key": testClientKey,
},
},
})
}))
defer srv.Close()
client := NewClient(Config{URL: srv.URL, Timeout: time.Second})
got, err := client.CreateSharedKey(context.Background(), testDeviceID, testClientKey, "provisioned from mg")
if err != nil {
t.Fatalf("create shared key failed: %v", err)
}
if got.CredentialID != testCredentialID || got.Key != testClientKey {
t.Fatalf("unexpected shared key response: %+v", got)
}
}
func TestRevealSharedKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if !strings.Contains(payload.Query, "revealSharedKey") {
t.Fatalf("query does not reveal shared key: %s", payload.Query)
}
if payload.Variables["entityId"] != testDeviceID || payload.Variables["credentialId"] != testCredentialID {
t.Fatalf("unexpected variables: %+v", payload.Variables)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"revealSharedKey": map[string]any{
"credentialId": testCredentialID,
"key": testClientKey,
},
},
})
}))
defer srv.Close()
client := NewClient(Config{URL: srv.URL, Timeout: time.Second})
got, err := client.RevealSharedKey(context.Background(), testDeviceID, testCredentialID)
if err != nil {
t.Fatalf("reveal shared key failed: %v", err)
}
if got.CredentialID != testCredentialID || got.Key != testClientKey {
t.Fatalf("unexpected shared key response: %+v", got)
}
}
func TestListCredentials(t *testing.T) {
createdAt := "2026-06-30T10:15:30Z"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if !strings.Contains(payload.Query, "credentials") {
t.Fatalf("query does not list credentials: %s", payload.Query)
}
if payload.Variables["entityId"] != testDeviceID {
t.Fatalf("unexpected variables: %+v", payload.Variables)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"credentials": map[string]any{
"total": 1,
"items": []map[string]any{{
"id": testCredentialID,
"entity_id": testDeviceID,
"kind": "shared_key",
"identifier": "",
"status": "active",
"created_at": createdAt,
}},
},
},
})
}))
defer srv.Close()
client := NewClient(Config{URL: srv.URL, Timeout: time.Second})
got, err := client.ListCredentials(context.Background(), testDeviceID)
if err != nil {
t.Fatalf("list credentials failed: %v", err)
}
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("unexpected credentials response: %+v", got)
}
item := got.Items[0]
if item.ID != testCredentialID || item.EntityID != testDeviceID || item.Kind != "shared_key" || item.Status != "active" {
t.Fatalf("unexpected credential item: %+v", item)
}
if item.CreatedAt.Format(time.RFC3339) != createdAt {
t.Fatalf("unexpected created_at: %s", item.CreatedAt.Format(time.RFC3339))
}
}
func TestLoadConfig(t *testing.T) {
t.Setenv("ATOM_URL", "http://atom:8080/")
t.Setenv("ATOM_ADMIN_TOKEN", "token")
+1 -44
View File
@@ -39,47 +39,4 @@ const (
const atomScopeModeObject = "object"
const (
atomObjectTypeResourceChannel = "resource:channel"
atomObjectTypeResourceRule = "resource:rule"
atomObjectTypeResourceReport = "resource:report"
atomObjectTypeResourceAlarm = "resource:alarm"
)
const atomDecisionAllow = "allow"
const (
atomInputKeyAction = "action"
atomInputKeyCredentialID = "credentialId"
atomInputKeyEntityID = "entityId"
atomInputKeyInput = "input"
atomInputKeyKind = "kind"
atomInputKeyName = "name"
atomInputKeyObjectKind = "objectKind"
atomInputKeySubjectID = "subjectId"
)
const (
atomContextDomainID = "domain_id"
atomContextLegacyObjectType = "legacy_object_type"
)
const (
atomAttributeCreatedAt = "created_at"
atomAttributeMetadata = "metadata"
atomAttributeRoute = "route"
atomAttributeSource = "source"
atomAttributeStatus = "status"
atomAttributeTags = "tags"
atomAttributeUpdatedAt = "updated_at"
atomAttributeUpdatedBy = "updated_by"
)
const atomAttributeSourceMagistrala = "magistrala"
const atomServiceTokenJournal = "journal"
const (
atomGraphQLPath = "/graphql"
atomAuthIntrospectPath = "/auth/introspect"
)
const atomGraphQLPath = "/graphql"
+9 -61
View File
@@ -5,7 +5,6 @@ package atom
import (
"context"
"fmt"
"net/http"
"strings"
@@ -15,21 +14,14 @@ import (
domainsv1 "github.com/absmach/magistrala/api/grpc/domains/v1"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/connections"
"github.com/absmach/magistrala/pkg/policies"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type clientsCompatAtomClient interface {
policyClient
policyWriter
LoginSharedKey(ctx context.Context, identifier, secret string) (LoginResponse, error)
}
type AtomClientsCompat struct {
Authn smqauthn.Authentication
Client clientsCompatAtomClient
Client *Client
}
func NewClientsCompat(authn smqauthn.Authentication, client ...*Client) clientsv1.ClientsServiceClient {
@@ -45,7 +37,7 @@ func (c AtomClientsCompat) Authenticate(ctx context.Context, in *clientsv1.Authn
if prefix, id, key, err := smqauthn.AuthUnpack(token); err == nil {
switch prefix {
case smqauthn.BasicAuth:
res, loginErr := c.Client.LoginSharedKey(ctx, id, key)
res, loginErr := c.Client.LoginPassword(ctx, id, key)
if loginErr == nil {
return &clientsv1.AuthnRes{Authenticated: true, Id: res.EntityID}, nil
}
@@ -79,26 +71,12 @@ func (c AtomClientsCompat) RetrieveEntities(context.Context, *commonv1.RetrieveE
return nil, status.Error(codes.Unimplemented, "atom clients compatibility only supports Authenticate")
}
func (c AtomClientsCompat) AddConnections(ctx context.Context, in *commonv1.AddConnectionsReq, _ ...grpc.CallOption) (*commonv1.AddConnectionsRes, error) {
prs, err := connectionPolicies(in.GetConnections())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if err := NewPolicyService(c.Client).AddPolicies(ctx, prs); err != nil {
return nil, err
}
return &commonv1.AddConnectionsRes{Ok: true}, nil
func (c AtomClientsCompat) AddConnections(context.Context, *commonv1.AddConnectionsReq, ...grpc.CallOption) (*commonv1.AddConnectionsRes, error) {
return nil, status.Error(codes.Unimplemented, "atom clients compatibility only supports Authenticate")
}
func (c AtomClientsCompat) RemoveConnections(ctx context.Context, in *commonv1.RemoveConnectionsReq, _ ...grpc.CallOption) (*commonv1.RemoveConnectionsRes, error) {
prs, err := connectionPolicies(in.GetConnections())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if err := NewPolicyService(c.Client).DeletePolicies(ctx, prs); err != nil {
return nil, err
}
return &commonv1.RemoveConnectionsRes{Ok: true}, nil
func (c AtomClientsCompat) RemoveConnections(context.Context, *commonv1.RemoveConnectionsReq, ...grpc.CallOption) (*commonv1.RemoveConnectionsRes, error) {
return nil, status.Error(codes.Unimplemented, "atom clients compatibility only supports Authenticate")
}
func (c AtomClientsCompat) RemoveChannelConnections(context.Context, *clientsv1.RemoveChannelConnectionsReq, ...grpc.CallOption) (*clientsv1.RemoveChannelConnectionsRes, error) {
@@ -109,36 +87,6 @@ func (c AtomClientsCompat) UnsetParentGroupFromClient(context.Context, *clientsv
return nil, status.Error(codes.Unimplemented, "atom clients compatibility only supports Authenticate")
}
func connectionPolicies(conns []*commonv1.Connection) ([]policies.Policy, error) {
prs := make([]policies.Policy, 0, len(conns))
for _, conn := range conns {
permission, err := connectionPermission(connections.ConnType(conn.GetType()))
if err != nil {
return nil, err
}
prs = append(prs, policies.Policy{
Domain: conn.GetDomainId(),
Subject: conn.GetClientId(),
SubjectType: policies.ClientType,
Object: conn.GetChannelId(),
ObjectType: policies.ChannelType,
Permission: permission,
})
}
return prs, nil
}
func connectionPermission(connType connections.ConnType) (string, error) {
switch connType {
case connections.Publish:
return policies.PublishPermission, nil
case connections.Subscribe:
return policies.SubscribePermission, nil
default:
return "", fmt.Errorf("unknown connection type %d", connType)
}
}
type AtomDomainsCompat struct {
Client *Client
}
@@ -200,7 +148,7 @@ func (c AtomChannelsCompat) Authorize(ctx context.Context, in *channelsv1.AuthzR
ObjectKind: atomObjectKindResource,
ObjectID: in.GetChannelId(),
Context: map[string]any{
atomContextDomainID: in.GetDomainId(),
"domain_id": in.GetDomainId(),
},
})
if err != nil {
@@ -235,11 +183,11 @@ func (c AtomChannelsCompat) RetrieveIDByRoute(ctx context.Context, in *commonv1.
return nil, err
}
for _, resource := range resources.Items {
if resource.Name == in.GetRoute() || attrString(resource.Attributes, atomAttributeRoute) == in.GetRoute() {
if resource.Name == in.GetRoute() || attrString(resource.Attributes, "route") == in.GetRoute() {
return &commonv1.RetrieveEntityRes{Entity: &commonv1.EntityBasic{
Id: resource.ID,
DomainId: resource.TenantID,
Status: atomStatusCode(attrString(resource.Attributes, atomAttributeStatus)),
Status: atomStatusCode(attrString(resource.Attributes, "status")),
}}, nil
}
}
+6 -106
View File
@@ -12,9 +12,7 @@ import (
"time"
clientsv1 "github.com/absmach/magistrala/api/grpc/clients/v1"
commonv1 "github.com/absmach/magistrala/api/grpc/common/v1"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/connections"
)
type recordingAuthn struct {
@@ -30,15 +28,7 @@ func (r *recordingAuthn) Authenticate(_ context.Context, token string) (smqauthn
return r.session, r.err
}
type fakeClientsCompatClient struct {
fakePolicyClient
}
func (f *fakeClientsCompatClient) LoginSharedKey(context.Context, string, string) (LoginResponse, error) {
return LoginResponse{}, nil
}
func TestAtomClientsCompatAuthenticatesBasicSharedKeyWithAtomLogin(t *testing.T) {
func TestAtomClientsCompatAuthenticatesBasicPasswordWithAtomLogin(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/auth/login" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
@@ -47,7 +37,7 @@ func TestAtomClientsCompatAuthenticatesBasicSharedKeyWithAtomLogin(t *testing.T)
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode login request: %v", err)
}
if got.Identifier != testEntityID || got.Secret != testDeviceSecret || got.Kind != "shared_key" {
if got.Identifier != testEntityID || got.Secret != testDeviceSecret || got.Kind != "password" {
t.Fatalf("unexpected login request: %+v", got)
}
_ = json.NewEncoder(w).Encode(LoginResponse{
@@ -65,17 +55,17 @@ func TestAtomClientsCompatAuthenticatesBasicSharedKeyWithAtomLogin(t *testing.T)
res, err := compat.Authenticate(context.Background(), &clientsv1.AuthnReq{Token: token})
if err != nil {
t.Fatalf("authenticate basic shared key: %v", err)
t.Fatalf("authenticate basic password: %v", err)
}
if !res.GetAuthenticated() || res.GetId() != testEntityID {
t.Fatalf("unexpected response: %+v", res)
}
if fallback.called {
t.Fatal("token fallback should not be called after successful Atom shared-key login")
t.Fatal("token fallback should not be called after successful Atom password login")
}
}
func TestAtomClientsCompatFallsBackToBearerTokenWhenBasicSharedKeyRejected(t *testing.T) {
func TestAtomClientsCompatFallsBackToBearerTokenWhenBasicPasswordRejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
}))
@@ -97,7 +87,7 @@ func TestAtomClientsCompatFallsBackToBearerTokenWhenBasicSharedKeyRejected(t *te
}
}
func TestAtomClientsCompatDoesNotHideAtomSharedKeyLoginFailures(t *testing.T) {
func TestAtomClientsCompatDoesNotHideAtomPasswordLoginFailures(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "atom unavailable", http.StatusInternalServerError)
}))
@@ -115,93 +105,3 @@ func TestAtomClientsCompatDoesNotHideAtomSharedKeyLoginFailures(t *testing.T) {
t.Fatal("token fallback should not be called for non-authentication Atom failures")
}
}
func TestAtomClientsCompatRemoveConnectionsDeletesConnectionPolicies(t *testing.T) {
client := &fakeClientsCompatClient{
fakePolicyClient: fakePolicyClient{
capIDs: map[string]string{
atomActionPublish: "cap-publish",
atomActionSubscribe: "cap-subscribe",
},
policies: []DirectPolicy{
{
ID: "delete-publish",
TenantID: testDomainID,
SubjectKind: atomObjectKindEntity,
SubjectID: testDeviceID,
PermissionBlock: PermissionBlock{
ID: "publish-block",
ScopeMode: atomScopeModeObject,
ObjectKind: atomObjectKindResource,
ObjectType: "resource:channel",
ObjectID: "channel-1",
Actions: []Capability{{ID: "cap-publish"}},
},
},
{
ID: "delete-subscribe",
TenantID: testDomainID,
SubjectKind: atomObjectKindEntity,
SubjectID: testDeviceID,
PermissionBlock: PermissionBlock{
ID: "subscribe-block",
ScopeMode: atomScopeModeObject,
ObjectKind: atomObjectKindResource,
ObjectType: "resource:channel",
ObjectID: "channel-1",
Actions: []Capability{{ID: "cap-subscribe"}},
},
},
{
ID: "keep-other-channel",
TenantID: testDomainID,
SubjectKind: atomObjectKindEntity,
SubjectID: testDeviceID,
PermissionBlock: PermissionBlock{
ID: "other-block",
ScopeMode: atomScopeModeObject,
ObjectKind: atomObjectKindResource,
ObjectType: "resource:channel",
ObjectID: "other-channel",
Actions: []Capability{{ID: "cap-publish"}},
},
},
},
},
}
compat := AtomClientsCompat{Client: client}
res, err := compat.RemoveConnections(context.Background(), &commonv1.RemoveConnectionsReq{
Connections: []*commonv1.Connection{
{
ClientId: testDeviceID,
ChannelId: "channel-1",
DomainId: testDomainID,
Type: uint32(connections.Publish),
},
{
ClientId: testDeviceID,
ChannelId: "channel-1",
DomainId: testDomainID,
Type: uint32(connections.Subscribe),
},
},
})
if err != nil {
t.Fatalf("remove connections: %v", err)
}
if !res.GetOk() {
t.Fatal("expected ok response")
}
if len(client.directPolicyQueries) != 2 {
t.Fatalf("unexpected direct policy query count: %d", len(client.directPolicyQueries))
}
for _, q := range client.directPolicyQueries {
if q.TenantID != testDomainID || q.SubjectKind != atomObjectKindEntity || q.SubjectID != testDeviceID {
t.Fatalf("unexpected direct policy query: %+v", q)
}
}
if len(client.deleted) != 2 || client.deleted[0] != "delete-publish" || client.deleted[1] != "delete-subscribe" {
t.Fatalf("unexpected deleted policies: %+v", client.deleted)
}
}
+30 -30
View File
@@ -22,10 +22,10 @@ func TenantFromFields(f ObjectFields) Tenant {
CreatedBy: f.CreatedBy,
UpdatedBy: f.UpdatedBy,
Attributes: compact(Attributes{
atomAttributeSource: atomAttributeSourceMagistrala,
atomAttributeMetadata: cloneMap(f.Metadata),
atomAttributeCreatedAt: timeString(f.CreatedAt),
atomAttributeUpdatedAt: timeString(f.UpdatedAt),
"source": "magistrala",
"metadata": cloneMap(f.Metadata),
"created_at": timeString(f.CreatedAt),
"updated_at": timeString(f.UpdatedAt),
}),
}
}
@@ -38,15 +38,15 @@ func EntityFromFields(f ObjectFields) Entity {
TenantID: f.TenantID,
Status: entityStatus(f.Status),
Attributes: compact(Attributes{
atomAttributeSource: atomAttributeSourceMagistrala,
"magistrala_kind": f.Kind,
atomAttributeTags: cloneStrings(f.Tags),
atomAttributeMetadata: cloneMap(f.Metadata),
"private_metadata": cloneMap(f.Private),
"parent_group_id": f.ParentID,
atomAttributeCreatedAt: timeString(f.CreatedAt),
atomAttributeUpdatedAt: timeString(f.UpdatedAt),
atomAttributeUpdatedBy: f.UpdatedBy,
"source": "magistrala",
"magistrala_kind": f.Kind,
"tags": cloneStrings(f.Tags),
"metadata": cloneMap(f.Metadata),
"private_metadata": cloneMap(f.Private),
"parent_group_id": f.ParentID,
"created_at": timeString(f.CreatedAt),
"updated_at": timeString(f.UpdatedAt),
"updated_by": f.UpdatedBy,
}),
}
}
@@ -97,14 +97,14 @@ func GroupFromFields(f ObjectFields) Group {
ParentID: f.ParentID,
Status: entityStatus(f.Status),
Attributes: compact(Attributes{
atomAttributeSource: atomAttributeSourceMagistrala,
"parent_id": f.ParentID,
atomAttributeTags: cloneStrings(f.Tags),
atomAttributeMetadata: cloneMap(f.Metadata),
atomAttributeStatus: f.Status,
atomAttributeCreatedAt: timeString(f.CreatedAt),
atomAttributeUpdatedAt: timeString(f.UpdatedAt),
atomAttributeUpdatedBy: f.UpdatedBy,
"source": "magistrala",
"parent_id": f.ParentID,
"tags": cloneStrings(f.Tags),
"metadata": cloneMap(f.Metadata),
"status": f.Status,
"created_at": timeString(f.CreatedAt),
"updated_at": timeString(f.UpdatedAt),
"updated_by": f.UpdatedBy,
}),
}
}
@@ -117,15 +117,15 @@ func ResourceFromFields(f ObjectFields) Resource {
TenantID: f.TenantID,
OwnerID: f.OwnerID,
Attributes: compact(Attributes{
atomAttributeSource: atomAttributeSourceMagistrala,
atomAttributeStatus: f.Status,
atomAttributeRoute: f.Route,
"parent_group_id": f.ParentID,
atomAttributeTags: cloneStrings(f.Tags),
atomAttributeMetadata: cloneMap(f.Metadata),
atomAttributeCreatedAt: timeString(f.CreatedAt),
atomAttributeUpdatedAt: timeString(f.UpdatedAt),
atomAttributeUpdatedBy: f.UpdatedBy,
"source": "magistrala",
"status": f.Status,
"route": f.Route,
"parent_group_id": f.ParentID,
"tags": cloneStrings(f.Tags),
"metadata": cloneMap(f.Metadata),
"created_at": timeString(f.CreatedAt),
"updated_at": timeString(f.UpdatedAt),
"updated_by": f.UpdatedBy,
}),
}
}
+3 -3
View File
@@ -27,9 +27,9 @@ func (pe PolicyEvaluator) CheckPolicy(ctx context.Context, pr policies.Policy) e
ObjectKind: policyObjectKind(pr),
ObjectID: pr.Object,
Context: map[string]any{
atomContextDomainID: pr.Domain,
atomContextLegacyObjectType: pr.ObjectType,
"legacy_relation": pr.Relation,
"domain_id": pr.Domain,
"legacy_object_type": pr.ObjectType,
"legacy_relation": pr.Relation,
},
})
if err != nil {
+2 -2
View File
@@ -50,7 +50,7 @@ func (ps PolicyService) AddPolicy(ctx context.Context, pr policies.Policy) error
ObjectKind: policyGrantObjectKind(pr),
ObjectType: policyGrantObjectType(pr),
ObjectID: policyGrantObjectID(pr),
Effect: atomDecisionAllow,
Effect: "allow",
Conditions: map[string]any{},
ActionIDs: []string{capID},
})
@@ -241,7 +241,7 @@ func policyGrantObjectType(pr policies.Policy) string {
}
func policyGrantObjectID(pr policies.Policy) string {
if policyGrantScopeMode(pr) != atomScopeModeObject {
if policyGrantScopeMode(pr) != "object" {
return ""
}
return policyResourceID(pr)
+9 -15
View File
@@ -11,15 +11,13 @@ import (
)
type fakePolicyClient struct {
authorized AuthorizedObjectIDs
queries []AuthorizedObjectIDsQuery
directPolicyQueries []DirectPolicyQuery
capID string
capIDs map[string]string
blocks []CreatePermissionBlock
created []CreateDirectPolicy
policies []DirectPolicy
deleted []string
authorized AuthorizedObjectIDs
queries []AuthorizedObjectIDsQuery
capID string
blocks []CreatePermissionBlock
created []CreateDirectPolicy
policies []DirectPolicy
deleted []string
}
func (f *fakePolicyClient) AuthorizedObjectIDs(_ context.Context, q AuthorizedObjectIDsQuery) (AuthorizedObjectIDs, error) {
@@ -31,10 +29,7 @@ func (f *fakePolicyClient) CheckAuthz(context.Context, AuthzRequest) (AuthzRespo
return AuthzResponse{Allowed: true}, nil
}
func (f *fakePolicyClient) CapabilityID(_ context.Context, name string) (string, error) {
if f.capIDs != nil && f.capIDs[name] != "" {
return f.capIDs[name], nil
}
func (f *fakePolicyClient) CapabilityID(context.Context, string) (string, error) {
if f.capID == "" {
return "cap-publish", nil
}
@@ -61,8 +56,7 @@ func (f *fakePolicyClient) CreateDirectPolicy(_ context.Context, policy CreateDi
return DirectPolicy{ID: "policy-1", PermissionBlockID: policy.PermissionBlockID}, nil
}
func (f *fakePolicyClient) ListDirectPolicies(_ context.Context, q DirectPolicyQuery) (DirectPolicyList, error) {
f.directPolicyQueries = append(f.directPolicyQueries, q)
func (f *fakePolicyClient) ListDirectPolicies(context.Context, DirectPolicyQuery) (DirectPolicyList, error) {
return DirectPolicyList{Items: f.policies, Total: uint64(len(f.policies))}, nil
}
+7 -7
View File
@@ -40,7 +40,7 @@ func DefaultServiceTokenSpecs() []ServiceTokenSpec {
{Name: "fluxmq-node1", Env: "MG_ATOM_TOKEN_FLUXMQ_NODE1", Description: "Magistrala Docker Compose token for fluxmq-node1"},
{Name: "fluxmq-node2", Env: "MG_ATOM_TOKEN_FLUXMQ_NODE2", Description: "Magistrala Docker Compose token for fluxmq-node2"},
{Name: "fluxmq-node3", Env: "MG_ATOM_TOKEN_FLUXMQ_NODE3", Description: "Magistrala Docker Compose token for fluxmq-node3"},
{Name: atomServiceTokenJournal, Env: "MG_ATOM_TOKEN_JOURNAL", Description: "Magistrala Docker Compose token for journal"},
{Name: "journal", Env: "MG_ATOM_TOKEN_JOURNAL", Description: "Magistrala Docker Compose token for journal"},
{Name: "notifications", Env: "MG_ATOM_TOKEN_NOTIFICATIONS", Description: "Magistrala Docker Compose token for notifications"},
{Name: "timescale-reader", Env: "MG_ATOM_TOKEN_TIMESCALE_READER", Description: "Magistrala Docker Compose token for timescale-reader"},
{Name: "re", Env: "MG_ATOM_TOKEN_RE", Description: "Magistrala Docker Compose token for rule engine"},
@@ -89,21 +89,21 @@ func ProvisionServiceTokens(ctx context.Context, client *Client, opts TokenProvi
}
}
if token != "" && shouldRotate {
credentialID, ok := CredentialIDFromAccessToken(token)
credentialID, ok := CredentialIDFromAPIKey(token)
if ok {
if err := client.RevokeCredential(ctx, entityID, credentialID); err != nil && !IsNotFound(err) {
return TokenProvisionResult{}, fmt.Errorf("revoke %s credential %s: %w", spec.Env, credentialID, err)
}
}
}
created, err := client.CreateUnscopedAccessToken(ctx, entityID, spec.Name, spec.Description)
created, err := client.CreateAPIKey(ctx, entityID, spec.Description)
if err != nil {
return TokenProvisionResult{}, fmt.Errorf("create %s token: %w", spec.Env, err)
}
if strings.TrimSpace(created.Token) == "" {
return TokenProvisionResult{}, fmt.Errorf("create %s token: atom returned an empty token", spec.Env)
if strings.TrimSpace(created.Key) == "" {
return TokenProvisionResult{}, fmt.Errorf("create %s token: atom returned an empty key", spec.Env)
}
values[spec.Env] = created.Token
values[spec.Env] = created.Key
if shouldRotate {
result.Rotated = append(result.Rotated, spec.Env)
} else {
@@ -125,7 +125,7 @@ func (c *Client) TokenActive(ctx context.Context, token string) (bool, error) {
return res.Active, nil
}
func CredentialIDFromAccessToken(token string) (string, bool) {
func CredentialIDFromAPIKey(token string) (string, bool) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), "atom_")
if !ok {
return "", false
+14 -27
View File
@@ -52,7 +52,7 @@ func TestProvisionServiceTokensCreatesMissingToken(t *testing.T) {
}
func TestProvisionServiceTokensPreservesExistingActiveToken(t *testing.T) {
token := accessTokenForCredentialID("11111111-1111-1111-1111-111111111111")
token := apiKeyForCredentialID("11111111-1111-1111-1111-111111111111")
fake := newFakeAtomTokenServer(t, map[string]bool{token: true})
defer fake.Close()
@@ -72,7 +72,7 @@ func TestProvisionServiceTokensPreservesExistingActiveToken(t *testing.T) {
t.Fatalf("expected token to be preserved, got result %+v", result)
}
if len(fake.created) != 0 {
t.Fatalf("expected no new access token, got %d", len(fake.created))
t.Fatalf("expected no new API key, got %d", len(fake.created))
}
values, err := readTokenEnvFile(output)
if err != nil {
@@ -85,7 +85,7 @@ func TestProvisionServiceTokensPreservesExistingActiveToken(t *testing.T) {
func TestProvisionServiceTokensRotatesToken(t *testing.T) {
oldCredentialID := "11111111-1111-1111-1111-111111111111"
token := accessTokenForCredentialID(oldCredentialID)
token := apiKeyForCredentialID(oldCredentialID)
fake := newFakeAtomTokenServer(t, map[string]bool{token: true})
defer fake.Close()
@@ -117,16 +117,16 @@ func TestProvisionServiceTokensRotatesToken(t *testing.T) {
}
}
func TestCredentialIDFromAccessToken(t *testing.T) {
func TestCredentialIDFromAPIKey(t *testing.T) {
want := "11111111-2222-3333-4444-555555555555"
got, ok := CredentialIDFromAccessToken(accessTokenForCredentialID(want))
got, ok := CredentialIDFromAPIKey(apiKeyForCredentialID(want))
if !ok {
t.Fatalf("expected credential id to parse")
}
if got != want {
t.Fatalf("unexpected credential id: got %s want %s", got, want)
}
if _, ok := CredentialIDFromAccessToken("not-an-access-token"); ok {
if _, ok := CredentialIDFromAPIKey("not-an-api-key"); ok {
t.Fatalf("expected invalid token to be rejected")
}
}
@@ -164,7 +164,7 @@ func (f *fakeAtomTokenServer) Client() *Client {
func (f *fakeAtomTokenServer) handle(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case atomAuthIntrospectPath:
case "/auth/introspect":
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if err := json.NewEncoder(w).Encode(IntrospectionResponse{Active: f.active[token], EntityID: "entity-1"}); err != nil {
f.t.Fatalf("encode introspection response: %v", err)
@@ -189,35 +189,22 @@ func (f *fakeAtomTokenServer) handleGraphQL(w http.ResponseWriter, r *http.Reque
}
switch {
case strings.Contains(payload.Query, "createAccessToken"):
case strings.Contains(payload.Query, "createApiKey"):
input := payload.Variables["input"].(map[string]any)
f.created = append(f.created, input)
if input["name"] != testTokenSpec().Name || input["description"] != testTokenSpec().Description {
f.t.Fatalf("unexpected createAccessToken input: %+v", input)
}
if input["subjectId"] != DefaultServiceEntityID {
f.t.Fatalf("unexpected createAccessToken subject: %+v", input)
}
if scoped, ok := input["scoped"].(bool); !ok || scoped {
f.t.Fatalf("expected unscoped access token input, got %+v", input)
}
if permissions, ok := input["permissions"].([]any); !ok || len(permissions) != 0 {
f.t.Fatalf("expected empty permissions for unscoped access token, got %+v", input)
}
f.nextID++
credentialID := credentialIDForIndex(f.nextID)
token := accessTokenForCredentialID(credentialID)
f.active[token] = true
key := apiKeyForCredentialID(credentialID)
f.active[key] = true
if err := json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"createAccessToken": AccessTokenResponse{
"createApiKey": APIKeyResponse{
CredentialID: credentialID,
Token: token,
Name: testTokenSpec().Name,
Key: key,
},
},
}); err != nil {
f.t.Fatalf("encode create access token response: %v", err)
f.t.Fatalf("encode create API key response: %v", err)
}
case strings.Contains(payload.Query, "revokeCredential"):
credentialID := payload.Variables["credentialId"].(string)
@@ -236,7 +223,7 @@ func testTokenSpec() ServiceTokenSpec {
return ServiceTokenSpec{Name: "journal", Env: "MG_ATOM_TOKEN_JOURNAL", Description: "test journal token"}
}
func accessTokenForCredentialID(id string) string {
func apiKeyForCredentialID(id string) string {
return "atom_" + strings.ReplaceAll(id, "-", "") + "_" + strings.Repeat("a", 64)
}
-4
View File
@@ -6,9 +6,5 @@ package atom
const (
testDeviceSecret = "device-secret"
testDomainID = "domain-1"
testTenantID = "tenant-1"
testEntityID = "entity-1"
testDeviceID = "device-1"
testClientKey = "client-key"
testCredentialID = "cred-1"
)
+3 -3
View File
@@ -54,9 +54,9 @@ func TestTokenVerifierRejectsExpiredToken(t *testing.T) {
}
}
func TestTokenVerifierIntrospectsAtomAccessToken(t *testing.T) {
func TestTokenVerifierIntrospectsAtomAPIKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != atomAuthIntrospectPath || r.Header.Get("Authorization") != "Bearer atom_test" {
if r.URL.Path != "/auth/introspect" || r.Header.Get("Authorization") != "Bearer atom_test" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(IntrospectionResponse{
@@ -69,7 +69,7 @@ func TestTokenVerifierIntrospectsAtomAccessToken(t *testing.T) {
claims, err := NewTokenVerifier(Config{URL: srv.URL, JWKSURL: srv.URL + "/jwks", Timeout: time.Second}).VerifyTokenClaims(context.Background(), "atom_test")
if err != nil {
t.Fatalf("verify access token: %v", err)
t.Fatalf("verify api key: %v", err)
}
if claims.SubjectID != "entity-2" || claims.TenantID != "tenant-2" {
t.Fatalf("unexpected claims: %+v", claims)
+1 -23
View File
@@ -232,34 +232,12 @@ type LoginResponse struct {
ExpiresAt time.Time `json:"expires_at"`
}
type AccessTokenResponse struct {
CredentialID string `json:"credentialId"`
Token string `json:"token"`
Name string `json:"name,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type SharedKeyResponse struct {
type APIKeyResponse struct {
CredentialID string `json:"credentialId"`
Key string `json:"key"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type Credential struct {
ID string `json:"id"`
EntityID string `json:"entity_id,omitempty"`
Kind string `json:"kind"`
Identifier string `json:"identifier,omitempty"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
type CredentialList struct {
Items []Credential `json:"items"`
Total uint64 `json:"total"`
}
type ResourceList struct {
Items []Resource `json:"items"`
Total uint64 `json:"total"`
+2 -6
View File
@@ -23,11 +23,7 @@ const (
withTLS
withmTLS
)
const (
buffSize = 10 * 1024 * 1024
withoutTLSLabel = "without TLS"
)
const buffSize = 10 * 1024 * 1024
var (
errGrpcConnect = errors.New("failed to connect to grpc server")
@@ -99,7 +95,7 @@ func (c *client) Secure() string {
case withoutTLS:
fallthrough
default:
return withoutTLSLabel
return "without TLS"
}
}
+2 -8
View File
@@ -15,12 +15,6 @@ import (
var _ messaging.Publisher = (*publisher)(nil)
const (
headerExternalID = "external_id"
headerProtocol = "protocol"
protocolMQTT = "mqtt"
)
type publisher struct {
client *fluxamqp.Client
options
@@ -86,8 +80,8 @@ func (pub *publisher) Publish(ctx context.Context, topic string, msg *messaging.
}
props := map[string]string{
headerExternalID: msg.GetPublisher(),
headerProtocol: msg.GetProtocol(),
"external_id": msg.GetPublisher(),
"protocol": msg.GetProtocol(),
}
if clientID := msg.ClientIdentity(); clientID != "" {
props["client_id"] = clientID
+3 -3
View File
@@ -225,11 +225,11 @@ func messageFromDelivery(body []byte, headers map[string]any, ts time.Time, pref
}
clientID := stringHeader(headers, "client_id")
publisher := stringHeader(headers, headerExternalID)
publisher := stringHeader(headers, "external_id")
protocol := stringHeader(headers, headerProtocol)
protocol := stringHeader(headers, "protocol")
if protocol == "" {
protocol = protocolMQTT
protocol = "mqtt"
}
created := time.Now().UnixNano()
+9 -12
View File
@@ -24,10 +24,7 @@ type calloutMiddleware struct {
entitiesOps permissions.EntitiesOperations[permissions.Operation]
}
const (
entityIDKey = "entity_id"
entityType = "rule"
)
const entityType = "rule"
func NewCallout(svc re.Service, callout callout.Callout, entitiesOps permissions.EntitiesOperations[permissions.Operation]) (re.Service, error) {
if err := entitiesOps.Validate(); err != nil {
@@ -56,7 +53,7 @@ func (cm *calloutMiddleware) AddRule(ctx context.Context, session authn.Session,
func (cm *calloutMiddleware) ViewRule(ctx context.Context, session authn.Session, id string, withRoles bool) (re.Rule, error) {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpViewRule, params); err != nil {
@@ -68,7 +65,7 @@ func (cm *calloutMiddleware) ViewRule(ctx context.Context, session authn.Session
func (cm *calloutMiddleware) UpdateRule(ctx context.Context, session authn.Session, r re.Rule) (re.Rule, error) {
params := map[string]any{
entityIDKey: r.ID,
"entity_id": r.ID,
}
if err := cm.callOut(ctx, session, operations.OpUpdateRule, params); err != nil {
@@ -80,7 +77,7 @@ func (cm *calloutMiddleware) UpdateRule(ctx context.Context, session authn.Sessi
func (cm *calloutMiddleware) UpdateRuleTags(ctx context.Context, session authn.Session, r re.Rule) (re.Rule, error) {
params := map[string]any{
entityIDKey: r.ID,
"entity_id": r.ID,
}
if err := cm.callOut(ctx, session, operations.OpUpdateRuleTags, params); err != nil {
@@ -92,7 +89,7 @@ func (cm *calloutMiddleware) UpdateRuleTags(ctx context.Context, session authn.S
func (cm *calloutMiddleware) UpdateRuleSchedule(ctx context.Context, session authn.Session, r re.Rule) (re.Rule, error) {
params := map[string]any{
entityIDKey: r.ID,
"entity_id": r.ID,
}
if err := cm.callOut(ctx, session, operations.OpUpdateRuleSchedule, params); err != nil {
@@ -116,7 +113,7 @@ func (cm *calloutMiddleware) ListRules(ctx context.Context, session authn.Sessio
func (cm *calloutMiddleware) RemoveRule(ctx context.Context, session authn.Session, id string) error {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpRemoveRule, params); err != nil {
@@ -128,7 +125,7 @@ func (cm *calloutMiddleware) RemoveRule(ctx context.Context, session authn.Sessi
func (cm *calloutMiddleware) EnableRule(ctx context.Context, session authn.Session, id string) (re.Rule, error) {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpEnableRule, params); err != nil {
@@ -140,7 +137,7 @@ func (cm *calloutMiddleware) EnableRule(ctx context.Context, session authn.Sessi
func (cm *calloutMiddleware) DisableRule(ctx context.Context, session authn.Session, id string) (re.Rule, error) {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpDisableRule, params); err != nil {
@@ -164,7 +161,7 @@ func (cm *calloutMiddleware) Cancel() error {
func (cm *calloutMiddleware) callOut(ctx context.Context, session authn.Session, op permissions.Operation, pld map[string]any) error {
var entityID string
if id, ok := pld[entityIDKey].(string); ok {
if id, ok := pld["entity_id"].(string); ok {
entityID = id
}
+1 -3
View File
@@ -13,8 +13,6 @@ import (
"github.com/absmach/magistrala/pkg/messaging"
)
const outputTypeKey = "type"
type Alarm struct {
AlarmsPub messaging.Publisher `json:"-"`
RuleID string `json:"rule_id"`
@@ -76,6 +74,6 @@ func (a *Alarm) processAlarm(ctx context.Context, msg *messaging.Message, alarm
func (a *Alarm) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
outputTypeKey: AlarmsType.String(),
"type": AlarmsType.String(),
})
}
+3 -3
View File
@@ -43,8 +43,8 @@ func (p *ChannelPublisher) Run(ctx context.Context, msg *messaging.Message, val
func (cp *ChannelPublisher) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{
outputTypeKey: ChannelsType.String(),
"channel": cp.Channel,
"topic": cp.Topic,
"type": ChannelsType.String(),
"channel": cp.Channel,
"topic": cp.Topic,
})
}
+4 -4
View File
@@ -46,9 +46,9 @@ func (e *Email) Run(ctx context.Context, msg *messaging.Message, val any) error
func (e *Email) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
outputTypeKey: EmailType.String(),
"to": e.To,
"subject": e.Subject,
"content": e.Content,
"type": EmailType.String(),
"to": e.To,
"subject": e.Subject,
"content": e.Content,
})
}
+8 -8
View File
@@ -95,13 +95,13 @@ func (p *Postgres) Run(ctx context.Context, msg *messaging.Message, val any) err
func (p *Postgres) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
outputTypeKey: SaveRemotePgType.String(),
"host": p.Host,
"port": p.Port,
"user": p.User,
"password": p.Password,
"database": p.Database,
"table": p.Table,
"mapping": p.Mapping,
"type": SaveRemotePgType.String(),
"host": p.Host,
"port": p.Port,
"user": p.User,
"password": p.Password,
"database": p.Database,
"table": p.Table,
"mapping": p.Mapping,
})
}
+1 -1
View File
@@ -48,6 +48,6 @@ func (s *SenML) Run(ctx context.Context, msg *messaging.Message, val any) error
func (senml *SenML) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{
outputTypeKey: SaveSenMLType.String(),
"type": SaveSenMLType.String(),
})
}
+4 -4
View File
@@ -64,9 +64,9 @@ func (s *Slack) Run(ctx context.Context, msg *messaging.Message, val any) error
func (s *Slack) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
outputTypeKey: SlackType.String(),
"token": s.Token,
"channel_id": s.ChannelID,
"message": s.Message,
"type": SlackType.String(),
"token": s.Token,
"channel_id": s.ChannelID,
"message": s.Message,
})
}
+5 -5
View File
@@ -174,15 +174,15 @@ func fromResponseMessages(protoMessages []*grpcReadersV1.Message) []readers.Mess
func parseAggregation(agg string) grpcReadersV1.Aggregation {
switch strings.ToUpper(agg) {
case aggregationMax:
case "MAX":
return grpcReadersV1.Aggregation_AGGREGATION_MAX
case aggregationMin:
case "MIN":
return grpcReadersV1.Aggregation_AGGREGATION_MIN
case aggregationSum:
case "SUM":
return grpcReadersV1.Aggregation_AGGREGATION_SUM
case aggregationCount:
case "COUNT":
return grpcReadersV1.Aggregation_AGGREGATION_COUNT
case aggregationAvg:
case "AVG":
return grpcReadersV1.Aggregation_AGGREGATION_AVG
default:
return grpcReadersV1.Aggregation_AGGREGATION_UNSPECIFIED
+2 -10
View File
@@ -12,17 +12,9 @@ import (
"github.com/absmach/magistrala/readers"
)
const (
maxLimitSize = 1000
const maxLimitSize = 1000
aggregationMax = "MAX"
aggregationMin = "MIN"
aggregationAvg = "AVG"
aggregationSum = "SUM"
aggregationCount = "COUNT"
)
var validAggregations = []string{aggregationMax, aggregationMin, aggregationAvg, aggregationSum, aggregationCount}
var validAggregations = []string{"MAX", "MIN", "AVG", "SUM", "COUNT"}
type readMessagesReq struct {
chanID string
+5 -5
View File
@@ -139,15 +139,15 @@ func stringifyAggregation(agg grpcReadersV1.Aggregation) string {
case grpcReadersV1.Aggregation_AGGREGATION_UNSPECIFIED:
return ""
case grpcReadersV1.Aggregation_AGGREGATION_MAX:
return aggregationMax
return "MAX"
case grpcReadersV1.Aggregation_AGGREGATION_MIN:
return aggregationMin
return "MIN"
case grpcReadersV1.Aggregation_AGGREGATION_AVG:
return aggregationAvg
return "AVG"
case grpcReadersV1.Aggregation_AGGREGATION_SUM:
return aggregationSum
return "SUM"
case grpcReadersV1.Aggregation_AGGREGATION_COUNT:
return aggregationCount
return "COUNT"
default:
return ""
}
+24 -33
View File
@@ -17,15 +17,6 @@ import (
var _ readers.MessageRepository = (*postgresRepository)(nil)
const (
messageFieldChannel = "channel"
messageFieldName = "name"
messageFieldProtocol = "protocol"
messageFieldPublisher = "publisher"
messageFieldSubtopic = "subtopic"
messageFieldValue = "value"
)
type postgresRepository struct {
db *sqlx.DB
}
@@ -52,19 +43,19 @@ func (tr postgresRepository) ReadAll(chanID string, rpm readers.PageMetadata) (r
LIMIT :limit OFFSET :offset;`, format, cond, order)
params := map[string]any{
messageFieldChannel: chanID,
"limit": rpm.Limit,
"offset": rpm.Offset,
messageFieldSubtopic: rpm.Subtopic,
messageFieldPublisher: rpm.Publisher,
messageFieldName: rpm.Name,
messageFieldProtocol: rpm.Protocol,
messageFieldValue: rpm.Value,
"bool_value": rpm.BoolValue,
"string_value": rpm.StringValue,
"data_value": rpm.DataValue,
"from": rpm.From,
"to": rpm.To,
"channel": chanID,
"limit": rpm.Limit,
"offset": rpm.Offset,
"subtopic": rpm.Subtopic,
"publisher": rpm.Publisher,
"name": rpm.Name,
"protocol": rpm.Protocol,
"value": rpm.Value,
"bool_value": rpm.BoolValue,
"string_value": rpm.StringValue,
"data_value": rpm.DataValue,
"from": rpm.From,
"to": rpm.To,
}
rows, err := tr.db.NamedQuery(q, params)
if err != nil {
@@ -141,10 +132,10 @@ func fmtCondition(chanID string, rpm readers.PageMetadata) string {
for name := range query {
switch name {
case
messageFieldSubtopic,
messageFieldPublisher,
messageFieldName,
messageFieldProtocol:
"subtopic",
"publisher",
"name",
"protocol":
condition = fmt.Sprintf(`%s AND %s = :%s`, condition, name, name)
case "v":
comparator := readers.ParseValueComparator(query)
@@ -194,13 +185,13 @@ type jsonMessage struct {
func (msg jsonMessage) toMap() (map[string]any, error) {
ret := map[string]any{
"id": msg.ID,
messageFieldChannel: msg.Channel,
"created": msg.Created,
messageFieldSubtopic: msg.Subtopic,
messageFieldPublisher: msg.Publisher,
messageFieldProtocol: msg.Protocol,
"payload": map[string]any{},
"id": msg.ID,
"channel": msg.Channel,
"created": msg.Created,
"subtopic": msg.Subtopic,
"publisher": msg.Publisher,
"protocol": msg.Protocol,
"payload": map[string]any{},
}
pld := make(map[string]any)
if err := json.Unmarshal(msg.Payload, &pld); err != nil {
+44 -53
View File
@@ -26,15 +26,6 @@ const (
var _ readers.MessageRepository = (*timescaleRepository)(nil)
const (
messageFieldChannel = "channel"
messageFieldName = "name"
messageFieldProtocol = "protocol"
messageFieldPublisher = "publisher"
messageFieldSubtopic = "subtopic"
messageFieldValue = "value"
)
type timescaleRepository struct {
db *sqlx.DB
}
@@ -112,19 +103,19 @@ func (tr timescaleRepository) ReadAll(chanID string, rpm readers.PageMetadata) (
}
params := map[string]any{
messageFieldChannel: chanID,
"limit": rpm.Limit,
"offset": rpm.Offset,
messageFieldSubtopic: rpm.Subtopic,
messageFieldPublisher: rpm.Publisher,
messageFieldName: rpm.Name,
messageFieldProtocol: rpm.Protocol,
messageFieldValue: rpm.Value,
"bool_value": rpm.BoolValue,
"string_value": rpm.StringValue,
"data_value": rpm.DataValue,
"from": rpm.From,
"to": rpm.To,
"channel": chanID,
"limit": rpm.Limit,
"offset": rpm.Offset,
"subtopic": rpm.Subtopic,
"publisher": rpm.Publisher,
"name": rpm.Name,
"protocol": rpm.Protocol,
"value": rpm.Value,
"bool_value": rpm.BoolValue,
"string_value": rpm.StringValue,
"data_value": rpm.DataValue,
"from": rpm.From,
"to": rpm.To,
}
rows, err := tr.db.NamedQuery(q, params)
@@ -202,15 +193,15 @@ func fmtCondition(rpm readers.PageMetadata) string {
conditions := []string{chCondition}
if _, ok := query[messageFieldSubtopic]; ok {
if _, ok := query["subtopic"]; ok {
conditions = append(conditions, " subtopic = :subtopic ")
}
if _, ok := query[messageFieldPublisher]; ok {
if _, ok := query["publisher"]; ok {
conditions = append(conditions, " publisher = :publisher ")
}
if _, ok := query[messageFieldName]; ok {
if _, ok := query["name"]; ok {
conditions = append(conditions, " name = :name ")
}
@@ -223,7 +214,7 @@ func fmtCondition(rpm readers.PageMetadata) string {
}
// Non Indexed columns conditions added after indexed columns conditions order.
if _, ok := query[messageFieldProtocol]; ok {
if _, ok := query["protocol"]; ok {
conditions = append(conditions, " protocol = :protocol ")
}
@@ -273,12 +264,12 @@ type jsonMessage struct {
func (msg jsonMessage) toMap() (map[string]any, error) {
ret := map[string]any{
messageFieldChannel: msg.Channel,
"created": msg.Created,
messageFieldSubtopic: msg.Subtopic,
messageFieldPublisher: msg.Publisher,
messageFieldProtocol: msg.Protocol,
"payload": map[string]any{},
"channel": msg.Channel,
"created": msg.Created,
"subtopic": msg.Subtopic,
"publisher": msg.Publisher,
"protocol": msg.Protocol,
"payload": map[string]any{},
}
pld := make(map[string]any)
if err := json.Unmarshal(msg.Payload, &pld); err != nil {
@@ -300,33 +291,33 @@ func applyOrdering(pm readers.PageMetadata, isAggregated bool, isSenml bool) str
}
aggCols := map[string]bool{
orderByTime: true,
messageFieldValue: true,
"sum": true,
messageFieldPublisher: true,
messageFieldProtocol: true,
messageFieldSubtopic: true,
messageFieldName: true,
"unit": true,
orderByTime: true,
"value": true,
"sum": true,
"publisher": true,
"protocol": true,
"subtopic": true,
"name": true,
"unit": true,
}
senmlCols := map[string]bool{
orderByTime: true,
messageFieldValue: true,
"bool_value": true,
"string_value": true,
"data_value": true,
messageFieldPublisher: true,
messageFieldName: true,
messageFieldProtocol: true,
messageFieldChannel: true,
messageFieldSubtopic: true,
"unit": true,
orderByTime: true,
"value": true,
"bool_value": true,
"string_value": true,
"data_value": true,
"publisher": true,
"name": true,
"protocol": true,
"channel": true,
"subtopic": true,
"unit": true,
}
jsonCols := map[string]bool{
orderByCreated: true, messageFieldPublisher: true, messageFieldProtocol: true,
messageFieldChannel: true, messageFieldSubtopic: true,
orderByCreated: true, "publisher": true, "protocol": true,
"channel": true, "subtopic": true,
}
if isAggregated {
+9 -13
View File
@@ -24,11 +24,7 @@ import (
"github.com/absmach/magistrala/pkg/transformers/senml"
)
const (
nanosecondThreshold = float64(10 * time.Second / time.Nanosecond)
templateFormatTime = "formatTime"
templateFormatValue = "formatValue"
)
const nanosecondThreshold = float64(10 * time.Second / time.Nanosecond)
type ReportData struct {
Title string
@@ -80,14 +76,14 @@ func (r *report) generatePDFReport(ctx context.Context, title string, reports []
func (r *report) generate(ctx context.Context, templateContent string, data ReportData) ([]byte, error) {
tmpl := template.New("report").Funcs(template.FuncMap{
templateFormatTime: func(t float64) string { return r.formatTimeWithTimezone(t, data.Timezone) },
templateFormatValue: formatValue,
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"iterate": func(count int) []int { return makeRange(count) },
"ge": func(a, b int) bool { return a >= b },
"lt": func(a, b int) bool { return a < b },
"eq": func(a, b int) bool { return a == b },
"formatTime": func(t float64) string { return r.formatTimeWithTimezone(t, data.Timezone) },
"formatValue": formatValue,
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"iterate": func(count int) []int { return makeRange(count) },
"ge": func(a, b int) bool { return a >= b },
"lt": func(a, b int) bool { return a < b },
"eq": func(a, b int) bool { return a == b },
"div": func(a, b int) int {
if b == 0 {
return 0
+12 -15
View File
@@ -23,10 +23,7 @@ type calloutMiddleware struct {
entitiesOps permissions.EntitiesOperations[permissions.Operation]
}
const (
entityIDKey = "entity_id"
entityType = "report"
)
const entityType = "report"
func NewCallout(svc reports.Service, callout callout.Callout, entitiesOps permissions.EntitiesOperations[permissions.Operation]) (reports.Service, error) {
if err := entitiesOps.Validate(); err != nil {
@@ -55,7 +52,7 @@ func (cm *calloutMiddleware) AddReportConfig(ctx context.Context, session authn.
func (cm *calloutMiddleware) ViewReportConfig(ctx context.Context, session authn.Session, id string, withRoles bool) (reports.ReportConfig, error) {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpViewReportConfig, params); err != nil {
@@ -67,7 +64,7 @@ func (cm *calloutMiddleware) ViewReportConfig(ctx context.Context, session authn
func (cm *calloutMiddleware) UpdateReportConfig(ctx context.Context, session authn.Session, cfg reports.ReportConfig) (reports.ReportConfig, error) {
params := map[string]any{
entityIDKey: cfg.ID,
"entity_id": cfg.ID,
}
if err := cm.callOut(ctx, session, operations.OpUpdateReportConfig, params); err != nil {
@@ -79,7 +76,7 @@ func (cm *calloutMiddleware) UpdateReportConfig(ctx context.Context, session aut
func (cm *calloutMiddleware) UpdateReportSchedule(ctx context.Context, session authn.Session, cfg reports.ReportConfig) (reports.ReportConfig, error) {
params := map[string]any{
entityIDKey: cfg.ID,
"entity_id": cfg.ID,
}
if err := cm.callOut(ctx, session, operations.OpUpdateReportSchedule, params); err != nil {
@@ -91,7 +88,7 @@ func (cm *calloutMiddleware) UpdateReportSchedule(ctx context.Context, session a
func (cm *calloutMiddleware) RemoveReportConfig(ctx context.Context, session authn.Session, id string) error {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpRemoveReportConfig, params); err != nil {
@@ -115,7 +112,7 @@ func (cm *calloutMiddleware) ListReportsConfig(ctx context.Context, session auth
func (cm *calloutMiddleware) EnableReportConfig(ctx context.Context, session authn.Session, id string) (reports.ReportConfig, error) {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpEnableReportConfig, params); err != nil {
@@ -127,7 +124,7 @@ func (cm *calloutMiddleware) EnableReportConfig(ctx context.Context, session aut
func (cm *calloutMiddleware) DisableReportConfig(ctx context.Context, session authn.Session, id string) (reports.ReportConfig, error) {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpDisableReportConfig, params); err != nil {
@@ -139,7 +136,7 @@ func (cm *calloutMiddleware) DisableReportConfig(ctx context.Context, session au
func (cm *calloutMiddleware) GenerateReport(ctx context.Context, session authn.Session, config reports.ReportConfig, action reports.ReportAction) (reports.ReportPage, error) {
params := map[string]any{
entityIDKey: config.ID,
"entity_id": config.ID,
}
if err := cm.callOut(ctx, session, operations.OpGenerateReport, params); err != nil {
@@ -151,7 +148,7 @@ func (cm *calloutMiddleware) GenerateReport(ctx context.Context, session authn.S
func (cm *calloutMiddleware) UpdateReportTemplate(ctx context.Context, session authn.Session, cfg reports.ReportConfig) error {
params := map[string]any{
entityIDKey: cfg.ID,
"entity_id": cfg.ID,
}
if err := cm.callOut(ctx, session, operations.OpUpdateReportTemplate, params); err != nil {
@@ -163,7 +160,7 @@ func (cm *calloutMiddleware) UpdateReportTemplate(ctx context.Context, session a
func (cm *calloutMiddleware) ViewReportTemplate(ctx context.Context, session authn.Session, id string) (reports.ReportTemplate, error) {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpViewReportTemplate, params); err != nil {
@@ -175,7 +172,7 @@ func (cm *calloutMiddleware) ViewReportTemplate(ctx context.Context, session aut
func (cm *calloutMiddleware) DeleteReportTemplate(ctx context.Context, session authn.Session, id string) error {
params := map[string]any{
entityIDKey: id,
"entity_id": id,
}
if err := cm.callOut(ctx, session, operations.OpDeleteReportTemplate, params); err != nil {
@@ -191,7 +188,7 @@ func (cm *calloutMiddleware) StartScheduler(ctx context.Context) error {
func (cm *calloutMiddleware) callOut(ctx context.Context, session authn.Session, op permissions.Operation, pld map[string]any) error {
var entityID string
if id, ok := pld[entityIDKey].(string); ok {
if id, ok := pld["entity_id"].(string); ok {
entityID = id
}
+8 -8
View File
@@ -49,14 +49,14 @@ func (temp ReportTemplate) Validate() error {
}
return a % b
},
"eq": func(a, b int) bool { return a == b },
"ge": func(a, b int) bool { return a >= b },
"lt": func(a, b int) bool { return a < b },
"iterate": func(count int) []int { return make([]int, count) },
"getStartRow": func(pageNum, firstPageRows, continuationPageRows int) int { return 0 },
"getEndRow": func(pageNum, firstPageRows, continuationPageRows, totalMessages int) int { return 0 },
templateFormatTime: func(t any) string { return "" },
templateFormatValue: func(v any) string { return "" },
"eq": func(a, b int) bool { return a == b },
"ge": func(a, b int) bool { return a >= b },
"lt": func(a, b int) bool { return a < b },
"iterate": func(count int) []int { return make([]int, count) },
"getStartRow": func(pageNum, firstPageRows, continuationPageRows int) int { return 0 },
"getEndRow": func(pageNum, firstPageRows, continuationPageRows, totalMessages int) int { return 0 },
"formatTime": func(t any) string { return "" },
"formatValue": func(v any) string { return "" },
})
parsed, err := tmpl.Parse(templateStr)
+43 -39
View File
@@ -11,7 +11,7 @@ database. Implemented as Go scripts.
| Topic | Decision |
|-------|----------|
| Passwords | **Force reset.** Users migrate with no `password` credential; they reset via Atom's email flow on first login. (bcrypt → argon2 is not convertible without plaintext.) |
| Scope | Core IAM + roles & policies + connections + PATs + rules/report configs as Atom resources. Alarm events remain in the alarms service database and are not migrated into Atom resources. |
| Scope | Core IAM + roles & policies + connections + PATs + rules/reports/alarms as Atom resources. |
| Execution | **Offline one-shot.** Stop Magistrala app services, snapshot, transform, load, start Atom. |
| IDs | **Preserve Magistrala UUIDs** as Atom UUIDs (PKs and FKs). Magistrala IDs are 36-char UUID strings — directly usable as Atom `UUID` PKs. Keeps audit trails, message payloads, external references, and SpiceDB-derived links intact. |
@@ -33,6 +33,7 @@ All `magistrala/magistrala`, port 5432, on network `magistrala-base-net`:
| `auth-db` | `auth` | `pats`, `pat_scopes` (skip `keys` — short-lived JWTs; skip legacy `policies`/`domains` mirror) |
| `re-db` | `rules_engine` | `rules`, `rules_roles*` |
| `reports-db` | `reports` | `report_config`, `reports_roles*` |
| `alarms-db` | `alarms` | `alarms` |
> Note: `groups` migrations are embedded into clients **and** channels **and** the
> standalone groups service. The authoritative groups data for default compose is
@@ -90,7 +91,7 @@ principal group, matching Atom's normal `create_entity` side effect.
| `name` | `entities.name` (per-tenant unique — handle collisions, §6) |
| `tags,metadata,private_metadata` | `attributes` |
| `identity` | `attributes.identity` (and/or `entities.alias` if slug-valid) |
| `secret` (plaintext) | `credentials` row: `kind='shared_key'`, `entity_id=client.id`, `secret_hash`=argon2(secret), `identifier`=identity if present, `status` per client status. **See §5.** |
| `secret` (plaintext) | `credentials` row: `kind='api_key'`, `entity_id=client.id`, `secret_hash`=argon2(secret), `identifier`=client.id, `status` per client status. **See §5 — key-format caveat.** |
| `status` 0/1 | `entities.status` |
| `parent_group_id` | `object_group_entities` membership (§3.5) |
@@ -104,25 +105,27 @@ principal group, matching Atom's normal `create_entity` side effect.
| `created_by` | `owner_id` (if the user migrated) |
| `parent_group_id` | `object_group_resources` membership |
### 3.4b rules / report configs → resources
### 3.4b rules / reports / alarms → resources
Rules-engine rules and report configs are domain-scoped configuration objects
with no Atom-native table, so they become **resources** alongside channels,
distinguished by `kind`. Service-specific columns Atom resources lack are folded
into `attributes` (JSONB). `tenant_id=domain_id`; rows whose domain has no
surviving tenant are skipped (§6.4). `owner_id`=`created_by` when that user
migrated. Names are deduped per tenant (§6.3) since these tables carry no
`(domain_id, name)` constraint.
Rules-engine rules, report configs, and alarms are domain-scoped objects with no
Atom-native table, so they become **resources** alongside channels, distinguished
by `kind`. Service-specific columns Atom resources lack are folded into
`attributes` (JSONB). `tenant_id=domain_id`; rows whose domain has no surviving
tenant are skipped (§6.4). `owner_id`=`created_by` when that user migrated
(alarms have no `created_by` → NULL). Names are deduped per tenant (§6.3) since
these tables carry no `(domain_id, name)` constraint; alarms (no name) use
`measurement` with an `id` fallback.
| Source | Atom `resources` |
|---|---|
| `rules_engine.rules` (id) | `kind='rule'`; `input_channel/topic, outputs, logic_type/value, recurring*, time, start_datetime, tags, status``attributes` |
| `reports.report_config` (id) | `kind='report'`; `description, config, email, metrics, report_template, due, recurring*, start_datetime, status``attributes` |
| `alarms.alarms` (id) | `kind='alarm'`; `rule_id, channel_id, client_id, subtopic, measurement, value, unit, threshold, cause, severity, alarm_status, assignee/assigned/acknowledged/resolved*``attributes` |
> Atom `resources.kind` must permit `rule` and `report` (in addition to
> `channel`). Rules and report configs have object-specific role families and
> those are migrated as resource-scoped roles. Alarm events can be high-volume
> operational data, so they stay in `alarms-db` and are not Atom resources.
> Atom `resources.kind` must permit `rule`, `report`, `alarm` (in addition to
> `channel`). Rules and reports have object-specific role families and those are
> migrated as resource-scoped roles. Alarms have no role family or
> `parent_group_id`, so only the resource rows are migrated for alarms.
### 3.5 groups → object_groups
Magistrala groups organize clients/channels within a domain (hierarchical,
@@ -203,33 +206,35 @@ avoid a foreign-key failure while preserving the invitation record.
## 5. Credentials (PATs + device secrets)
### Device secrets (clients.secret) — RESOLVED: preserve as shared keys
### Device secrets (clients.secret) — RESOLVED: re-issue keys
Magistrala stores the device secret in plaintext (looked up `WHERE secret = ...`).
Atom's access-token/API-key path cannot reuse it because `auth_from_api_key`
requires an `atom_<32 hex cred-id>_<64 hex secret>` bearer value. Atom's newer
`shared_key` credential path can reuse operator-provided machine secrets.
Atom **cannot reuse it.** Verified against Atom source (`src/auth.rs`):
- `auth_from_api_key` calls `parse_api_key` then looks up `WHERE c.id = <embedded
cred id>` — lookup is by the credential UUID **embedded in the key**, not by an
identifier.
- `parse_api_key` requires exactly `atom_<32 hex cred-id>_<64 hex secret>` (secret
must be 32 raw bytes); anything else is rejected as malformed.
**Resolution: preserve.** `phaseDeviceCreds` creates a deterministic
`credentials(kind='shared_key')` row per client and stores an argon2 verifier for
the existing plaintext secret. Using `ATOM_KEY_ENCRYPTION_KEY`, it also stores
the same encrypted reveal material and HMAC lookup digest Atom writes for newly
created shared keys:
- authentication uses the indexed `secret_lookup_hash` path;
- Atom can reveal migrated shared keys later to authorized operators;
- no plaintext secrets are written to a migration report file;
- re-runs are idempotent because the credential id is uuidv5-derived from the
client id.
So a raw Magistrala secret neither fits the format nor is reachable by lookup.
**Resolution: re-issue.** `phaseDeviceCreds` (`newAtomAPIKey`) mints a fresh
`atom_<credId>_<secret>` per device, stores `argon2(raw 32-byte secret)` with
`credentials.id = credId`, and exports `device-keys-<stamp>.csv`
(`client_id, domain_id, identity, api_key`, mode 0600) for re-provisioning
(bootstrap configs / device reflash). Credential id is derived (uuidv5 of client
id) so re-runs are idempotent; the plaintext key is only emitted by the apply run
that generated it. Validated: emitted key parses and argon2-verifies exactly as
Atom's auth path does.
### PATs (auth.pats + pat_scopes) — RESOLVED: re-issue
`pats.secret` is hashed (Magistrala PAT format), so plaintext isn't recoverable;
even if it were, it would not fit Atom's `atom_<credId>_<secret>` access-token
format. So PATs are **re-issue, no exception.**
even if it were, it would not fit Atom's `atom_<credId>_<secret>` format (same
constraint as device keys above). So PATs are **re-issue, no exception.**
`pat_scopes` are preserved in the credential `metadata.scopes` array
(`domain_id, entity_type, operation, entity_id`) for reference / future policy
reconstruction.
- Migrate metadata as `credentials(kind='access_token', entity_id=user_id,
- Migrate metadata as `credentials(kind='api_key', entity_id=user_id,
identifier=pat.id, metadata={name,description,scopes,expires_at,...},
status=revoked?revoked:active, expires_at)`.
- Because the secret can't be verified by Atom argon2, **mark migrated PATs as
@@ -268,8 +273,8 @@ Checks below; the email check matters mainly for dumps merged across instances
2. entities (human, device) — without created_by/updated_by FKs first…
3. …then backfill tenants.created_by/updated_by and resources.owner_id
4. entity_emails
5. credentials (device shared_key; PAT access_token metadata)
6. resources (channels, rules, reports)
5. credentials (device api_key; PAT metadata)
6. resources (channels, rules, reports, alarms)
7. object_groups → object_group_hierarchy → object_group_entities/resources
8. roles → permission_blocks → permission_block_actions → role_permission_blocks
9. role_assignments, direct_policies
@@ -335,19 +340,18 @@ Still recommended manually post-cutover:
- Spot `POST /authz/check` for a sample of (user, domain, action) and
(device, channel, publish) allowed pre-migration.
- Admin login (seeded atom-admin) works; a migrated user completes password reset.
- A migrated client shared key authenticates (§5).
- A re-issued device key authenticates (§5).
---
## 11. Open items — status
1. **Device key format** (§5) — RESOLVED. Atom access tokens still require
`atom_<32hex>_<64hex>`, but client secrets now migrate through Atom
`shared_key` credentials instead of access tokens. Existing client keys are
preserved and no CSV export is needed.
1. **Device key format** (§5) — RESOLVED. Atom looks up by embedded cred UUID and
requires `atom_<32hex>_<64hex>`; MG secrets can't be carried → re-issue + CSV
export. Implemented + validated.
2. **argon2 params** — RESOLVED. Atom uses `Argon2::default()` (argon2id, v=19,
m=19456, t=2, p=1, 32-byte tag); migrator emits the matching PHC string and
migrated client shared keys verify against Atom's path.
re-issued keys verify against Atom's path.
3. **Groups semantics** (§3.5) — RESOLVED. Magistrala groups have no user-member
table; only `parent_group_id` (clients/channels) + group-scoped roles. They are
object groupings → `object_groups`. No principal-group case.
+11 -148
View File
@@ -3,140 +3,6 @@
Offline, idempotent migrator: Magistrala v0.30.0 (per-service Postgres) → Atom IAM
(single Postgres). See [PLAN.md](./PLAN.md) for the full mapping and runbook.
## One-command migration (recommended)
If you just want to migrate an old deployment and bring it up with
`make run_latest`, use the orchestrator — it handles all the port / volume /
container-name collisions for you:
```bash
make migrate_atom # dry-run: reads + validates, writes nothing
make migrate_atom args="--apply" # perform the migration
make migrate_atom args="--verify" # reconcile source vs Atom afterwards
```
Then:
```bash
# stop the old stack, then:
make run_latest
```
…and the new deployment serves the migrated data.
How it stays collision-free: [`migrate.sh`](./migrate.sh) +
[`docker-compose.migrate.yaml`](./docker-compose.migrate.yaml) run everything in
their own Compose project (`atommig`) on a private network, binding **no host
ports** and using **no fixed container names**, so they never clash with a
running Magistrala (old or `run_latest`) stack. It:
1. mounts the eight old per-service DB volumes
(`magistrala_magistrala-<svc>-db-volume`) into throwaway Postgres containers
(Postgres major version + data-dir layout auto-detected from the volume);
2. brings up an Atom + Postgres on the **same** volume `make run_latest` mounts
(`<DOCKER_PROJECT>_magistrala-atom-db-volume`), so Atom seeds its schema there
and the migrated rows persist for the next `run_latest`;
3. runs the migrator on that private network (reaching every DB by service name);
4. tears the stack down, leaving every volume intact.
All migration volumes are declared `external`, so `down` can never destroy data.
Prerequisites: run this on the machine that hosted the old Magistrala compose
stack. Stop that stack (`docker compose ... down`, **without** `-v`) so the
per-service DB volumes are free but still present locally — the migrator mounts
them directly. Reports are written under `tools/atom-migration/report/`.
Env overrides: `SRC_VOL_PREFIX` (old volume prefix, default
`magistrala_magistrala-`), `SRC_DB_USER` / `SRC_DB_PASS` (old Postgres creds,
default `magistrala`), `DOCKER_PROJECT` (run_latest project; default derived like
the Makefile), `MIGRATE_PROJECT` (isolated project name, default `atommig`),
`ATOM_IMAGE` (full Atom image reference; default from `docker/.env` or
`ghcr.io/absmach/atom:latest`), `ATOM_IMAGE_TAG` (shorthand for
`ghcr.io/absmach/atom:<tag>` when `ATOM_IMAGE` is unset), and
`ATOM_PULL_POLICY` (default `always` for the default Atom image, `missing` for
custom images). Pass `--keep` to leave the stack up for debugging.
### Atom schema freshness (`column "alias" does not exist`)
The migrator writes the **current** Atom schema. The schema is created by the
configured Atom image (`ATOM_IMAGE`, or `ghcr.io/absmach/atom:<ATOM_IMAGE_TAG>`)
the first time its target volume is seeded. Two things follow:
- A target volume that was **already seeded by an older Atom** (e.g. a previous
`make run_latest`) keeps that old schema — Atom does not re-run an
already-applied baseline, so newer columns like `tenants.alias` never appear.
The migrator then fails with `column "alias" of relation "tenants" does not
exist`.
- The orchestrator guards against this: it waits for `tenants.alias` to exist
before running the migrator and aborts with guidance if it never does (instead
of failing mid-apply).
Fix: rebuild the schema from scratch with
```bash
make migrate_atom args="--apply --fresh-atom"
```
`--fresh-atom` removes the existing Atom target volume so the current image lays
down the current schema. **Destructive** for that volume only — it discards any
data already in the Atom DB (the old per-service source volumes are never
touched). Equivalent manual reset: `docker volume rm
<DOCKER_PROJECT>_magistrala-atom-db-volume`.
### How volume names are resolved
Names are **derived by convention, not auto-discovered**. There are two sets.
**Source volumes (old deployment, read-only inputs).** Built from a prefix plus
a fixed per-service suffix:
```
<SRC_VOL_PREFIX><svc>-db-volume # svc ∈ domains users clients channels groups auth re reports
```
`SRC_VOL_PREFIX` defaults to `magistrala_magistrala-`, i.e. old Compose project
`magistrala` + Docker Compose's own `magistrala-` volume key. So
`auth``magistrala_magistrala-auth-db-volume`. `migrate.sh` `docker volume
inspect`s all eight up front and aborts loudly if any is missing. The same
`${SRC_VOL_PREFIX}` feeds the `external` volume names in
`docker-compose.migrate.yaml`, so the script and Compose always agree. If your
old deployment used a different Compose project name, set `SRC_VOL_PREFIX`
(e.g. `SRC_VOL_PREFIX=myproj_magistrala-`).
**Atom target volume (where migrated data is written).** Must equal exactly the
volume `make run_latest` mounts, or the new stack would come up on a different,
empty volume. `make run_latest` mounts `magistrala-atom-db-volume`, which Docker
Compose prefixes with the project name `DOCKER_PROJECT`:
```
<DOCKER_PROJECT>_magistrala-atom-db-volume
```
`DOCKER_PROJECT` is itself derived from the git remote, replicating the Makefile
formula:
```sh
repo=$(git remote get-url origin | sed -E 's@.*/([^/]+)/([^/.]+)(\.git)?@\1_\2@') # owner_repo
DOCKER_PROJECT=$(echo "$repo" | sed -E 's/[^a-zA-Z0-9]/_/g' | tr '[:upper:]' '[:lower:]')
ATOM_TARGET_VOLUME="${DOCKER_PROJECT}_magistrala-atom-db-volume"
```
`make migrate_atom` also passes `DOCKER_PROJECT="$(DOCKER_PROJECT)"` straight from
the Makefile, so the two stay in lockstep even if the git derivation would differ.
The target volume is created if it does not yet exist (so the schema-seed step can
write to it); `make run_latest` then reuses the same name. With no usable git
remote, or a remote that does not match the run_latest project, pass
`DOCKER_PROJECT=` explicitly.
**Source Postgres layout** (mount point + `PGDATA` + image major version) is the
one thing actually probed, not assumed: `migrate.sh` mounts the `users` source
volume in a throwaway `alpine` container, locates `PG_VERSION`, and derives the
mount path / `PGDATA` / `postgres:<major>-alpine` image from it (e.g. Postgres 18
keeps data under `/var/lib/postgresql/<major>/docker`). This makes the tool work
regardless of which Postgres version the old deployment ran.
The manual, lower-level steps below are still available if you need finer control.
## Build
Plain binary:
@@ -155,14 +21,14 @@ docker build -f tools/atom-migration/Dockerfile -t magistrala/atom-migration:dev
## Start only the source databases
The migrator reads Postgres directly — it does **not** need the Magistrala app
services running. To migrate from restored volumes, start just the eight source DB
services running. To migrate from restored volumes, start just the nine source DB
containers (`--no-deps` keeps compose from pulling in the app services they
depend on):
```bash
docker compose -f docker/docker-compose.yaml up -d --no-deps \
auth-db users-db domains-db clients-db channels-db groups-db \
re-db reports-db
re-db reports-db alarms-db
```
They mount the `magistrala_magistrala-<svc>-db-volume` volumes and attach to
@@ -251,23 +117,20 @@ connection must have a matching authz edge. Missing rows are reported as blockin
| `--report-dir` | `tools/atom-migration/report` | JSON+markdown report output |
| `--unmapped-action` | `manage` | fallback for unmapped MG actions: `manage` or `skip` |
## Credential handling
## Credentials are re-issued, not carried
Atom has two machine credential paths that matter for the migration:
`access_token` bearer credentials (`atom_<32hex>_<64hex>`) and `shared_key`
credentials used by machine basic-auth style login.
Atom authenticates API keys by a credential UUID embedded in the key
(`atom_<32hex>_<64hex>`, argon2 over the raw 32-byte secret — see Atom
`src/auth.rs`). Magistrala secrets fit neither the format nor the lookup, so:
- **Device/client keys** (`clients.secret`) are preserved as Atom `shared_key`
credentials. The migrator stores an argon2 verifier for the existing plaintext
secret plus Atom-compatible encrypted reveal material and lookup digest using
`ATOM_KEY_ENCRYPTION_KEY`. That keeps authentication compatible without writing
client secrets to a report file, and Atom can reveal the migrated shared key to
authorized operators later.
- **Device keys** are re-issued. On `--apply` the migrator writes
`report/device-keys-<stamp>.csv` (`client_id,domain_id,identity,api_key`, mode
0600). Re-provision devices/bootstrap configs from it, then delete it — the
plaintext secret is shown only once.
- **User passwords** (bcrypt → argon2 unconvertible): users land with no password
credential. Report's `password_reset` TODO lists every user for the email reset.
- **PAT secrets** (hashed + format): metadata migrates, secret must be re-issued.
Migrated PAT rows use Atom `access_token` kind with `secret_hash = NULL` and
`metadata.needs_reissue = true`; Report's `pat_reissue` TODO lists them.
Report's `pat_reissue` TODO.
- Transient data not migrated: OTP verifications, short-lived auth `keys`, login
attempts.
+5 -17
View File
@@ -5,7 +5,6 @@ package main
import (
"bufio"
"encoding/base64"
"fmt"
"os"
"strings"
@@ -40,12 +39,10 @@ type config struct {
Auth dbConn
RE dbConn // rules engine
Reports dbConn
Alarms dbConn
AtomDSN string
UnmappedAction string
AtomKeyEncryptionKey []byte
AtomKeyEncryptionKeyID string
}
// loadConfig reads docker/.env for MG_*_DB_* keys. When fromHost is true the
@@ -81,26 +78,17 @@ func loadConfig(envPath, atomDSN string, fromHost bool) (config, error) {
Auth: mk("MG_AUTH"),
RE: mk("MG_RE"),
Reports: mk("MG_REPORTS"),
Alarms: mk("MG_ALARMS"),
AtomDSN: atomDSN,
}
if key := strings.TrimSpace(firstNonEmpty(env["ATOM_KEY_ENCRYPTION_KEY"], os.Getenv("ATOM_KEY_ENCRYPTION_KEY"))); key != "" {
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return config{}, fmt.Errorf("ATOM_KEY_ENCRYPTION_KEY must be base64 encoded: %w", err)
}
if len(decoded) != 32 {
return config{}, fmt.Errorf("ATOM_KEY_ENCRYPTION_KEY must decode to exactly 32 bytes")
}
cfg.AtomKeyEncryptionKey = decoded
}
cfg.AtomKeyEncryptionKeyID = orDef(strings.TrimSpace(firstNonEmpty(env["ATOM_KEY_ENCRYPTION_KEY_ID"], os.Getenv("ATOM_KEY_ENCRYPTION_KEY_ID"))), "local:v1")
// Default names if .env omitted them.
defName := map[*string]string{
&cfg.Domains.Name: collectionDomains, &cfg.Users.Name: "users",
&cfg.Domains.Name: "domains", &cfg.Users.Name: "users",
&cfg.Clients.Name: "clients", &cfg.Channels.Name: "channels",
&cfg.Groups.Name: collectionGroups, &cfg.Auth.Name: "auth",
&cfg.Groups.Name: "groups", &cfg.Auth.Name: "auth",
&cfg.RE.Name: "rules_engine", &cfg.Reports.Name: "reports",
&cfg.Alarms.Name: "alarms",
}
for p, n := range defName {
if *p == "" {
+23 -53
View File
@@ -4,13 +4,11 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
@@ -25,20 +23,8 @@ const (
argonThreads = 1
argonKeyLen = 32
argonSaltLen = 16
aeadNonceLen = 12
)
const sharedKeyAEADAlg = "AES-256-GCM"
type sharedKeyMaterial struct {
Hash string
Ciphertext []byte
Nonce []byte
KeyID string
EncAlg string
LookupHash []byte
}
// hashArgon2id produces a PHC-encoded argon2id hash compatible with Atom.
func hashArgon2id(secret []byte) (string, error) {
salt := make([]byte, argonSaltLen)
@@ -55,43 +41,27 @@ func hashArgon2id(secret []byte) (string, error) {
), nil
}
func newSharedKeyMaterial(credentialID, secret string, cfg config) (sharedKeyMaterial, error) {
if len(cfg.AtomKeyEncryptionKey) != 32 {
return sharedKeyMaterial{}, fmt.Errorf("ATOM_KEY_ENCRYPTION_KEY is required to migrate client shared keys")
}
hash, err := hashArgon2id([]byte(secret))
if err != nil {
return sharedKeyMaterial{}, err
}
credUUID, err := uuid.Parse(credentialID)
if err != nil {
return sharedKeyMaterial{}, err
}
block, err := aes.NewCipher(cfg.AtomKeyEncryptionKey)
if err != nil {
return sharedKeyMaterial{}, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return sharedKeyMaterial{}, err
}
nonce := make([]byte, aeadNonceLen)
if _, err := rand.Read(nonce); err != nil {
return sharedKeyMaterial{}, err
}
ciphertext := aead.Seal(nil, nonce, []byte(secret), credUUID[:])
// newAtomAPIKey mints a fresh Atom-format API key for a device. Atom expects
// `atom_<32hex-credId>_<64hex-secret>` and verifies argon2 over the raw 32 secret
// bytes (see atom src/auth.rs parse_api_key / auth_from_api_key). Magistrala's
// own device secret cannot be reused (arbitrary format, looked up differently),
// so the key is re-issued and must be re-provisioned to the device.
//
// credID is derived deterministically from the client id so re-runs are
// idempotent (same credential row id), but the returned plaintext key is only
// usable from the run that generated it.
func newAtomAPIKey(clientID string) (credID, plaintextKey, secretHash string, err error) {
cu := uuid.NewSHA1(uuidNS, []byte("devcred|"+clientID))
credIDHex := strings.ReplaceAll(cu.String(), "-", "")
mac := hmac.New(sha256.New, cfg.AtomKeyEncryptionKey)
if _, err := mac.Write([]byte(secret)); err != nil {
return sharedKeyMaterial{}, err
secret := make([]byte, 32)
if _, err = rand.Read(secret); err != nil {
return "", "", "", err
}
return sharedKeyMaterial{
Hash: hash,
Ciphertext: ciphertext,
Nonce: nonce,
KeyID: cfg.AtomKeyEncryptionKeyID,
EncAlg: sharedKeyAEADAlg,
LookupHash: mac.Sum(nil),
}, nil
hash, err := hashArgon2id(secret)
if err != nil {
return "", "", "", err
}
key := "atom_" + credIDHex + "_" + hex.EncodeToString(secret)
return cu.String(), key, hash, nil
}
@@ -1,222 +0,0 @@
# Copyright (c) Abstract Machines
# SPDX-License-Identifier: Apache-2.0
# Isolated, collision-free migration stack driven by migrate.sh.
#
# Everything here lives in its own Compose project (default: atommig) on its own
# private network, binds NO host ports and uses NO fixed container_names, so it
# can never collide with a running Magistrala (old or `make run_latest`) stack.
#
# * source DBs - eight Postgres containers that mount the OLD per-service
# Magistrala volumes (external, read by the migrator).
# * atom-db - Postgres mounting the NEW run_latest Atom volume, so data
# written here is exactly what `make run_latest` later serves.
# * atom - seeds/upgrades the Atom schema in that volume on boot.
# * migrator - the atom-migration binary; run on demand by migrate.sh.
#
# Variables are supplied by migrate.sh (and docker/.env for the ATOM_* / atom
# runtime settings). Source volume layout (mount point + PGDATA) is detected by
# migrate.sh and injected, so this file works regardless of the Postgres major
# version the old deployment used.
x-src-db: &src-db
image: ${SRC_PG_IMAGE:-postgres:18-alpine}
restart: "no"
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${SRC_DB_USER:-magistrala}"]
interval: 3s
timeout: 5s
retries: 20
networks:
- migrate-net
services:
domains-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: domains
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- domains-src:${SRC_MOUNT:-/var/lib/postgresql}
users-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: users
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- users-src:${SRC_MOUNT:-/var/lib/postgresql}
clients-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: clients
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- clients-src:${SRC_MOUNT:-/var/lib/postgresql}
channels-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: channels
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- channels-src:${SRC_MOUNT:-/var/lib/postgresql}
groups-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: groups
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- groups-src:${SRC_MOUNT:-/var/lib/postgresql}
auth-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: auth
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- auth-src:${SRC_MOUNT:-/var/lib/postgresql}
re-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: rules_engine
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- re-src:${SRC_MOUNT:-/var/lib/postgresql}
reports-db:
<<: *src-db
environment:
POSTGRES_USER: ${SRC_DB_USER:-magistrala}
POSTGRES_PASSWORD: ${SRC_DB_PASS:-magistrala}
POSTGRES_DB: reports
PGDATA: ${SRC_PGDATA:-/var/lib/postgresql/18/docker}
volumes:
- reports-src:${SRC_MOUNT:-/var/lib/postgresql}
# Target: the SAME volume `make run_latest` mounts, so migrated data persists.
atom-db:
image: postgres:16-alpine
restart: "no"
environment:
POSTGRES_USER: ${ATOM_DB_USER:-atom}
POSTGRES_PASSWORD: ${ATOM_DB_PASSWORD:-atom}
POSTGRES_DB: ${ATOM_DB_NAME:-atom}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${ATOM_DB_USER:-atom} -d ${ATOM_DB_NAME:-atom}"]
interval: 3s
timeout: 5s
retries: 20
volumes:
- atom-target:/var/lib/postgresql/data
networks:
- migrate-net
# Seeds / upgrades the Atom schema in the target volume on boot (migrations run
# at startup). Certs disabled so it needs no mounted CA. No host ports.
atom:
image: ${ATOM_IMAGE}
pull_policy: ${ATOM_PULL_POLICY:-always}
restart: "no"
depends_on:
atom-db:
condition: service_healthy
env_file:
- ../../docker/.env
environment:
DATABASE_URL: postgres://${ATOM_DB_USER:-atom}:${ATOM_DB_PASSWORD:-atom}@atom-db:5432/${ATOM_DB_NAME:-atom}
LISTEN_ADDR: 0.0.0.0:8080
ATOM_CERTS_ENABLED: "false"
networks:
- migrate-net
# The migration binary. Run on demand: `compose run --rm migrator <flags>`.
migrator:
image: magistrala/atom-migration:dev
depends_on:
atom-db:
condition: service_healthy
domains-db:
condition: service_healthy
users-db:
condition: service_healthy
clients-db:
condition: service_healthy
channels-db:
condition: service_healthy
groups-db:
condition: service_healthy
auth-db:
condition: service_healthy
re-db:
condition: service_healthy
reports-db:
condition: service_healthy
environment:
ATOM_DATABASE_URL: host=atom-db port=5432 user=${ATOM_DB_USER:-atom} password=${ATOM_DB_PASSWORD:-atom} dbname=${ATOM_DB_NAME:-atom} sslmode=disable
ATOM_KEY_ENCRYPTION_KEY: ${ATOM_KEY_ENCRYPTION_KEY}
ATOM_KEY_ENCRYPTION_KEY_ID: ${ATOM_KEY_ENCRYPTION_KEY_ID:-local:v1}
user: "${HOST_UID:-0}:${HOST_GID:-0}"
working_dir: /work
volumes:
- ../../:/work
networks:
- migrate-net
# Args (e.g. --apply / --verify) are supplied by migrate.sh via `compose run`.
entrypoint: ["atom-migration", "--env", "tools/atom-migration/migrate.db.env"]
networks:
migrate-net:
driver: bridge
# All source volumes and the Atom target are pre-existing (external): Compose
# never creates or deletes them, so `down` can never destroy migration data.
volumes:
domains-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}domains-db-volume
users-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}users-db-volume
clients-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}clients-db-volume
channels-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}channels-db-volume
groups-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}groups-db-volume
auth-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}auth-db-volume
re-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}re-db-volume
reports-src:
external: true
name: ${SRC_VOL_PREFIX:-magistrala_magistrala-}reports-db-volume
atom-target:
external: true
name: ${ATOM_TARGET_VOLUME}
+1
View File
@@ -60,6 +60,7 @@ func runMain() int {
log.Printf("init: %v", err)
return 1
}
m.reportDir = *reportDir
defer m.Close()
rep := newReport(mode)
-57
View File
@@ -1,57 +0,0 @@
# Copyright (c) Abstract Machines
# SPDX-License-Identifier: Apache-2.0
#
# Source DB connection details for the migrator when it runs inside the isolated
# docker-compose.migrate.yaml network. Hosts are the Compose service names, so
# this resolves only from a container attached to that network (which is how
# migrate.sh runs it). The Atom target DSN is passed separately via
# ATOM_DATABASE_URL. Override SRC_DB_USER/SRC_DB_PASS in migrate.sh if the old
# deployment used non-default Postgres credentials.
MG_DOMAINS_DB_HOST=domains-db
MG_DOMAINS_DB_PORT=5432
MG_DOMAINS_DB_USER=magistrala
MG_DOMAINS_DB_PASS=magistrala
MG_DOMAINS_DB_NAME=domains
MG_USERS_DB_HOST=users-db
MG_USERS_DB_PORT=5432
MG_USERS_DB_USER=magistrala
MG_USERS_DB_PASS=magistrala
MG_USERS_DB_NAME=users
MG_CLIENTS_DB_HOST=clients-db
MG_CLIENTS_DB_PORT=5432
MG_CLIENTS_DB_USER=magistrala
MG_CLIENTS_DB_PASS=magistrala
MG_CLIENTS_DB_NAME=clients
MG_CHANNELS_DB_HOST=channels-db
MG_CHANNELS_DB_PORT=5432
MG_CHANNELS_DB_USER=magistrala
MG_CHANNELS_DB_PASS=magistrala
MG_CHANNELS_DB_NAME=channels
MG_GROUPS_DB_HOST=groups-db
MG_GROUPS_DB_PORT=5432
MG_GROUPS_DB_USER=magistrala
MG_GROUPS_DB_PASS=magistrala
MG_GROUPS_DB_NAME=groups
MG_AUTH_DB_HOST=auth-db
MG_AUTH_DB_PORT=5432
MG_AUTH_DB_USER=magistrala
MG_AUTH_DB_PASS=magistrala
MG_AUTH_DB_NAME=auth
MG_RE_DB_HOST=re-db
MG_RE_DB_PORT=5432
MG_RE_DB_USER=magistrala
MG_RE_DB_PASS=magistrala
MG_RE_DB_NAME=rules_engine
MG_REPORTS_DB_HOST=reports-db
MG_REPORTS_DB_PORT=5432
MG_REPORTS_DB_USER=magistrala
MG_REPORTS_DB_PASS=magistrala
MG_REPORTS_DB_NAME=reports
-236
View File
@@ -1,236 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) Abstract Machines
# SPDX-License-Identifier: Apache-2.0
#
# One-command Magistrala v0.30.0 -> Atom migration.
#
# Brings up an isolated, collision-free stack (its own Compose project + private
# network, no host ports, no fixed container names), seeds the Atom schema into
# the SAME volume `make run_latest` uses, runs the migrator, then tears the stack
# down leaving every volume intact. After it finishes you can simply:
#
# make run_latest
#
# and the new deployment serves the migrated data.
#
# Usage:
# tools/atom-migration/migrate.sh # dry-run (reads + validates, writes nothing)
# tools/atom-migration/migrate.sh --apply # perform the migration
# tools/atom-migration/migrate.sh --verify # reconcile source vs Atom after apply
# tools/atom-migration/migrate.sh --apply --fresh-atom # rebuild Atom schema from scratch
# tools/atom-migration/migrate.sh --apply --keep # leave the stack running for debugging
#
# --fresh-atom discards any existing Atom target volume first, so the current Atom
# image lays down the current schema. Use it when a previous run/`make run_latest`
# left an older Atom schema in the volume (symptom: "column alias does not exist").
#
# Env overrides:
# DOCKER_PROJECT run_latest Compose project (default: derived like the Makefile)
# SRC_VOL_PREFIX old DB volume name prefix (default: magistrala_magistrala-)
# SRC_DB_USER/PASS old Postgres credentials (default: magistrala/magistrala)
# ATOM_IMAGE Atom image used to seed schema (default: docker/.env or ghcr.io/absmach/atom:latest)
# ATOM_IMAGE_TAG shorthand for ghcr.io/absmach/atom:<tag> when ATOM_IMAGE is unset
# ATOM_PULL_POLICY Compose pull policy for Atom image (default: always for ghcr.io/absmach/atom, missing otherwise)
# MIGRATE_PROJECT isolated Compose project name (default: atommig)
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.migrate.yaml"
ENV_FILE="$REPO_ROOT/docker/.env"
MIGRATE_PROJECT="${MIGRATE_PROJECT:-atommig}"
SRC_VOL_PREFIX="${SRC_VOL_PREFIX:-magistrala_magistrala-}"
MIGRATOR_ARGS=()
KEEP=false
FRESH_ATOM=false
for arg in "$@"; do
case "$arg" in
--keep) KEEP=true ;;
--fresh-atom) FRESH_ATOM=true ;;
--apply|--verify|--dry-run) MIGRATOR_ARGS+=("$arg") ;;
--unmapped-action=*|--report-dir=*) MIGRATOR_ARGS+=("$arg") ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//' | head -40; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
command -v docker >/dev/null || die "docker not found"
[[ -f "$ENV_FILE" ]] || die "missing $ENV_FILE"
# DOCKER_PROJECT: match the Makefile derivation so we target the volume that
# `make run_latest` will mount.
if [[ -z "${DOCKER_PROJECT:-}" ]]; then
repo="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null \
| sed -E 's@.*/([^/]+)/([^/.]+)(\.git)?@\1_\2@')"
DOCKER_PROJECT="$(echo "$repo" | sed -E 's/[^a-zA-Z0-9]/_/g' | tr '[:upper:]' '[:lower:]')"
fi
[[ -n "$DOCKER_PROJECT" ]] || die "could not determine DOCKER_PROJECT (set it explicitly)"
ATOM_TARGET_VOLUME="${DOCKER_PROJECT}_magistrala-atom-db-volume"
# Atom DB connection settings come from docker/.env (Compose interpolates them
# from --env-file). Read the same values here so our readiness poll connects with
# the right user/db -- defaulting to "atom" would loop forever if .env overrides
# them.
envget() { sed -nE "s/^[[:space:]]*$1=//p" "$ENV_FILE" | tail -1 | tr -d '"'"'"; }
ATOM_DB_USER="$(envget ATOM_DB_USER)"; ATOM_DB_USER="${ATOM_DB_USER:-atom}"
ATOM_DB_NAME="$(envget ATOM_DB_NAME)"; ATOM_DB_NAME="${ATOM_DB_NAME:-atom}"
atom_image_from_env_file="$(envget ATOM_IMAGE)"
if [[ -n "${ATOM_IMAGE:-}" ]]; then
:
elif [[ -n "${ATOM_IMAGE_TAG:-}" ]]; then
ATOM_IMAGE="ghcr.io/absmach/atom:$ATOM_IMAGE_TAG"
elif [[ -n "$atom_image_from_env_file" ]]; then
ATOM_IMAGE="$atom_image_from_env_file"
else
ATOM_IMAGE="ghcr.io/absmach/atom:latest"
fi
if [[ -z "${ATOM_PULL_POLICY:-}" ]]; then
if [[ "$ATOM_IMAGE" == ghcr.io/absmach/atom:* ]]; then
ATOM_PULL_POLICY=always
else
ATOM_PULL_POLICY=missing
fi
fi
export ATOM_IMAGE ATOM_PULL_POLICY
log "Isolated project : $MIGRATE_PROJECT"
log "Source volumes : ${SRC_VOL_PREFIX}<svc>-db-volume"
log "Atom target vol : $ATOM_TARGET_VOLUME"
log "Atom image : $ATOM_IMAGE (pull_policy=$ATOM_PULL_POLICY)"
# --- preflight: every source volume must exist ---
missing=()
for svc in domains users clients channels groups auth re reports; do
vol="${SRC_VOL_PREFIX}${svc}-db-volume"
docker volume inspect "$vol" >/dev/null 2>&1 || missing+=("$vol")
done
((${#missing[@]} == 0)) || die "missing source volume(s): ${missing[*]}
Run this on the machine whose stopped old Magistrala stack still has these
volumes, or set SRC_VOL_PREFIX if the old Compose project used another name."
# --fresh-atom: rebuild the target schema from scratch. A pre-existing Atom volume
# (e.g. from an earlier `make run_latest`) keeps whatever schema it was seeded
# with -- Atom's migrations won't re-run an already-applied baseline, so an old
# schema (missing newer columns like tenants.alias) would survive and break the
# load. Removing the volume forces the current Atom image to lay down the current
# schema. Destructive: any data already in that Atom volume is discarded.
if [[ "$FRESH_ATOM" == true ]]; then
if docker volume inspect "$ATOM_TARGET_VOLUME" >/dev/null 2>&1; then
log "Resetting Atom target volume $ATOM_TARGET_VOLUME (--fresh-atom)"
docker compose --env-file "$ENV_FILE" -p "$MIGRATE_PROJECT" -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
docker volume rm -f "$ATOM_TARGET_VOLUME" >/dev/null
fi
fi
# Atom target volume: created here if absent so the seed step can write to it;
# `make run_latest` reuses the same name.
docker volume inspect "$ATOM_TARGET_VOLUME" >/dev/null 2>&1 || {
log "Creating Atom target volume $ATOM_TARGET_VOLUME"
docker volume create "$ATOM_TARGET_VOLUME" >/dev/null
}
# --- detect source Postgres layout (mount point + PGDATA + image major) ---
log "Detecting source Postgres layout"
layout="$(docker run --rm -v "${SRC_VOL_PREFIX}users-db-volume":/d alpine:3.22 sh -c '
if [ -f /d/PG_VERSION ]; then
echo "classic $(cat /d/PG_VERSION) /"
else
p=$(find /d -maxdepth 4 -name PG_VERSION 2>/dev/null | head -1)
[ -n "$p" ] && echo "nested $(cat "$p") ${p#/d/}" || echo "unknown 0 /"
fi')"
read -r kind major rel <<<"$layout"
[[ "$kind" != "unknown" ]] || die "could not find PG_VERSION in source volume"
if [[ "$kind" == "nested" ]]; then
# rel = <major>/docker/PG_VERSION -> PGDATA dir = /var/lib/postgresql/<major>/docker
subdir="$(dirname "/$rel")" # /<major>/docker
SRC_MOUNT="/var/lib/postgresql"
SRC_PGDATA="/var/lib/postgresql${subdir}"
else
SRC_MOUNT="/var/lib/postgresql/data"
SRC_PGDATA="/var/lib/postgresql/data"
fi
SRC_PG_IMAGE="postgres:${major}-alpine"
log "Source Postgres : $SRC_PG_IMAGE (mount $SRC_MOUNT, PGDATA $SRC_PGDATA)"
# --- build the migrator image ---
log "Building migrator image"
docker build -q -f "$SCRIPT_DIR/Dockerfile" -t magistrala/atom-migration:dev "$REPO_ROOT" >/dev/null
# Variables consumed by the compose file. docker/.env is passed via --env-file so
# ATOM_DB_* interpolate; these exports take precedence.
export SRC_VOL_PREFIX SRC_PG_IMAGE SRC_MOUNT SRC_PGDATA ATOM_TARGET_VOLUME ATOM_IMAGE ATOM_PULL_POLICY
export SRC_DB_USER="${SRC_DB_USER:-magistrala}" SRC_DB_PASS="${SRC_DB_PASS:-magistrala}"
export HOST_UID="$(id -u)" HOST_GID="$(id -g)"
dc() { docker compose --env-file "$ENV_FILE" -p "$MIGRATE_PROJECT" -f "$COMPOSE_FILE" "$@"; }
cleanup() {
if [[ "$KEEP" == true ]]; then
log "Leaving stack up (--keep). Tear down with:"
echo " docker compose -p $MIGRATE_PROJECT -f $COMPOSE_FILE down"
return
fi
log "Tearing down isolated stack (volumes are external and preserved)"
dc down --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
# --- bring up source DBs + Atom DB, seed the Atom schema ---
log "Starting source DBs, Atom DB and Atom schema seeder"
dc up -d --wait domains-db users-db clients-db channels-db groups-db auth-db re-db reports-db atom-db
dc up -d atom
log "Waiting for Atom to apply its schema into the target volume"
# Require a column from a late migration (tenants.alias), not just table
# existence: Atom adds tables early and columns later, so checking only for the
# tables can race ahead of a not-yet-complete migration and hit the same
# "column alias does not exist" error mid-apply. A timeout here with alias still
# absent means the volume holds an old/incompatible Atom schema -> --fresh-atom.
schema_ready_q="SELECT (to_regclass('public.entities') IS NOT NULL)
AND EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='tenants' AND column_name='alias');"
atom_cid="$(dc ps -q atom)"
for i in $(seq 1 60); do
if dc exec -T atom-db psql -U "$ATOM_DB_USER" -d "$ATOM_DB_NAME" -tAc \
"$schema_ready_q" 2>/dev/null | grep -qx t; then
log "Atom schema ready"
break
fi
# If the Atom container has stopped it will never create the schema -- fail
# fast with its logs instead of waiting out the whole timeout.
if [[ -n "$atom_cid" ]] && [[ "$(docker inspect -f '{{.State.Running}}' "$atom_cid" 2>/dev/null)" != "true" ]]; then
echo "----- atom logs -----" >&2; dc logs --no-color --tail 50 atom >&2 || true
die "Atom container exited before applying its schema (see logs above).
Common causes: missing/invalid ATOM_* settings in docker/.env (JWT_SECRET,
ATOM_KEY_ENCRYPTION_KEY, ATOM_SERVICE_SECRET, ATOM_ADMIN_SECRET) or it could not
reach atom-db."
fi
[[ $i -eq 60 ]] && { echo "----- atom logs -----" >&2; dc logs --no-color --tail 50 atom >&2 || true; die "Atom schema in $ATOM_TARGET_VOLUME never reached the expected version
(tenants.alias missing) within the timeout. If Atom is still migrating, retry; if
the volume holds an older Atom schema, re-run with --fresh-atom (discards that
volume's Atom data) or 'docker volume rm $ATOM_TARGET_VOLUME'."; }
sleep 2
done
# --- run the migration ---
log "Running migrator ${MIGRATOR_ARGS[*]:-(dry-run)}"
set +e
dc run --rm migrator "${MIGRATOR_ARGS[@]}"
rc=$?
set -e
echo
if [[ $rc -eq 0 ]]; then
log "Migrator finished OK. Report: tools/atom-migration/report/"
if printf '%s\n' "${MIGRATOR_ARGS[@]}" | grep -qx -- --apply; then
log "Next: stop the old stack, then 'make run_latest' to serve migrated data."
fi
else
die "migrator exited $rc (see report in tools/atom-migration/report/)"
fi
+137 -73
View File
@@ -6,8 +6,11 @@ package main
import (
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -26,6 +29,7 @@ type migrator struct {
authDB *sqlx.DB
reDB *sqlx.DB
reportsDB *sqlx.DB
alarmsDB *sqlx.DB
atom *sqlx.DB
profileID map[string]string // profile key (e.g. "user","client") -> uuid
@@ -49,6 +53,9 @@ type migrator struct {
tenantAlias map[string]string
clientAlias map[string]string
channelAlias map[string]string
reportDir string
deviceKeys [][]string // client_id, domain_id, identity, plaintext key (apply only)
}
func newMigrator(ctx context.Context, cfg config, apply bool) (*migrator, error) {
@@ -82,14 +89,15 @@ func newMigrator(ctx context.Context, cfg config, apply bool) (*migrator, error)
db, err = openDB(ctx, name, dsn)
return db
}
m.domainsDB = open(collectionDomains, cfg.Domains.DSN())
m.domainsDB = open("domains", cfg.Domains.DSN())
m.usersDB = open("users", cfg.Users.DSN())
m.clientsDB = open("clients", cfg.Clients.DSN())
m.channelsDB = open("channels", cfg.Channels.DSN())
m.groupsDB = open(collectionGroups, cfg.Groups.DSN())
m.groupsDB = open("groups", cfg.Groups.DSN())
m.authDB = open("auth", cfg.Auth.DSN())
m.reDB = open("rules_engine", cfg.RE.DSN())
m.reportsDB = open("reports", cfg.Reports.DSN())
m.alarmsDB = open("alarms", cfg.Alarms.DSN())
m.atom = open("atom", cfg.AtomDSN)
if err != nil {
return nil, err
@@ -97,26 +105,10 @@ func newMigrator(ctx context.Context, cfg config, apply bool) (*migrator, error)
return m, nil
}
const (
authenticatedUsersGroupID = "00000000-0000-0000-0000-000000000005"
collectionDomains = "domains"
collectionGroups = "groups"
attributeStatus = "status"
permissionScopeTenant = "tenant"
permissionScopeObject = "object"
permissionScopeGroup = "group"
permissionScopeGroupDirectObjects = "group_direct_objects"
permissionScopeGroupDescendantObjects = "group_descendant_objects"
objectKindEntity = "entity"
objectKindResource = "resource"
)
const authenticatedUsersGroupID = "00000000-0000-0000-0000-000000000005"
func (m *migrator) Close() {
for _, db := range []*sqlx.DB{m.domainsDB, m.usersDB, m.clientsDB, m.channelsDB, m.groupsDB, m.authDB, m.reDB, m.reportsDB, m.atom} {
for _, db := range []*sqlx.DB{m.domainsDB, m.usersDB, m.clientsDB, m.channelsDB, m.groupsDB, m.authDB, m.reDB, m.reportsDB, m.alarmsDB, m.atom} {
if db != nil {
_ = db.Close()
}
@@ -144,10 +136,11 @@ func (m *migrator) Run(ctx context.Context, rep *report) error {
{"tenants", m.phaseTenants},
{"entities.users", m.phaseUsers},
{"entities.clients", m.phaseClients},
{"credentials.device_shared_keys", m.phaseDeviceCreds},
{"credentials.devices", m.phaseDeviceCreds},
{"resources.channels", m.phaseChannels},
{"resources.rules", m.phaseRules},
{"resources.reports", m.phaseReports},
{"resources.alarms", m.phaseAlarms},
{"object_groups", m.phaseGroups},
{"group_membership", m.phaseGroupMembership},
{"roles", m.phaseRoles},
@@ -161,9 +154,34 @@ func (m *migrator) Run(ctx context.Context, rep *report) error {
return fmt.Errorf("phase %s: %w", p.name, err)
}
}
if m.apply && len(m.deviceKeys) > 0 {
if err := m.writeDeviceKeys(); err != nil {
return fmt.Errorf("write device keys: %w", err)
}
}
return nil
}
// writeDeviceKeys exports the re-issued device API keys (secret shown once) for
// re-provisioning. Treat the file as a secret and delete after use.
func (m *migrator) writeDeviceKeys() error {
if err := os.MkdirAll(m.reportDir, 0o755); err != nil {
return err
}
path := filepath.Join(m.reportDir, "device-keys-"+time.Now().UTC().Format("20060102-150405")+".csv")
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
if err := w.Write([]string{"client_id", "domain_id", "identity", "api_key"}); err != nil {
return err
}
return w.WriteAll(m.deviceKeys)
}
func (m *migrator) loadLookups(ctx context.Context) error {
rows, err := m.atom.QueryxContext(ctx,
`SELECT p.key, p.id, pv.id
@@ -351,30 +369,28 @@ func (m *migrator) phaseDeviceCreds(ctx context.Context, rep *report) error {
if _, ok := m.clientDomain[c.ID]; !ok || !c.Secret.Valid || c.Secret.String == "" {
continue
}
// Atom shared keys can preserve the existing Magistrala client secret.
// Store the same recoverable/lookup material Atom writes for new shared
// keys so migrated credentials authenticate through the indexed path and
// can still be revealed by operators.
credentialID := clientSharedKeyCredentialID(c.ID)
material, err := newSharedKeyMaterial(credentialID, c.Secret.String, m.cfg)
// Atom cannot reuse the Magistrala secret (format + lookup differ); the
// device key is re-issued in atom_<credId>_<secret> form and exported for
// re-provisioning. See newAtomAPIKey / PLAN §5.
credID, plaintext, hash, err := newAtomAPIKey(c.ID)
if err != nil {
return err
}
if err := m.exec(ctx,
`INSERT INTO credentials (
id, entity_id, kind, identifier, secret_hash,
secret_ciphertext, secret_nonce, secret_key_id, secret_enc_alg,
secret_lookup_hash, metadata, status
)
VALUES ($1,$2,'shared_key',$3,$4,$5,$6,$7,$8,$9,'{"source":"magistrala-client-secret","revealable":true}',$10)
`INSERT INTO credentials (id, entity_id, kind, identifier, secret_hash, metadata, status)
VALUES ($1,$2,'api_key',$3,$4,'{"source":"magistrala-client-reissued"}',$5)
ON CONFLICT (id) DO NOTHING`,
credentialID, c.ID, nullStr(c.Identity.String), material.Hash,
material.Ciphertext, material.Nonce, material.KeyID, material.EncAlg,
material.LookupHash, statusCred(c.Status),
credID, c.ID, c.ID, hash, statusCred(c.Status),
); err != nil {
return err
}
rep.count("credentials.device_shared_keys", 1)
if m.apply {
m.deviceKeys = append(m.deviceKeys, []string{c.ID, c.DomainID, c.Identity.String, plaintext})
}
rep.count("credentials.devices", 1)
}
if len(m.deviceKeys) > 0 {
rep.todo("device_reprovision", fmt.Sprintf("%d device keys re-issued -> see device-keys CSV in report dir", len(m.deviceKeys)))
}
return nil
}
@@ -396,7 +412,7 @@ func (m *migrator) phaseChannels(ctx context.Context, rep *report) error {
if ch.CreatedBy.Valid && m.migratedUsers[ch.CreatedBy.String] {
owner = ch.CreatedBy
}
extra := map[string]any{attributeStatus: entityStatus(ch.Status)}
extra := map[string]any{"status": entityStatus(ch.Status)}
putTags(extra, ch.Tags)
if err := m.exec(ctx,
`INSERT INTO resources (id, kind, name, tenant_id, owner_id, attributes, alias, created_at, updated_at)
@@ -411,9 +427,9 @@ func (m *migrator) phaseChannels(ctx context.Context, rep *report) error {
return nil
}
// insertResource writes one row into Atom resources. Entity-specific columns
// Magistrala has but Atom resources lack are folded into the attributes JSONB.
// ON CONFLICT (id) keeps it idempotent.
// insertResource writes one row into Atom resources (kind = channel/rule/report/
// alarm). Entity-specific columns Magistrala has but Atom resources lack are
// folded into the attributes JSONB. ON CONFLICT (id) keeps it idempotent.
func (m *migrator) insertResource(ctx context.Context, id, kind, name, tenant string, owner sql.NullString, attributes string, createdAt time.Time, updatedAt any) error {
return m.exec(ctx,
`INSERT INTO resources (id, kind, name, tenant_id, owner_id, attributes, created_at, updated_at)
@@ -431,7 +447,7 @@ func (m *migrator) ownerOf(createdBy sql.NullString) sql.NullString {
}
// uniqueResName makes a resource name unique within a tenant (Atom enforces
// resources(name, tenant_id); rules/reports carry no such Magistrala
// resources(name, tenant_id); rules/reports/alarms carry no such Magistrala
// constraint, so same-tenant dups are possible). Suffixes -2, -3, … on collision.
func uniqueResName(seen map[string]int, tenant, name string) string {
key := tenant + "|" + strings.ToLower(name)
@@ -457,8 +473,8 @@ func (m *migrator) phaseRules(ctx context.Context, rep *report) error {
m.resourceDomain[r.ID] = r.DomainID
name := uniqueResName(seen, r.DomainID, firstNonEmpty(nsToStr(r.Name), r.ID))
extra := map[string]any{
attributeStatus: entityStatus(r.Status),
"logic_type": r.LogicType,
"status": entityStatus(r.Status),
"logic_type": r.LogicType,
}
putStr(extra, "input_channel", r.InputChannel)
putStr(extra, "input_topic", r.InputTopic)
@@ -507,7 +523,7 @@ func (m *migrator) phaseReports(ctx context.Context, rep *report) error {
}
m.resourceDomain[rp.ID] = rp.DomainID
name := uniqueResName(seen, rp.DomainID, firstNonEmpty(nsToStr(rp.Name), rp.ID))
extra := map[string]any{attributeStatus: entityStatus(rp.Status)}
extra := map[string]any{"status": entityStatus(rp.Status)}
putStr(extra, "description", rp.Description)
putStr(extra, "report_template", rp.ReportTemplate)
putStr(extra, "updated_by", rp.UpdatedBy)
@@ -541,6 +557,58 @@ func (m *migrator) phaseReports(ctx context.Context, rep *report) error {
return nil
}
// phaseAlarms: alarms.alarms -> resources (kind=alarm). Alarms have no name in
// Magistrala; the measurement is used (id fallback), deduped per tenant.
func (m *migrator) phaseAlarms(ctx context.Context, rep *report) error {
rows, err := readAlarms(ctx, m.alarmsDB)
if err != nil {
return err
}
seen := map[string]int{}
for _, a := range rows {
if !m.tenants[a.DomainID] {
rep.skip("alarm_orphan_domain")
continue
}
m.resourceDomain[a.ID] = a.DomainID
name := uniqueResName(seen, a.DomainID, firstNonEmpty(a.Measurement, a.ID))
extra := map[string]any{
"rule_id": a.RuleID,
"channel_id": a.ChannelID,
"client_id": a.ClientID,
"subtopic": a.Subtopic,
"measurement": a.Measurement,
"value": a.Value,
"unit": a.Unit,
"threshold": a.Threshold,
"cause": a.Cause,
"alarm_status": a.Status,
"severity": a.Severity,
}
putStr(extra, "assignee_id", a.AssigneeID)
putStr(extra, "updated_by", a.UpdatedBy)
putStr(extra, "assigned_by", a.AssignedBy)
putStr(extra, "acknowledged_by", a.AcknowledgedBy)
putStr(extra, "resolved_by", a.ResolvedBy)
if a.AssignedAt.Valid {
extra["assigned_at"] = a.AssignedAt.Time
}
if a.AcknowledgedAt.Valid {
extra["acknowledged_at"] = a.AcknowledgedAt.Time
}
if a.ResolvedAt.Valid {
extra["resolved_at"] = a.ResolvedAt.Time
}
// Alarms carry no created_by; owner_id stays NULL.
if err := m.insertResource(ctx, a.ID, "alarm", name, a.DomainID, sql.NullString{},
attrs(a.Metadata, extra), ntToTime(a.CreatedAt), ntPtr(a.UpdatedAt)); err != nil {
return err
}
rep.count("resources.alarms", 1)
}
return nil
}
func (m *migrator) phaseGroups(ctx context.Context, rep *report) error {
rows, err := readGroups(ctx, m.groupsDB)
if err != nil {
@@ -637,12 +705,12 @@ type roleScope struct {
func (m *migrator) phaseRoles(ctx context.Context, rep *report) error {
families := []roleScope{
{collectionDomains, m.domainsDB, func(id string) (string, bool) { return id, m.tenants[id] }, permissionScopeTenant, ""},
{"clients", m.clientsDB, func(id string) (string, bool) { d, ok := m.clientDomain[id]; return d, ok }, permissionScopeObject, objectKindEntity},
{"channels", m.channelsDB, func(id string) (string, bool) { d, ok := m.channelDomain[id]; return d, ok }, permissionScopeObject, objectKindResource},
{"rules", m.reDB, func(id string) (string, bool) { d, ok := m.resourceDomain[id]; return d, ok }, permissionScopeObject, objectKindResource},
{"reports", m.reportsDB, func(id string) (string, bool) { d, ok := m.resourceDomain[id]; return d, ok }, permissionScopeObject, objectKindResource},
{collectionGroups, m.groupsDB, func(id string) (string, bool) { d, ok := m.groupDomain[id]; return d, ok }, permissionScopeGroup, ""},
{"domains", m.domainsDB, func(id string) (string, bool) { return id, m.tenants[id] }, "tenant", ""},
{"clients", m.clientsDB, func(id string) (string, bool) { d, ok := m.clientDomain[id]; return d, ok }, "object", "entity"},
{"channels", m.channelsDB, func(id string) (string, bool) { d, ok := m.channelDomain[id]; return d, ok }, "object", "resource"},
{"rules", m.reDB, func(id string) (string, bool) { d, ok := m.resourceDomain[id]; return d, ok }, "object", "resource"},
{"reports", m.reportsDB, func(id string) (string, bool) { d, ok := m.resourceDomain[id]; return d, ok }, "object", "resource"},
{"groups", m.groupsDB, func(id string) (string, bool) { d, ok := m.groupDomain[id]; return d, ok }, "group", ""},
}
for _, f := range families {
if err := m.migrateRoleFamily(ctx, rep, f); err != nil {
@@ -674,7 +742,7 @@ func (m *migrator) migrateRoleFamily(ctx context.Context, rep *report, f roleSco
// (e.g. every client has an "admin" role). Embed the object id to keep the
// Atom role name unique within the tenant.
roleName := f.prefix + ":" + r.EntityID + ":" + r.Name
if f.scopeMode == permissionScopeTenant {
if f.scopeMode == "tenant" {
roleName = f.prefix + ":" + r.Name // domain roles: one set per tenant
}
if err := m.exec(ctx,
@@ -750,7 +818,7 @@ func (m *migrator) migrateRoleFamily(ctx context.Context, rep *report, f roleSco
return err
}
rep.count("role_assignments", 1)
if f.prefix == collectionDomains {
if f.prefix == "domains" {
if err := m.exec(ctx,
`INSERT INTO tenant_memberships (tenant_id, entity_id, status)
VALUES ($1,$2,'active') ON CONFLICT DO NOTHING`,
@@ -775,16 +843,16 @@ type permissionBlockPlan struct {
}
func (f roleScope) blockPlans(roleID, objectID, tenant, rawAction string) []permissionBlockPlan {
if f.scopeMode != permissionScopeGroup {
if f.scopeMode != "group" {
blockID := derivedUUID("block", f.prefix, roleID)
switch f.scopeMode {
case permissionScopeTenant:
case "tenant":
return []permissionBlockPlan{{
ID: blockID, TenantID: tenant, ScopeMode: permissionScopeTenant,
ID: blockID, TenantID: tenant, ScopeMode: "tenant",
}}
default:
return []permissionBlockPlan{{
ID: blockID, TenantID: tenant, ScopeMode: permissionScopeObject,
ID: blockID, TenantID: tenant, ScopeMode: "object",
ObjectKind: f.objKind, ObjectID: objectID,
}}
}
@@ -793,30 +861,30 @@ func (f roleScope) blockPlans(roleID, objectID, tenant, rawAction string) []perm
action := strings.ToLower(strings.TrimSpace(rawAction))
switch {
case strings.HasPrefix(action, "subgroup_client"):
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "descendant", objectKindEntity, "entity:device")}
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "descendant", "entity", "entity:device")}
case strings.HasPrefix(action, "client"):
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "direct", objectKindEntity, "entity:device")}
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "direct", "entity", "entity:device")}
case strings.HasPrefix(action, "subgroup_channel"):
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "descendant", objectKindResource, "resource:channel")}
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "descendant", "resource", "resource:channel")}
case strings.HasPrefix(action, "channel"):
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "direct", objectKindResource, "resource:channel")}
return []permissionBlockPlan{groupObjectBlock(roleID, tenant, objectID, "direct", "resource", "resource:channel")}
case strings.HasPrefix(action, "subgroup"):
return []permissionBlockPlan{groupKindBlock(roleID, tenant, objectID, "descendant")}
default:
return []permissionBlockPlan{{
ID: derivedUUID("block", collectionGroups, roleID, "self"),
TenantID: tenant, ScopeMode: permissionScopeObject, ObjectKind: permissionScopeGroup, ObjectID: objectID,
ID: derivedUUID("block", "groups", roleID, "self"),
TenantID: tenant, ScopeMode: "object", ObjectKind: "group", ObjectID: objectID,
}}
}
}
func groupObjectBlock(roleID, tenant, groupID, depth, objectKind, objectType string) permissionBlockPlan {
scopeMode := permissionScopeGroupDirectObjects
scopeMode := "group_direct_objects"
if depth == "descendant" {
scopeMode = permissionScopeGroupDescendantObjects
scopeMode = "group_descendant_objects"
}
return permissionBlockPlan{
ID: derivedUUID("block", collectionGroups, roleID, depth, objectType),
ID: derivedUUID("block", "groups", roleID, depth, objectType),
TenantID: tenant,
ScopeMode: scopeMode,
ObjectKind: objectKind,
@@ -831,7 +899,7 @@ func groupKindBlock(roleID, tenant, groupID, depth string) permissionBlockPlan {
scopeMode = "group_descendant_groups"
}
return permissionBlockPlan{
ID: derivedUUID("block", collectionGroups, roleID, depth, permissionScopeGroup),
ID: derivedUUID("block", "groups", roleID, depth, "group"),
TenantID: tenant,
ScopeMode: scopeMode,
GroupID: groupID,
@@ -951,7 +1019,7 @@ func (m *migrator) phasePATs(ctx context.Context, rep *report) error {
// secret_hash NULL: Magistrala PAT secret is not convertible (see PLAN §5).
if err := m.exec(ctx,
`INSERT INTO credentials (id, entity_id, kind, identifier, metadata, status, expires_at)
VALUES ($1,$2,'access_token',$3,$4,$5,$6) ON CONFLICT (id) DO NOTHING`,
VALUES ($1,$2,'api_key',$3,$4,$5,$6) ON CONFLICT (id) DO NOTHING`,
p.ID, p.UserID.String, p.ID, string(meta), status, ntPtr(p.ExpiresAt),
); err != nil {
return err
@@ -981,7 +1049,7 @@ func (m *migrator) phaseInvitations(ctx context.Context, rep *report) error {
if m.migratedUsers[iv.InviteeID] {
invitee = sql.NullString{String: iv.InviteeID, Valid: true}
}
roleID := derivedUUID("role", collectionDomains, iv.RoleID)
roleID := derivedUUID("role", "domains", iv.RoleID)
roleArg := any(roleID)
if !m.migratedRoles[roleID] {
roleArg = nil
@@ -1113,10 +1181,6 @@ func statusCred(s int16) string {
return "revoked"
}
func clientSharedKeyCredentialID(clientID string) string {
return derivedUUID("shared-key", clientID)
}
// attrs merges Magistrala metadata jsonb with extra keys into an Atom attributes
// JSON string.
func attrs(meta []byte, extra map[string]any) string {
-145
View File
@@ -1,145 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package main
import (
"crypto/aes"
"crypto/cipher"
"testing"
"github.com/google/uuid"
)
func TestGroupRoleBlockPlansUseAtomSupportedScopes(t *testing.T) {
scope := roleScope{prefix: "groups", scopeMode: "group"}
roleID := "role-1"
groupID := "11111111-1111-1111-1111-111111111111"
tenantID := "22222222-2222-2222-2222-222222222222"
cases := []struct {
name string
action string
scopeMode string
objectKind any
objectType any
objectID any
groupID any
}{
{
name: "direct device action", action: "client_read",
scopeMode: "group_direct_objects", objectKind: "entity", objectType: "entity:device", groupID: groupID,
},
{
name: "descendant device action", action: "subgroup_client_set_parent_group",
scopeMode: "group_descendant_objects", objectKind: "entity", objectType: "entity:device", groupID: groupID,
},
{
name: "direct channel action", action: "channel_publish",
scopeMode: "group_direct_objects", objectKind: "resource", objectType: "resource:channel", groupID: groupID,
},
{
name: "descendant channel action", action: "subgroup_channel_subscribe",
scopeMode: "group_descendant_objects", objectKind: "resource", objectType: "resource:channel", groupID: groupID,
},
{
name: "descendant group action", action: "subgroup_set_child",
scopeMode: "group_descendant_groups", groupID: groupID,
},
{
name: "self group action", action: "manage_role",
scopeMode: "object", objectKind: "group", objectID: groupID,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plans := scope.blockPlans(roleID, groupID, tenantID, tc.action)
if len(plans) != 1 {
t.Fatalf("expected one plan, got %d", len(plans))
}
got := plans[0]
if got.ScopeMode != tc.scopeMode {
t.Fatalf("scope mode = %q, want %q", got.ScopeMode, tc.scopeMode)
}
if got.TenantID != tenantID {
t.Fatalf("tenant = %q, want %q", got.TenantID, tenantID)
}
if got.ObjectKind != tc.objectKind || got.ObjectType != tc.objectType || got.ObjectID != tc.objectID || got.GroupID != tc.groupID {
t.Fatalf("plan = %+v, want objectKind=%v objectType=%v objectID=%v groupID=%v", got, tc.objectKind, tc.objectType, tc.objectID, tc.groupID)
}
})
}
}
func TestMapActionPreservesChannelPublishSubscribeVariants(t *testing.T) {
cases := map[string]string{
"channel_publish": actionPublish,
"subgroup_channel_publish": actionPublish,
"channel_subscribe": actionSubscribe,
"subgroup_channel_subscribe": actionSubscribe,
}
for raw, want := range cases {
got, ok := mapAction(raw)
if !ok {
t.Fatalf("mapAction(%q) returned not ok", raw)
}
if got != want {
t.Fatalf("mapAction(%q) = %q, want %q", raw, got, want)
}
}
}
func TestClientSharedKeyCredentialIDIsStable(t *testing.T) {
clientID := "11111111-1111-1111-1111-111111111111"
got := clientSharedKeyCredentialID(clientID)
if got == "" {
t.Fatal("expected credential id")
}
if again := clientSharedKeyCredentialID(clientID); again != got {
t.Fatalf("credential id is not stable: got %q then %q", got, again)
}
if oldAPIKeyID := derivedUUID("devcred", clientID); oldAPIKeyID == got {
t.Fatalf("shared-key credential id must not collide with legacy API-key id %q", oldAPIKeyID)
}
}
func TestNewSharedKeyMaterialEncryptsRecoverableSecret(t *testing.T) {
secret := "client-secret"
credentialID := clientSharedKeyCredentialID("11111111-1111-1111-1111-111111111111")
cfg := config{
AtomKeyEncryptionKey: []byte("0123456789abcdef0123456789abcdef"),
AtomKeyEncryptionKeyID: "local:test",
}
material, err := newSharedKeyMaterial(credentialID, secret, cfg)
if err != nil {
t.Fatalf("new shared-key material: %v", err)
}
if material.Hash == "" || len(material.Ciphertext) == 0 || len(material.Nonce) != aeadNonceLen || len(material.LookupHash) != 32 {
t.Fatalf("unexpected material: %+v", material)
}
if material.KeyID != cfg.AtomKeyEncryptionKeyID || material.EncAlg != sharedKeyAEADAlg {
t.Fatalf("unexpected encryption metadata: %+v", material)
}
block, err := aes.NewCipher(cfg.AtomKeyEncryptionKey)
if err != nil {
t.Fatalf("cipher: %v", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("gcm: %v", err)
}
credUUID, err := uuid.Parse(credentialID)
if err != nil {
t.Fatalf("credential id: %v", err)
}
plaintext, err := aead.Open(nil, material.Nonce, material.Ciphertext, credUUID[:])
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if string(plaintext) != secret {
t.Fatalf("plaintext = %q, want %q", plaintext, secret)
}
}
+11
View File
@@ -295,6 +295,17 @@ func (m *migrator) pfOrphans(ctx context.Context, rep *report) error {
}
return out
}, "reports")
alarms, err := readAlarms(ctx, m.alarmsDB)
if err != nil {
return err
}
count(func() []string {
out := make([]string, len(alarms))
for i, a := range alarms {
out[i] = a.DomainID
}
return out
}, "alarms")
return nil
}
+38
View File
@@ -165,6 +165,34 @@ type srcReport struct {
ReportTemplate sql.NullString `db:"report_template"`
}
// srcAlarm is an alarm (alarms.alarms) -> Atom resource kind=alarm.
type srcAlarm struct {
ID string `db:"id"`
RuleID string `db:"rule_id"`
DomainID string `db:"domain_id"`
ChannelID string `db:"channel_id"`
Subtopic string `db:"subtopic"`
ClientID string `db:"client_id"`
Measurement string `db:"measurement"`
Value string `db:"value"`
Unit string `db:"unit"`
Threshold string `db:"threshold"`
Cause string `db:"cause"`
Status int16 `db:"status"`
Severity int16 `db:"severity"`
AssigneeID sql.NullString `db:"assignee_id"`
CreatedAt sql.NullTime `db:"created_at"`
UpdatedAt sql.NullTime `db:"updated_at"`
UpdatedBy sql.NullString `db:"updated_by"`
AssignedAt sql.NullTime `db:"assigned_at"`
AssignedBy sql.NullString `db:"assigned_by"`
AcknowledgedAt sql.NullTime `db:"acknowledged_at"`
AcknowledgedBy sql.NullString `db:"acknowledged_by"`
ResolvedAt sql.NullTime `db:"resolved_at"`
ResolvedBy sql.NullString `db:"resolved_by"`
Metadata []byte `db:"metadata"`
}
// --- readers ---
func readDomains(ctx context.Context, db *sqlx.DB) ([]srcDomain, error) {
@@ -228,6 +256,16 @@ func readReports(ctx context.Context, db *sqlx.DB) ([]srcReport, error) {
return out, db.SelectContext(ctx, &out, q)
}
func readAlarms(ctx context.Context, db *sqlx.DB) ([]srcAlarm, error) {
var out []srcAlarm
q := `SELECT id, rule_id, domain_id, channel_id, subtopic, client_id, measurement, value,
unit, threshold, cause, status, severity, assignee_id, created_at, updated_at,
updated_by, assigned_at, assigned_by, acknowledged_at, acknowledged_by,
resolved_at, resolved_by, metadata
FROM alarms`
return out, db.SelectContext(ctx, &out, q)
}
// readRoleFamily reads <prefix>_roles, _role_actions, _role_members for one service.
func readRoleFamily(ctx context.Context, db *sqlx.DB, prefix string) ([]srcRole, []srcRoleAction, []srcRoleMember, error) {
var roles []srcRole
+8 -24
View File
@@ -39,14 +39,6 @@ func (m *migrator) Verify(ctx context.Context, rep *report) error {
if err != nil {
return err
}
atomSharedKeys, err := idSet(ctx, m.atom, `SELECT id::text FROM credentials WHERE kind = 'shared_key'`)
if err != nil {
return err
}
atomAccessTokens, err := idSet(ctx, m.atom, `SELECT id::text FROM credentials WHERE kind = 'access_token'`)
if err != nil {
return err
}
// 1. tenants
doms, err := readDomains(ctx, m.domainsDB)
@@ -72,21 +64,6 @@ func (m *migrator) Verify(ctx context.Context, rep *report) error {
m.reconcile(rep, "entities.clients", idsOf(len(clients), func(i int) (string, bool) {
return clients[i].ID, domSet[clients[i].DomainID]
}), atomEntities)
m.reconcile(rep, "credentials.device_shared_keys", idsOf(len(clients), func(i int) (string, bool) {
return clientSharedKeyCredentialID(clients[i].ID), domSet[clients[i].DomainID] && clients[i].Secret.Valid && clients[i].Secret.String != ""
}), atomSharedKeys)
pats, err := readPATs(ctx, m.authDB)
if err != nil {
return err
}
userSet := map[string]bool{}
for _, u := range users {
userSet[u.ID] = true
}
m.reconcile(rep, "credentials.pats", idsOf(len(pats), func(i int) (string, bool) {
return pats[i].ID, pats[i].UserID.Valid && userSet[pats[i].UserID.String]
}), atomAccessTokens)
// 4. resources
chans, err := readChannels(ctx, m.channelsDB)
@@ -97,7 +74,7 @@ func (m *migrator) Verify(ctx context.Context, rep *report) error {
return chans[i].ID, domSet[chans[i].DomainID]
}), atomResources)
// 4b. resources: rules, reports
// 4b. resources: rules, reports, alarms
rules, err := readRules(ctx, m.reDB)
if err != nil {
return err
@@ -112,6 +89,13 @@ func (m *migrator) Verify(ctx context.Context, rep *report) error {
m.reconcile(rep, "resources.reports", idsOf(len(reports), func(i int) (string, bool) {
return reports[i].ID, domSet[reports[i].DomainID]
}), atomResources)
alarms, err := readAlarms(ctx, m.alarmsDB)
if err != nil {
return err
}
m.reconcile(rep, "resources.alarms", idsOf(len(alarms), func(i int) (string, bool) {
return alarms[i].ID, domSet[alarms[i].DomainID]
}), atomResources)
// 5. object_groups
grps, err := readGroups(ctx, m.groupsDB)
-3
View File
@@ -100,9 +100,6 @@ linters:
- linters:
- godot
path: cli/commands_test.go
- linters:
- goconst
path: (.+)_test\.go$
- path: (.+)\.go$
text: |-
string `Usage:
+12 -13
View File
@@ -31,7 +31,6 @@ const (
clientsPort = "9006"
channelsPort = "9005"
domainsPort = "9003"
updateKey = "Update"
)
var (
@@ -381,7 +380,7 @@ func read(ctx context.Context, s sdk.SDK, conf Config, domainID, token string, u
func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.User, groups []sdk.Group, clients []sdk.Client, channels []sdk.Channel) error {
for _, user := range users {
user.FirstName = namesgenerator.Generate()
user.Metadata = sdk.Metadata{updateKey: namesgenerator.Generate()}
user.Metadata = sdk.Metadata{"Update": namesgenerator.Generate()}
rUser, err := s.UpdateUser(ctx, user, token)
if err != nil {
return fmt.Errorf("failed to update user %w", err)
@@ -389,8 +388,8 @@ func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.
if rUser.FirstName != user.FirstName {
return fmt.Errorf("failed to update user name before %s after %s", user.FirstName, rUser.FirstName)
}
if rUser.Metadata[updateKey] != user.Metadata[updateKey] {
return fmt.Errorf("failed to update user metadata before %s after %s", user.Metadata[updateKey], rUser.Metadata[updateKey])
if rUser.Metadata["Update"] != user.Metadata["Update"] {
return fmt.Errorf("failed to update user metadata before %s after %s", user.Metadata["Update"], rUser.Metadata["Update"])
}
user = rUser
user.Credentials.Username = namesgenerator.Generate()
@@ -437,7 +436,7 @@ func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.
}
for _, group := range groups {
group.Name = namesgenerator.Generate()
group.Metadata = sdk.Metadata{updateKey: namesgenerator.Generate()}
group.Metadata = sdk.Metadata{"Update": namesgenerator.Generate()}
rGroup, err := s.UpdateGroup(ctx, group, domainID, token)
if err != nil {
return fmt.Errorf("failed to update group %w", err)
@@ -445,8 +444,8 @@ func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.
if rGroup.Name != group.Name {
return fmt.Errorf("failed to update group name before %s after %s", group.Name, rGroup.Name)
}
if rGroup.Metadata[updateKey] != group.Metadata[updateKey] {
return fmt.Errorf("failed to update group metadata before %s after %s", group.Metadata[updateKey], rGroup.Metadata[updateKey])
if rGroup.Metadata["Update"] != group.Metadata["Update"] {
return fmt.Errorf("failed to update group metadata before %s after %s", group.Metadata["Update"], rGroup.Metadata["Update"])
}
group = rGroup
rGroup, err = s.DisableGroup(ctx, group.ID, domainID, token)
@@ -467,7 +466,7 @@ func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.
}
for _, t := range clients {
t.Name = namesgenerator.Generate()
t.Metadata = sdk.Metadata{updateKey: namesgenerator.Generate()}
t.Metadata = sdk.Metadata{"Update": namesgenerator.Generate()}
rClient, err := s.UpdateClient(ctx, t, domainID, token)
if err != nil {
return fmt.Errorf("failed to update client %w", err)
@@ -475,8 +474,8 @@ func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.
if rClient.Name != t.Name {
return fmt.Errorf("failed to update client name before %s after %s", t.Name, rClient.Name)
}
if rClient.Metadata[updateKey] != t.Metadata[updateKey] {
return fmt.Errorf("failed to update client metadata before %s after %s", t.Metadata[updateKey], rClient.Metadata[updateKey])
if rClient.Metadata["Update"] != t.Metadata["Update"] {
return fmt.Errorf("failed to update client metadata before %s after %s", t.Metadata["Update"], rClient.Metadata["Update"])
}
t = rClient
rClient, err = s.UpdateClientSecret(ctx, t.ID, t.Credentials.Secret, domainID, token)
@@ -511,7 +510,7 @@ func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.
}
for _, channel := range channels {
channel.Name = namesgenerator.Generate()
channel.Metadata = sdk.Metadata{updateKey: namesgenerator.Generate()}
channel.Metadata = sdk.Metadata{"Update": namesgenerator.Generate()}
rChannel, err := s.UpdateChannel(ctx, channel, domainID, token)
if err != nil {
return fmt.Errorf("failed to update channel %w", err)
@@ -519,8 +518,8 @@ func update(ctx context.Context, s sdk.SDK, domainID, token string, users []sdk.
if rChannel.Name != channel.Name {
return fmt.Errorf("failed to update channel name before %s after %s", channel.Name, rChannel.Name)
}
if rChannel.Metadata[updateKey] != channel.Metadata[updateKey] {
return fmt.Errorf("failed to update channel metadata before %s after %s", channel.Metadata[updateKey], rChannel.Metadata[updateKey])
if rChannel.Metadata["Update"] != channel.Metadata["Update"] {
return fmt.Errorf("failed to update channel metadata before %s after %s", channel.Metadata["Update"], rChannel.Metadata["Update"])
}
channel = rChannel
rChannel, err = s.DisableChannel(ctx, channel.ID, domainID, token)