mirror of
https://github.com/absmach/magistrala.git
synced 2026-08-07 15:25:48 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05ce424453 | |||
| afd852d7b4 | |||
| 3c7e81fe1f | |||
| db81b3033b | |||
| 710f18b95a | |||
| 59f8cc9b1a | |||
| a6264fb5ae | |||
| 8408fac353 | |||
| 369cc49874 | |||
| d252655ab4 | |||
| 2dd5225288 | |||
| 22ffcda201 | |||
| 3a13e81956 | |||
| eed4d4aa00 | |||
| 601bbacd17 | |||
| d7c49e068d | |||
| 9cc0991227 | |||
| 523305c120 | |||
| 19bdd334b2 | |||
| 746086f1cd | |||
| b929d51b3f | |||
| 0bb8b0947a | |||
| dbbd8453e9 | |||
| aee8474e49 | |||
| 36cfd989fe | |||
| baf507e699 | |||
| 4921cb4cf5 | |||
| 9a186fd26f |
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)+"/#"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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 == "" {
|
||||
|
||||
@@ -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}
|
||||
@@ -60,6 +60,7 @@ func runMain() int {
|
||||
log.Printf("init: %v", err)
|
||||
return 1
|
||||
}
|
||||
m.reportDir = *reportDir
|
||||
defer m.Close()
|
||||
|
||||
rep := newReport(mode)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -100,9 +100,6 @@ linters:
|
||||
- linters:
|
||||
- godot
|
||||
path: cli/commands_test.go
|
||||
- linters:
|
||||
- goconst
|
||||
path: (.+)_test\.go$
|
||||
- path: (.+)\.go$
|
||||
text: |-
|
||||
string `Usage:
|
||||
|
||||
+12
-13
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user