Compare commits

..

3 Commits

Author SHA1 Message Date
Felix Gateru 0ff4e4e4fa refactor(provisionmanage.go): make RemoveMemberDomain generic
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
2026-04-23 12:57:34 +03:00
Felix Gateru c669f7c9a4 refactor: add user removal event handling
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
2026-04-23 12:57:30 +03:00
Felix Gateru 687f638f0a feat: remove user from domain
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
2026-04-23 12:54:28 +03:00
620 changed files with 187476 additions and 13446 deletions
+49 -52
View File
@@ -10,7 +10,7 @@ on:
paths:
- ".github/workflows/api-tests.yaml"
- "api/**"
- "internal/atom/**"
- "auth/api/http/**"
- "channels/api/http/**"
- "clients/api/http/**"
- "domains/api/http/**"
@@ -20,9 +20,9 @@ on:
- "bootstrap/api/**"
- "certs/api/http/**"
- "readers/api/http/**"
- "re/**"
- "alarms/**"
- "reports/**"
- "re/api/**"
- "alarms/api/**"
- "reports/api/**"
- "apidocs/openapi/**"
pull_request:
branches:
@@ -30,7 +30,7 @@ on:
paths:
- ".github/workflows/api-tests.yaml"
- "api/**"
- "internal/atom/**"
- "auth/api/http/**"
- "channels/api/http/**"
- "clients/api/http/**"
- "domains/api/http/**"
@@ -40,9 +40,9 @@ on:
- "bootstrap/api/**"
- "certs/api/http/**"
- "readers/api/http/**"
- "re/**"
- "alarms/**"
- "reports/**"
- "re/api/**"
- "alarms/api/**"
- "reports/api/**"
- "apidocs/openapi/**"
concurrency:
@@ -50,15 +50,17 @@ concurrency:
cancel-in-progress: true
env:
ATOM_LOGIN_URL: http://localhost/auth/login
USER_IDENTITY: admin
TOKENS_URL: http://localhost:9002/users/tokens/issue
CREATE_DOMAINS_URL: http://localhost:9003/domains
USER_IDENTITY: admin@example.com
USER_SECRET: 12345678
DOMAIN_NAME: demo-test
USERS_URL: http://localhost
DOMAIN_URL: http://localhost
CLIENTS_URL: http://localhost
CHANNELS_URL: http://localhost
GROUPS_URL: http://localhost
USERS_URL: http://localhost:9002
DOMAIN_URL: http://localhost:9003
CLIENTS_URL: http://localhost:9006
CHANNELS_URL: http://localhost:9005
GROUPS_URL: http://localhost:9004
AUTH_URL: http://localhost:9001
JOURNAL_URL: http://localhost:9021
BOOTSTRAP_URL: http://localhost:9013
CERTS_URL: http://localhost:9019
@@ -72,7 +74,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -94,29 +96,28 @@ jobs:
- "apidocs/openapi/journal.yaml"
- "journal/api/**"
auth:
- "apidocs/openapi/auth.yaml"
- "auth/api/http/**"
domains:
- "apidocs/openapi/domains.yaml"
- "internal/atom/**"
- "domains/api/http/**"
clients:
- "apidocs/openapi/clients.yaml"
- "internal/atom/**"
- "clients/api/http/**"
channels:
- "apidocs/openapi/channels.yaml"
- "internal/atom/**"
- "channels/api/http/**"
groups:
- "apidocs/openapi/groups.yaml"
- "internal/atom/**"
- "groups/api/http/**"
users:
- "apidocs/openapi/users.yaml"
- "internal/atom/**"
- "users/api/**"
bootstrap:
@@ -133,30 +134,21 @@ jobs:
re:
- "apidocs/openapi/rules.yaml"
- "re/**"
- "cmd/re/**"
- "internal/atom/**"
- "re/api/**"
alarms:
- "apidocs/openapi/alarms.yaml"
- "alarms/**"
- "cmd/alarms/**"
- "internal/atom/**"
- "alarms/api/**"
reports:
- "apidocs/openapi/reports.yaml"
- "reports/**"
- "cmd/reports/**"
- "internal/atom/**"
- "reports/api/**"
- name: Build images
run: make all -j $(nproc) && make dockers_dev -j $(nproc)
- name: Provision Atom service tokens
run: make provision_atom_tokens
- name: Start containers
run: make run_latest_ci up args="-d" && make run_addons up args="-d"
run: make run_latest up args="-d" && make run_addons up args="-d"
- name: Wait for services to be ready
run: |
@@ -165,28 +157,24 @@ jobs:
# Check if services are responding
for i in {1..30}; do
if curl -f -s http://localhost/health > /dev/null 2>&1; then
if curl -f -s http://localhost:9002/health > /dev/null 2>&1; then
echo "Services are ready!"
exit 0
break
fi
echo "Waiting for services... ($i/30)"
sleep 2
done
echo "Services failed to become ready" >&2
docker compose -f docker/docker-compose.yaml -f docker/docker-compose-ci.yaml --env-file docker/.env --env-file docker/.env.tokens -p "${USER_REPO:-absmach_magistrala}" ps || true
docker logs --tail 100 magistrala-nginx || true
docker logs --tail 100 magistrala-atom || true
docker logs --tail 100 magistrala-atom-bootstrap || true
exit 1
- name: Set access token
run: |
export USER_TOKEN=$(curl -fsS -X POST "$ATOM_LOGIN_URL" -H "Content-Type: application/json" -d "{\"identifier\":\"$USER_IDENTITY\",\"secret\":\"$USER_SECRET\",\"kind\":\"password\"}" | jq -er .token)
export USER_TOKEN=$(curl -sSX POST $TOKENS_URL -H "Content-Type: application/json" -d "{\"identity\": \"$USER_IDENTITY\",\"secret\": \"$USER_SECRET\"}" | jq -r .access_token)
export DOMAIN_ID=$(curl -sSX POST $CREATE_DOMAINS_URL -H "Content-Type: application/json" -H "Authorization: Bearer $USER_TOKEN" -d "{\"name\":\"$DOMAIN_NAME\",\"route\":\"$DOMAIN_NAME\"}" | jq -r .id)
echo "USER_TOKEN=$USER_TOKEN" >> $GITHUB_ENV
export CLIENT_SECRET=$(magistrala-cli provision test | /usr/bin/grep -Eo '"secret": "[^"]+"' | awk 'NR % 2 == 0' | sed 's/"secret": "\(.*\)"/\1/')
echo "CLIENT_SECRET=$CLIENT_SECRET" >> $GITHUB_ENV
- name: Run Users API tests
if: (steps.changes.outputs.users == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('users/api/**') != ''
if: steps.changes.outputs.users == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/users.yaml
@@ -195,7 +183,7 @@ jobs:
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --exclude-operation-id=requestPasswordReset --phases=examples'
- name: Run Groups API tests
if: (steps.changes.outputs.groups == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('groups/api/http/**') != ''
if: steps.changes.outputs.groups == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/groups.yaml
@@ -204,7 +192,7 @@ jobs:
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Clients API tests
if: (steps.changes.outputs.clients == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('clients/api/http/**') != ''
if: steps.changes.outputs.clients == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/clients.yaml
@@ -213,7 +201,7 @@ jobs:
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Channels API tests
if: (steps.changes.outputs.channels == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('channels/api/http/**') != ''
if: steps.changes.outputs.channels == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/channels.yaml
@@ -221,8 +209,17 @@ jobs:
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Auth API tests
if: steps.changes.outputs.auth == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/auth.yaml
base-url: ${{ env.AUTH_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Domains API tests
if: (steps.changes.outputs.domains == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('domains/api/http/**') != ''
if: steps.changes.outputs.domains == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/domains.yaml
@@ -240,7 +237,7 @@ jobs:
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Bootstrap API tests
if: (steps.changes.outputs.bootstrap == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('bootstrap/api/**') != ''
if: steps.changes.outputs.bootstrap == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/bootstrap.yaml
@@ -249,7 +246,7 @@ jobs:
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Certs API tests
if: (steps.changes.outputs.certs == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('docker/addons/certs/docker-compose.yaml') != ''
if: steps.changes.outputs.certs == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/certs.yaml
@@ -295,4 +292,4 @@ jobs:
- name: Stop containers
if: always()
run: make run_latest_ci down args="-v" && make run_addons down args="-v"
run: make run_latest down args="-v" && make run_addons down args="-v"
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
+1 -2
View File
@@ -17,7 +17,7 @@ jobs:
PROTOC_GEN_GO_GRPC_VERSION: "v1.6.0"
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Install Go
uses: actions/setup-go@v6
@@ -50,7 +50,6 @@ jobs:
- "pkg/messaging/*.pb.go"
mocks:
- "tools/config/.mockery.yaml"
- ".github/workflows/check-generated-files.yaml"
- "pkg/sdk/sdk.go"
- "users/postgres/clients.go"
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Check License Header
run: |
+15 -7
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
@@ -21,7 +21,7 @@ jobs:
cache-dependency-path: "go.sum"
- name: Run linters
uses: golangci/golangci-lint-action@v9.2.1
uses: golangci/golangci-lint-action@v9.2.0
with:
version: v2.10.1
args: --config ./tools/config/.golangci.yaml
@@ -32,7 +32,7 @@ jobs:
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
@@ -45,12 +45,20 @@ jobs:
make all -j $(nproc)
compile-check:
name: Compile Check Redis Event Store
name: Compile Check ${{ matrix.variant.name }}
runs-on: ubuntu-latest
needs: lint
strategy:
fail-fast: true
matrix:
variant:
- name: redis
env: MG_ES_TYPE=es_redis
target: fluxmq
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
@@ -58,6 +66,6 @@ jobs:
go-version-file: go.mod
cache-dependency-path: "go.sum"
- name: Compile check
- name: Compile check for ${{ matrix.variant.name }}
run: |
MG_ES_TYPE=es_redis make all -j $(nproc)
${{ matrix.variant.env }} make ${{ matrix.variant.target }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Build Swagger UI
run: |
+40 -15
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
@@ -54,7 +54,7 @@ jobs:
modules: ${{ steps.set-matrix.outputs.modules }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -66,8 +66,21 @@ jobs:
workflow:
- ".github/workflows/tests.yaml"
auth:
- "auth/**"
- "cmd/auth/**"
- "auth.proto"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "pkg/ulid/**"
- "pkg/uuid/**"
channels:
- "channels/**"
- "cmd/channels/**"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "auth/**"
- "pkg/sdk/**"
- "clients/api/grpc/**"
- "groups/api/grpc/**"
@@ -81,6 +94,10 @@ jobs:
clients:
- "clients/**"
- "cmd/clients/**"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "auth/**"
- "pkg/ulid/**"
- "pkg/uuid/**"
- "pkg/events/**"
@@ -91,10 +108,18 @@ jobs:
domains:
- "domains/**"
- "cmd/domains/**"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "auth/**"
- "internal/grpc/**"
groups:
- "groups/**"
- "cmd/groups/**"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "auth/**"
- "pkg/ulid/**"
- "pkg/uuid/**"
- "clients/api/grpc/**"
@@ -108,6 +133,9 @@ jobs:
journal:
- "journal/**"
- "cmd/journal/**"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "auth/**"
- "pkg/events/**"
logger:
@@ -130,6 +158,7 @@ jobs:
- "pkg/sdk/**"
- "pkg/errors/**"
- "pkg/groups/**"
- "auth/**"
- "internal/*"
- "clients/**"
- "users/**"
@@ -153,6 +182,10 @@ jobs:
users:
- "users/**"
- "cmd/users/**"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "auth/**"
- "pkg/ulid/**"
- "pkg/uuid/**"
- "pkg/events/**"
@@ -160,6 +193,8 @@ jobs:
notifications:
- "notifications/**"
- "cmd/notifications/**"
- "auth.pb.go"
- "auth_grpc.pb.go"
- "consumers/notifier.go"
- "pkg/events/**"
@@ -246,7 +281,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
@@ -271,19 +306,9 @@ jobs:
pkg-transformers) dir="pkg/transformers" ;;
pkg-ulid) dir="pkg/ulid" ;;
pkg-uuid) dir="pkg/uuid" ;;
channels) dir="pkg/channels" ;;
clients) dir="pkg/clients" ;;
domains) dir="pkg/domains" ;;
groups) dir="pkg/groups" ;;
*) dir="${{ matrix.module }}" ;;
esac
if [[ ! -d "$dir" ]]; then
echo "Skipping ${{ matrix.module }}; ./$dir is not present in this branch"
echo "mode: atomic" > coverage-${{ matrix.module }}.out
exit 0
fi
go test -mod=readonly --race -v -count=1 -failfast -coverprofile=coverage-${{ matrix.module }}.out ./$dir/...
- name: Upload coverage
@@ -300,7 +325,7 @@ jobs:
if: always() && needs.run-tests.result != 'cancelled'
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Download all coverage artifacts
uses: actions/download-artifact@v8
@@ -310,7 +335,7 @@ jobs:
merge-multiple: true
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV }}
directory: ./coverage
-6
View File
@@ -19,11 +19,5 @@ coverage
# Ignore Openbao data directory as it contains runtime-generated data
docker/addons/certs/openbao/
# Ignore generated local Atom service tokens.
docker/.env.tokens
# Ignore SeaweedFS data directory as it contains runtime-generated data
docker/data/*
demo-ui
node_modules
+18 -98
View File
@@ -4,8 +4,8 @@
override MG_DOCKER_IMAGE_NAME_PREFIX := ghcr.io/absmach/magistrala
MG_DOCKER_VOLUME_NAME_PREFIX ?= magistrala
BUILD_DIR ?= build
SERVICES = atom-bootstrap notifications certs re postgres-writer postgres-reader timescale-writer timescale-reader alarms reports journal fluxmq
TEST_API_SERVICES = journal certs clients users channels groups domains
SERVICES = auth users clients groups channels domains notifications certs re postgres-writer postgres-reader timescale-writer timescale-reader cli alarms reports bootstrap journal fluxmq
TEST_API_SERVICES = journal auth certs clients users channels groups domains
TEST_API = $(addprefix test_api_,$(TEST_API_SERVICES))
DOCKERS = $(addprefix docker_,$(SERVICES))
DOCKERS_DEV = $(addprefix docker_dev_,$(SERVICES))
@@ -23,15 +23,6 @@ space:= $(empty) $(empty)
DOCKER_PROJECT ?= $(shell echo $(subst $(space),,$(USER_REPO)) | sed -E 's/[^a-zA-Z0-9]/_/g' | tr '[:upper:]' '[:lower:]')
DOCKER_COMPOSE_COMMANDS_SUPPORTED := up down config restart
DEFAULT_DOCKER_COMPOSE_COMMAND := up
ATOM_TOKENS_ENV ?= docker/.env.tokens
REQUIRED_ATOM_TOKEN_ENVS := MG_ATOM_TOKEN_FLUXMQ_AUTH MG_ATOM_TOKEN_FLUXMQ_NODE1 MG_ATOM_TOKEN_FLUXMQ_NODE2 MG_ATOM_TOKEN_FLUXMQ_NODE3 MG_ATOM_TOKEN_JOURNAL MG_ATOM_TOKEN_NOTIFICATIONS MG_ATOM_TOKEN_TIMESCALE_READER MG_ATOM_TOKEN_RE MG_ATOM_TOKEN_ALARMS MG_ATOM_TOKEN_REPORTS MG_ATOM_TOKEN_POSTGRES_READER
PROVISION_ATOM_TOKENS ?= false
PROVISION_ATOM_TOKEN_GOALS := provision-atom-tokens
DOCKER_BASE_ENV_FILES := --env-file docker/.env
DOCKER_ENV_FILES = $(if $(filter down,$(DOCKER_COMPOSE_COMMAND)),$(DOCKER_BASE_ENV_FILES),$(DOCKER_BASE_ENV_FILES) --env-file $(ATOM_TOKENS_ENV))
DOCKER_PROVISION_ENV_FILES = $(DOCKER_BASE_ENV_FILES) $(if $(wildcard $(ATOM_TOKENS_ENV)),--env-file $(ATOM_TOKENS_ENV))
HOST_UID := $(shell id -u)
HOST_GID := $(shell id -g)
GRPC_MTLS_CERT_FILES_EXISTS = 0
MOCKERY = $(GOBIN)/mockery
MOCKERY_VERSION=3.6.4
@@ -88,48 +79,7 @@ define make_docker_dev
-f docker/Dockerfile.dev ./build
endef
define require_atom_tokens_env
@if [ -z "$(filter down,$(DOCKER_COMPOSE_COMMAND))" ]; then \
if [ ! -f "$(ATOM_TOKENS_ENV)" ]; then \
echo "Missing $(ATOM_TOKENS_ENV). Run 'make provision_atom_tokens' before starting the Docker Compose stack."; \
exit 2; \
fi; \
missing=""; \
for env_name in $(REQUIRED_ATOM_TOKEN_ENVS); do \
if ! grep -q "^$${env_name}=" "$(ATOM_TOKENS_ENV)"; then \
missing="$${missing} $${env_name}"; \
fi; \
done; \
if [ -n "$${missing}" ]; then \
echo "Missing Atom service token(s) in $(ATOM_TOKENS_ENV):$${missing}. Run 'make provision_atom_tokens' before starting the Docker Compose stack."; \
exit 2; \
fi; \
fi
endef
define ensure_atom_tokens_env
@if [ "$(PROVISION_ATOM_TOKENS)" = "true" ] && [ -z "$(filter down,$(DOCKER_COMPOSE_COMMAND))" ]; then \
$(MAKE) provision_atom_tokens; \
elif [ -z "$(filter down,$(DOCKER_COMPOSE_COMMAND))" ]; then \
if [ ! -f "$(ATOM_TOKENS_ENV)" ]; then \
echo "Missing $(ATOM_TOKENS_ENV). Run 'make provision_atom_tokens' before starting the Docker Compose stack."; \
exit 2; \
fi; \
missing=""; \
for env_name in $(REQUIRED_ATOM_TOKEN_ENVS); do \
if ! grep -q "^$${env_name}=" "$(ATOM_TOKENS_ENV)"; then \
missing="$${missing} $${env_name}"; \
fi; \
done; \
if [ -n "$${missing}" ]; then \
echo "Missing Atom service token(s) in $(ATOM_TOKENS_ENV):$${missing}. Run 'make provision_atom_tokens' before starting the Docker Compose stack."; \
exit 2; \
fi; \
fi
endef
define run_with_arch_detection
$(call require_atom_tokens_env)
@echo "Detecting architecture..."
@if [ "$(DETECTED_ARCH)" = "arm64" ] || [ "$(DETECTED_ARCH)" = "aarch64" ]; then \
echo "ARM64 architecture detected."; \
@@ -139,12 +89,12 @@ define run_with_arch_detection
docker tag $(MG_DOCKER_IMAGE_NAME_PREFIX)/$$svc $(MG_DOCKER_IMAGE_NAME_PREFIX)/$$svc:latest; \
done; \
sed -i.bak 's/^MG_RELEASE_TAG=.*/MG_RELEASE_TAG=latest/' docker/.env && rm -f docker/.env.bak; \
docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args); \
docker compose -f docker/docker-compose.yaml --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args); \
else \
echo "x86_64 architecture detected."; \
git checkout $(1); \
sed -i.bak 's/^MG_RELEASE_TAG=.*/MG_RELEASE_TAG=$(2)/' docker/.env && rm -f docker/.env.bak; \
docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args); \
docker compose -f docker/docker-compose.yaml --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args); \
fi
endef
@@ -175,9 +125,6 @@ DOCKER_PLATFORM ?=
ifneq ($(filter run%,$(firstword $(MAKECMDGOALS))),)
temp_args := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
DOCKER_COMPOSE_COMMAND := $(if $(filter $(DOCKER_COMPOSE_COMMANDS_SUPPORTED),$(temp_args)), $(filter $(DOCKER_COMPOSE_COMMANDS_SUPPORTED),$(temp_args)), $(DEFAULT_DOCKER_COMPOSE_COMMAND))
ifneq ($(filter $(PROVISION_ATOM_TOKEN_GOALS),$(temp_args)),)
override PROVISION_ATOM_TOKENS := true
endif
$(eval $(DOCKER_COMPOSE_COMMAND):;@)
endif
@@ -197,7 +144,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 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 run_latest run_stable run_addons grpc_mtls_certs check_mtls check_certs test_api mocks
clean:
rm -rf ${BUILD_DIR}
@@ -252,11 +199,12 @@ define test_api_service
--phases=examples,stateful
endef
test_api_users: TEST_API_URL := http://localhost:9000
test_api_clients: TEST_API_URL := http://localhost:9000
test_api_domains: TEST_API_URL := http://localhost:9000
test_api_channels: TEST_API_URL := http://localhost:9000
test_api_groups: TEST_API_URL := http://localhost:9000
test_api_users: TEST_API_URL := http://localhost:9002
test_api_clients: TEST_API_URL := http://localhost:9006
test_api_domains: TEST_API_URL := http://localhost:9003
test_api_channels: TEST_API_URL := http://localhost:9005
test_api_groups: TEST_API_URL := http://localhost:9004
test_api_auth: TEST_API_URL := http://localhost:9001
test_api_certs: TEST_API_URL := http://localhost:9019
test_api_journal: TEST_API_URL := http://localhost:9021
@@ -314,15 +262,7 @@ rundev:
cd scripts && ./run.sh
grpc_mtls_certs:
$(MAKE) -C docker/ssl clients_grpc_certs
provision_atom_tokens:
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml $(DOCKER_PROVISION_ENV_FILES) -p $(DOCKER_PROJECT) up -d --wait --wait-timeout 120 atom
$(MAKE) docker_atom-bootstrap
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml $(DOCKER_PROVISION_ENV_FILES) -p $(DOCKER_PROJECT) run --rm --no-deps --user "$(HOST_UID):$(HOST_GID)" -v "$(PWD)/docker:/host/docker" atom-bootstrap provision-tokens --output /host/docker/.env.tokens
provision-atom-tokens:
@:
$(MAKE) -C docker/ssl auth_grpc_certs clients_grpc_certs
check_tls:
ifeq ($(GRPC_TLS),true)
@@ -344,47 +284,27 @@ check_certs: check_mtls check_tls
ifeq ($(GRPC_MTLS_CERT_FILES_EXISTS),0)
ifeq ($(filter true,$(GRPC_MTLS) $(GRPC_TLS)),true)
ifeq ($(filter $(DEFAULT_DOCKER_COMPOSE_COMMAND),$(DOCKER_COMPOSE_COMMAND)),$(DEFAULT_DOCKER_COMPOSE_COMMAND))
$(MAKE) -C docker/ssl clients_grpc_certs
$(MAKE) -C docker/ssl auth_grpc_certs clients_grpc_certs
endif
endif
endif
run_latest: check_certs
$(SED_INPLACE) 's/^MG_RELEASE_TAG=.*/MG_RELEASE_TAG=latest/' docker/.env
$(call ensure_atom_tokens_env)
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
run_latest_ci: check_certs
$(call require_atom_tokens_env)
$(SED_INPLACE) 's/^MG_RELEASE_TAG=.*/MG_RELEASE_TAG=latest/' docker/.env
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml -f docker/docker-compose-ci.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
run_tls:
@test -n "$(host)" || (echo "Usage: make run_tls host=example.com [email=admin@example.com] [letsencrypt=false] [staging=true] [force=true]" && exit 2)
@if [ "$(or $(letsencrypt),true)" != "false" ] && [ -z "$(email)" ]; then echo "Usage: make run_tls host=example.com email=admin@example.com [letsencrypt=false] [staging=true] [force=true]"; exit 2; fi
MG_PUBLIC_HOST="$(host)" \
MG_LETSENCRYPT_ENABLED="$(or $(letsencrypt),true)" \
MG_LETSENCRYPT_EMAIL="$(email)" \
MG_LETSENCRYPT_STAGING="$(or $(staging),false)" \
MG_LETSENCRYPT_FORCE_RENEWAL="$(or $(force),false)" \
DOCKER_PROJECT="$(DOCKER_PROJECT)" \
./docker/setup-tls.sh
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
run_stable: check_certs
$(call require_atom_tokens_env)
$(eval version = $(shell git describe --abbrev=0 --tags))
git checkout $(version)
$(SED_INPLACE) 's/^MG_RELEASE_TAG=.*/MG_RELEASE_TAG=$(version)/' docker/.env
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
run_addons: check_certs
$(call require_atom_tokens_env)
$(foreach SVC,$(RUN_ADDON_ARGS),$(if $(filter $(SVC),$(ADDON_SERVICES) $(EXTERNAL_SERVICES)),,$(error Invalid Service $(SVC))))
@$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) up -d atom jaeger
@$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml --env-file ./docker/.env -p $(DOCKER_PROJECT) up -d auth domains jaeger
@for SVC in $(RUN_ADDON_ARGS); do \
MG_ADDONS_CERTS_PATH_PREFIX="../" $(DOCKER_PLATFORM) docker compose -f docker/addons/$$SVC/docker-compose.yaml -p $(DOCKER_PROJECT) $(DOCKER_ENV_FILES) $(DOCKER_COMPOSE_COMMAND) $(args) & \
MG_ADDONS_CERTS_PATH_PREFIX="../" $(DOCKER_PLATFORM) docker compose -f docker/addons/$$SVC/docker-compose.yaml -p $(DOCKER_PROJECT) --env-file ./docker/.env $(DOCKER_COMPOSE_COMMAND) $(args) & \
done
run_live: check_certs
$(call require_atom_tokens_env)
GOPATH=$(go env GOPATH) $(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml -f docker/docker-compose-live.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
GOPATH=$(go env GOPATH) $(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml -f docker/docker-compose-live.yaml --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
+1 -133
View File
@@ -46,7 +46,7 @@ It is extremely flexible and lets you build systems the way you want — from si
At the same time, it avoids the typical complexity of many IoT platforms, where you need to learn an entirely new set of concepts before you can even get started.
Magistrala is built around a small number of main concepts:
Magistrala is built around a small number of core concepts:
- users
- clients (devices)
- channels
@@ -141,138 +141,6 @@ Magistrala provides a complete set of building blocks for IoT systems — from d
- Documentation focused on getting you running quickly
---
## Atom Integration Model
Magistrala uses **Atom** as the backend for identity, authorization, and the catalog.
Atom is the source of truth for:
- domains
- users
- clients
- channels
- groups
- roles
- access policies
Magistrala services such as rules, alarms, and reports remain Magistrala services, but they use Atom for identity and authorization.
### Core Entity Mapping
| Magistrala concept | Atom concept | Meaning |
|--------------------|--------------|---------|
| Domain | Tenant | Isolation boundary for one organization, project, or environment |
| User | Entity with kind `human` | A person who logs in and uses the UI/API |
| Client | Entity with kind `device` | A device or application that sends/receives data |
| Channel | Resource with kind `channel` | A messaging/data path that clients can publish or subscribe to |
| Group | Group | A collection of users, clients, channels, or other grouped objects |
In simple terms:
```text
MG Domain = Atom Tenant
MG User = Atom Human Entity
MG Client = Atom Device Entity
MG Channel = Atom Channel Resource
MG Group = Atom Group
```
### Actions, Permission Blocks, Roles, and Assignments
Atom access control has these basic parts:
| Atom word | Simple meaning | Example |
|-----------|----------------|---------|
| Action | One permission verb | `read`, `write`, `delete`, `role.manage`, `policy.manage` |
| Permission Block | Where actions apply | all channels in domain `d1` can `read`, `publish` |
| Role | A bundle of permission blocks | `tenant-admin` bundles domain, role, and member access |
| Role Assignment | Who gets a role | give `user1` the `tenant-admin` role |
Read an assignment like this:
```text
Give <who> this <role>.
The role contains permission blocks that say where and what.
```
Example:
```text
Give user1 the tenant-admin role on domain d1.
```
That means:
```text
user1 can use the tenant-admin permissions inside domain d1.
```
### How MG Roles Work With Atom
MG UI shows actions such as:
- read
- update
- delete
- manage roles
- add/remove members
- publish
- subscribe
These are mapped to Atom actions:
| MG action | Atom action |
|-----------|-----------------|
| view/read | `read` |
| create/update/edit/connect | `write` |
| delete/remove | `delete` |
| manage roles | `role.manage` |
| add/remove members or access | `policy.manage` |
| channel publish | `publish` |
| channel subscribe | `subscribe` |
So when MG UI checks:
```text
Can user1 manage roles for client1?
```
Atom checks:
```text
Does user1 have role.manage on client1, or on the domain that contains client1?
```
When MG UI checks:
```text
Can user1 add a member to channel1?
```
Atom checks:
```text
Does user1 have policy.manage on channel1, or on the domain that contains channel1?
```
### Practical Rule
If a user is domain admin, they usually receive a tenant-scoped role in Atom.
That tenant-scoped role can allow them to manage objects inside the domain:
- clients
- channels
- groups
- rules
- alarms
- reports
For narrower access, create object-scoped roles. For example:
```text
Give user2 a reader role only on channel1.
```
Then user2 can read only that channel, not the whole domain.
## Installation
```bash
+11 -6
View File
@@ -26,11 +26,16 @@ The service is configured using the following environment variables (values show
| `MG_MESSAGE_BROKER_URL` | Message broker URL for alarm ingestion | `nats://nats:4222` |
| `MG_JAEGER_URL` | Jaeger collector endpoint | `http://jaeger:4318/v1/traces` |
| `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_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_AUTH_GRPC_URL` | Auth gRPC endpoint | `auth:7001` |
| `MG_AUTH_GRPC_TIMEOUT` | Auth gRPC timeout | `300s` |
| `MG_AUTH_GRPC_CLIENT_CERT` | Auth gRPC client cert path | `${GRPC_MTLS:+./ssl/certs/auth-grpc-client.crt}` |
| `MG_AUTH_GRPC_CLIENT_KEY` | Auth gRPC client key path | `${GRPC_MTLS:+./ssl/certs/auth-grpc-client.key}` |
| `MG_AUTH_GRPC_SERVER_CA_CERTS` | Auth gRPC server CA path | `${GRPC_MTLS:+./ssl/certs/ca.crt}` |
| `MG_DOMAINS_GRPC_URL` | Domains gRPC endpoint | `domains:7003` |
| `MG_DOMAINS_GRPC_TIMEOUT` | Domains gRPC timeout | `300s` |
| `MG_DOMAINS_GRPC_CLIENT_CERT` | Domains gRPC client cert path | `${GRPC_MTLS:+./ssl/certs/domains-grpc-client.crt}` |
| `MG_DOMAINS_GRPC_CLIENT_KEY` | Domains gRPC client key path | `${GRPC_MTLS:+./ssl/certs/domains-grpc-client.key}` |
| `MG_DOMAINS_GRPC_SERVER_CA_CERTS` | Domains gRPC server CA path | `${GRPC_MTLS:+./ssl/certs/ca.crt}` |
| `MG_ALLOW_UNVERIFIED_USER` | Allow unverified users to access | `true` |
## Features
@@ -39,7 +44,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.
- **Auth and authorization**: Authn/authz enforced via gRPC auth and domains services.
## Architecture
+2 -1
View File
@@ -106,7 +106,7 @@ func (a Alarm) Validate() error {
// Service specifies an API that must be fulfilled by the domain service.
type Service interface {
CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error)
CreateAlarm(ctx context.Context, alarm Alarm) error
UpdateAlarm(ctx context.Context, session authn.Session, alarm Alarm) (Alarm, error)
ViewAlarm(ctx context.Context, session authn.Session, id string) (Alarm, error)
ListAlarms(ctx context.Context, session authn.Session, pm PageMetadata) (AlarmsPage, error)
@@ -118,5 +118,6 @@ type Repository interface {
UpdateAlarm(ctx context.Context, alarm Alarm) (Alarm, error)
ViewAlarm(ctx context.Context, alarmID, domainID string) (Alarm, error)
ListAllAlarms(ctx context.Context, pm PageMetadata) (AlarmsPage, error)
ListUserAlarms(ctx context.Context, userID string, pm PageMetadata) (AlarmsPage, error)
DeleteAlarm(ctx context.Context, id string) error
}
-97
View File
@@ -1,97 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"time"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
)
type atomService struct {
Service
projector atom.Projector
}
func WithAtom(svc Service, projector atom.Projector) Service {
if projector == nil {
return svc
}
return atomService{Service: svc, projector: projector}
}
func (svc atomService) CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error) {
created, err := svc.Service.CreateAlarm(ctx, alarm)
if err != nil {
return created, err
}
if created.ID == "" {
return created, nil
}
if err := svc.projector.UpsertResource(ctx, alarmProjection(created)); err != nil {
return created, nil
}
return created, nil
}
func (svc atomService) UpdateAlarm(ctx context.Context, session authn.Session, alarm Alarm) (Alarm, error) {
updated, err := svc.Service.UpdateAlarm(ctx, session, alarm)
if err != nil {
return updated, err
}
if err := svc.projector.UpsertResource(ctx, alarmProjection(updated)); err != nil {
return updated, nil
}
return updated, nil
}
func (svc atomService) DeleteAlarm(ctx context.Context, session authn.Session, id string) error {
if err := svc.Service.DeleteAlarm(ctx, session, id); err != nil {
return err
}
_ = svc.projector.DeleteResource(ctx, id)
return nil
}
func alarmProjection(a Alarm) atom.Resource {
res := atom.ResourceFromFields(atom.ObjectFields{
ID: a.ID,
Kind: atom.KindAlarm,
Name: a.Cause,
TenantID: a.DomainID,
OwnerID: a.AssigneeID,
Status: a.Status.String(),
Metadata: map[string]any(a.Metadata),
UpdatedBy: a.UpdatedBy,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
res.Attributes["rule_id"] = a.RuleID
res.Attributes["channel_id"] = a.ChannelID
res.Attributes["client_id"] = a.ClientID
res.Attributes["subtopic"] = a.Subtopic
res.Attributes["severity"] = a.Severity
res.Attributes["measurement"] = a.Measurement
res.Attributes["value"] = a.Value
res.Attributes["unit"] = a.Unit
res.Attributes["threshold"] = a.Threshold
res.Attributes["cause"] = a.Cause
res.Attributes["assignee_id"] = a.AssigneeID
res.Attributes["assigned_at"] = alarmTimeString(a.AssignedAt)
res.Attributes["assigned_by"] = a.AssignedBy
res.Attributes["acknowledged_at"] = alarmTimeString(a.AcknowledgedAt)
res.Attributes["acknowledged_by"] = a.AcknowledgedBy
res.Attributes["resolved_at"] = alarmTimeString(a.ResolvedAt)
res.Attributes["resolved_by"] = a.ResolvedBy
return res
}
func alarmTimeString(ts time.Time) string {
if ts.IsZero() {
return ""
}
return ts.Format(time.RFC3339Nano)
}
-83
View File
@@ -1,83 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"testing"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
)
func TestAtomServiceCreateAlarmProjectsCreatedAlarm(t *testing.T) {
projector := &alarmProjector{}
svc := WithAtom(alarmService{
create: Alarm{
ID: "alarm-1",
RuleID: "rule-1",
DomainID: "domain-1",
ChannelID: "channel-1",
ClientID: "client-1",
Cause: "high temperature",
Measurement: "temperature",
Value: "92.4",
Unit: "C",
Threshold: "80",
Severity: 90,
Status: ActiveStatus,
},
}, projector)
created, err := svc.CreateAlarm(context.Background(), Alarm{RuleID: "rule-1"})
if err != nil {
t.Fatalf("create alarm: %v", err)
}
if created.ID != "alarm-1" {
t.Fatalf("unexpected created alarm: %#v", created)
}
if projector.resource.ID != "alarm-1" || projector.resource.Kind != atom.KindAlarm {
t.Fatalf("unexpected projection: %#v", projector.resource)
}
if projector.resource.Attributes["rule_id"] != "rule-1" {
t.Fatalf("missing rule projection: %#v", projector.resource.Attributes)
}
if projector.resource.Attributes["value"] != "92.4" || projector.resource.Attributes["threshold"] != "80" {
t.Fatalf("missing alarm value projection: %#v", projector.resource.Attributes)
}
}
type alarmService struct {
create Alarm
}
func (svc alarmService) CreateAlarm(context.Context, Alarm) (Alarm, error) {
return svc.create, nil
}
func (svc alarmService) UpdateAlarm(context.Context, authn.Session, Alarm) (Alarm, error) {
return Alarm{}, nil
}
func (svc alarmService) ViewAlarm(context.Context, authn.Session, string) (Alarm, error) {
return Alarm{}, nil
}
func (svc alarmService) ListAlarms(context.Context, authn.Session, PageMetadata) (AlarmsPage, error) {
return AlarmsPage{}, nil
}
func (svc alarmService) DeleteAlarm(context.Context, authn.Session, string) error {
return nil
}
type alarmProjector struct {
atom.Projector
resource atom.Resource
}
func (p *alarmProjector) UpsertResource(_ context.Context, resource atom.Resource) error {
p.resource = resource
return nil
}
+1 -2
View File
@@ -48,8 +48,7 @@ func (h handler) Handle(msg *messaging.Message) (err error) {
return err
}
_, err = h.svc.CreateAlarm(context.Background(), alarm)
return err
return h.svc.CreateAlarm(context.Background(), alarm)
}
func (h handler) Cancel() error {
+12 -37
View File
@@ -9,7 +9,6 @@ import (
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/alarms/operations"
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
smqauthz "github.com/absmach/magistrala/pkg/authz"
"github.com/absmach/magistrala/pkg/errors"
@@ -27,7 +26,6 @@ var (
type authorizationMiddleware struct {
svc alarms.Service
authz smqauthz.Authorization
atomAuthz atom.Authorizer
entitiesOps permissions.EntitiesOperations[permissions.Operation]
}
@@ -45,19 +43,7 @@ func NewAuthorizationMiddleware(svc alarms.Service, authz smqauthz.Authorization
}, nil
}
func NewAtomAuthorizationMiddleware(svc alarms.Service, authz atom.Authorizer, entitiesOps permissions.EntitiesOperations[permissions.Operation]) (alarms.Service, error) {
if err := entitiesOps.Validate(); err != nil {
return nil, err
}
return &authorizationMiddleware{
svc: svc,
atomAuthz: authz,
entitiesOps: entitiesOps,
}, nil
}
func (am *authorizationMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
func (am *authorizationMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) error {
return am.svc.CreateAlarm(ctx, alarm)
}
@@ -72,19 +58,17 @@ func (am *authorizationMiddleware) UpdateAlarm(ctx context.Context, session auth
if err := am.authorize(ctx, operations.OpAssignAlarm, session, policies.DomainType, session.DomainID); err != nil {
return alarms.Alarm{}, errors.Wrap(errDomainUpdateAlarms, err)
}
if am.atomAuthz == nil {
domainUserID := auth.EncodeDomainUserID(session.DomainID, alarm.AssigneeID)
if err := am.authz.Authorize(ctx, smqauthz.PolicyReq{
Domain: session.DomainID,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Subject: domainUserID,
Permission: policies.MembershipPermission,
ObjectType: policies.DomainType,
Object: session.DomainID,
}, nil); err != nil {
return alarms.Alarm{}, err
}
domainUserID := auth.EncodeDomainUserID(session.DomainID, alarm.AssigneeID)
if err := am.authz.Authorize(ctx, smqauthz.PolicyReq{
Domain: session.DomainID,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Subject: domainUserID,
Permission: policies.MembershipPermission,
ObjectType: policies.DomainType,
Object: session.DomainID,
}, nil); err != nil {
return alarms.Alarm{}, err
}
}
@@ -120,9 +104,6 @@ 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, operations.EntityType, auth.AnyIDs); err != nil {
return alarms.AlarmsPage{}, errors.Wrap(errDomainViewAlarms, err)
}
default:
return alarms.AlarmsPage{}, err
}
@@ -143,9 +124,6 @@ func (am *authorizationMiddleware) authorize(ctx context.Context, op permissions
if err != nil {
return err
}
if am.atomAuthz != nil {
return atom.Authorize(ctx, am.atomAuthz, session, perm.String(), objType, obj, atom.KindAlarm)
}
pr := smqauthz.PolicyReq{
Domain: session.DomainID,
@@ -181,9 +159,6 @@ func (am *authorizationMiddleware) checkSuperAdmin(ctx context.Context, session
if session.Role != authn.SuperAdminRole {
return svcerr.ErrSuperAdminAction
}
if am.atomAuthz != nil {
return atom.Authorize(ctx, am.atomAuthz, session, policies.AdminPermission, policies.PlatformType, policies.MagistralaObject, policies.PlatformType)
}
if err := am.authz.Authorize(ctx, smqauthz.PolicyReq{
SubjectType: policies.UserType,
Subject: session.UserID,
-109
View File
@@ -1,109 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"testing"
"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"
"github.com/absmach/magistrala/pkg/permissions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type recordingAtomAuthorizer struct {
allowed bool
reqs []atom.AuthzRequest
}
func (a *recordingAtomAuthorizer) CheckAuthz(_ context.Context, req atom.AuthzRequest) (atom.AuthzResponse, error) {
a.reqs = append(a.reqs, req)
return atom.AuthzResponse{Allowed: a.allowed}, nil
}
func TestListAlarmsAuthorizesRegularUser(t *testing.T) {
svc := mocks.NewService(t)
pm := alarms.PageMetadata{Limit: 10}
expectedPM := pm
expectedPM.DomainID = "domain-1"
session := authn.Session{UserID: "user-1", DomainID: "domain-1", DomainUserID: "domain-1_user-1"}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListAlarms", mock.Anything, session, expectedPM).Return(alarms.AlarmsPage{Limit: 10}, nil).Once()
page, err := wrapped.ListAlarms(context.Background(), session, pm)
require.NoError(t, err)
assert.Equal(t, uint64(10), page.Limit)
require.Len(t, authz.reqs, 1)
assert.Equal(t, atom.AuthzRequest{
SubjectID: "user-1",
Action: "list",
ResourceID: auth.AnyIDs,
ObjectKind: "resource",
ObjectID: auth.AnyIDs,
Context: map[string]any{
"domain_id": "domain-1",
"legacy_object_type": operations.EntityType,
},
}, authz.reqs[0])
}
func TestListAlarmsDeniedRegularUserDoesNotDelegate(t *testing.T) {
svc := mocks.NewService(t)
authz := &recordingAtomAuthorizer{allowed: false}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
_, err = wrapped.ListAlarms(context.Background(), authn.Session{UserID: "user-1", DomainID: "domain-1"}, alarms.PageMetadata{})
assert.True(t, pkgerrors.Contains(err, pkgerrors.ErrAuthorization))
require.Len(t, authz.reqs, 1)
}
func TestListAlarmsSuperAdminSkipsListAuthorization(t *testing.T) {
svc := mocks.NewService(t)
pm := alarms.PageMetadata{Limit: 10}
expectedPM := pm
expectedPM.DomainID = "domain-1"
session := authn.Session{UserID: "admin-1", DomainID: "domain-1", Role: authn.SuperAdminRole}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListAlarms", mock.Anything, mock.MatchedBy(func(s authn.Session) bool {
return s.SuperAdmin
}), expectedPM).Return(alarms.AlarmsPage{Limit: 10}, nil).Once()
_, err = wrapped.ListAlarms(context.Background(), session, pm)
require.NoError(t, err)
require.Len(t, authz.reqs, 1)
assert.Equal(t, "manage", authz.reqs[0].Action)
}
func testEntitiesOps(t *testing.T) permissions.EntitiesOperations[permissions.Operation] {
t.Helper()
details := operations.OperationDetails()
perms := make(map[string]permissions.Permission, len(details))
for _, detail := range details {
if detail.PermissionRequired {
perms[detail.Name] = permissions.Permission(detail.Name)
}
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{operations.EntityType: perms},
permissions.EntitiesOperationDetails[permissions.Operation]{operations.EntityType: details},
)
require.NoError(t, err)
return entitiesOps
}
+2 -2
View File
@@ -27,7 +27,7 @@ func NewLoggingMiddleware(logger *slog.Logger, service alarms.Service) alarms.Se
}
}
func (lm *loggingMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (created alarms.Alarm, err error) {
func (lm *loggingMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
@@ -52,7 +52,7 @@ func (lm *loggingMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm
lm.logger.Warn("Create alarm failed", args...)
return
}
if created.ID != "" {
if alarm.ID != "" {
lm.logger.Info("Create alarm completed successfully", args...)
}
}(time.Now())
+1 -1
View File
@@ -28,7 +28,7 @@ func NewMetricsMiddleware(counter metrics.Counter, latency metrics.Histogram, se
}
}
func (mm *metricsMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
func (mm *metricsMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) error {
defer func(begin time.Time) {
mm.counter.With("method", "create_alarm").Add(1)
mm.latency.With("method", "create_alarm").Observe(time.Since(begin).Seconds())
+1 -1
View File
@@ -27,7 +27,7 @@ func NewTracingMiddleware(tracer trace.Tracer, svc alarms.Service) alarms.Servic
}
}
func (tm *tracingMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
func (tm *tracingMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) error {
ctx, span := smqTracing.StartSpan(ctx, tm.tracer, "create_alarm", trace.WithAttributes(
attribute.String("rule_id", alarm.RuleID),
attribute.String("measurement", alarm.Measurement),
+72
View File
@@ -231,6 +231,78 @@ func (_c *Repository_ListAllAlarms_Call) RunAndReturn(run func(ctx context.Conte
return _c
}
// ListUserAlarms provides a mock function for the type Repository
func (_mock *Repository) ListUserAlarms(ctx context.Context, userID string, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
ret := _mock.Called(ctx, userID, pm)
if len(ret) == 0 {
panic("no return value specified for ListUserAlarms")
}
var r0 alarms.AlarmsPage
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alarms.PageMetadata) (alarms.AlarmsPage, error)); ok {
return returnFunc(ctx, userID, pm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alarms.PageMetadata) alarms.AlarmsPage); ok {
r0 = returnFunc(ctx, userID, pm)
} else {
r0 = ret.Get(0).(alarms.AlarmsPage)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, alarms.PageMetadata) error); ok {
r1 = returnFunc(ctx, userID, pm)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Repository_ListUserAlarms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListUserAlarms'
type Repository_ListUserAlarms_Call struct {
*mock.Call
}
// ListUserAlarms is a helper method to define mock.On call
// - ctx context.Context
// - userID string
// - pm alarms.PageMetadata
func (_e *Repository_Expecter) ListUserAlarms(ctx interface{}, userID interface{}, pm interface{}) *Repository_ListUserAlarms_Call {
return &Repository_ListUserAlarms_Call{Call: _e.mock.On("ListUserAlarms", ctx, userID, pm)}
}
func (_c *Repository_ListUserAlarms_Call) Run(run func(ctx context.Context, userID string, pm alarms.PageMetadata)) *Repository_ListUserAlarms_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alarms.PageMetadata
if args[2] != nil {
arg2 = args[2].(alarms.PageMetadata)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Repository_ListUserAlarms_Call) Return(alarmsPage alarms.AlarmsPage, err error) *Repository_ListUserAlarms_Call {
_c.Call.Return(alarmsPage, err)
return _c
}
func (_c *Repository_ListUserAlarms_Call) RunAndReturn(run func(ctx context.Context, userID string, pm alarms.PageMetadata) (alarms.AlarmsPage, error)) *Repository_ListUserAlarms_Call {
_c.Call.Return(run)
return _c
}
// UpdateAlarm provides a mock function for the type Repository
func (_mock *Repository) UpdateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
ret := _mock.Called(ctx, alarm)
+8 -17
View File
@@ -44,29 +44,20 @@ func (_m *Service) EXPECT() *Service_Expecter {
}
// CreateAlarm provides a mock function for the type Service
func (_mock *Service) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
func (_mock *Service) CreateAlarm(ctx context.Context, alarm alarms.Alarm) error {
ret := _mock.Called(ctx, alarm)
if len(ret) == 0 {
panic("no return value specified for CreateAlarm")
}
var r0 alarms.Alarm
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) (alarms.Alarm, error)); ok {
return returnFunc(ctx, alarm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) alarms.Alarm); ok {
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) error); ok {
r0 = returnFunc(ctx, alarm)
} else {
r0 = ret.Get(0).(alarms.Alarm)
r0 = ret.Error(0)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, alarms.Alarm) error); ok {
r1 = returnFunc(ctx, alarm)
} else {
r1 = ret.Error(1)
}
return r0, r1
return r0
}
// Service_CreateAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateAlarm'
@@ -99,12 +90,12 @@ func (_c *Service_CreateAlarm_Call) Run(run func(ctx context.Context, alarm alar
return _c
}
func (_c *Service_CreateAlarm_Call) Return(alarm1 alarms.Alarm, err error) *Service_CreateAlarm_Call {
_c.Call.Return(alarm1, err)
func (_c *Service_CreateAlarm_Call) Return(err error) *Service_CreateAlarm_Call {
_c.Call.Return(err)
return _c
}
func (_c *Service_CreateAlarm_Call) RunAndReturn(run func(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error)) *Service_CreateAlarm_Call {
func (_c *Service_CreateAlarm_Call) RunAndReturn(run func(ctx context.Context, alarm alarms.Alarm) error) *Service_CreateAlarm_Call {
_c.Call.Return(run)
return _c
}
+29
View File
@@ -198,6 +198,35 @@ func (r *repository) ListAllAlarms(ctx context.Context, pm alarms.PageMetadata)
return r.alarmsPage(ctx, comQuery, pm)
}
func (r *repository) ListUserAlarms(ctx context.Context, userID string, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
clauses := []string{
`(
EXISTS (
SELECT 1
FROM rules_roles rr
JOIN rules_role_members rrm ON rrm.role_id = rr.id
WHERE rr.entity_id = alarms.rule_id AND rrm.member_id = :user_id
)
OR EXISTS (
SELECT 1
FROM domains_roles dr
JOIN domains_role_members drm ON drm.role_id = dr.id
JOIN domains_role_actions dra ON dra.role_id = dr.id
WHERE dr.entity_id = alarms.domain_id
AND drm.member_id = :user_id
AND dra.action LIKE 'alarm%'
)
)`,
}
clauses = append(clauses, pageQueryConditions(pm)...)
query := fmt.Sprintf("WHERE %s", strings.Join(clauses, " AND "))
pm.UserID = userID
comQuery := fmt.Sprintf(`SELECT DISTINCT %s FROM alarms %s`, alarmColumns, query)
return r.alarmsPage(ctx, comQuery, pm)
}
func (r *repository) alarmsPage(ctx context.Context, comQuery string, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
dir := api.DescDir
if pm.Dir == api.AscDir {
+209
View File
@@ -415,6 +415,215 @@ func TestListAlarms(t *testing.T) {
}
}
func TestListUserAlarms(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM domains_role_actions")
require.Nil(t, err, fmt.Sprintf("clean domains_role_actions unexpected error: %s", err))
_, err = db.Exec("DELETE FROM domains_role_members")
require.Nil(t, err, fmt.Sprintf("clean domains_role_members unexpected error: %s", err))
_, err = db.Exec("DELETE FROM domains_roles")
require.Nil(t, err, fmt.Sprintf("clean domains_roles unexpected error: %s", err))
_, err = db.Exec("DELETE FROM domains")
require.Nil(t, err, fmt.Sprintf("clean domains unexpected error: %s", err))
_, err = db.Exec("DELETE FROM alarms")
require.Nil(t, err, fmt.Sprintf("clean alarms unexpected error: %s", err))
_, err = db.Exec("DELETE FROM rules")
require.Nil(t, err, fmt.Sprintf("clean rules unexpected error: %s", err))
})
repo := postgres.NewAlarmsRepo(db)
domainID := generateUUID(t)
domainRoute := generateUUID(t)
userID := generateUUID(t)
otherUserID := generateUUID(t)
adminUserID := generateUUID(t)
domainUserID := generateUUID(t)
_, err := db.Exec(`INSERT INTO domains (id, name, route, status) VALUES ($1, $2, $3, $4)`, domainID, namegen.Generate(), domainRoute, 0)
require.Nil(t, err, fmt.Sprintf("insert domains unexpected error: %s", err))
// Create 10 rules and 10 alarms referencing them.
// Assign userID to the first 6 rules via role membership.
var ruleIDs []string
var createdAlarms []alarms.Alarm
for i := range 10 {
ruleID := generateUUID(t)
_, err := db.Exec(`INSERT INTO rules (id, name, domain_id, status, logic_type, logic_value) VALUES ($1, $2, $3, 0, 0, '')`,
ruleID, fmt.Sprintf("rule-%d", i), domainID)
require.Nil(t, err, fmt.Sprintf("insert rule unexpected error: %s", err))
ruleIDs = append(ruleIDs, ruleID)
alarm := alarms.Alarm{
ID: generateUUID(t),
RuleID: ruleID,
DomainID: domainID,
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC().Add(time.Duration(i) * time.Minute),
}
alarm, err = repo.CreateAlarm(context.Background(), alarm)
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
createdAlarms = append(createdAlarms, alarm)
}
// Assign userID to the first 6 rules via rules_roles + rules_role_members.
userRoleIDs := make([]string, 6)
for i := range 6 {
roleID := generateUUID(t)
userRoleIDs[i] = roleID
_, err := db.Exec(`INSERT INTO rules_roles (id, name, entity_id) VALUES ($1, $2, $3)`, roleID, "admin", ruleIDs[i])
require.Nil(t, err, fmt.Sprintf("insert rules_roles unexpected error: %s", err))
_, err = db.Exec(`INSERT INTO rules_role_members (role_id, member_id, entity_id) VALUES ($1, $2, $3)`, roleID, userID, ruleIDs[i])
require.Nil(t, err, fmt.Sprintf("insert rules_role_members unexpected error: %s", err))
}
for i := range 10 {
var roleID string
if i < 6 {
roleID = userRoleIDs[i]
} else {
roleID = generateUUID(t)
_, err := db.Exec(`INSERT INTO rules_roles (id, name, entity_id) VALUES ($1, $2, $3)`, roleID, "admin", ruleIDs[i])
require.Nil(t, err, fmt.Sprintf("insert rules_roles unexpected error: %s", err))
}
_, err := db.Exec(`INSERT INTO rules_role_members (role_id, member_id, entity_id) VALUES ($1, $2, $3)`, roleID, adminUserID, ruleIDs[i])
require.Nil(t, err, fmt.Sprintf("insert rules_role_members unexpected error: %s", err))
}
domainRoleID := generateUUID(t)
_, err = db.Exec(`INSERT INTO domains_roles (id, name, entity_id) VALUES ($1, $2, $3)`, domainRoleID, "admin", domainID)
require.Nil(t, err, fmt.Sprintf("insert domains_roles unexpected error: %s", err))
_, err = db.Exec(`INSERT INTO domains_role_members (role_id, member_id, entity_id) VALUES ($1, $2, $3)`, domainRoleID, domainUserID, domainID)
require.Nil(t, err, fmt.Sprintf("insert domains_role_members unexpected error: %s", err))
_, err = db.Exec(`INSERT INTO domains_role_actions (role_id, action) VALUES ($1, $2)`, domainRoleID, "alarm_read")
require.Nil(t, err, fmt.Sprintf("insert domains_role_actions unexpected error: %s", err))
_ = createdAlarms
cases := []struct {
desc string
userID string
pm alarms.PageMetadata
count int
err error
}{
{
desc: "list user alarms returns only accessible alarms",
userID: userID,
pm: alarms.PageMetadata{
Offset: 0,
Limit: 100,
},
count: 6,
err: nil,
},
{
desc: "list user alarms with limit",
userID: userID,
pm: alarms.PageMetadata{
Offset: 0,
Limit: 3,
},
count: 3,
err: nil,
},
{
desc: "list user alarms with offset",
userID: userID,
pm: alarms.PageMetadata{
Offset: 4,
Limit: 100,
},
count: 2,
err: nil,
},
{
desc: "list user alarms with domain filter",
userID: userID,
pm: alarms.PageMetadata{
DomainID: domainID,
Offset: 0,
Limit: 100,
},
count: 6,
err: nil,
},
{
desc: "list user alarms with non-existing domain returns 0",
userID: userID,
pm: alarms.PageMetadata{
DomainID: generateUUID(t),
Offset: 0,
Limit: 100,
},
count: 0,
err: nil,
},
{
desc: "list alarms for user with no role assignments returns 0",
userID: otherUserID,
pm: alarms.PageMetadata{
Offset: 0,
Limit: 100,
},
count: 0,
err: nil,
},
{
desc: "list alarms for admin user with role on all rules returns all alarms",
userID: adminUserID,
pm: alarms.PageMetadata{
Offset: 0,
Limit: 100,
},
count: 10,
err: nil,
},
{
desc: "list alarms for user with domain-level rule access returns all alarms",
userID: domainUserID,
pm: alarms.PageMetadata{
Offset: 0,
Limit: 100,
},
count: 10,
err: nil,
},
{
desc: "list user alarms ordered by created_at ascending",
userID: userID,
pm: alarms.PageMetadata{
Offset: 0,
Limit: 100,
Order: "created_at",
Dir: "asc",
},
count: 6,
err: nil,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
page, err := repo.ListUserAlarms(context.Background(), tc.userID, tc.pm)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
assert.Equal(t, tc.count, len(page.Alarms), fmt.Sprintf("%s: expected %d alarms, got %d", tc.desc, tc.count, len(page.Alarms)))
})
}
}
func TestDeleteAlarm(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM alarms")
+10
View File
@@ -4,6 +4,9 @@
package postgres
import (
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
rpostgres "github.com/absmach/magistrala/re/postgres"
_ "github.com/jackc/pgx/v5/stdlib" // required for SQL access
migrate "github.com/rubenv/sql-migrate"
)
@@ -51,5 +54,12 @@ func Migration() (*migrate.MemoryMigrationSource, error) {
},
}
rulesMigration, err := rpostgres.Migration()
if err != nil {
return &migrate.MemoryMigrationSource{}, errors.Wrap(repoerr.ErrRoleMigration, err)
}
alarmsMigration.Migrations = append(alarmsMigration.Migrations, rulesMigration.Migrations...)
return alarmsMigration, nil
}
+10 -12
View File
@@ -26,10 +26,10 @@ func NewService(idp magistrala.IDProvider, repo Repository) Service {
}
}
func (s *service) CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error) {
func (s *service) CreateAlarm(ctx context.Context, alarm Alarm) error {
id, err := s.idp.ID()
if err != nil {
return Alarm{}, err
return err
}
alarm.ID = id
if alarm.CreatedAt.IsZero() {
@@ -37,18 +37,14 @@ func (s *service) CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error) {
}
if err := alarm.Validate(); err != nil {
return Alarm{}, err
return err
}
created, err := s.repo.CreateAlarm(ctx, alarm)
if err != nil && err != repoerr.ErrNotFound {
return Alarm{}, err
}
if err == repoerr.ErrNotFound {
return Alarm{}, nil
if _, err = s.repo.CreateAlarm(ctx, alarm); err != nil && err != repoerr.ErrNotFound {
return err
}
return created, nil
return nil
}
func (s *service) ViewAlarm(ctx context.Context, session authn.Session, alarmID string) (Alarm, error) {
@@ -56,8 +52,10 @@ func (s *service) ViewAlarm(ctx context.Context, session authn.Session, alarmID
}
func (s *service) ListAlarms(ctx context.Context, session authn.Session, pm PageMetadata) (AlarmsPage, error) {
pm.DomainID = session.DomainID
return s.repo.ListAllAlarms(ctx, pm)
if session.SuperAdmin {
return s.repo.ListAllAlarms(ctx, pm)
}
return s.repo.ListUserAlarms(ctx, session.UserID, pm)
}
func (s *service) DeleteAlarm(ctx context.Context, session authn.Session, alarmID string) error {
+2 -2
View File
@@ -72,7 +72,7 @@ func TestCreateAlarm(t *testing.T) {
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
repoCall := repo.On("CreateAlarm", context.Background(), mock.Anything).Return(tc.alarm, tc.err)
_, err := svc.CreateAlarm(context.Background(), tc.alarm)
err := svc.CreateAlarm(context.Background(), tc.alarm)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
repoCall.Unset()
})
@@ -205,7 +205,7 @@ func TestListAlarms(t *testing.T) {
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
s := authn.Session{DomainID: tc.pm.DomainID}
repoCall := repo.On("ListAllAlarms", context.Background(), tc.pm).Return(tc.page, tc.err)
repoCall := repo.On("ListUserAlarms", context.Background(), s.UserID, tc.pm).Return(tc.page, tc.err)
_, err := svc.ListAlarms(context.Background(), s, tc.pm)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
+3 -2
View File
@@ -10,11 +10,12 @@
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
+6 -18
View File
@@ -13,7 +13,10 @@ import (
"github.com/absmach/magistrala"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/clients"
"github.com/absmach/magistrala/groups"
"github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/users"
"github.com/gofrs/uuid/v5"
)
@@ -77,9 +80,9 @@ const (
DefStartLevel = 1
DefEndLevel = 0
DefStatus = "enabled"
DefClientStatus = "enabled"
DefUserStatus = "enabled"
DefGroupStatus = "enabled"
DefClientStatus = clients.Enabled
DefUserStatus = users.Enabled
DefGroupStatus = groups.Enabled
// ContentType represents JSON content type.
ContentType = "application/json"
@@ -181,21 +184,6 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) {
return
}
if errors.Contains(err, errors.ErrAuthentication) {
w.WriteHeader(http.StatusUnauthorized)
if err := json.NewEncoder(w).Encode(err); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
if errors.Contains(err, errors.ErrAuthorization) {
w.WriteHeader(http.StatusForbidden)
if err := json.NewEncoder(w).Encode(err); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
switch retErr := err.(type) {
case *errors.RequestError:
w.WriteHeader(http.StatusBadRequest)
-12
View File
@@ -260,24 +260,12 @@ func TestEncodeError(t *testing.T) {
code: http.StatusUnauthorized,
hasBody: true,
},
{
desc: "Generic Authentication Failed",
err: errors.ErrAuthentication,
code: http.StatusUnauthorized,
hasBody: true,
},
{
desc: "AuthZError - Authorization Failed",
err: svcerr.ErrAuthorization,
code: http.StatusForbidden,
hasBody: true,
},
{
desc: "Generic Authorization Failed",
err: errors.Wrap(errors.New("not authorized"), errors.ErrAuthorization),
code: http.StatusForbidden,
hasBody: true,
},
{
desc: "AuthZError - Domain Authorization Failed",
err: svcerr.ErrDomainAuthorization,
File diff suppressed because it is too large Load Diff
-2
View File
@@ -66,8 +66,6 @@ paths:
description: Failed due to malformed query parameters.
"401":
description: Missing or invalid access token provided.
"403":
description: Failed to perform authorization over the entity.
"500":
$ref: "#/components/responses/ServiceError"
/health:
-3
View File
@@ -94,9 +94,6 @@ func (client authGrpcClient) Authorize(ctx context.Context, req *grpcAuthV1.Auth
}
if patReq != nil {
if patReq.GetDomain() != "" {
authReqData.Domain = patReq.GetDomain()
}
authReqData.UserID = patReq.GetUserId()
authReqData.PatID = patReq.GetPatId()
authReqData.EntityType = patReq.GetEntityType()
+358
View File
@@ -0,0 +1,358 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package auth_test
import (
"context"
"fmt"
"net"
"testing"
"time"
grpcAuthV1 "github.com/absmach/magistrala/api/grpc/auth/v1"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/auth"
grpcapi "github.com/absmach/magistrala/auth/api/grpc/auth"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/policies"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
port = 8081
id = "testID"
usersType = "users"
adminPermission = "admin"
authoritiesObj = "authorities"
memberRelation = "member"
validToken = "valid"
inValidToken = "invalid"
validPATToken = "valid"
)
var (
domainID = testsutil.GenerateUUID(&testing.T{})
authAddr = fmt.Sprintf("localhost:%d", port)
clientID = testsutil.GenerateUUID(&testing.T{})
)
func startGRPCServer(svc auth.Service, port int) *grpc.Server {
listener, _ := net.Listen("tcp", fmt.Sprintf(":%d", port))
server := grpc.NewServer()
grpcAuthV1.RegisterAuthServiceServer(server, grpcapi.NewAuthServer(svc))
go func() {
err := server.Serve(listener)
assert.Nil(&testing.T{}, err, fmt.Sprintf(`"Unexpected error creating auth server %s"`, err))
}()
return server
}
func TestIdentify(t *testing.T) {
conn, err := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
assert.Nil(t, err, fmt.Sprintf("Unexpected error creating client connection %s", err))
defer conn.Close()
grpcClient := grpcapi.NewAuthClient(conn, time.Second)
cases := []struct {
desc string
token string
key auth.Key
idt *grpcAuthV1.AuthNRes
svcErr error
err error
}{
{
desc: "authenticate user with valid user token",
token: validToken,
key: auth.Key{ID: "", Subject: id, Role: auth.UserRole},
idt: &grpcAuthV1.AuthNRes{UserId: id, UserRole: uint32(auth.UserRole)},
err: nil,
},
{
desc: "authenticate user with invalid user token",
token: "invalid",
key: auth.Key{},
idt: &grpcAuthV1.AuthNRes{},
svcErr: svcerr.ErrAuthentication,
err: svcerr.ErrAuthentication,
},
{
desc: "authenticate user with empty token",
token: "",
idt: &grpcAuthV1.AuthNRes{},
err: apiutil.ErrBearerToken,
},
{
desc: "authenticate user with valid PAT token",
token: "pat_" + validPATToken,
key: auth.Key{ID: id, Type: auth.PersonalAccessToken, Subject: clientID, Role: auth.UserRole},
idt: &grpcAuthV1.AuthNRes{Id: id, UserId: clientID, UserRole: uint32(auth.UserRole), TokenType: uint32(auth.PersonalAccessToken)},
err: nil,
},
{
desc: "authenticate user with invalid PAT token",
token: "pat_invalid",
key: auth.Key{},
idt: &grpcAuthV1.AuthNRes{},
svcErr: svcerr.ErrAuthentication,
err: svcerr.ErrAuthentication,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
svcCall := svc.On("Identify", mock.Anything, tc.token).Return(tc.key, tc.svcErr)
idt, err := grpcClient.Authenticate(context.Background(), &grpcAuthV1.AuthNReq{Token: tc.token})
if idt != nil {
assert.Equal(t, tc.idt, idt, fmt.Sprintf("%s: expected %v got %v", tc.desc, tc.idt, idt))
}
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
svcCall.Unset()
})
}
}
func TestAuthorize(t *testing.T) {
conn, err := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
assert.Nil(t, err, fmt.Sprintf("Unexpected error creating client connection %s", err))
defer conn.Close()
grpcClient := grpcapi.NewAuthClient(conn, time.Second)
cases := []struct {
desc string
token string
authRequest *grpcAuthV1.AuthZReq
authResponse *grpcAuthV1.AuthZRes
err error
}{
{
desc: "authorize user with authorized token",
token: validToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: usersType,
Object: authoritiesObj,
ObjectType: usersType,
Relation: memberRelation,
Permission: adminPermission,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: true},
err: nil,
},
{
desc: "authorize user with unauthorized token",
token: inValidToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: usersType,
Object: authoritiesObj,
ObjectType: usersType,
Relation: memberRelation,
Permission: adminPermission,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: svcerr.ErrAuthorization,
},
{
desc: "authorize user with empty subject",
token: validToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: "",
SubjectType: usersType,
Object: authoritiesObj,
ObjectType: usersType,
Relation: memberRelation,
Permission: adminPermission,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: apiutil.ErrMissingPolicySub,
},
{
desc: "authorize user with empty subject type",
token: validToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: "",
Object: authoritiesObj,
ObjectType: usersType,
Relation: memberRelation,
Permission: adminPermission,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: apiutil.ErrMissingPolicySub,
},
{
desc: "authorize user with empty object",
token: validToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: usersType,
Object: "",
ObjectType: usersType,
Relation: memberRelation,
Permission: adminPermission,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: apiutil.ErrMissingPolicyObj,
},
{
desc: "authorize user with empty object type",
token: validToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: usersType,
Object: authoritiesObj,
ObjectType: "",
Relation: memberRelation,
Permission: adminPermission,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: apiutil.ErrMissingPolicyObj,
},
{
desc: "authorize user with empty permission",
token: validToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: usersType,
Object: authoritiesObj,
ObjectType: usersType,
Relation: memberRelation,
Permission: "",
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: apiutil.ErrMalformedPolicyPer,
},
{
desc: "authorize user with valid PAT token",
token: validPATToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Permission: policies.ViewPermission,
ObjectType: policies.ClientType,
Domain: domainID,
Object: clientID,
},
PatReq: &grpcAuthV1.PATReq{
PatId: id,
Domain: domainID,
Operation: "view",
UserId: id,
EntityId: clientID,
EntityType: auth.ClientsScopeStr,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: true},
err: nil,
},
{
desc: "authorize user with unauthorized PAT token",
token: inValidToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Permission: policies.ViewPermission,
ObjectType: policies.ClientType,
Domain: domainID,
Object: clientID,
},
PatReq: &grpcAuthV1.PATReq{
PatId: id,
Domain: domainID,
Operation: "view",
UserId: id,
EntityId: clientID,
EntityType: auth.ClientsScopeStr,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: svcerr.ErrAuthorization,
},
{
desc: "authorize PAT with missing user id",
token: validPATToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Permission: policies.ViewPermission,
ObjectType: policies.ClientType,
Domain: domainID,
Object: clientID,
},
PatReq: &grpcAuthV1.PATReq{
PatId: id,
Domain: domainID,
Operation: "view",
EntityId: clientID,
EntityType: auth.ClientsScopeStr,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: apiutil.ErrMissingUserID,
},
{
desc: "authorize PAT with missing entity id",
token: validPATToken,
authRequest: &grpcAuthV1.AuthZReq{
PolicyReq: &grpcAuthV1.PolicyReq{
Subject: id,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Permission: policies.ViewPermission,
ObjectType: policies.ClientType,
Domain: domainID,
Object: clientID,
},
PatReq: &grpcAuthV1.PATReq{
PatId: id,
Domain: domainID,
Operation: "view",
UserId: id,
EntityType: auth.ClientsScopeStr,
},
},
authResponse: &grpcAuthV1.AuthZRes{Authorized: false},
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
svcCall := svc.On("Authorize", mock.Anything, mock.Anything, mock.Anything).Return(tc.err)
ar, err := grpcClient.Authorize(context.Background(), tc.authRequest)
if ar != nil {
assert.Equal(t, tc.authResponse, ar, fmt.Sprintf("%s: expected %v got %v", tc.desc, tc.authResponse, ar))
}
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
svcCall.Unset()
})
}
}
-3
View File
@@ -88,9 +88,6 @@ func decodeAuthorizeRequest(_ context.Context, grpcReq any) (any, error) {
}
if patReq != nil {
if patReq.GetDomain() != "" {
authRequest.Domain = patReq.GetDomain()
}
authRequest.UserID = patReq.GetUserId()
authRequest.PatID = patReq.GetPatId()
authRequest.EntityType = patReq.GetEntityType()
+24
View File
@@ -0,0 +1,24 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package auth_test
import (
"os"
"testing"
"github.com/absmach/magistrala/auth/mocks"
)
var svc *mocks.Service
func TestMain(m *testing.M) {
svc = new(mocks.Service)
server := startGRPCServer(svc, port)
code := m.Run()
server.GracefulStop()
os.Exit(code)
}
+245
View File
@@ -0,0 +1,245 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package token_test
import (
"context"
"fmt"
"net"
"testing"
"time"
grpcTokenV1 "github.com/absmach/magistrala/api/grpc/token/v1"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/auth"
grpcapi "github.com/absmach/magistrala/auth/api/grpc/token"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
port = 8082
validToken = "valid"
inValidToken = "invalid"
invalidID = "invalid"
)
var (
validID = testsutil.GenerateUUID(&testing.T{})
authAddr = fmt.Sprintf("localhost:%d", port)
)
func startGRPCServer(svc auth.Service, port int) *grpc.Server {
listener, _ := net.Listen("tcp", fmt.Sprintf(":%d", port))
server := grpc.NewServer()
grpcTokenV1.RegisterTokenServiceServer(server, grpcapi.NewTokenServer(svc))
go func() {
err := server.Serve(listener)
assert.Nil(&testing.T{}, err, fmt.Sprintf(`"Unexpected error creating auth server %s"`, err))
}()
return server
}
func TestIssue(t *testing.T) {
conn, err := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
assert.Nil(t, err, fmt.Sprintf("Unexpected error creating client connection %s", err))
grpcClient := grpcapi.NewTokenClient(conn, time.Second)
defer conn.Close()
cases := []struct {
desc string
userId string
kind auth.KeyType
issueResponse auth.Token
err error
}{
{
desc: "issue for user with valid token",
userId: validID,
kind: auth.AccessKey,
issueResponse: auth.Token{
AccessToken: validToken,
RefreshToken: validToken,
},
err: nil,
},
{
desc: "issue recovery key",
userId: validID,
kind: auth.RecoveryKey,
issueResponse: auth.Token{
AccessToken: validToken,
RefreshToken: validToken,
},
err: nil,
},
{
desc: "issue API key unauthenticated",
userId: validID,
kind: auth.APIKey,
issueResponse: auth.Token{},
err: svcerr.ErrAuthentication,
},
{
desc: "issue for invalid key type",
userId: validID,
kind: 32,
issueResponse: auth.Token{},
err: errors.ErrMalformedEntity,
},
{
desc: "issue for user that does notexist",
userId: "",
kind: auth.APIKey,
issueResponse: auth.Token{},
err: svcerr.ErrAuthentication,
},
}
for _, tc := range cases {
svcCall := svc.On("Issue", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.issueResponse, tc.err)
_, err := grpcClient.Issue(context.Background(), &grpcTokenV1.IssueReq{UserId: tc.userId, Type: uint32(tc.kind)})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
svcCall.Unset()
}
}
func TestRefresh(t *testing.T) {
conn, err := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
assert.Nil(t, err, fmt.Sprintf("Unexpected error creating client connection %s", err))
grpcClient := grpcapi.NewTokenClient(conn, time.Second)
defer conn.Close()
cases := []struct {
desc string
token string
issueResponse auth.Token
err error
}{
{
desc: "refresh token with valid token",
token: validToken,
issueResponse: auth.Token{
AccessToken: validToken,
RefreshToken: validToken,
},
err: nil,
},
{
desc: "refresh token with invalid token",
token: inValidToken,
issueResponse: auth.Token{},
err: svcerr.ErrAuthentication,
},
{
desc: "refresh token with empty token",
token: "",
issueResponse: auth.Token{},
err: apiutil.ErrMissingSecret,
},
}
for _, tc := range cases {
svcCall := svc.On("Issue", mock.Anything, mock.Anything, mock.Anything).Return(tc.issueResponse, tc.err)
_, err := grpcClient.Refresh(context.Background(), &grpcTokenV1.RefreshReq{RefreshToken: tc.token})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
svcCall.Unset()
}
}
func TestRevoke(t *testing.T) {
conn, err := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
assert.Nil(t, err, fmt.Sprintf("Unexpected error creating client connection %s", err))
grpcClient := grpcapi.NewTokenClient(conn, time.Second)
defer conn.Close()
cases := []struct {
desc string
id string
err error
}{
{
desc: "revoke token with valid id",
id: validID,
err: nil,
},
{
desc: "revoke token with invalid id",
id: invalidID,
err: svcerr.ErrAuthentication,
},
{
desc: "revoke token with empty id",
id: "",
err: apiutil.ErrMissingID,
},
{
desc: "revoke already revoked token",
id: validID,
err: svcerr.ErrConflict,
},
}
for _, tc := range cases {
svcCall := svc.On("RevokeToken", mock.Anything, mock.Anything, tc.id).Return(tc.err)
_, err := grpcClient.Revoke(context.Background(), &grpcTokenV1.RevokeReq{TokenId: tc.id})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
svcCall.Unset()
}
}
func TestListUserRefreshTokens(t *testing.T) {
conn, err := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
assert.Nil(t, err, fmt.Sprintf("Unexpected error creating client connection %s", err))
grpcClient := grpcapi.NewTokenClient(conn, time.Second)
defer conn.Close()
cases := []struct {
desc string
userID string
listResponse []auth.TokenInfo
err error
}{
{
desc: "list tokens for user with valid id",
userID: validID,
listResponse: []auth.TokenInfo{
{ID: testsutil.GenerateUUID(&testing.T{}), Description: "Token 1"},
{ID: testsutil.GenerateUUID(&testing.T{}), Description: "Token 2"},
},
err: nil,
},
{
desc: "list tokens for user with empty list",
userID: validID,
listResponse: []auth.TokenInfo{},
err: nil,
},
{
desc: "list tokens with invalid user id",
userID: invalidID,
listResponse: nil,
err: svcerr.ErrAuthentication,
},
{
desc: "list tokens with empty user id",
userID: "",
listResponse: nil,
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
svcCall := svc.On("ListUserRefreshTokens", mock.Anything, tc.userID).Return(tc.listResponse, tc.err)
_, err := grpcClient.ListUserRefreshTokens(context.Background(), &grpcTokenV1.ListUserRefreshTokensReq{UserId: tc.userID})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
svcCall.Unset()
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package token_test
import (
"os"
"testing"
"github.com/absmach/magistrala/auth/mocks"
)
var svc *mocks.Service
func TestMain(m *testing.M) {
svc = new(mocks.Service)
server := startGRPCServer(svc, port)
code := m.Run()
server.GracefulStop()
os.Exit(code)
}
+1 -1
View File
@@ -2,5 +2,5 @@
// SPDX-License-Identifier: Apache-2.0
// Package hasher contains the domain concept definitions needed to
// support Magistrala users password hasher sub-service functionality.
// support Supermq users password hasher sub-service functionality.
package hasher
+1 -1
View File
@@ -21,7 +21,7 @@ type loggingMiddleware struct {
svc auth.Service
}
// NewLogging adds logging facilities to the service.
// NewLogging adds logging facilities to the core service.
func NewLogging(svc auth.Service, logger *slog.Logger) auth.Service {
return &loggingMiddleware{logger, svc}
}
+1 -1
View File
@@ -22,7 +22,7 @@ type metricsMiddleware struct {
svc auth.Service
}
// NewMetrics instruments service by tracking request count and latency.
// NewMetrics instruments core service by tracking request count and latency.
func NewMetrics(svc auth.Service, counter metrics.Counter, latency metrics.Histogram) auth.Service {
return &metricsMiddleware{
counter: counter,
+1 -7
View File
@@ -76,7 +76,6 @@ const (
GroupsType EntityType = iota
ChannelsType
ClientsType
BootstrapType
DashboardType
MessagesType
DomainsType
@@ -89,7 +88,6 @@ const (
GroupsScopeStr = "groups"
ChannelsScopeStr = "channels"
ClientsScopeStr = "clients"
BootstrapStr = "bootstrap"
DashboardsStr = "dashboards"
MessagesStr = "messages"
DomainsStr = "domains"
@@ -106,8 +104,6 @@ func (et EntityType) String() string {
return ChannelsScopeStr
case ClientsType:
return ClientsScopeStr
case BootstrapType:
return BootstrapStr
case DashboardType:
return DashboardsStr
case MessagesType:
@@ -133,8 +129,6 @@ func ParseEntityType(et string) (EntityType, error) {
return ChannelsType, nil
case ClientsScopeStr:
return ClientsType, nil
case BootstrapStr:
return BootstrapType, nil
case DashboardsStr:
return DashboardType, nil
case MessagesStr:
@@ -175,7 +169,7 @@ func (et *EntityType) UnmarshalText(data []byte) (err error) {
func IsValidOperationForEntity(entityType EntityType, operation string) bool {
switch entityType {
case ClientsType, ChannelsType, GroupsType, BootstrapType, DomainsType, RulesType, ReportsType:
case ClientsType, ChannelsType, GroupsType, DomainsType, RulesType, ReportsType:
return true
case DashboardType:
return operation == OpDashboardShare || operation == OpDashboardUnshare
-35
View File
@@ -31,11 +31,6 @@ func TestEntityTypeString(t *testing.T) {
et: auth.ClientsType,
expected: "clients",
},
{
desc: "Bootstrap entity type",
et: auth.BootstrapType,
expected: "bootstrap",
},
{
desc: "Dashboard entity type",
et: auth.DashboardType,
@@ -96,12 +91,6 @@ func TestParseEntityType(t *testing.T) {
expected: auth.ClientsType,
err: false,
},
{
desc: "Parse bootstrap",
et: "bootstrap",
expected: auth.BootstrapType,
err: false,
},
{
desc: "Parse dashboards",
et: "dashboards",
@@ -166,12 +155,6 @@ func TestEntityTypeMarshalJSON(t *testing.T) {
expected: []byte(`"clients"`),
err: nil,
},
{
desc: "Marshal bootstrap",
et: auth.BootstrapType,
expected: []byte(`"bootstrap"`),
err: nil,
},
{
desc: "Marshal rules",
et: auth.RulesType,
@@ -214,12 +197,6 @@ func TestEntityTypeUnmarshalJSON(t *testing.T) {
expected: auth.ChannelsType,
err: false,
},
{
desc: "Unmarshal bootstrap",
data: []byte(`"bootstrap"`),
expected: auth.BootstrapType,
err: false,
},
{
desc: "Unmarshal rules",
data: []byte(`"rules"`),
@@ -273,12 +250,6 @@ func TestEntityTypeMarshalText(t *testing.T) {
expected: []byte("channels"),
err: nil,
},
{
desc: "Marshal bootstrap as text",
et: auth.BootstrapType,
expected: []byte("bootstrap"),
err: nil,
},
}
for _, tc := range cases {
@@ -309,12 +280,6 @@ func TestEntityTypeUnmarshalText(t *testing.T) {
expected: auth.ChannelsType,
err: false,
},
{
desc: "Unmarshal bootstrap from text",
data: []byte("bootstrap"),
expected: auth.BootstrapType,
err: false,
},
{
desc: "Unmarshal unknown from text",
data: []byte("unknown"),
+376
View File
@@ -0,0 +1,376 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package auth_test
import (
"testing"
"time"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/auth"
channelsOps "github.com/absmach/magistrala/channels/operations"
clientsOps "github.com/absmach/magistrala/clients/operations"
groupsOps "github.com/absmach/magistrala/groups/operations"
"github.com/stretchr/testify/assert"
)
func TestScopeAuthorized(t *testing.T) {
cases := []struct {
desc string
scope *auth.Scope
entityType auth.EntityType
domainID string
operation string
entityID string
expected bool
}{
{
desc: "Authorized with matching entity type, domain, operation and entity ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "entity1",
},
entityType: auth.GroupsType,
domainID: "domain1",
operation: "view",
entityID: "entity1",
expected: true,
},
{
desc: "Authorized with wildcard entity ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "*",
},
entityType: auth.GroupsType,
domainID: "domain1",
operation: "view",
entityID: "any-entity",
expected: true,
},
{
desc: "Authorized without domain ID",
scope: &auth.Scope{
EntityType: auth.ClientsType,
DomainID: "",
Operation: "view",
EntityID: "client1",
},
entityType: auth.ClientsType,
domainID: "domain1",
operation: "view",
entityID: "client1",
expected: true,
},
{
desc: "Not authorized with different entity type",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "entity1",
},
entityType: auth.ChannelsType,
domainID: "domain1",
operation: "view",
entityID: "entity1",
expected: false,
},
{
desc: "Not authorized with different domain ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "entity1",
},
entityType: auth.GroupsType,
domainID: "domain2",
operation: "view",
entityID: "entity1",
expected: false,
},
{
desc: "Not authorized with different operation",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "entity1",
},
entityType: auth.GroupsType,
domainID: "domain1",
operation: "delete",
entityID: "entity1",
expected: false,
},
{
desc: "Not authorized with different entity ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "entity1",
},
entityType: auth.GroupsType,
domainID: "domain1",
operation: "view",
entityID: "entity2",
expected: false,
},
{
desc: "Not authorized with nil scope",
scope: nil,
entityType: auth.GroupsType,
domainID: "domain1",
operation: "view",
entityID: "entity1",
expected: false,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
result := tc.scope.Authorized(tc.entityType, tc.domainID, tc.operation, tc.entityID)
assert.Equal(t, tc.expected, result, "Authorized() = %v, expected %v", result, tc.expected)
})
}
}
func TestScopeValidate(t *testing.T) {
cases := []struct {
desc string
scope *auth.Scope
err error
}{
{
desc: "Valid scope for groups with domain ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "entity1",
},
err: nil,
},
{
desc: "Valid scope for channels with domain ID",
scope: &auth.Scope{
EntityType: auth.ChannelsType,
DomainID: "domain1",
Operation: "view",
EntityID: "channel1",
},
err: nil,
},
{
desc: "Valid scope for clients with domain ID",
scope: &auth.Scope{
EntityType: auth.ClientsType,
DomainID: "domain1",
Operation: "update",
EntityID: "client1",
},
err: nil,
},
{
desc: "Valid scope for messages with domain ID",
scope: &auth.Scope{
EntityType: auth.MessagesType,
DomainID: "domain1",
Operation: "message_publish",
EntityID: "message1",
},
err: nil,
},
{
desc: "Valid scope for dashboard with domain ID",
scope: &auth.Scope{
EntityType: auth.DashboardType,
DomainID: "domain1",
Operation: "dashboard_share",
EntityID: "dashboard1",
},
err: nil,
},
{
desc: "Valid scope with wildcard entity ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: "view",
EntityID: "*",
},
err: nil,
},
{
desc: "Invalid nil scope",
scope: nil,
err: assert.AnError, // Will be checked with Contains
},
{
desc: "Invalid scope without entity ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "domain1",
Operation: groupsOps.OperationDetails()[groupsOps.OpViewGroup].Name,
EntityID: "",
},
err: apiutil.ErrMissingEntityID,
},
{
desc: "Invalid scope for groups without domain ID",
scope: &auth.Scope{
EntityType: auth.GroupsType,
DomainID: "",
Operation: groupsOps.OperationDetails()[groupsOps.OpViewGroup].Name,
EntityID: "entity1",
},
err: apiutil.ErrMissingDomainID,
},
{
desc: "Invalid scope for channels without domain ID",
scope: &auth.Scope{
EntityType: auth.ChannelsType,
DomainID: "",
Operation: channelsOps.OperationDetails()[channelsOps.OpViewChannel].Name,
EntityID: "channel1",
},
err: apiutil.ErrMissingDomainID,
},
{
desc: "Invalid scope for clients without domain ID",
scope: &auth.Scope{
EntityType: auth.ClientsType,
DomainID: "",
Operation: clientsOps.OperationDetails()[clientsOps.OpViewClient].Name,
EntityID: "client1",
},
err: apiutil.ErrMissingDomainID,
},
{
desc: "Invalid scope for dashboard without domain ID",
scope: &auth.Scope{
EntityType: auth.DashboardType,
DomainID: "",
Operation: auth.OpShare,
EntityID: "dashboard1",
},
err: apiutil.ErrMissingDomainID,
},
{
desc: "Invalid scope for messages without domain ID",
scope: &auth.Scope{
EntityType: auth.MessagesType,
DomainID: "",
Operation: auth.OpPublish,
EntityID: "message1",
},
err: apiutil.ErrMissingDomainID,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
err := tc.scope.Validate()
if tc.err != nil {
assert.Error(t, err, "Validate() should return error")
if tc.err != assert.AnError {
assert.Equal(t, tc.err, err, "Validate() error = %v, expected %v", err, tc.err)
}
} else {
assert.NoError(t, err, "Validate() should not return error")
}
})
}
}
func TestPATValidate(t *testing.T) {
cases := []struct {
desc string
pat *auth.PAT
err bool
}{
{
desc: "Valid PAT",
pat: &auth.PAT{
ID: "pat-id",
User: "user-id",
Name: "test-pat",
Description: "test description",
},
err: false,
},
{
desc: "Invalid nil PAT",
pat: nil,
err: true,
},
{
desc: "Invalid PAT without name",
pat: &auth.PAT{
ID: "pat-id",
User: "user-id",
Name: "",
Description: "test description",
},
err: true,
},
{
desc: "Invalid PAT without user",
pat: &auth.PAT{
ID: "pat-id",
User: "",
Name: "test-pat",
Description: "test description",
},
err: true,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
err := tc.pat.Validate()
if tc.err {
assert.Error(t, err, "Validate() should return error")
} else {
assert.NoError(t, err, "Validate() should not return error")
}
})
}
}
func TestPATMarshalUnmarshalBinary(t *testing.T) {
pat := auth.PAT{
ID: "pat-id",
User: "user-id",
Name: "test-pat",
Description: "test description",
Secret: "secret",
IssuedAt: time.Now().UTC().Round(time.Second),
ExpiresAt: time.Now().UTC().Add(24 * time.Hour).Round(time.Second),
Status: auth.ActiveStatus,
}
// Marshal
data, err := pat.MarshalBinary()
assert.NoError(t, err, "MarshalBinary() should not return error")
assert.NotNil(t, data, "MarshalBinary() should return data")
// Unmarshal
var newPAT auth.PAT
err = newPAT.UnmarshalBinary(data)
assert.NoError(t, err, "UnmarshalBinary() should not return error")
assert.Equal(t, pat.ID, newPAT.ID, "ID mismatch")
assert.Equal(t, pat.User, newPAT.User, "User mismatch")
assert.Equal(t, pat.Name, newPAT.Name, "Name mismatch")
assert.Equal(t, pat.Description, newPAT.Description, "Description mismatch")
assert.Equal(t, pat.Secret, newPAT.Secret, "Secret mismatch")
assert.Equal(t, pat.Status, newPAT.Status, "Status mismatch")
}
+122
View File
@@ -0,0 +1,122 @@
# BOOTSTRAP SERVICE
New devices need to be configured properly and connected to the Magistrala. Bootstrap service is used in order to accomplish that. This service provides the following features:
1. Creating new Magistrala Clients
2. Providing basic configuration for the newly created Clients
3. Enabling/disabling Clients
Pre-provisioning a new Client is as simple as sending Configuration data to the Bootstrap service. Once the Client is online, it sends a request for initial config to Bootstrap service. Bootstrap service provides an API for enabling and disabling Clients. Only enabled Clients can exchange messages over Magistrala. Bootstrapping does not implicitly enable Clients, it has to be done manually.
In order to bootstrap successfully, the Client needs to send bootstrapping request to the specific URL, as well as a secret key. This key and URL are pre-provisioned during the manufacturing process. If the Client is provisioned on the Bootstrap service side, the corresponding configuration will be sent as a response. Otherwise, the Client will be saved so that it can be provisioned later.
## Client Configuration Entity
Client Configuration consists of two logical parts: the custom configuration that can be interpreted by the Client itself and Magistrala-related configuration. Magistrala config contains:
1. corresponding Magistrala Client ID
2. corresponding Magistrala Client key
3. list of the Magistrala channels the Client is connected to
> Note: list of channels contains IDs of the Magistrala channels. These channels are _pre-provisioned_ on the Magistrala side and, unlike corresponding Magistrala Client, Bootstrap service is not able to create Magistrala Channels.
Enabling and disabling Client (adding Client to/from whitelist) is as simple as connecting corresponding Magistrala Client to the given list of Channels. Configuration keeps _state_ of the Client:
| State | What it means |
| -------- | ---------------------------------------------- |
| Inactive | Client is created, but isn't enabled |
| Active | Client is able to communicate using Magistrala |
Switching between states `Active` and `Inactive` enables and disables Client, respectively.
Client configuration also contains the so-called `external ID` and `external key`. An external ID is a unique identifier of corresponding Client. For example, a device MAC address is a good choice for external ID. External key is a secret key that is used for authentication during the bootstrapping procedure.
## Configuration
The service is configured using the environment variables presented in the following table. Note that any unset variables will be replaced with their default values.
| Variable | Description | Default |
| ------------------------------ | -------------------------------------------------------------------------------- | --------------------------------- |
| MG_BOOTSTRAP_LOG_LEVEL | Log level for Bootstrap (debug, info, warn, error) | info |
| MG_BOOTSTRAP_DB_HOST | Database host address | localhost |
| MG_BOOTSTRAP_DB_PORT | Database host port | 5432 |
| MG_BOOTSTRAP_DB_USER | Database user | magistrala |
| MG_BOOTSTRAP_DB_PASS | Database password | magistrala |
| MG_BOOTSTRAP_DB_NAME | Name of the database used by the service | bootstrap |
| MG_BOOTSTRAP_DB_SSL_MODE | Database connection SSL mode (disable, require, verify-ca, verify-full) | disable |
| MG_BOOTSTRAP_DB_SSL_CERT | Path to the PEM encoded certificate file | "" |
| MG_BOOTSTRAP_DB_SSL_KEY | Path to the PEM encoded key file | "" |
| MG_BOOTSTRAP_DB_SSL_ROOT_CERT | Path to the PEM encoded root certificate file | "" |
| MG_BOOTSTRAP_ENCRYPT_KEY | Secret key for secure bootstrapping encryption | 12345678910111213141516171819202 |
| MG_BOOTSTRAP_HTTP_HOST | Bootstrap service HTTP host | "" |
| MG_BOOTSTRAP_HTTP_PORT | Bootstrap service HTTP port | 9013 |
| MG_BOOTSTRAP_HTTP_SERVER_CERT | Path to server certificate in pem format | "" |
| MG_BOOTSTRAP_HTTP_SERVER_KEY | Path to server key in pem format | "" |
| MG_BOOTSTRAP_EVENT_CONSUMER | Bootstrap service event source consumer name | bootstrap |
| MG_ES_URL | Event store URL | <nats://localhost:4222> |
| MG_AUTH_GRPC_URL | Auth service Auth gRPC URL | <localhost:8181> |
| MG_AUTH_GRPC_TIMEOUT | Auth service Auth gRPC request timeout in seconds | 1s |
| MG_AUTH_GRPC_CLIENT_CERT | Path to the PEM encoded auth service Auth gRPC client certificate file | "" |
| MG_AUTH_GRPC_CLIENT_KEY | Path to the PEM encoded auth service Auth gRPC client key file | "" |
| MG_AUTH_GRPC_SERVER_CERTS | Path to the PEM encoded auth server Auth gRPC server trusted CA certificate file | "" |
| MG_CLIENTS_URL | Base URL for Magistrala Clients | <http://localhost:9000> |
| MG_JAEGER_URL | Jaeger server URL | <http://localhost:4318/v1/traces> |
| MG_JAEGER_TRACE_RATIO | Jaeger sampling ratio | 1.0 |
| MG_SEND_TELEMETRY | Send telemetry to magistrala call home server | true |
| MG_BOOTSTRAP_INSTANCE_ID | Bootstrap service instance ID | "" |
## Deployment
The service itself is distributed as Docker container. Check the [`bootstrap`](https://github.com/absmach/magistrala/blob/main/docker/addons/bootstrap/docker-compose.yaml) service section in docker-compose file to see how service is deployed.
To start the service outside of the container, execute the following shell script:
```bash
# download the latest version of the service
git clone https://github.com/absmach/magistrala
cd magistrala
# compile the servic e
make bootstrap
# copy binary to bin
make install
# set the environment variables and run the service
MG_BOOTSTRAP_LOG_LEVEL=info \
MG_BOOTSTRAP_DB_HOST=localhost \
MG_BOOTSTRAP_DB_PORT=5432 \
MG_BOOTSTRAP_DB_USER=magistrala \
MG_BOOTSTRAP_DB_PASS=magistrala \
MG_BOOTSTRAP_DB_NAME=bootstrap \
MG_BOOTSTRAP_DB_SSL_MODE=disable \
MG_BOOTSTRAP_DB_SSL_CERT="" \
MG_BOOTSTRAP_DB_SSL_KEY="" \
MG_BOOTSTRAP_DB_SSL_ROOT_CERT="" \
MG_BOOTSTRAP_HTTP_HOST=localhost \
MG_BOOTSTRAP_HTTP_PORT=9013 \
MG_BOOTSTRAP_HTTP_SERVER_CERT="" \
MG_BOOTSTRAP_HTTP_SERVER_KEY="" \
MG_BOOTSTRAP_EVENT_CONSUMER=bootstrap \
MG_ES_URL=nats://localhost:4222 \
MG_AUTH_GRPC_URL=localhost:8181 \
MG_AUTH_GRPC_TIMEOUT=1s \
MG_AUTH_GRPC_CLIENT_CERT="" \
MG_AUTH_GRPC_CLIENT_KEY="" \
MG_AUTH_GRPC_SERVER_CERTS="" \
MG_CLIENTS_URL=http://localhost:9000 \
MG_JAEGER_URL=http://localhost:14268/api/traces \
MG_JAEGER_TRACE_RATIO=1.0 \
MG_SEND_TELEMETRY=true \
MG_BOOTSTRAP_INSTANCE_ID="" \
$GOBIN/magistrala-bootstrap
```
Setting `MG_BOOTSTRAP_HTTP_SERVER_CERT` and `MG_BOOTSTRAP_HTTP_SERVER_KEY` will enable TLS against the service. The service expects a file in PEM format for both the certificate and the key.
Setting `MG_AUTH_GRPC_CLIENT_CERT` and `MG_AUTH_GRPC_CLIENT_KEY` will enable TLS against the auth service. The service expects a file in PEM format for both the certificate and the key. Setting `MG_AUTH_GRPC_SERVER_CERTS` will enable TLS against the auth service trusting only those CAs that are provided. The service expects a file in PEM format of trusted CAs.
## Usage
For more information about service capabilities and its usage, please check out the [API documentation](https://docs.api.magistrala.absmach.eu/?urls.primaryName=bootstrap.yaml).
+5
View File
@@ -0,0 +1,5 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package api contains implementation of bootstrap service HTTP API.
package api
+289
View File
@@ -0,0 +1,289 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"context"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/go-kit/kit/endpoint"
)
func addEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(addReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
channels := []bootstrap.Channel{}
for _, c := range req.Channels {
channels = append(channels, bootstrap.Channel{ID: c})
}
config := bootstrap.Config{
ClientID: req.ClientID,
ExternalID: req.ExternalID,
ExternalKey: req.ExternalKey,
Channels: channels,
Name: req.Name,
ClientCert: req.ClientCert,
ClientKey: req.ClientKey,
CACert: req.CACert,
Content: req.Content,
}
saved, err := svc.Add(ctx, session, req.token, config)
if err != nil {
return nil, err
}
res := configRes{
id: saved.ClientID,
created: true,
}
return res, nil
}
}
func updateCertEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(updateCertReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
cfg, err := svc.UpdateCert(ctx, session, req.clientID, req.ClientCert, req.ClientKey, req.CACert)
if err != nil {
return nil, err
}
res := updateConfigRes{
ClientID: cfg.ClientID,
ClientCert: cfg.ClientCert,
CACert: cfg.CACert,
ClientKey: cfg.ClientKey,
}
return res, nil
}
}
func viewEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(entityReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
config, err := svc.View(ctx, session, req.id)
if err != nil {
return nil, err
}
var channels []channelRes
for _, ch := range config.Channels {
channels = append(channels, channelRes{
ID: ch.ID,
Name: ch.Name,
Metadata: ch.Metadata,
})
}
res := viewRes{
ClientID: config.ClientID,
CLientSecret: config.ClientSecret,
Channels: channels,
ExternalID: config.ExternalID,
ExternalKey: config.ExternalKey,
Name: config.Name,
Content: config.Content,
State: config.State,
}
return res, nil
}
}
func updateEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(updateReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
config := bootstrap.Config{
ClientID: req.id,
Name: req.Name,
Content: req.Content,
}
if err := svc.Update(ctx, session, config); err != nil {
return nil, err
}
res := configRes{
id: config.ClientID,
created: false,
}
return res, nil
}
}
func updateConnEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(updateConnReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
if err := svc.UpdateConnections(ctx, session, req.token, req.id, req.Channels); err != nil {
return nil, err
}
res := configRes{
id: req.id,
created: false,
}
return res, nil
}
}
func listEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(listReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
page, err := svc.List(ctx, session, req.filter, req.offset, req.limit)
if err != nil {
return nil, err
}
res := listRes{
Total: page.Total,
Offset: page.Offset,
Limit: page.Limit,
Configs: []viewRes{},
}
for _, cfg := range page.Configs {
var channels []channelRes
for _, ch := range cfg.Channels {
channels = append(channels, channelRes{
ID: ch.ID,
Name: ch.Name,
Metadata: ch.Metadata,
})
}
view := viewRes{
ClientID: cfg.ClientID,
CLientSecret: cfg.ClientSecret,
Channels: channels,
ExternalID: cfg.ExternalID,
ExternalKey: cfg.ExternalKey,
Name: cfg.Name,
Content: cfg.Content,
State: cfg.State,
}
res.Configs = append(res.Configs, view)
}
return res, nil
}
}
func removeEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(entityReq)
if err := req.validate(); err != nil {
return removeRes{}, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
if err := svc.Remove(ctx, session, req.id); err != nil {
return nil, err
}
return removeRes{}, nil
}
}
func bootstrapEndpoint(svc bootstrap.Service, reader bootstrap.ConfigReader, secure bool) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(bootstrapReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
cfg, err := svc.Bootstrap(ctx, req.key, req.id, secure)
if err != nil {
return nil, err
}
return reader.ReadConfig(cfg, secure)
}
}
func stateEndpoint(svc bootstrap.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(changeStateReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
if err := svc.ChangeState(ctx, session, req.token, req.id, req.State); err != nil {
return nil, err
}
return stateRes{}, nil
}
}
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/bootstrap"
)
const maxLimitSize = 100
type addReq struct {
token string
ClientID string `json:"client_id"`
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key"`
Channels []string `json:"channels"`
Name string `json:"name"`
Content string `json:"content"`
ClientCert string `json:"client_cert"`
ClientKey string `json:"client_key"`
CACert string `json:"ca_cert"`
}
func (req addReq) validate() error {
if req.token == "" {
return apiutil.ErrBearerToken
}
if req.ExternalID == "" {
return apiutil.ErrMissingID
}
if req.ExternalKey == "" {
return apiutil.ErrBearerKey
}
if len(req.Channels) == 0 {
return apiutil.ErrEmptyList
}
for _, channel := range req.Channels {
if channel == "" {
return apiutil.ErrMissingID
}
}
return nil
}
type entityReq struct {
id string
}
func (req entityReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type updateReq struct {
id string
Name string `json:"name"`
Content string `json:"content"`
}
func (req updateReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type updateCertReq struct {
clientID string
ClientCert string `json:"client_cert"`
ClientKey string `json:"client_key"`
CACert string `json:"ca_cert"`
}
func (req updateCertReq) validate() error {
if req.clientID == "" {
return apiutil.ErrMissingID
}
return nil
}
type updateConnReq struct {
token string
id string
Channels []string `json:"channels"`
}
func (req updateConnReq) validate() error {
if req.token == "" {
return apiutil.ErrBearerToken
}
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type listReq struct {
filter bootstrap.Filter
offset uint64
limit uint64
}
func (req listReq) validate() error {
if req.limit > maxLimitSize {
return apiutil.ErrLimitSize
}
return nil
}
type bootstrapReq struct {
key string
id string
}
func (req bootstrapReq) validate() error {
if req.key == "" {
return apiutil.ErrBearerKey
}
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type changeStateReq struct {
token string
id string
State bootstrap.State `json:"state"`
}
func (req changeStateReq) validate() error {
if req.token == "" {
return apiutil.ErrBearerToken
}
if req.id == "" {
return apiutil.ErrMissingID
}
if req.State != bootstrap.Inactive &&
req.State != bootstrap.Active {
return bootstrap.ErrBootstrapState
}
return nil
}
+313
View File
@@ -0,0 +1,313 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"fmt"
"testing"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/stretchr/testify/assert"
)
var (
channel1 = testsutil.GenerateUUID(&testing.T{})
channel2 = testsutil.GenerateUUID(&testing.T{})
)
func TestAddReqValidation(t *testing.T) {
cases := []struct {
desc string
token string
externalID string
externalKey string
channels []string
err error
}{
{
desc: "valid request",
token: "token",
externalID: "external-id",
externalKey: "external-key",
channels: []string{channel1, channel2},
err: nil,
},
{
desc: "empty token",
token: "",
externalID: "external-id",
externalKey: "external-key",
channels: []string{channel1, channel2},
err: apiutil.ErrBearerToken,
},
{
desc: "empty external ID",
token: "token",
externalID: "",
externalKey: "external-key",
channels: []string{channel1, channel2},
err: apiutil.ErrMissingID,
},
{
desc: "empty external key",
token: "token",
externalID: "external-id",
externalKey: "",
channels: []string{channel1, channel2},
err: apiutil.ErrBearerKey,
},
{
desc: "empty external key and external ID",
token: "token",
externalID: "",
externalKey: "",
channels: []string{channel1, channel2},
err: apiutil.ErrMissingID,
},
{
desc: "empty channels",
token: "token",
externalID: "external-id",
externalKey: "external-key",
channels: []string{},
err: apiutil.ErrEmptyList,
},
{
desc: "empty channel value",
token: "token",
externalID: "external-id",
externalKey: "external-key",
channels: []string{channel1, ""},
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
req := addReq{
token: tc.token,
ExternalID: tc.externalID,
ExternalKey: tc.externalKey,
Channels: tc.channels,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestEntityReqValidation(t *testing.T) {
cases := []struct {
desc string
id string
err error
}{
{
desc: "empty id",
id: "",
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
req := entityReq{
id: tc.id,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestUpdateReqValidation(t *testing.T) {
cases := []struct {
desc string
id string
err error
}{
{
desc: "valid request",
id: "id",
err: nil,
},
{
desc: "empty id",
id: "",
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
req := updateReq{
id: tc.id,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestUpdateCertReqValidation(t *testing.T) {
cases := []struct {
desc string
clientID string
err error
}{
{
desc: "empty client id",
clientID: "",
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
req := updateCertReq{
clientID: tc.clientID,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestUpdateConnReqValidation(t *testing.T) {
cases := []struct {
desc string
id string
token string
err error
}{
{
desc: "empty token",
token: "",
id: "id",
err: apiutil.ErrBearerToken,
},
{
desc: "empty id",
token: "token",
id: "",
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
req := updateConnReq{
token: tc.token,
id: tc.id,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestListReqValidation(t *testing.T) {
cases := []struct {
desc string
offset uint64
limit uint64
err error
}{
{
desc: "too large limit",
offset: 0,
limit: maxLimitSize + 1,
err: apiutil.ErrLimitSize,
},
{
desc: "default limit",
offset: 0,
limit: defLimit,
err: nil,
},
}
for _, tc := range cases {
req := listReq{
offset: tc.offset,
limit: tc.limit,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestBootstrapReqValidation(t *testing.T) {
cases := []struct {
desc string
externKey string
externID string
err error
}{
{
desc: "empty external key",
externKey: "",
externID: "id",
err: apiutil.ErrBearerKey,
},
{
desc: "empty external id",
externKey: "key",
externID: "",
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
req := bootstrapReq{
id: tc.externID,
key: tc.externKey,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestChangeStateReqValidation(t *testing.T) {
cases := []struct {
desc string
token string
id string
state bootstrap.State
err error
}{
{
desc: "empty token",
token: "",
id: "id",
state: bootstrap.State(1),
err: apiutil.ErrBearerToken,
},
{
desc: "empty id",
token: "token",
id: "",
state: bootstrap.State(0),
err: apiutil.ErrMissingID,
},
{
desc: "invalid state",
token: "token",
id: "id",
state: bootstrap.State(14),
err: bootstrap.ErrBootstrapState,
},
}
for _, tc := range cases {
req := changeStateReq{
token: tc.token,
id: tc.id,
State: tc.state,
}
err := req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"fmt"
"net/http"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/bootstrap"
)
var (
_ magistrala.Response = (*removeRes)(nil)
_ magistrala.Response = (*configRes)(nil)
_ magistrala.Response = (*stateRes)(nil)
_ magistrala.Response = (*viewRes)(nil)
_ magistrala.Response = (*listRes)(nil)
)
type removeRes struct{}
func (res removeRes) Code() int {
return http.StatusNoContent
}
func (res removeRes) Headers() map[string]string {
return map[string]string{}
}
func (res removeRes) Empty() bool {
return true
}
type configRes struct {
id string
created bool
}
func (res configRes) Code() int {
if res.created {
return http.StatusCreated
}
return http.StatusOK
}
func (res configRes) Headers() map[string]string {
if res.created {
return map[string]string{
"Location": fmt.Sprintf("/clients/configs/%s", res.id),
}
}
return map[string]string{}
}
func (res configRes) Empty() bool {
return true
}
type channelRes struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
type viewRes struct {
ClientID string `json:"client_id,omitempty"`
CLientSecret string `json:"client_secret,omitempty"`
Channels []channelRes `json:"channels,omitempty"`
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key,omitempty"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
State bootstrap.State `json:"state"`
ClientCert string `json:"client_cert,omitempty"`
CACert string `json:"ca_cert,omitempty"`
}
func (res viewRes) Code() int {
return http.StatusOK
}
func (res viewRes) Headers() map[string]string {
return map[string]string{}
}
func (res viewRes) Empty() bool {
return false
}
type listRes struct {
Total uint64 `json:"total"`
Offset uint64 `json:"offset"`
Limit uint64 `json:"limit"`
Configs []viewRes `json:"configs"`
}
func (res listRes) Code() int {
return http.StatusOK
}
func (res listRes) Headers() map[string]string {
return map[string]string{}
}
func (res listRes) Empty() bool {
return false
}
type stateRes struct{}
func (res stateRes) Code() int {
return http.StatusOK
}
func (res stateRes) Headers() map[string]string {
return map[string]string{}
}
func (res stateRes) Empty() bool {
return true
}
type updateConfigRes struct {
ClientID string `json:"client_id,omitempty"`
CACert string `json:"ca_cert,omitempty"`
ClientCert string `json:"client_cert,omitempty"`
ClientKey string `json:"client_key,omitempty"`
}
func (res updateConfigRes) Code() int {
return http.StatusOK
}
func (res updateConfigRes) Headers() map[string]string {
return map[string]string{}
}
func (res updateConfigRes) Empty() bool {
return false
}
+283
View File
@@ -0,0 +1,283 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/absmach/magistrala"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/bootstrap"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
"github.com/go-chi/chi/v5"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
const (
contentType = "application/json"
byteContentType = "application/octet-stream"
offsetKey = "offset"
limitKey = "limit"
defOffset = 0
defLimit = 10
)
var (
fullMatch = []string{"state", "external_id", "client_id", "client_key"}
partialMatch = []string{"name"}
// ErrBootstrap indicates error in getting bootstrap configuration.
ErrBootstrap = errors.New("failed to read bootstrap configuration")
)
// MakeHandler returns a HTTP handler for API endpoints.
func MakeHandler(svc bootstrap.Service, authn smqauthn.AuthNMiddleware, reader bootstrap.ConfigReader, logger *slog.Logger, instanceID string) http.Handler {
opts := []kithttp.ServerOption{
kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, api.EncodeError)),
}
r := chi.NewRouter()
r.Route("/{domainID}/clients", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authn.WithOptions(smqauthn.WithDomainCheck(true)).Middleware())
r.Route("/configs", func(r chi.Router) {
r.Post("/", otelhttp.NewHandler(kithttp.NewServer(
addEndpoint(svc),
decodeAddRequest,
api.EncodeResponse,
opts...), "add").ServeHTTP)
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
listEndpoint(svc),
decodeListRequest,
api.EncodeResponse,
opts...), "list").ServeHTTP)
r.Get("/{configID}", otelhttp.NewHandler(kithttp.NewServer(
viewEndpoint(svc),
decodeEntityRequest,
api.EncodeResponse,
opts...), "view").ServeHTTP)
r.Put("/{configID}", otelhttp.NewHandler(kithttp.NewServer(
updateEndpoint(svc),
decodeUpdateRequest,
api.EncodeResponse,
opts...), "update").ServeHTTP)
r.Delete("/{configID}", otelhttp.NewHandler(kithttp.NewServer(
removeEndpoint(svc),
decodeEntityRequest,
api.EncodeResponse,
opts...), "remove").ServeHTTP)
r.Patch("/certs/{certID}", otelhttp.NewHandler(kithttp.NewServer(
updateCertEndpoint(svc),
decodeUpdateCertRequest,
api.EncodeResponse,
opts...), "update_cert").ServeHTTP)
r.Put("/connections/{connID}", otelhttp.NewHandler(kithttp.NewServer(
updateConnEndpoint(svc),
decodeUpdateConnRequest,
api.EncodeResponse,
opts...), "update_connections").ServeHTTP)
})
})
r.With(authn.WithOptions(smqauthn.WithDomainCheck(true)).Middleware()).Put("/state/{clientID}", otelhttp.NewHandler(kithttp.NewServer(
stateEndpoint(svc),
decodeStateRequest,
api.EncodeResponse,
opts...), "update_state").ServeHTTP)
})
r.Route("/clients/bootstrap", func(r chi.Router) {
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
bootstrapEndpoint(svc, reader, false),
decodeBootstrapRequest,
api.EncodeResponse,
opts...), "bootstrap").ServeHTTP)
r.Get("/{externalID}", otelhttp.NewHandler(kithttp.NewServer(
bootstrapEndpoint(svc, reader, false),
decodeBootstrapRequest,
api.EncodeResponse,
opts...), "bootstrap").ServeHTTP)
r.Get("/secure/{externalID}", otelhttp.NewHandler(kithttp.NewServer(
bootstrapEndpoint(svc, reader, true),
decodeBootstrapRequest,
encodeSecureRes,
opts...), "bootstrap_secure").ServeHTTP)
})
r.Get("/health", magistrala.Health("bootstrap", instanceID))
r.Handle("/metrics", promhttp.Handler())
return r
}
func decodeAddRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), contentType) {
return nil, apiutil.ErrUnsupportedContentType
}
req := addReq{
token: apiutil.ExtractBearerToken(r),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeUpdateRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), contentType) {
return nil, apiutil.ErrUnsupportedContentType
}
req := updateReq{
id: chi.URLParam(r, "configID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeUpdateCertRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), contentType) {
return nil, apiutil.ErrUnsupportedContentType
}
req := updateCertReq{
clientID: chi.URLParam(r, "certID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeUpdateConnRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), contentType) {
return nil, apiutil.ErrUnsupportedContentType
}
req := updateConnReq{
token: apiutil.ExtractBearerToken(r),
id: chi.URLParam(r, "connID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeListRequest(_ context.Context, r *http.Request) (any, error) {
o, err := apiutil.ReadNumQuery[uint64](r, offsetKey, defOffset)
if err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
l, err := apiutil.ReadNumQuery[uint64](r, limitKey, defLimit)
if err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrInvalidQueryParams)
}
req := listReq{
filter: parseFilter(q),
offset: o,
limit: l,
}
return req, nil
}
func decodeBootstrapRequest(_ context.Context, r *http.Request) (any, error) {
req := bootstrapReq{
id: chi.URLParam(r, "externalID"),
key: apiutil.ExtractClientSecret(r),
}
return req, nil
}
func decodeStateRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), contentType) {
return nil, apiutil.ErrUnsupportedContentType
}
req := changeStateReq{
token: apiutil.ExtractBearerToken(r),
id: chi.URLParam(r, "clientID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeEntityRequest(_ context.Context, r *http.Request) (any, error) {
req := entityReq{
id: chi.URLParam(r, "configID"),
}
return req, nil
}
func encodeSecureRes(_ context.Context, w http.ResponseWriter, response any) error {
w.Header().Set("Content-Type", byteContentType)
w.WriteHeader(http.StatusOK)
if b, ok := response.([]byte); ok {
if _, err := w.Write(b); err != nil {
return err
}
}
return nil
}
func parseFilter(values url.Values) bootstrap.Filter {
ret := bootstrap.Filter{
FullMatch: make(map[string]string),
PartialMatch: make(map[string]string),
}
for k := range values {
if contains(fullMatch, k) {
ret.FullMatch[k] = values.Get(k)
}
if contains(partialMatch, k) {
ret.PartialMatch[k] = strings.ToLower(values.Get(k))
}
}
return ret
}
func contains(l []string, s string) bool {
for _, v := range l {
if v == s {
return true
}
}
return false
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package bootstrap
import (
"context"
"time"
"github.com/absmach/magistrala/clients"
)
// Config represents Configuration entity. It wraps information about external entity
// as well as info about corresponding Magistrala entities.
// MGClient represents corresponding Magistrala Client ID.
// MGKey is key of corresponding Magistrala Client.
// MGChannels is a list of Magistrala Channels corresponding Magistrala Client connects to.
type Config struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
DomainID string `json:"domain_id,omitempty"`
Name string `json:"name,omitempty"`
ClientCert string `json:"client_cert,omitempty"`
ClientKey string `json:"client_key,omitempty"`
CACert string `json:"ca_cert,omitempty"`
Channels []Channel `json:"channels,omitempty"`
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key"`
Content string `json:"content,omitempty"`
State State `json:"state"`
}
// Channel represents Magistrala channel corresponding Magistrala Client is connected to.
type Channel struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
DomainID string `json:"domain_id"`
Parent string `json:"parent_id,omitempty"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
Status clients.Status `json:"status"`
}
// Filter is used for the search filters.
type Filter struct {
FullMatch map[string]string
PartialMatch map[string]string
}
// ConfigsPage contains page related metadata as well as list of Configs that
// belong to this page.
type ConfigsPage struct {
Total uint64 `json:"total"`
Offset uint64 `json:"offset"`
Limit uint64 `json:"limit"`
Configs []Config `json:"configs"`
}
// ConfigRepository specifies a Config persistence API.
type ConfigRepository interface {
// Save persists the Config. Successful operation is indicated by non-nil
// error response.
Save(ctx context.Context, cfg Config, chsConnIDs []string) (string, error)
// RetrieveByID retrieves the Config having the provided identifier, that is owned
// by the specified user.
RetrieveByID(ctx context.Context, domainID, id string) (Config, error)
// RetrieveAll retrieves a subset of Configs that are owned
// by the specific user, with given filter parameters.
RetrieveAll(ctx context.Context, domainID string, clientIDs []string, filter Filter, offset, limit uint64) ConfigsPage
// RetrieveByExternalID returns Config for given external ID.
RetrieveByExternalID(ctx context.Context, externalID string) (Config, error)
// Update updates an existing Config. A non-nil error is returned
// to indicate operation failure.
Update(ctx context.Context, cfg Config) error
// UpdateCerts updates and returns an existing Config certificate and domainID.
// A non-nil error is returned to indicate operation failure.
UpdateCert(ctx context.Context, domainID, clientID, clientCert, clientKey, caCert string) (Config, error)
// UpdateConnections updates a list of Channels the Config is connected to
// adding new Channels if needed.
UpdateConnections(ctx context.Context, domainID, id string, channels []Channel, connections []string) error
// Remove removes the Config having the provided identifier, that is owned
// by the specified user.
Remove(ctx context.Context, domainID, id string) error
// ChangeState changes of the Config, that is owned by the specific user.
ChangeState(ctx context.Context, domainID, id string, state State) error
// ListExisting retrieves those channels from the given list that exist in DB.
ListExisting(ctx context.Context, domainID string, ids []string) ([]Channel, error)
// Methods RemoveClient, UpdateChannel, and RemoveChannel are related to
// event sourcing. That's why these methods surpass ownership check.
// RemoveClient removes Config of the Client with the given ID.
RemoveClient(ctx context.Context, id string) error
// UpdateChannel updates channel with the given ID.
UpdateChannel(ctx context.Context, c Channel) error
// RemoveChannel removes channel with the given ID.
RemoveChannel(ctx context.Context, id string) error
// ConnectClient changes state of the Config when the corresponding Client is connected to the Channel.
ConnectClient(ctx context.Context, channelID, clientID string) error
// DisconnectClient changes state of the Config when the corresponding Client is disconnected from the Channel.
DisconnectClient(ctx context.Context, channelID, clientID string) error
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package bootstrap contains the domain concept definitions needed to support
// Magistrala bootstrap service functionality.
package bootstrap
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package consumer contains events consumer for events
// published by Bootstrap service.
package consumer
+24
View File
@@ -0,0 +1,24 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package consumer
import "time"
type removeEvent struct {
id string
}
type updateChannelEvent struct {
id string
name string
metadata map[string]any
updatedAt time.Time
updatedBy string
}
// Connection event is either connect or disconnect event.
type connectionEvent struct {
clientIDs []string
channelID string
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package consumer
import (
"context"
"time"
"github.com/absmach/magistrala/bootstrap"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/events"
)
const (
clientRemove = "client.remove"
clientConnect = "group.assign"
clientDisconnect = "group.unassign"
channelPrefix = "channels."
channelUpdate = channelPrefix + "update"
channelRemove = channelPrefix + "remove"
memberKind = "client"
relation = "group"
)
type eventHandler struct {
svc bootstrap.Service
}
// NewEventHandler returns new event store handler.
func NewEventHandler(svc bootstrap.Service) events.EventHandler {
return &eventHandler{
svc: svc,
}
}
func (es *eventHandler) Handle(ctx context.Context, event events.Event) error {
msg, err := event.Encode()
if err != nil {
return err
}
switch msg["operation"] {
case clientRemove:
rte := decodeRemoveClient(msg)
err = es.svc.RemoveConfigHandler(ctx, rte.id)
case clientConnect:
cte := decodeConnectClient(msg)
if cte.channelID == "" || len(cte.clientIDs) == 0 {
return svcerr.ErrMalformedEntity
}
for _, clientID := range cte.clientIDs {
if clientID == "" {
return svcerr.ErrMalformedEntity
}
if err := es.svc.ConnectClientHandler(ctx, cte.channelID, clientID); err != nil {
return err
}
}
case clientDisconnect:
dte := decodeDisconnectClient(msg)
if dte.channelID == "" || len(dte.clientIDs) == 0 {
return svcerr.ErrMalformedEntity
}
for _, clientID := range dte.clientIDs {
if clientID == "" {
return svcerr.ErrMalformedEntity
}
}
for _, c := range dte.clientIDs {
if err = es.svc.DisconnectClientHandler(ctx, dte.channelID, c); err != nil {
return err
}
}
case channelUpdate:
uce := decodeUpdateChannel(msg)
err = es.handleUpdateChannel(ctx, uce)
case channelRemove:
rce := decodeRemoveChannel(msg)
err = es.svc.RemoveChannelHandler(ctx, rce.id)
}
if err != nil {
return err
}
return nil
}
func decodeRemoveClient(event map[string]any) removeEvent {
return removeEvent{
id: events.Read(event, "id", ""),
}
}
func decodeUpdateChannel(event map[string]any) updateChannelEvent {
metadata := events.Read(event, "metadata", map[string]any{})
return updateChannelEvent{
id: events.Read(event, "id", ""),
name: events.Read(event, "name", ""),
metadata: metadata,
updatedAt: events.Read(event, "updated_at", time.Now()),
updatedBy: events.Read(event, "updated_by", ""),
}
}
func decodeRemoveChannel(event map[string]any) removeEvent {
return removeEvent{
id: events.Read(event, "id", ""),
}
}
func decodeConnectClient(event map[string]any) connectionEvent {
if events.Read(event, "memberKind", "") != memberKind && events.Read(event, "relation", "") != relation {
return connectionEvent{}
}
return connectionEvent{
channelID: events.Read(event, "group_id", ""),
clientIDs: events.ReadStringSlice(event, "member_ids"),
}
}
func decodeDisconnectClient(event map[string]any) connectionEvent {
if events.Read(event, "memberKind", "") != memberKind && events.Read(event, "relation", "") != relation {
return connectionEvent{}
}
return connectionEvent{
channelID: events.Read(event, "group_id", ""),
clientIDs: events.ReadStringSlice(event, "member_ids"),
}
}
func (es *eventHandler) handleUpdateChannel(ctx context.Context, uce updateChannelEvent) error {
channel := bootstrap.Channel{
ID: uce.id,
Name: uce.name,
Metadata: uce.metadata,
UpdatedAt: uce.updatedAt,
UpdatedBy: uce.updatedBy,
}
return es.svc.UpdateChannelHandler(ctx, channel)
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package events provides the domain concept definitions needed to support
// bootstrap events functionality.
package events
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package producer contains the domain events needed to support
// event sourcing of Bootstrap service actions.
package producer
+277
View File
@@ -0,0 +1,277 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package producer
import (
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/pkg/events"
)
const (
configPrefix = "bootstrap.config."
configCreate = configPrefix + "create"
configUpdate = configPrefix + "update"
configRemove = configPrefix + "remove"
configView = configPrefix + "view"
configList = configPrefix + "list"
configHandlerRemove = configPrefix + "remove_handler"
clientPrefix = "bootstrap.client."
clientBootstrap = clientPrefix + "bootstrap"
clientStateChange = clientPrefix + "change_state"
clientUpdateConnections = clientPrefix + "update_connections"
clientConnect = clientPrefix + "connect"
clientDisconnect = clientPrefix + "disconnect"
channelPrefix = "bootstrap.channel."
channelHandlerRemove = channelPrefix + "remove_handler"
channelUpdateHandler = channelPrefix + "update_handler"
certUpdate = "bootstrap.cert.update"
)
var (
_ events.Event = (*configEvent)(nil)
_ events.Event = (*removeConfigEvent)(nil)
_ events.Event = (*bootstrapEvent)(nil)
_ events.Event = (*changeStateEvent)(nil)
_ events.Event = (*updateConnectionsEvent)(nil)
_ events.Event = (*updateCertEvent)(nil)
_ events.Event = (*listConfigsEvent)(nil)
_ events.Event = (*removeHandlerEvent)(nil)
)
type configEvent struct {
bootstrap.Config
operation string
}
func (ce configEvent) Encode() (map[string]any, error) {
val := map[string]any{
"state": ce.State.String(),
"operation": ce.operation,
}
if ce.ClientID != "" {
val["client_id"] = ce.ClientID
}
if ce.Content != "" {
val["content"] = ce.Content
}
if ce.DomainID != "" {
val["domain_id "] = ce.DomainID
}
if ce.Name != "" {
val["name"] = ce.Name
}
if ce.ExternalID != "" {
val["external_id"] = ce.ExternalID
}
if len(ce.Channels) > 0 {
channels := make([]string, len(ce.Channels))
for i, ch := range ce.Channels {
channels[i] = ch.ID
}
val["channels"] = channels
}
if ce.ClientCert != "" {
val["client_cert"] = ce.ClientCert
}
if ce.ClientKey != "" {
val["client_key"] = ce.ClientKey
}
if ce.CACert != "" {
val["ca_cert"] = ce.CACert
}
if ce.Content != "" {
val["content"] = ce.Content
}
return val, nil
}
type removeConfigEvent struct {
client string
}
func (rce removeConfigEvent) Encode() (map[string]any, error) {
return map[string]any{
"client_id": rce.client,
"operation": configRemove,
}, nil
}
type listConfigsEvent struct {
offset uint64
limit uint64
fullMatch map[string]string
partialMatch map[string]string
}
func (rce listConfigsEvent) Encode() (map[string]any, error) {
val := map[string]any{
"offset": rce.offset,
"limit": rce.limit,
"operation": configList,
}
if len(rce.fullMatch) > 0 {
val["full_match"] = rce.fullMatch
}
if len(rce.partialMatch) > 0 {
val["full_match"] = rce.partialMatch
}
return val, nil
}
type bootstrapEvent struct {
bootstrap.Config
externalID string
success bool
}
func (be bootstrapEvent) Encode() (map[string]any, error) {
val := map[string]any{
"external_id": be.externalID,
"success": be.success,
"operation": clientBootstrap,
}
if be.ClientID != "" {
val["client_id"] = be.ClientID
}
if be.Content != "" {
val["content"] = be.Content
}
if be.DomainID != "" {
val["domain_id "] = be.DomainID
}
if be.Name != "" {
val["name"] = be.Name
}
if be.ExternalID != "" {
val["external_id"] = be.ExternalID
}
if len(be.Channels) > 0 {
channels := make([]string, len(be.Channels))
for i, ch := range be.Channels {
channels[i] = ch.ID
}
val["channels"] = channels
}
if be.ClientCert != "" {
val["client_cert"] = be.ClientCert
}
if be.ClientKey != "" {
val["client_key"] = be.ClientKey
}
if be.CACert != "" {
val["ca_cert"] = be.CACert
}
if be.Content != "" {
val["content"] = be.Content
}
return val, nil
}
type changeStateEvent struct {
mgClient string
state bootstrap.State
}
func (cse changeStateEvent) Encode() (map[string]any, error) {
return map[string]any{
"client_id": cse.mgClient,
"state": cse.state.String(),
"operation": clientStateChange,
}, nil
}
type updateConnectionsEvent struct {
mgClient string
mgChannels []string
}
func (uce updateConnectionsEvent) Encode() (map[string]any, error) {
return map[string]any{
"client_id": uce.mgClient,
"channels": uce.mgChannels,
"operation": clientUpdateConnections,
}, nil
}
type updateCertEvent struct {
clientID string
clientCert string
clientKey string
caCert string
}
func (uce updateCertEvent) Encode() (map[string]any, error) {
return map[string]any{
"client_id": uce.clientID,
"client_cert": uce.clientCert,
"client_key": uce.clientKey,
"ca_cert": uce.caCert,
"operation": certUpdate,
}, nil
}
type removeHandlerEvent struct {
id string
operation string
}
func (rhe removeHandlerEvent) Encode() (map[string]any, error) {
return map[string]any{
"config_id": rhe.id,
"operation": rhe.operation,
}, nil
}
type updateChannelHandlerEvent struct {
bootstrap.Channel
}
func (uche updateChannelHandlerEvent) Encode() (map[string]any, error) {
val := map[string]any{
"operation": channelUpdateHandler,
}
if uche.ID != "" {
val["channel_id"] = uche.ID
}
if uche.Name != "" {
val["name"] = uche.Name
}
if uche.Metadata != nil {
val["metadata"] = uche.Metadata
}
return val, nil
}
type connectClientEvent struct {
clientID string
channelID string
}
func (cte connectClientEvent) Encode() (map[string]any, error) {
return map[string]any{
"client_id": cte.clientID,
"channel_id": cte.channelID,
"operation": clientConnect,
}, nil
}
type disconnectClientEvent struct {
clientID string
channelID string
}
func (dte disconnectClientEvent) Encode() (map[string]any, error) {
return map[string]any{
"client_id": dte.clientID,
"channel_id": dte.channelID,
"operation": clientDisconnect,
}, nil
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package producer_test
import (
"context"
"fmt"
"log"
"os"
"testing"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/redis/go-redis/v9"
)
var (
redisClient *redis.Client
redisURL string
)
func TestMain(m *testing.M) {
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}
container, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "redis",
Tag: "7.2.4-alpine",
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
if err != nil {
log.Fatalf("Could not start container: %s", err)
}
redisURL = fmt.Sprintf("redis://localhost:%s/0", container.GetPort("6379/tcp"))
opts, err := redis.ParseURL(redisURL)
if err != nil {
log.Fatalf("Could not parse redis URL: %s", err)
}
if err := pool.Retry(func() error {
redisClient = redis.NewClient(opts)
return redisClient.Ping(context.Background()).Err()
}); err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}
code := m.Run()
if err := pool.Purge(container); err != nil {
log.Fatalf("Could not purge container: %s", err)
}
os.Exit(code)
}
+253
View File
@@ -0,0 +1,253 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package producer
import (
"context"
"github.com/absmach/magistrala/bootstrap"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/events"
)
var _ bootstrap.Service = (*eventStore)(nil)
const (
magistralaPrefix = "magistrala."
createStream = magistralaPrefix + configCreate
viewStream = magistralaPrefix + configView
listStream = magistralaPrefix + configList
updateStream = magistralaPrefix + configUpdate
removeStream = magistralaPrefix + configRemove
updateCertStream = magistralaPrefix + certUpdate
updateConnectionsStream = magistralaPrefix + clientUpdateConnections
removeHandlerStream = magistralaPrefix + configHandlerRemove
bootstrapStream = magistralaPrefix + clientBootstrap
stateChangeStream = magistralaPrefix + clientStateChange
connectStream = magistralaPrefix + clientConnect
disconnectStream = magistralaPrefix + clientDisconnect
updateHandlerStream = magistralaPrefix + channelUpdateHandler
removeChannelHandlerStream = magistralaPrefix + channelHandlerRemove
)
type eventStore struct {
events.Publisher
svc bootstrap.Service
}
// NewEventStoreMiddleware returns wrapper around bootstrap service that sends
// events to event store.
func NewEventStoreMiddleware(svc bootstrap.Service, publisher events.Publisher) bootstrap.Service {
return &eventStore{
svc: svc,
Publisher: publisher,
}
}
func (es *eventStore) Add(ctx context.Context, session smqauthn.Session, token string, cfg bootstrap.Config) (bootstrap.Config, error) {
saved, err := es.svc.Add(ctx, session, token, cfg)
if err != nil {
return saved, err
}
ev := configEvent{
saved, configCreate,
}
if err := es.Publish(ctx, createStream, ev); err != nil {
return saved, err
}
return saved, err
}
func (es *eventStore) View(ctx context.Context, session smqauthn.Session, id string) (bootstrap.Config, error) {
cfg, err := es.svc.View(ctx, session, id)
if err != nil {
return cfg, err
}
ev := configEvent{
cfg, configView,
}
if err := es.Publish(ctx, configView, ev); err != nil {
return cfg, err
}
return cfg, err
}
func (es *eventStore) Update(ctx context.Context, session smqauthn.Session, cfg bootstrap.Config) error {
if err := es.svc.Update(ctx, session, cfg); err != nil {
return err
}
ev := configEvent{
cfg, configUpdate,
}
return es.Publish(ctx, configUpdate, ev)
}
func (es eventStore) UpdateCert(ctx context.Context, session smqauthn.Session, clientID, clientCert, clientKey, caCert string) (bootstrap.Config, error) {
cfg, err := es.svc.UpdateCert(ctx, session, clientID, clientCert, clientKey, caCert)
if err != nil {
return cfg, err
}
ev := updateCertEvent{
clientID: clientID,
clientCert: clientCert,
clientKey: clientKey,
caCert: caCert,
}
if err := es.Publish(ctx, updateCertStream, ev); err != nil {
return cfg, err
}
return cfg, nil
}
func (es *eventStore) UpdateConnections(ctx context.Context, session smqauthn.Session, token, id string, connections []string) error {
if err := es.svc.UpdateConnections(ctx, session, token, id, connections); err != nil {
return err
}
ev := updateConnectionsEvent{
mgClient: id,
mgChannels: connections,
}
return es.Publish(ctx, updateConnectionsStream, ev)
}
func (es *eventStore) List(ctx context.Context, session smqauthn.Session, filter bootstrap.Filter, offset, limit uint64) (bootstrap.ConfigsPage, error) {
bp, err := es.svc.List(ctx, session, filter, offset, limit)
if err != nil {
return bp, err
}
ev := listConfigsEvent{
offset: offset,
limit: limit,
fullMatch: filter.FullMatch,
partialMatch: filter.PartialMatch,
}
if err := es.Publish(ctx, listStream, ev); err != nil {
return bp, err
}
return bp, nil
}
func (es *eventStore) Remove(ctx context.Context, session smqauthn.Session, id string) error {
if err := es.svc.Remove(ctx, session, id); err != nil {
return err
}
ev := removeConfigEvent{
client: id,
}
return es.Publish(ctx, removeStream, ev)
}
func (es *eventStore) Bootstrap(ctx context.Context, externalKey, externalID string, secure bool) (bootstrap.Config, error) {
cfg, err := es.svc.Bootstrap(ctx, externalKey, externalID, secure)
ev := bootstrapEvent{
cfg,
externalID,
true,
}
if err != nil {
ev.success = false
}
if err := es.Publish(ctx, bootstrapStream, ev); err != nil {
return cfg, err
}
return cfg, err
}
func (es *eventStore) ChangeState(ctx context.Context, session smqauthn.Session, token, id string, state bootstrap.State) error {
if err := es.svc.ChangeState(ctx, session, token, id, state); err != nil {
return err
}
ev := changeStateEvent{
mgClient: id,
state: state,
}
return es.Publish(ctx, stateChangeStream, ev)
}
func (es *eventStore) RemoveConfigHandler(ctx context.Context, id string) error {
if err := es.svc.RemoveConfigHandler(ctx, id); err != nil {
return err
}
ev := removeHandlerEvent{
id: id,
operation: configHandlerRemove,
}
return es.Publish(ctx, removeHandlerStream, ev)
}
func (es *eventStore) RemoveChannelHandler(ctx context.Context, id string) error {
if err := es.svc.RemoveChannelHandler(ctx, id); err != nil {
return err
}
ev := removeHandlerEvent{
id: id,
operation: channelHandlerRemove,
}
return es.Publish(ctx, removeChannelHandlerStream, ev)
}
func (es *eventStore) UpdateChannelHandler(ctx context.Context, channel bootstrap.Channel) error {
if err := es.svc.UpdateChannelHandler(ctx, channel); err != nil {
return err
}
ev := updateChannelHandlerEvent{
channel,
}
return es.Publish(ctx, updateStream, ev)
}
func (es *eventStore) ConnectClientHandler(ctx context.Context, channelID, clientID string) error {
if err := es.svc.ConnectClientHandler(ctx, channelID, clientID); err != nil {
return err
}
ev := connectClientEvent{
clientID: clientID,
channelID: channelID,
}
return es.Publish(ctx, connectStream, ev)
}
func (es *eventStore) DisconnectClientHandler(ctx context.Context, channelID, clientID string) error {
if err := es.svc.DisconnectClientHandler(ctx, channelID, clientID); err != nil {
return err
}
ev := disconnectClientEvent{
clientID: clientID,
channelID: channelID,
}
return es.Publish(ctx, disconnectStream, ev)
}
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"github.com/absmach/magistrala/bootstrap"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/authz"
"github.com/absmach/magistrala/pkg/policies"
)
const (
updatePermission = "update_permission"
readPermission = "read_permission"
deletePermission = "delete_permission"
)
var _ bootstrap.Service = (*authorizationMiddleware)(nil)
type authorizationMiddleware struct {
svc bootstrap.Service
authz authz.Authorization
}
// AuthorizationMiddleware adds authorization to the clients service.
func AuthorizationMiddleware(svc bootstrap.Service, authz authz.Authorization) bootstrap.Service {
return &authorizationMiddleware{
svc: svc,
authz: authz,
}
}
func (am *authorizationMiddleware) Add(ctx context.Context, session smqauthn.Session, token string, cfg bootstrap.Config) (bootstrap.Config, error) {
if err := am.authorize(ctx, "", policies.UserType, policies.UsersKind, session.DomainUserID, policies.MembershipPermission, policies.DomainType, session.DomainID); err != nil {
return bootstrap.Config{}, err
}
return am.svc.Add(ctx, session, token, cfg)
}
func (am *authorizationMiddleware) View(ctx context.Context, session smqauthn.Session, id string) (bootstrap.Config, error) {
if err := am.authorize(ctx, session.DomainID, policies.UserType, policies.UsersKind, session.DomainUserID, readPermission, policies.ClientType, id); err != nil {
return bootstrap.Config{}, err
}
return am.svc.View(ctx, session, id)
}
func (am *authorizationMiddleware) Update(ctx context.Context, session smqauthn.Session, cfg bootstrap.Config) error {
if err := am.authorize(ctx, session.DomainID, policies.UserType, policies.UsersKind, session.DomainUserID, updatePermission, policies.ClientType, cfg.ClientID); err != nil {
return err
}
return am.svc.Update(ctx, session, cfg)
}
func (am *authorizationMiddleware) UpdateCert(ctx context.Context, session smqauthn.Session, clientID, clientCert, clientKey, caCert string) (bootstrap.Config, error) {
if err := am.authorize(ctx, session.DomainID, policies.UserType, policies.UsersKind, session.DomainUserID, updatePermission, policies.ClientType, clientID); err != nil {
return bootstrap.Config{}, err
}
return am.svc.UpdateCert(ctx, session, clientID, clientCert, clientKey, caCert)
}
func (am *authorizationMiddleware) UpdateConnections(ctx context.Context, session smqauthn.Session, token, id string, connections []string) error {
if err := am.authorize(ctx, session.DomainID, policies.UserType, policies.UsersKind, session.DomainUserID, updatePermission, policies.ClientType, id); err != nil {
return err
}
return am.svc.UpdateConnections(ctx, session, token, id, connections)
}
func (am *authorizationMiddleware) List(ctx context.Context, session smqauthn.Session, filter bootstrap.Filter, offset, limit uint64) (bootstrap.ConfigsPage, error) {
if err := am.checkSuperAdmin(ctx, session.DomainUserID); err == nil {
session.SuperAdmin = true
}
if err := am.authorize(ctx, "", policies.UserType, policies.UsersKind, session.DomainUserID, policies.AdminPermission, policies.DomainType, session.DomainID); err == nil {
session.SuperAdmin = true
}
return am.svc.List(ctx, session, filter, offset, limit)
}
func (am *authorizationMiddleware) Remove(ctx context.Context, session smqauthn.Session, id string) error {
if err := am.authorize(ctx, session.DomainID, policies.UserType, policies.UsersKind, session.DomainUserID, deletePermission, policies.ClientType, id); err != nil {
return err
}
return am.svc.Remove(ctx, session, id)
}
func (am *authorizationMiddleware) Bootstrap(ctx context.Context, externalKey, externalID string, secure bool) (bootstrap.Config, error) {
return am.svc.Bootstrap(ctx, externalKey, externalID, secure)
}
func (am *authorizationMiddleware) ChangeState(ctx context.Context, session smqauthn.Session, token, id string, state bootstrap.State) error {
return am.svc.ChangeState(ctx, session, token, id, state)
}
func (am *authorizationMiddleware) UpdateChannelHandler(ctx context.Context, channel bootstrap.Channel) error {
return am.svc.UpdateChannelHandler(ctx, channel)
}
func (am *authorizationMiddleware) RemoveConfigHandler(ctx context.Context, id string) error {
return am.svc.RemoveConfigHandler(ctx, id)
}
func (am *authorizationMiddleware) RemoveChannelHandler(ctx context.Context, id string) error {
return am.svc.RemoveChannelHandler(ctx, id)
}
func (am *authorizationMiddleware) ConnectClientHandler(ctx context.Context, channelID, clientID string) error {
return am.svc.ConnectClientHandler(ctx, channelID, clientID)
}
func (am *authorizationMiddleware) DisconnectClientHandler(ctx context.Context, channelID, clientID string) error {
return am.svc.DisconnectClientHandler(ctx, channelID, clientID)
}
func (am *authorizationMiddleware) checkSuperAdmin(ctx context.Context, adminID string) error {
if err := am.authz.Authorize(ctx, authz.PolicyReq{
SubjectType: policies.UserType,
Subject: adminID,
Permission: policies.AdminPermission,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
}, nil); err != nil {
return err
}
return nil
}
func (am *authorizationMiddleware) authorize(ctx context.Context, domain, subjType, subjKind, subj, perm, objType, obj string) error {
req := authz.PolicyReq{
Domain: domain,
SubjectType: subjType,
SubjectKind: subjKind,
Subject: subj,
Permission: perm,
ObjectType: objType,
Object: obj,
}
if err := am.authz.Authorize(ctx, req, nil); err != nil {
return err
}
return nil
}
+295
View File
@@ -0,0 +1,295 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
//go:build !test
package middleware
import (
"context"
"log/slog"
"time"
"github.com/absmach/magistrala/bootstrap"
smqauthn "github.com/absmach/magistrala/pkg/authn"
)
var _ bootstrap.Service = (*loggingMiddleware)(nil)
type loggingMiddleware struct {
logger *slog.Logger
svc bootstrap.Service
}
// LoggingMiddleware adds logging facilities to the bootstrap service.
func LoggingMiddleware(svc bootstrap.Service, logger *slog.Logger) bootstrap.Service {
return &loggingMiddleware{logger, svc}
}
// Add logs the add request. It logs the client ID and the time it took to complete the request.
// If the request fails, it logs the error.
func (lm *loggingMiddleware) Add(ctx context.Context, session smqauthn.Session, token string, cfg bootstrap.Config) (saved bootstrap.Config, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("client_id", saved.ClientID),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Add new bootstrap failed", args...)
return
}
lm.logger.Info("Add new bootstrap completed successfully", args...)
}(time.Now())
return lm.svc.Add(ctx, session, token, cfg)
}
// View logs the view request. It logs the client ID and the time it took to complete the request.
// If the request fails, it logs the error.
func (lm *loggingMiddleware) View(ctx context.Context, session smqauthn.Session, id string) (saved bootstrap.Config, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("client_id", id),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("View client config failed", args...)
return
}
lm.logger.Info("View client config completed successfully", args...)
}(time.Now())
return lm.svc.View(ctx, session, id)
}
// Update logs the update request. It logs bootstrap client ID and the time it took to complete the request.
// If the request fails, it logs the error.
func (lm *loggingMiddleware) Update(ctx context.Context, session smqauthn.Session, cfg bootstrap.Config) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.Group("config",
slog.String("client_id", cfg.ClientID),
slog.String("name", cfg.Name),
),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Update bootstrap config failed", args...)
return
}
lm.logger.Info("Update bootstrap config completed successfully", args...)
}(time.Now())
return lm.svc.Update(ctx, session, cfg)
}
// UpdateCert logs the update_cert request. It logs client ID and the time it took to complete the request.
// If the request fails, it logs the error.
func (lm *loggingMiddleware) UpdateCert(ctx context.Context, session smqauthn.Session, clientID, clientCert, clientKey, caCert string) (cfg bootstrap.Config, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("client_id", cfg.ClientID),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Update bootstrap config certificate failed", args...)
return
}
lm.logger.Info("Update bootstrap config certificate completed successfully", args...)
}(time.Now())
return lm.svc.UpdateCert(ctx, session, clientID, clientCert, clientKey, caCert)
}
// UpdateConnections logs the update_connections request. It logs bootstrap ID and the time it took to complete the request.
// If the request fails, it logs the error.
func (lm *loggingMiddleware) UpdateConnections(ctx context.Context, session smqauthn.Session, token, id string, connections []string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("client_id", id),
slog.Any("connections", connections),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Update config connections failed", args...)
return
}
lm.logger.Info("Update config connections completed successfully", args...)
}(time.Now())
return lm.svc.UpdateConnections(ctx, session, token, id, connections)
}
// List logs the list request. It logs offset, limit and the time it took to complete the request.
// If the request fails, it logs the error.
func (lm *loggingMiddleware) List(ctx context.Context, session smqauthn.Session, filter bootstrap.Filter, offset, limit uint64) (res bootstrap.ConfigsPage, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.Group("page",
slog.Any("filter", filter),
slog.Uint64("offset", offset),
slog.Uint64("limit", limit),
slog.Uint64("total", res.Total),
),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("List configs failed", args...)
return
}
lm.logger.Info("List configs completed successfully", args...)
}(time.Now())
return lm.svc.List(ctx, session, filter, offset, limit)
}
// Remove logs the remove request. It logs bootstrap ID and the time it took to complete the request.
// If the request fails, it logs the error.
func (lm *loggingMiddleware) Remove(ctx context.Context, session smqauthn.Session, id string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("client_id", id),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Remove bootstrap config failed", args...)
return
}
lm.logger.Info("Remove bootstrap config completed successfully", args...)
}(time.Now())
return lm.svc.Remove(ctx, session, id)
}
func (lm *loggingMiddleware) Bootstrap(ctx context.Context, externalKey, externalID string, secure bool) (cfg bootstrap.Config, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("external_id", externalID),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("View bootstrap config failed", args...)
return
}
lm.logger.Info("View bootstrap completed successfully", args...)
}(time.Now())
return lm.svc.Bootstrap(ctx, externalKey, externalID, secure)
}
func (lm *loggingMiddleware) ChangeState(ctx context.Context, session smqauthn.Session, token, id string, state bootstrap.State) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("id", id),
slog.Any("state", state),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Change client state failed", args...)
return
}
lm.logger.Info("Change client state completed successfully", args...)
}(time.Now())
return lm.svc.ChangeState(ctx, session, token, id, state)
}
func (lm *loggingMiddleware) UpdateChannelHandler(ctx context.Context, channel bootstrap.Channel) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.Group("channel",
slog.String("id", channel.ID),
slog.String("name", channel.Name),
slog.Any("metadata", channel.Metadata),
),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Update channel handler failed", args...)
return
}
lm.logger.Info("Update channel handler completed successfully", args...)
}(time.Now())
return lm.svc.UpdateChannelHandler(ctx, channel)
}
func (lm *loggingMiddleware) RemoveConfigHandler(ctx context.Context, id string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("config_id", id),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Remove config handler failed", args...)
return
}
lm.logger.Info("Remove config handler completed successfully", args...)
}(time.Now())
return lm.svc.RemoveConfigHandler(ctx, id)
}
func (lm *loggingMiddleware) RemoveChannelHandler(ctx context.Context, id string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("channel_id", id),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Remove channel handler failed", args...)
return
}
lm.logger.Info("Remove channel handler completed successfully", args...)
}(time.Now())
return lm.svc.RemoveChannelHandler(ctx, id)
}
func (lm *loggingMiddleware) ConnectClientHandler(ctx context.Context, channelID, clientID string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("channel_id", channelID),
slog.String("client_id", clientID),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Connect client handler failed", args...)
return
}
lm.logger.Info("Connect client handler completed successfully", args...)
}(time.Now())
return lm.svc.ConnectClientHandler(ctx, channelID, clientID)
}
func (lm *loggingMiddleware) DisconnectClientHandler(ctx context.Context, channelID, clientID string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("channel_id", channelID),
slog.String("client_id", clientID),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Disconnect client handler failed", args...)
return
}
lm.logger.Info("Disconnect client handler completed successfully", args...)
}(time.Now())
return lm.svc.DisconnectClientHandler(ctx, channelID, clientID)
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
//go:build !test
package middleware
import (
"context"
"time"
"github.com/absmach/magistrala/bootstrap"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/go-kit/kit/metrics"
)
var _ bootstrap.Service = (*metricsMiddleware)(nil)
type metricsMiddleware struct {
counter metrics.Counter
latency metrics.Histogram
svc bootstrap.Service
}
// MetricsMiddleware instruments core service by tracking request count and latency.
func MetricsMiddleware(svc bootstrap.Service, counter metrics.Counter, latency metrics.Histogram) bootstrap.Service {
return &metricsMiddleware{
counter: counter,
latency: latency,
svc: svc,
}
}
// Add instruments Add method with metrics.
func (mm *metricsMiddleware) Add(ctx context.Context, session smqauthn.Session, token string, cfg bootstrap.Config) (saved bootstrap.Config, err error) {
defer func(begin time.Time) {
mm.counter.With("method", "add").Add(1)
mm.latency.With("method", "add").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.Add(ctx, session, token, cfg)
}
// View instruments View method with metrics.
func (mm *metricsMiddleware) View(ctx context.Context, session smqauthn.Session, id string) (saved bootstrap.Config, err error) {
defer func(begin time.Time) {
mm.counter.With("method", "view").Add(1)
mm.latency.With("method", "view").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.View(ctx, session, id)
}
// Update instruments Update method with metrics.
func (mm *metricsMiddleware) Update(ctx context.Context, session smqauthn.Session, cfg bootstrap.Config) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "update").Add(1)
mm.latency.With("method", "update").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.Update(ctx, session, cfg)
}
// UpdateCert instruments UpdateCert method with metrics.
func (mm *metricsMiddleware) UpdateCert(ctx context.Context, session smqauthn.Session, clientID, clientCert, clientKey, caCert string) (cfg bootstrap.Config, err error) {
defer func(begin time.Time) {
mm.counter.With("method", "update_cert").Add(1)
mm.latency.With("method", "update_cert").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.UpdateCert(ctx, session, clientID, clientCert, clientKey, caCert)
}
// UpdateConnections instruments UpdateConnections method with metrics.
func (mm *metricsMiddleware) UpdateConnections(ctx context.Context, session smqauthn.Session, token, id string, connections []string) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "update_connections").Add(1)
mm.latency.With("method", "update_connections").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.UpdateConnections(ctx, session, token, id, connections)
}
// List instruments List method with metrics.
func (mm *metricsMiddleware) List(ctx context.Context, session smqauthn.Session, filter bootstrap.Filter, offset, limit uint64) (saved bootstrap.ConfigsPage, err error) {
defer func(begin time.Time) {
mm.counter.With("method", "list").Add(1)
mm.latency.With("method", "list").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.List(ctx, session, filter, offset, limit)
}
// Remove instruments Remove method with metrics.
func (mm *metricsMiddleware) Remove(ctx context.Context, session smqauthn.Session, id string) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "remove").Add(1)
mm.latency.With("method", "remove").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.Remove(ctx, session, id)
}
// Bootstrap instruments Bootstrap method with metrics.
func (mm *metricsMiddleware) Bootstrap(ctx context.Context, externalKey, externalID string, secure bool) (cfg bootstrap.Config, err error) {
defer func(begin time.Time) {
mm.counter.With("method", "bootstrap").Add(1)
mm.latency.With("method", "bootstrap").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.Bootstrap(ctx, externalKey, externalID, secure)
}
// ChangeState instruments ChangeState method with metrics.
func (mm *metricsMiddleware) ChangeState(ctx context.Context, session smqauthn.Session, token, id string, state bootstrap.State) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "change_state").Add(1)
mm.latency.With("method", "change_state").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.ChangeState(ctx, session, token, id, state)
}
// UpdateChannelHandler instruments UpdateChannelHandler method with metrics.
func (mm *metricsMiddleware) UpdateChannelHandler(ctx context.Context, channel bootstrap.Channel) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "update_channel").Add(1)
mm.latency.With("method", "update_channel").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.UpdateChannelHandler(ctx, channel)
}
// RemoveConfigHandler instruments RemoveConfigHandler method with metrics.
func (mm *metricsMiddleware) RemoveConfigHandler(ctx context.Context, id string) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "remove_config").Add(1)
mm.latency.With("method", "remove_config").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.RemoveConfigHandler(ctx, id)
}
// RemoveChannelHandler instruments RemoveChannelHandler method with metrics.
func (mm *metricsMiddleware) RemoveChannelHandler(ctx context.Context, id string) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "remove_channel").Add(1)
mm.latency.With("method", "remove_channel").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.RemoveChannelHandler(ctx, id)
}
// ConnectClientHandler instruments ConnectClientHandler method with metrics.
func (mm *metricsMiddleware) ConnectClientHandler(ctx context.Context, channelID, clientID string) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "connect_client_handler").Add(1)
mm.latency.With("method", "connect_client_handler").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.ConnectClientHandler(ctx, channelID, clientID)
}
// DisconnectClientHandler instruments DisconnectClientHandler method with metrics.
func (mm *metricsMiddleware) DisconnectClientHandler(ctx context.Context, channelID, clientID string) (err error) {
defer func(begin time.Time) {
mm.counter.With("method", "disconnect_client_handler").Add(1)
mm.latency.With("method", "disconnect_client_handler").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.svc.DisconnectClientHandler(ctx, channelID, clientID)
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"github.com/absmach/magistrala/bootstrap"
mock "github.com/stretchr/testify/mock"
)
// NewConfigReader creates a new instance of ConfigReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewConfigReader(t interface {
mock.TestingT
Cleanup(func())
}) *ConfigReader {
mock := &ConfigReader{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// ConfigReader is an autogenerated mock type for the ConfigReader type
type ConfigReader struct {
mock.Mock
}
type ConfigReader_Expecter struct {
mock *mock.Mock
}
func (_m *ConfigReader) EXPECT() *ConfigReader_Expecter {
return &ConfigReader_Expecter{mock: &_m.Mock}
}
// ReadConfig provides a mock function for the type ConfigReader
func (_mock *ConfigReader) ReadConfig(config bootstrap.Config, b bool) (any, error) {
ret := _mock.Called(config, b)
if len(ret) == 0 {
panic("no return value specified for ReadConfig")
}
var r0 any
var r1 error
if returnFunc, ok := ret.Get(0).(func(bootstrap.Config, bool) (any, error)); ok {
return returnFunc(config, b)
}
if returnFunc, ok := ret.Get(0).(func(bootstrap.Config, bool) any); ok {
r0 = returnFunc(config, b)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(any)
}
}
if returnFunc, ok := ret.Get(1).(func(bootstrap.Config, bool) error); ok {
r1 = returnFunc(config, b)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ConfigReader_ReadConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadConfig'
type ConfigReader_ReadConfig_Call struct {
*mock.Call
}
// ReadConfig is a helper method to define mock.On call
// - config bootstrap.Config
// - b bool
func (_e *ConfigReader_Expecter) ReadConfig(config interface{}, b interface{}) *ConfigReader_ReadConfig_Call {
return &ConfigReader_ReadConfig_Call{Call: _e.mock.On("ReadConfig", config, b)}
}
func (_c *ConfigReader_ReadConfig_Call) Run(run func(config bootstrap.Config, b bool)) *ConfigReader_ReadConfig_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 bootstrap.Config
if args[0] != nil {
arg0 = args[0].(bootstrap.Config)
}
var arg1 bool
if args[1] != nil {
arg1 = args[1].(bool)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *ConfigReader_ReadConfig_Call) Return(v any, err error) *ConfigReader_ReadConfig_Call {
_c.Call.Return(v, err)
return _c
}
func (_c *ConfigReader_ReadConfig_Call) RunAndReturn(run func(config bootstrap.Config, b bool) (any, error)) *ConfigReader_ReadConfig_Call {
_c.Call.Return(run)
return _c
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+771
View File
@@ -0,0 +1,771 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/clients"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/absmach/magistrala/pkg/postgres"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jmoiron/sqlx"
)
var (
errSaveChannels = errors.New("failed to insert channels to database")
errSaveConnections = errors.New("failed to insert connections to database")
errUpdateChannels = errors.New("failed to update channels in bootstrap configuration database")
errRemoveChannels = errors.New("failed to remove channels from bootstrap configuration in database")
errConnectClient = errors.New("failed to connect client in bootstrap configuration in database")
errDisconnectClient = errors.New("failed to disconnect client in bootstrap configuration in database")
)
const cleanupQuery = `DELETE FROM channels ch WHERE NOT EXISTS (
SELECT channel_id FROM connections c WHERE ch.magistrala_channel = c.channel_id);`
var _ bootstrap.ConfigRepository = (*configRepository)(nil)
type configRepository struct {
db postgres.Database
log *slog.Logger
}
// NewConfigRepository instantiates a PostgreSQL implementation of config
// repository.
func NewConfigRepository(db postgres.Database, log *slog.Logger) bootstrap.ConfigRepository {
return &configRepository{db: db, log: log}
}
func (cr configRepository) Save(ctx context.Context, cfg bootstrap.Config, chsConnIDs []string) (clientID string, err error) {
q := `INSERT INTO configs (magistrala_client, domain_id, name, client_cert, client_key, ca_cert, magistrala_secret, external_id, external_key, content, state)
VALUES (:magistrala_client, :domain_id, :name, :client_cert, :client_key, :ca_cert, :magistrala_secret, :external_id, :external_key, :content, :state)`
tx, err := cr.db.BeginTxx(ctx, nil)
if err != nil {
return "", errors.Wrap(repoerr.ErrCreateEntity, err)
}
dbcfg := toDBConfig(cfg)
defer func() {
if err != nil {
err = cr.rollback("Save method", err, tx)
}
}()
if _, err := tx.NamedExec(q, dbcfg); err != nil {
switch pgErr := err.(type) {
case *pgconn.PgError:
if pgErr.Code == pgerrcode.UniqueViolation {
err = repoerr.ErrConflict
}
}
return "", err
}
if err := insertChannels(cfg.DomainID, cfg.Channels, tx); err != nil {
return "", errors.Wrap(errSaveChannels, err)
}
if err := insertConnections(ctx, cfg, chsConnIDs, tx); err != nil {
return "", errors.Wrap(errSaveConnections, err)
}
if commitErr := tx.Commit(); commitErr != nil {
return "", commitErr
}
return cfg.ClientID, nil
}
func (cr configRepository) RetrieveByID(ctx context.Context, domainID, id string) (bootstrap.Config, error) {
q := `SELECT magistrala_client, magistrala_secret, external_id, external_key, name, content, state, client_cert, ca_cert
FROM configs
WHERE magistrala_client = :magistrala_client AND domain_id = :domain_id`
dbcfg := dbConfig{
ClientID: id,
DomainID: domainID,
}
row, err := cr.db.NamedQueryContext(ctx, q, dbcfg)
if err != nil {
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
if !row.Next() {
return bootstrap.Config{}, repoerr.ErrNotFound
}
if err := row.StructScan(&dbcfg); err != nil {
return bootstrap.Config{}, err
}
q = `SELECT magistrala_channel, name, metadata FROM channels ch
INNER JOIN connections conn
ON ch.magistrala_channel = conn.channel_id AND ch.domain_id = conn.domain_id
WHERE conn.config_id = :magistrala_client AND conn.domain_id = :domain_id`
rows, err := cr.db.NamedQueryContext(ctx, q, dbcfg)
if err != nil {
cr.log.Error(fmt.Sprintf("Failed to retrieve connected due to %s", err))
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
defer rows.Close()
chans := []bootstrap.Channel{}
for rows.Next() {
dbch := dbChannel{}
if err := rows.StructScan(&dbch); err != nil {
cr.log.Error(fmt.Sprintf("Failed to read connected client due to %s", err))
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
dbch.DomainID = nullString(dbcfg.DomainID)
ch, err := toChannel(dbch)
if err != nil {
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
chans = append(chans, ch)
}
cfg := toConfig(dbcfg)
cfg.Channels = chans
return cfg, nil
}
func (cr configRepository) RetrieveAll(ctx context.Context, domainID string, clientIDs []string, filter bootstrap.Filter, offset, limit uint64) bootstrap.ConfigsPage {
search, params := buildRetrieveQueryParams(domainID, clientIDs, filter)
n := len(params)
q := `SELECT magistrala_client, magistrala_secret, external_id, external_key, name, content, state
FROM configs %s ORDER BY magistrala_client LIMIT $%d OFFSET $%d`
q = fmt.Sprintf(q, search, n+1, n+2)
rows, err := cr.db.QueryContext(ctx, q, append(params, limit, offset)...)
if err != nil {
cr.log.Error(fmt.Sprintf("Failed to retrieve configs due to %s", err))
return bootstrap.ConfigsPage{}
}
defer rows.Close()
var name, content sql.NullString
configs := []bootstrap.Config{}
for rows.Next() {
c := bootstrap.Config{DomainID: domainID}
if err := rows.Scan(&c.ClientID, &c.ClientSecret, &c.ExternalID, &c.ExternalKey, &name, &content, &c.State); err != nil {
cr.log.Error(fmt.Sprintf("Failed to read retrieved config due to %s", err))
return bootstrap.ConfigsPage{}
}
c.Name = name.String
c.Content = content.String
configs = append(configs, c)
}
q = fmt.Sprintf(`SELECT COUNT(*) FROM configs %s`, search)
var total uint64
if err := cr.db.QueryRowxContext(ctx, q, params...).Scan(&total); err != nil {
cr.log.Error(fmt.Sprintf("Failed to count configs due to %s", err))
return bootstrap.ConfigsPage{}
}
return bootstrap.ConfigsPage{
Total: total,
Limit: limit,
Offset: offset,
Configs: configs,
}
}
func (cr configRepository) RetrieveByExternalID(ctx context.Context, externalID string) (bootstrap.Config, error) {
q := `SELECT magistrala_client, magistrala_secret, external_key, domain_id, name, client_cert, client_key, ca_cert, content, state
FROM configs
WHERE external_id = :external_id`
dbcfg := dbConfig{
ExternalID: externalID,
}
row, err := cr.db.NamedQueryContext(ctx, q, dbcfg)
if err != nil {
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
if !row.Next() {
return bootstrap.Config{}, repoerr.ErrNotFound
}
if err := row.StructScan(&dbcfg); err != nil {
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
q = `SELECT magistrala_channel, name, metadata FROM channels ch
INNER JOIN connections conn
ON ch.magistrala_channel = conn.channel_id AND ch.domain_id = conn.domain_id
WHERE conn.config_id = :magistrala_client AND conn.domain_id = :domain_id`
rows, err := cr.db.NamedQueryContext(ctx, q, dbcfg)
if err != nil {
cr.log.Error(fmt.Sprintf("Failed to retrieve connected due to %s", err))
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
defer rows.Close()
channels := []bootstrap.Channel{}
for rows.Next() {
dbch := dbChannel{}
if err := rows.StructScan(&dbch); err != nil {
cr.log.Error(fmt.Sprintf("Failed to read connected client due to %s", err))
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
ch, err := toChannel(dbch)
if err != nil {
cr.log.Error(fmt.Sprintf("Failed to deserialize channel due to %s", err))
return bootstrap.Config{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
channels = append(channels, ch)
}
cfg := toConfig(dbcfg)
cfg.Channels = channels
return cfg, nil
}
func (cr configRepository) Update(ctx context.Context, cfg bootstrap.Config) error {
q := `UPDATE configs SET name = :name, content = :content WHERE magistrala_client = :magistrala_client AND domain_id = :domain_id `
dbcfg := dbConfig{
Name: nullString(cfg.Name),
Content: nullString(cfg.Content),
ClientID: cfg.ClientID,
DomainID: cfg.DomainID,
}
res, err := cr.db.NamedExecContext(ctx, q, dbcfg)
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
cnt, err := res.RowsAffected()
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
if cnt == 0 {
return repoerr.ErrNotFound
}
return nil
}
func (cr configRepository) UpdateCert(ctx context.Context, domainID, clientID, clientCert, clientKey, caCert string) (bootstrap.Config, error) {
q := `UPDATE configs SET client_cert = :client_cert, client_key = :client_key, ca_cert = :ca_cert WHERE magistrala_client = :magistrala_client AND domain_id = :domain_id
RETURNING magistrala_client, client_cert, client_key, ca_cert`
dbcfg := dbConfig{
ClientID: clientID,
ClientCert: nullString(clientCert),
DomainID: domainID,
ClientKey: nullString(clientKey),
CaCert: nullString(caCert),
}
row, err := cr.db.NamedQueryContext(ctx, q, dbcfg)
if err != nil {
return bootstrap.Config{}, errors.Wrap(repoerr.ErrUpdateEntity, err)
}
defer row.Close()
if ok := row.Next(); !ok {
return bootstrap.Config{}, errors.Wrap(repoerr.ErrNotFound, row.Err())
}
if err := row.StructScan(&dbcfg); err != nil {
return bootstrap.Config{}, err
}
return toConfig(dbcfg), nil
}
func (cr configRepository) UpdateConnections(ctx context.Context, domainID, id string, channels []bootstrap.Channel, connections []string) (err error) {
tx, err := cr.db.BeginTxx(ctx, nil)
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
defer func() {
if err != nil {
err = cr.rollback("UpdateConnections method", err, tx)
} else {
if commitErr := tx.Commit(); commitErr != nil {
err = commitErr
}
}
}()
if err = insertChannels(domainID, channels, tx); err != nil {
err = errors.Wrap(repoerr.ErrUpdateEntity, err)
return err
}
if err = updateConnections(domainID, id, connections, tx); err != nil {
if e, ok := err.(*pgconn.PgError); ok {
if e.Code == pgerrcode.ForeignKeyViolation {
err = repoerr.ErrNotFound
}
}
err = errors.Wrap(repoerr.ErrUpdateEntity, err)
return err
}
return nil
}
func (cr configRepository) Remove(ctx context.Context, domainID, id string) error {
q := `DELETE FROM configs WHERE magistrala_client = :magistrala_client AND domain_id = :domain_id`
dbcfg := dbConfig{
ClientID: id,
DomainID: domainID,
}
if _, err := cr.db.NamedExecContext(ctx, q, dbcfg); err != nil {
return errors.Wrap(repoerr.ErrRemoveEntity, err)
}
if _, err := cr.db.ExecContext(ctx, cleanupQuery); err != nil {
cr.log.Warn("Failed to clean dangling channels after removal")
}
return nil
}
func (cr configRepository) ChangeState(ctx context.Context, domainID, id string, state bootstrap.State) error {
q := `UPDATE configs SET state = :state WHERE magistrala_client = :magistrala_client AND domain_id = :domain_id;`
dbcfg := dbConfig{
ClientID: id,
State: state,
DomainID: domainID,
}
res, err := cr.db.NamedExecContext(ctx, q, dbcfg)
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
cnt, err := res.RowsAffected()
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
if cnt == 0 {
return repoerr.ErrNotFound
}
return nil
}
func (cr configRepository) ListExisting(ctx context.Context, domainID string, ids []string) ([]bootstrap.Channel, error) {
var channels []bootstrap.Channel
if len(ids) == 0 {
return channels, nil
}
var chans pgtype.TextArray
if err := chans.Set(ids); err != nil {
return []bootstrap.Channel{}, err
}
q := "SELECT magistrala_channel, name, metadata FROM channels WHERE domain_id = $1 AND magistrala_channel = ANY ($2)"
rows, err := cr.db.QueryxContext(ctx, q, domainID, chans)
if err != nil {
return []bootstrap.Channel{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
for rows.Next() {
var dbch dbChannel
if err := rows.StructScan(&dbch); err != nil {
cr.log.Error(fmt.Sprintf("Failed to read retrieved channels due to %s", err))
return []bootstrap.Channel{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
ch, err := toChannel(dbch)
if err != nil {
cr.log.Error(fmt.Sprintf("Failed to deserialize channel due to %s", err))
return []bootstrap.Channel{}, err
}
channels = append(channels, ch)
}
return channels, nil
}
func (cr configRepository) RemoveClient(ctx context.Context, id string) error {
q := `DELETE FROM configs WHERE magistrala_client = $1`
_, err := cr.db.ExecContext(ctx, q, id)
if _, err := cr.db.ExecContext(ctx, cleanupQuery); err != nil {
cr.log.Warn("Failed to clean dangling channels after removal")
}
if err != nil {
return errors.Wrap(repoerr.ErrRemoveEntity, err)
}
return nil
}
func (cr configRepository) UpdateChannel(ctx context.Context, c bootstrap.Channel) error {
dbch, err := toDBChannel("", c)
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
q := `UPDATE channels SET name = :name, metadata = :metadata, updated_at = :updated_at, updated_by = :updated_by
WHERE magistrala_channel = :magistrala_channel`
if _, err = cr.db.NamedExecContext(ctx, q, dbch); err != nil {
return errors.Wrap(errUpdateChannels, err)
}
return nil
}
func (cr configRepository) RemoveChannel(ctx context.Context, id string) error {
q := `DELETE FROM channels WHERE magistrala_channel = $1`
if _, err := cr.db.ExecContext(ctx, q, id); err != nil {
return errors.Wrap(errRemoveChannels, err)
}
return nil
}
func (cr configRepository) ConnectClient(ctx context.Context, channelID, clientID string) error {
q := `UPDATE configs SET state = $1
WHERE magistrala_client = $2
AND EXISTS (SELECT 1 FROM connections WHERE config_id = $2 AND channel_id = $3)`
result, err := cr.db.ExecContext(ctx, q, bootstrap.Active, clientID, channelID)
if err != nil {
return errors.Wrap(errConnectClient, err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return repoerr.ErrNotFound
}
return nil
}
func (cr configRepository) DisconnectClient(ctx context.Context, channelID, clientID string) error {
q := `UPDATE configs SET state = $1
WHERE magistrala_client = $2
AND EXISTS (SELECT 1 FROM connections WHERE config_id = $2 AND channel_id = $3)`
_, err := cr.db.ExecContext(ctx, q, bootstrap.Inactive, clientID, channelID)
if err != nil {
return errors.Wrap(errDisconnectClient, err)
}
return nil
}
func buildRetrieveQueryParams(domainID string, clientIDs []string, filter bootstrap.Filter) (string, []any) {
params := []any{}
queries := []string{}
if len(clientIDs) != 0 {
queries = append(queries, fmt.Sprintf("magistrala_client IN ('%s')", strings.Join(clientIDs, "','")))
} else if domainID != "" {
params = append(params, domainID)
queries = append(queries, fmt.Sprintf("domain_id = $%d", len(params)))
}
// Adjust the starting point for placeholders based on the current length of params
counter := len(params) + 1
for k, v := range filter.FullMatch {
params = append(params, v)
queries = append(queries, fmt.Sprintf("%s = $%d", k, counter))
counter++
}
for k, v := range filter.PartialMatch {
params = append(params, v)
queries = append(queries, fmt.Sprintf("LOWER(%s) LIKE '%%' || $%d || '%%'", k, counter))
counter++
}
if len(queries) > 0 {
return "WHERE " + strings.Join(queries, " AND "), params
}
return "", params
}
func (cr configRepository) rollback(content string, defErr error, tx *sqlx.Tx) error {
if err := tx.Rollback(); err != nil {
return errors.Wrap(defErr, errors.Wrap(errors.New("failed to rollback at "+content), err))
}
return defErr
}
func insertChannels(domainID string, channels []bootstrap.Channel, tx *sqlx.Tx) error {
if len(channels) == 0 {
return nil
}
var chans []dbChannel
for _, ch := range channels {
dbch, err := toDBChannel(domainID, ch)
if err != nil {
return err
}
chans = append(chans, dbch)
}
q := `INSERT INTO channels (magistrala_channel, domain_id, name, metadata, parent_id, description, created_at, updated_at, updated_by, status)
VALUES (:magistrala_channel, :domain_id, :name, :metadata, :parent_id, :description, :created_at, :updated_at, :updated_by, :status)`
if _, err := tx.NamedExec(q, chans); err != nil {
e := err
if pqErr, ok := err.(*pgconn.PgError); ok && pqErr.Code == pgerrcode.UniqueViolation {
e = repoerr.ErrConflict
}
return e
}
return nil
}
func insertConnections(_ context.Context, cfg bootstrap.Config, connections []string, tx *sqlx.Tx) error {
if len(connections) == 0 {
return nil
}
q := `INSERT INTO connections (config_id, channel_id, domain_id)
VALUES (:config_id, :channel_id, :domain_id)`
conns := []dbConnection{}
for _, conn := range connections {
dbconn := dbConnection{
Config: cfg.ClientID,
Channel: conn,
DomainID: cfg.DomainID,
}
conns = append(conns, dbconn)
}
_, err := tx.NamedExec(q, conns)
return err
}
func updateConnections(domainID, id string, connections []string, tx *sqlx.Tx) error {
if len(connections) == 0 {
return nil
}
q := `DELETE FROM connections
WHERE config_id = $1 AND domain_id = $2
AND channel_id NOT IN ($3)`
var conn pgtype.TextArray
if err := conn.Set(connections); err != nil {
return err
}
res, err := tx.Exec(q, id, domainID, conn)
if err != nil {
return err
}
cnt, err := res.RowsAffected()
if err != nil {
return err
}
q = `INSERT INTO connections (config_id, channel_id, domain_id)
VALUES (:config_id, :channel_id, :domain_id)`
conns := []dbConnection{}
for _, conn := range connections {
dbconn := dbConnection{
Config: id,
Channel: conn,
DomainID: domainID,
}
conns = append(conns, dbconn)
}
if _, err := tx.NamedExec(q, conns); err != nil {
return err
}
if cnt == 0 {
return nil
}
_, err = tx.Exec(cleanupQuery)
return err
}
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{
String: s,
Valid: true,
}
}
func nullTime(t time.Time) sql.NullTime {
if t.IsZero() {
return sql.NullTime{}
}
return sql.NullTime{
Time: t,
Valid: true,
}
}
type dbConfig struct {
DomainID string `db:"domain_id"`
ClientID string `db:"magistrala_client"`
ClientSecret string `db:"magistrala_secret"`
Name sql.NullString `db:"name"`
ClientCert sql.NullString `db:"client_cert"`
ClientKey sql.NullString `db:"client_key"`
CaCert sql.NullString `db:"ca_cert"`
ExternalID string `db:"external_id"`
ExternalKey string `db:"external_key"`
Content sql.NullString `db:"content"`
State bootstrap.State `db:"state"`
}
func toDBConfig(cfg bootstrap.Config) dbConfig {
return dbConfig{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
DomainID: cfg.DomainID,
Name: nullString(cfg.Name),
ClientCert: nullString(cfg.ClientCert),
ClientKey: nullString(cfg.ClientKey),
CaCert: nullString(cfg.CACert),
ExternalID: cfg.ExternalID,
ExternalKey: cfg.ExternalKey,
Content: nullString(cfg.Content),
State: cfg.State,
}
}
func toConfig(dbcfg dbConfig) bootstrap.Config {
cfg := bootstrap.Config{
ClientID: dbcfg.ClientID,
ClientSecret: dbcfg.ClientSecret,
DomainID: dbcfg.DomainID,
ExternalID: dbcfg.ExternalID,
ExternalKey: dbcfg.ExternalKey,
State: dbcfg.State,
}
if dbcfg.Name.Valid {
cfg.Name = dbcfg.Name.String
}
if dbcfg.Content.Valid {
cfg.Content = dbcfg.Content.String
}
if dbcfg.ClientCert.Valid {
cfg.ClientCert = dbcfg.ClientCert.String
}
if dbcfg.ClientKey.Valid {
cfg.ClientKey = dbcfg.ClientKey.String
}
if dbcfg.CaCert.Valid {
cfg.CACert = dbcfg.CaCert.String
}
return cfg
}
type dbChannel struct {
ID string `db:"magistrala_channel"`
Name sql.NullString `db:"name"`
DomainID sql.NullString `db:"domain_id"`
Metadata string `db:"metadata"`
Parent sql.NullString `db:"parent_id,omitempty"`
Description string `db:"description,omitempty"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt sql.NullTime `db:"updated_at,omitempty"`
UpdatedBy sql.NullString `db:"updated_by,omitempty"`
Status clients.Status `db:"status"`
}
func toDBChannel(domainID string, ch bootstrap.Channel) (dbChannel, error) {
dbch := dbChannel{
ID: ch.ID,
Name: nullString(ch.Name),
DomainID: nullString(domainID),
Parent: nullString(ch.Parent),
Description: ch.Description,
CreatedAt: ch.CreatedAt,
UpdatedAt: nullTime(ch.UpdatedAt),
UpdatedBy: nullString(ch.UpdatedBy),
Status: ch.Status,
}
metadata, err := json.Marshal(ch.Metadata)
if err != nil {
return dbChannel{}, errors.Wrap(errors.ErrMalformedEntity, err)
}
dbch.Metadata = string(metadata)
return dbch, nil
}
func toChannel(dbch dbChannel) (bootstrap.Channel, error) {
ch := bootstrap.Channel{
ID: dbch.ID,
Description: dbch.Description,
CreatedAt: dbch.CreatedAt,
Status: dbch.Status,
}
if dbch.Name.Valid {
ch.Name = dbch.Name.String
}
if dbch.DomainID.Valid {
ch.DomainID = dbch.DomainID.String
}
if dbch.Parent.Valid {
ch.Parent = dbch.Parent.String
}
if dbch.UpdatedBy.Valid {
ch.UpdatedBy = dbch.UpdatedBy.String
}
if dbch.UpdatedAt.Valid {
ch.UpdatedAt = dbch.UpdatedAt.Time
}
if err := json.Unmarshal([]byte(dbch.Metadata), &ch.Metadata); err != nil {
return bootstrap.Channel{}, errors.Wrap(errors.ErrMalformedEntity, err)
}
return ch, nil
}
type dbConnection struct {
Config string `db:"config_id"`
Channel string `db:"channel_id"`
DomainID string `db:"domain_id"`
}
+913
View File
@@ -0,0 +1,913 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres_test
import (
"context"
"fmt"
"strconv"
"testing"
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/bootstrap/postgres"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/gofrs/uuid/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const numConfigs = 10
var (
config = bootstrap.Config{
ClientID: "mg-client",
ClientSecret: "mg-key",
ExternalID: "external-id",
ExternalKey: "external-key",
DomainID: testsutil.GenerateUUID(&testing.T{}),
Channels: []bootstrap.Channel{
{ID: "1", Name: "name 1", Metadata: map[string]any{"meta": 1.0}},
{ID: "2", Name: "name 2", Metadata: map[string]any{"meta": 2.0}},
},
Content: "content",
State: bootstrap.Inactive,
}
channels = []string{"1", "2"}
)
func TestSave(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
diff := "different"
duplicateClient := config
duplicateClient.ExternalID = diff
duplicateClient.ClientSecret = diff
duplicateClient.Channels = []bootstrap.Channel{}
duplicateExternal := config
duplicateExternal.ClientID = diff
duplicateExternal.ClientSecret = diff
duplicateExternal.Channels = []bootstrap.Channel{}
duplicateChannels := config
duplicateChannels.ExternalID = diff
duplicateChannels.ClientSecret = diff
duplicateChannels.ClientID = diff
cases := []struct {
desc string
config bootstrap.Config
connections []string
err error
}{
{
desc: "save a config",
config: config,
connections: channels,
err: nil,
},
{
desc: "save config with same Client ID",
config: duplicateClient,
connections: nil,
err: repoerr.ErrConflict,
},
{
desc: "save config with same external ID",
config: duplicateExternal,
connections: nil,
err: repoerr.ErrConflict,
},
{
desc: "save config with same Channels",
config: duplicateChannels,
connections: channels,
err: repoerr.ErrConflict,
},
}
for _, tc := range cases {
id, err := repo.Save(context.Background(), tc.config, tc.connections)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
if err == nil {
assert.Equal(t, id, tc.config.ClientID, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.config.ClientID, id))
}
}
}
func TestRetrieveByID(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
require.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
id, err := repo.Save(context.Background(), c, channels)
require.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
nonexistentConfID, err := uuid.NewV4()
require.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
cases := []struct {
desc string
domainID string
id string
err error
}{
{
desc: "retrieve config",
domainID: c.DomainID,
id: id,
err: nil,
},
{
desc: "retrieve config with wrong domain ID ",
domainID: "2",
id: id,
err: repoerr.ErrNotFound,
},
{
desc: "retrieve a non-existing config",
domainID: c.DomainID,
id: nonexistentConfID.String(),
err: repoerr.ErrNotFound,
},
{
desc: "retrieve a config with invalid ID",
domainID: c.DomainID,
id: "invalid",
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
_, err := repo.RetrieveByID(context.Background(), tc.domainID, tc.id)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestRetrieveAll(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
clientIDs := make([]string, numConfigs)
for i := 0; i < numConfigs; i++ {
c := config
// Use UUID to prevent conflict errors.
uid, err := uuid.NewV4()
require.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ExternalID = uid.String()
c.Name = fmt.Sprintf("name %d", i)
c.ClientID = uid.String()
c.ClientSecret = uid.String()
clientIDs[i] = c.ClientID
if i%2 == 0 {
c.State = bootstrap.Active
}
if i > 0 {
c.Channels = nil
}
_, err = repo.Save(context.Background(), c, channels)
require.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
}
cases := []struct {
desc string
domainID string
clientID []string
offset uint64
limit uint64
filter bootstrap.Filter
size int
}{
{
desc: "retrieve all configs",
domainID: config.DomainID,
clientID: []string{},
offset: 0,
limit: uint64(numConfigs),
size: numConfigs,
},
{
desc: "retrieve a subset of configs",
domainID: config.DomainID,
clientID: []string{},
offset: 5,
limit: uint64(numConfigs - 5),
size: numConfigs - 5,
},
{
desc: "retrieve with wrong domain ID ",
domainID: "2",
clientID: []string{},
offset: 0,
limit: uint64(numConfigs),
size: 0,
},
{
desc: "retrieve all active configs ",
domainID: config.DomainID,
clientID: []string{},
offset: 0,
limit: uint64(numConfigs),
filter: bootstrap.Filter{FullMatch: map[string]string{"state": bootstrap.Active.String()}},
size: numConfigs / 2,
},
{
desc: "retrieve all with partial match filter",
domainID: config.DomainID,
clientID: []string{},
offset: 0,
limit: uint64(numConfigs),
filter: bootstrap.Filter{PartialMatch: map[string]string{"name": "1"}},
size: 1,
},
{
desc: "retrieve search by name",
domainID: config.DomainID,
clientID: []string{},
offset: 0,
limit: uint64(numConfigs),
filter: bootstrap.Filter{PartialMatch: map[string]string{"name": "1"}},
size: 1,
},
{
desc: "retrieve by valid clientIDs",
domainID: config.DomainID,
clientID: clientIDs,
offset: 0,
limit: uint64(numConfigs),
size: 10,
},
{
desc: "retrieve by non-existing clientID",
domainID: config.DomainID,
clientID: []string{"non-existing"},
offset: 0,
limit: uint64(numConfigs),
size: 0,
},
}
for _, tc := range cases {
ret := repo.RetrieveAll(context.Background(), tc.domainID, tc.clientID, tc.filter, tc.offset, tc.limit)
size := len(ret.Configs)
assert.Equal(t, tc.size, size, fmt.Sprintf("%s: expected %d got %d\n", tc.desc, tc.size, size))
}
}
func TestRetrieveByExternalID(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
_, err = repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
cases := []struct {
desc string
externalID string
err error
}{
{
desc: "retrieve with invalid external ID",
externalID: strconv.Itoa(numConfigs + 1),
err: repoerr.ErrNotFound,
},
{
desc: "retrieve with external key",
externalID: c.ExternalID,
err: nil,
},
}
for _, tc := range cases {
_, err := repo.RetrieveByExternalID(context.Background(), tc.externalID)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestUpdate(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
_, err = repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
c.Content = "new content"
c.Name = "new name"
wrongDomainID := c
wrongDomainID.DomainID = "3"
cases := []struct {
desc string
id string
config bootstrap.Config
err error
}{
{
desc: "update with wrong domainID ",
config: wrongDomainID,
err: repoerr.ErrNotFound,
},
{
desc: "update a config",
config: c,
err: nil,
},
}
for _, tc := range cases {
err := repo.Update(context.Background(), tc.config)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestUpdateCert(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
_, err = repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
c.Content = "new content"
c.Name = "new name"
wrongDomainID := c
wrongDomainID.DomainID = "3"
cases := []struct {
desc string
clientID string
domainID string
cert string
certKey string
ca string
expectedConfig bootstrap.Config
err error
}{
{
desc: "update with wrong domain ID ",
clientID: "",
cert: "cert",
certKey: "certKey",
ca: "",
domainID: wrongDomainID.DomainID,
expectedConfig: bootstrap.Config{},
err: repoerr.ErrNotFound,
},
{
desc: "update a config",
clientID: c.ClientID,
cert: "cert",
certKey: "certKey",
ca: "ca",
domainID: c.DomainID,
expectedConfig: bootstrap.Config{
ClientID: c.ClientID,
ClientCert: "cert",
CACert: "ca",
ClientKey: "certKey",
DomainID: c.DomainID,
},
err: nil,
},
}
for _, tc := range cases {
cfg, err := repo.UpdateCert(context.Background(), tc.domainID, tc.clientID, tc.cert, tc.certKey, tc.ca)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
assert.Equal(t, tc.expectedConfig, cfg, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.expectedConfig, cfg))
}
}
func TestUpdateConnections(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
_, err = repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
// Use UUID to prevent conflicts.
uid, err = uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
c.Channels = []bootstrap.Channel{}
c2, err := repo.Save(context.Background(), c, []string{channels[0]})
assert.Nil(t, err, fmt.Sprintf("Saving a config expected to succeed: %s.\n", err))
cases := []struct {
desc string
domainID string
id string
channels []bootstrap.Channel
connections []string
err error
}{
{
desc: "update connections of non-existing config",
domainID: config.DomainID,
id: "unknown",
channels: nil,
connections: []string{channels[1]},
err: repoerr.ErrNotFound,
},
{
desc: "update connections",
domainID: config.DomainID,
id: c.ClientID,
channels: nil,
connections: []string{channels[1]},
err: nil,
},
{
desc: "update connections with existing channels",
domainID: config.DomainID,
id: c2,
channels: nil,
connections: channels,
err: nil,
},
{
desc: "update connections no channels",
domainID: config.DomainID,
id: c.ClientID,
channels: nil,
connections: nil,
err: nil,
},
}
for _, tc := range cases {
err := repo.UpdateConnections(context.Background(), tc.domainID, tc.id, tc.channels, tc.connections)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestRemove(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
id, err := repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
// Removal works the same for both existing and non-existing
// (removed) config
for i := 0; i < 2; i++ {
err := repo.Remove(context.Background(), c.DomainID, id)
assert.Nil(t, err, fmt.Sprintf("%d: failed to remove config due to: %s", i, err))
_, err = repo.RetrieveByID(context.Background(), c.DomainID, id)
assert.True(t, errors.Contains(err, repoerr.ErrNotFound), fmt.Sprintf("%d: expected %s got %s", i, repoerr.ErrNotFound, err))
}
}
func TestChangeState(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
saved, err := repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
cases := []struct {
desc string
domainID string
id string
state bootstrap.State
err error
}{
{
desc: "change state with wrong domain ID ",
id: saved,
domainID: "2",
err: repoerr.ErrNotFound,
},
{
desc: "change state with wrong id",
id: "wrong",
domainID: c.DomainID,
err: repoerr.ErrNotFound,
},
{
desc: "change state to Active",
id: saved,
domainID: c.DomainID,
state: bootstrap.Active,
err: nil,
},
{
desc: "change state to Inactive",
id: saved,
domainID: c.DomainID,
state: bootstrap.Inactive,
err: nil,
},
}
for _, tc := range cases {
err := repo.ChangeState(context.Background(), tc.domainID, tc.id, tc.state)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestListExisting(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
_, err = repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
var chs []bootstrap.Channel
chs = append(chs, config.Channels...)
cases := []struct {
desc string
domainID string
connections []string
existing []bootstrap.Channel
}{
{
desc: "list all existing channels",
domainID: c.DomainID,
connections: channels,
existing: chs,
},
{
desc: "list a subset of existing channels",
domainID: c.DomainID,
connections: []string{channels[0], "5"},
existing: []bootstrap.Channel{chs[0]},
},
{
desc: "list a subset of existing channels empty",
domainID: c.DomainID,
connections: []string{"5", "6"},
existing: []bootstrap.Channel{},
},
}
for _, tc := range cases {
existing, err := repo.ListExisting(context.Background(), tc.domainID, tc.connections)
assert.Nil(t, err, fmt.Sprintf("%s: unexpected error: %s", tc.desc, err))
assert.ElementsMatch(t, tc.existing, existing, fmt.Sprintf("%s: Got non-matching elements.", tc.desc))
}
}
func TestRemoveClient(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
saved, err := repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
for i := 0; i < 2; i++ {
err := repo.RemoveClient(context.Background(), saved)
assert.Nil(t, err, fmt.Sprintf("an unexpected error occurred: %s\n", err))
}
}
func TestUpdateChannel(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
_, err = repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
id := c.Channels[0].ID
update := bootstrap.Channel{
ID: id,
Name: "update name",
Metadata: map[string]any{"update": "metadata update"},
}
err = repo.UpdateChannel(context.Background(), update)
assert.Nil(t, err, fmt.Sprintf("updating config expected to succeed: %s.\n", err))
cfg, err := repo.RetrieveByID(context.Background(), c.DomainID, c.ClientID)
assert.Nil(t, err, fmt.Sprintf("Retrieving config expected to succeed: %s.\n", err))
var retrieved bootstrap.Channel
for _, c := range cfg.Channels {
if c.ID == id {
retrieved = c
break
}
}
update.DomainID = retrieved.DomainID
assert.Equal(t, update, retrieved, fmt.Sprintf("expected %s, go %s", update, retrieved))
}
func TestRemoveChannel(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
_, err = repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
err = repo.RemoveChannel(context.Background(), c.Channels[0].ID)
assert.Nil(t, err, fmt.Sprintf("Retrieving config expected to succeed: %s.\n", err))
cfg, err := repo.RetrieveByID(context.Background(), c.DomainID, c.ClientID)
assert.Nil(t, err, fmt.Sprintf("Retrieving config expected to succeed: %s.\n", err))
assert.NotContains(t, cfg.Channels, c.Channels[0], fmt.Sprintf("expected to remove channel %s from %s", c.Channels[0], cfg.Channels))
}
func TestConnectClient(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
c.State = bootstrap.Inactive
saved, err := repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
wrongID := testsutil.GenerateUUID(&testing.T{})
connectedClient := c
randomClient := c
randomClientID, _ := uuid.NewV4()
randomClient.ClientID = randomClientID.String()
emptyClient := c
emptyClient.ClientID = ""
cases := []struct {
desc string
domainID string
id string
state bootstrap.State
channels []bootstrap.Channel
connections []string
err error
}{
{
desc: "connect disconnected client",
domainID: c.DomainID,
id: saved,
state: bootstrap.Inactive,
channels: c.Channels,
connections: channels,
err: nil,
},
{
desc: "connect already connected client",
domainID: c.DomainID,
id: connectedClient.ClientID,
state: connectedClient.State,
channels: c.Channels,
connections: channels,
err: nil,
},
{
desc: "connect non-existent client",
domainID: c.DomainID,
id: wrongID,
channels: c.Channels,
connections: channels,
err: repoerr.ErrNotFound,
},
{
desc: "connect random client",
domainID: c.DomainID,
id: randomClient.ClientID,
channels: c.Channels,
connections: channels,
err: repoerr.ErrNotFound,
},
{
desc: "connect empty client",
domainID: c.DomainID,
id: emptyClient.ClientID,
channels: c.Channels,
connections: channels,
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
for i, ch := range tc.channels {
if i == 0 {
err = repo.ConnectClient(context.Background(), ch.ID, tc.id)
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: Expected error: %s, got: %s.\n", tc.desc, tc.err, err))
cfg, err := repo.RetrieveByID(context.Background(), c.DomainID, c.ClientID)
assert.Nil(t, err, fmt.Sprintf("Retrieving config expected to succeed: %s.\n", err))
assert.Equal(t, cfg.State, bootstrap.Active, fmt.Sprintf("expected to be active when a connection is added from %s", cfg))
} else {
_ = repo.ConnectClient(context.Background(), ch.ID, tc.id)
}
}
cfg, err := repo.RetrieveByID(context.Background(), c.DomainID, c.ClientID)
assert.Nil(t, err, fmt.Sprintf("Retrieving config expected to succeed: %s.\n", err))
assert.Equal(t, cfg.State, bootstrap.Active, fmt.Sprintf("expected to be active when a connection is added from %s", cfg))
}
}
func TestDisconnectClient(t *testing.T) {
repo := postgres.NewConfigRepository(db, testLog)
err := deleteChannels(context.Background(), repo)
require.Nil(t, err, "Channels cleanup expected to succeed.")
c := config
// Use UUID to prevent conflicts.
uid, err := uuid.NewV4()
assert.Nil(t, err, fmt.Sprintf("Got unexpected error: %s.\n", err))
c.ClientSecret = uid.String()
c.ClientID = uid.String()
c.ExternalID = uid.String()
c.ExternalKey = uid.String()
c.State = bootstrap.Inactive
saved, err := repo.Save(context.Background(), c, channels)
assert.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
wrongID := testsutil.GenerateUUID(&testing.T{})
connectedClient := c
randomClient := c
randomClientID, _ := uuid.NewV4()
randomClient.ClientID = randomClientID.String()
emptyClient := c
emptyClient.ClientID = ""
cases := []struct {
desc string
domainID string
id string
state bootstrap.State
channels []bootstrap.Channel
connections []string
err error
}{
{
desc: "disconnect connected client",
domainID: c.DomainID,
id: connectedClient.ClientID,
state: connectedClient.State,
channels: c.Channels,
connections: channels,
err: nil,
},
{
desc: "disconnect already disconnected client",
domainID: c.DomainID,
id: saved,
state: bootstrap.Inactive,
channels: c.Channels,
connections: channels,
err: nil,
},
{
desc: "disconnect invalid client",
domainID: c.DomainID,
id: wrongID,
channels: c.Channels,
connections: channels,
err: nil,
},
{
desc: "disconnect random client",
domainID: c.DomainID,
id: randomClient.ClientID,
channels: c.Channels,
connections: channels,
err: nil,
},
{
desc: "disconnect empty client",
domainID: c.DomainID,
id: emptyClient.ClientID,
channels: c.Channels,
connections: channels,
err: nil,
},
}
for _, tc := range cases {
for _, ch := range tc.channels {
err = repo.DisconnectClient(context.Background(), ch.ID, tc.id)
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: Expected error: %s, got: %s.\n", tc.desc, tc.err, err))
}
cfg, err := repo.RetrieveByID(context.Background(), c.DomainID, c.ClientID)
assert.Nil(t, err, fmt.Sprintf("Retrieving config expected to succeed: %s.\n", err))
assert.Equal(t, cfg.State, bootstrap.Inactive, fmt.Sprintf("expected to be inactive when a connection is removed from %s", cfg))
}
}
func deleteChannels(ctx context.Context, repo bootstrap.ConfigRepository) error {
for _, ch := range channels {
if err := repo.RemoveChannel(ctx, ch); err != nil {
return err
}
}
return nil
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package postgres contains repository implementations using PostgreSQL as
// the underlying database.
package postgres
+108
View File
@@ -0,0 +1,108 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres
import migrate "github.com/rubenv/sql-migrate"
// Migration of bootstrap service.
func Migration() *migrate.MemoryMigrationSource {
return &migrate.MemoryMigrationSource{
Migrations: []*migrate.Migration{
{
Id: "configs_1",
Up: []string{
`CREATE TABLE IF NOT EXISTS configs (
mainflux_client TEXT UNIQUE NOT NULL,
owner VARCHAR(254),
name TEXT,
mainflux_key CHAR(36) UNIQUE NOT NULL,
external_id TEXT UNIQUE NOT NULL,
external_key TEXT NOT NULL,
content TEXT,
client_cert TEXT,
client_key TEXT,
ca_cert TEXT,
state BIGINT NOT NULL,
PRIMARY KEY (mainflux_client, owner)
)`,
`CREATE TABLE IF NOT EXISTS unknown_configs (
external_id TEXT UNIQUE NOT NULL,
external_key TEXT NOT NULL,
PRIMARY KEY (external_id, external_key)
)`,
`CREATE TABLE IF NOT EXISTS channels (
mainflux_channel TEXT UNIQUE NOT NULL,
owner VARCHAR(254),
name TEXT,
metadata JSON,
PRIMARY KEY (mainflux_channel, owner)
)`,
`CREATE TABLE IF NOT EXISTS connections (
channel_id TEXT,
channel_owner VARCHAR(256),
config_id TEXT,
config_owner VARCHAR(256),
FOREIGN KEY (channel_id, channel_owner) REFERENCES channels (mainflux_channel, owner) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (config_id, config_owner) REFERENCES configs (mainflux_client, owner) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY (channel_id, channel_owner, config_id, config_owner)
)`,
},
Down: []string{
"DROP TABLE connections",
"DROP TABLE configs",
"DROP TABLE channels",
"DROP TABLE unknown_configs",
},
},
{
Id: "configs_2",
Up: []string{
"DROP TABLE IF EXISTS unknown_configs",
},
Down: []string{
"CREATE TABLE IF NOT EXISTS unknown_configs",
},
},
{
Id: "configs_3",
Up: []string{
`ALTER TABLE IF EXISTS channels ADD COLUMN IF NOT EXISTS parent_id VARCHAR(36)`,
`ALTER TABLE IF EXISTS channels ADD COLUMN IF NOT EXISTS description VARCHAR(1024)`,
`ALTER TABLE IF EXISTS channels ADD COLUMN IF NOT EXISTS created_at TIMESTAMP`,
`ALTER TABLE IF EXISTS channels ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP`,
`ALTER TABLE IF EXISTS channels ADD COLUMN IF NOT EXISTS updated_by VARCHAR(254)`,
`ALTER TABLE IF EXISTS channels ADD COLUMN IF NOT EXISTS status SMALLINT NOT NULL DEFAULT 0 CHECK (status >= 0)`,
},
},
{
Id: "configs_4",
Up: []string{
`ALTER TABLE IF EXISTS configs RENAME COLUMN mainflux_client TO magistrala_client`,
`ALTER TABLE IF EXISTS configs RENAME COLUMN mainflux_key TO magistrala_secret`,
`ALTER TABLE IF EXISTS channels RENAME COLUMN mainflux_channel TO magistrala_channel`,
},
},
{
Id: "configs_5",
Up: []string{
`ALTER TABLE IF EXISTS configs RENAME COLUMN owner TO domain_id`,
`ALTER TABLE IF EXISTS channels RENAME COLUMN owner TO domain_id`,
`ALTER TABLE IF EXISTS configs ADD CONSTRAINT configs_name_domain_id_key UNIQUE (name, domain_id)`,
},
},
{
Id: "configs_6",
Up: []string{
`ALTER TABLE IF EXISTS connections DROP CONSTRAINT IF EXISTS connections_pkey`,
`ALTER TABLE IF EXISTS connections DROP COLUMN IF EXISTS channel_owner`,
`ALTER TABLE IF EXISTS connections DROP COLUMN IF EXISTS config_owner`,
`ALTER TABLE IF EXISTS connections ADD COLUMN IF NOT EXISTS domain_id VARCHAR(256) NOT NULL`,
`ALTER TABLE IF EXISTS connections ADD CONSTRAINT connections_pkey PRIMARY KEY (channel_id, config_id, domain_id)`,
`ALTER TABLE IF EXISTS connections ADD FOREIGN KEY (channel_id, domain_id) REFERENCES channels (magistrala_channel, domain_id) ON DELETE CASCADE ON UPDATE CASCADE`,
`ALTER TABLE IF EXISTS connections ADD FOREIGN KEY (config_id, domain_id) REFERENCES configs (magistrala_client, domain_id) ON DELETE CASCADE ON UPDATE CASCADE`,
},
},
},
}
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres_test
import (
"fmt"
"log"
"os"
"testing"
"github.com/absmach/magistrala/bootstrap/postgres"
mglog "github.com/absmach/magistrala/logger"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/jmoiron/sqlx"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
)
var (
testLog, _ = mglog.New(os.Stdout, "info")
db *sqlx.DB
)
func TestMain(m *testing.M) {
pool, err := dockertest.NewPool("")
if err != nil {
testLog.Error(fmt.Sprintf("Could not connect to docker: %s", err))
}
container, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "postgres",
Tag: "16.2-alpine",
Env: []string{
"POSTGRES_USER=test",
"POSTGRES_PASSWORD=test",
"POSTGRES_DB=test",
"listen_addresses = '*'",
},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
if err != nil {
log.Fatalf("Could not start container: %s", err)
}
port := container.GetPort("5432/tcp")
if err := pool.Retry(func() error {
url := fmt.Sprintf("host=localhost port=%s user=test dbname=test password=test sslmode=disable", port)
db, err = sqlx.Open("pgx", url)
if err != nil {
return err
}
return db.Ping()
}); err != nil {
testLog.Error(fmt.Sprintf("Could not connect to docker: %s", err))
}
dbConfig := pgclient.Config{
Host: "localhost",
Port: port,
User: "test",
Pass: "test",
Name: "test",
SSLMode: "disable",
SSLCert: "",
SSLKey: "",
SSLRootCert: "",
}
if db, err = pgclient.Setup(dbConfig, *postgres.Migration()); err != nil {
testLog.Error(fmt.Sprintf("Could not setup test DB connection: %s", err))
}
code := m.Run()
// Defers will not be run when using os.Exit
db.Close()
if err := pool.Purge(container); err != nil {
testLog.Error(fmt.Sprintf("Could not purge container: %s", err))
}
os.Exit(code)
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package bootstrap
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"io"
"net/http"
)
// bootstrapRes represent Magistrala Response to the Bootatrap request.
// This is used as a response from ConfigReader and can easily be
// replace with any other response format.
type bootstrapRes struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Channels []channelRes `json:"channels"`
Content string `json:"content,omitempty"`
ClientCert string `json:"client_cert,omitempty"`
ClientKey string `json:"client_key,omitempty"`
CACert string `json:"ca_cert,omitempty"`
}
type channelRes struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
func (res bootstrapRes) Code() int {
return http.StatusOK
}
func (res bootstrapRes) Headers() map[string]string {
return map[string]string{}
}
func (res bootstrapRes) Empty() bool {
return false
}
type reader struct {
encKey []byte
}
// NewConfigReader return new reader which is used to generate response
// from the config.
func NewConfigReader(encKey []byte) ConfigReader {
return reader{encKey: encKey}
}
func (r reader) ReadConfig(cfg Config, secure bool) (any, error) {
var channels []channelRes
for _, ch := range cfg.Channels {
channels = append(channels, channelRes{ID: ch.ID, Name: ch.Name, Metadata: ch.Metadata})
}
res := bootstrapRes{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Channels: channels,
Content: cfg.Content,
ClientCert: cfg.ClientCert,
ClientKey: cfg.ClientKey,
CACert: cfg.CACert,
}
if secure {
b, err := json.Marshal(res)
if err != nil {
return nil, err
}
return r.encrypt(b)
}
return res, nil
}
func (r reader) encrypt(in []byte) ([]byte, error) {
block, err := aes.NewCipher(r.encKey)
if err != nil {
return nil, err
}
ciphertext := make([]byte, aes.BlockSize+len(in))
iv := ciphertext[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], in)
return ciphertext, nil
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package bootstrap_test
import (
"crypto/aes"
"crypto/cipher"
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/pkg/errors"
"github.com/stretchr/testify/assert"
)
type readChan struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
type readResp struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Channels []readChan `json:"channels"`
Content string `json:"content,omitempty"`
ClientCert string `json:"client_cert,omitempty"`
ClientKey string `json:"client_key,omitempty"`
CACert string `json:"ca_cert,omitempty"`
}
func dec(in []byte) ([]byte, error) {
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, err
}
if len(in) < aes.BlockSize {
return nil, errors.ErrMalformedEntity
}
iv := in[:aes.BlockSize]
in = in[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(in, in)
return in, nil
}
func TestReadConfig(t *testing.T) {
cfg := bootstrap.Config{
ClientID: "smq_id",
ClientCert: "client_cert",
ClientKey: "client_key",
CACert: "ca_cert",
ClientSecret: "smq_key",
Channels: []bootstrap.Channel{
{
ID: "smq_id",
Name: "smq_name",
Metadata: map[string]any{"key": "value}"},
},
},
Content: "content",
}
ret := readResp{
ClientID: "smq_id",
ClientSecret: "smq_key",
Channels: []readChan{
{
ID: "smq_id",
Name: "smq_name",
Metadata: map[string]any{"key": "value}"},
},
},
Content: "content",
ClientCert: "client_cert",
ClientKey: "client_key",
CACert: "ca_cert",
}
bin, err := json.Marshal(ret)
assert.Nil(t, err, fmt.Sprintf("Marshalling expected to succeed: %s.\n", err))
reader := bootstrap.NewConfigReader(encKey)
cases := []struct {
desc string
config bootstrap.Config
enc []byte
secret bool
err error
}{
{
desc: "read a config",
config: cfg,
enc: bin,
secret: false,
},
{
desc: "read encrypted config",
config: cfg,
enc: bin,
secret: true,
},
}
for _, tc := range cases {
res, err := reader.ReadConfig(tc.config, tc.secret)
assert.Nil(t, err, fmt.Sprintf("Reading config to succeed: %s.\n", err))
if tc.secret {
d, err := dec(res.([]byte))
assert.Nil(t, err, fmt.Sprintf("Decrypting expected to succeed: %s.\n", err))
assert.Equal(t, tc.enc, d, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.enc, d))
continue
}
b, err := json.Marshal(res)
assert.Nil(t, err, fmt.Sprintf("Marshalling expected to succeed: %s.\n", err))
assert.Equal(t, tc.enc, b, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.enc, b))
resp, ok := res.(magistrala.Response)
assert.True(t, ok, "If not encrypted, reader should return response.")
assert.False(t, resp.Empty(), fmt.Sprintf("Response should not be empty %s.", err))
assert.Equal(t, http.StatusOK, resp.Code(), "Default config response code should be 200.")
}
}
+503
View File
@@ -0,0 +1,503 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package bootstrap
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/hex"
"github.com/absmach/magistrala"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/policies"
mgsdk "github.com/absmach/magistrala/pkg/sdk"
)
var (
// ErrClients indicates failure to communicate with Magistrala Clients service.
// It can be due to networking error or invalid/unauthenticated request.
ErrClients = errors.New("failed to receive response from Clients service")
// ErrExternalKey indicates a non-existent bootstrap configuration for given external key.
ErrExternalKey = errors.NewAuthZError("failed to get bootstrap configuration for given external key")
// ErrExternalKeySecure indicates error in getting bootstrap configuration for given encrypted external key.
ErrExternalKeySecure = errors.NewAuthZError("failed to get bootstrap configuration for given encrypted external key")
// ErrBootstrap indicates error in getting bootstrap configuration.
ErrBootstrap = errors.New("failed to read bootstrap configuration")
// ErrAddBootstrap indicates error in adding bootstrap configuration.
ErrAddBootstrap = errors.NewServiceError("failed to add bootstrap configuration")
// ErrBootstrapState indicates an invalid bootstrap state.
ErrBootstrapState = errors.NewRequestError("invalid bootstrap state")
// ErrNotInSameDomain indicates entities are not in the same domain.
errNotInSameDomain = errors.New("entities are not in the same domain")
errUpdateConnections = errors.New("failed to update connections")
errRemoveBootstrap = errors.New("failed to remove bootstrap configuration")
errChangeState = errors.New("failed to change state of bootstrap configuration")
errUpdateChannel = errors.New("failed to update channel")
errRemoveConfig = errors.New("failed to remove bootstrap configuration")
errRemoveChannel = errors.New("failed to remove channel")
errCreateClient = errors.New("failed to create client")
errConnectClient = errors.New("failed to connect client")
errDisconnectClient = errors.New("failed to disconnect client")
errCheckChannels = errors.New("failed to check if channels exists")
errConnectionChannels = errors.New("failed to check channels connections")
errClientNotFound = errors.New("failed to find client")
errUpdateCert = errors.New("failed to update cert")
)
var _ Service = (*bootstrapService)(nil)
// Service specifies an API that must be fulfilled by the domain service
// implementation, and all of its decorators (e.g. logging & metrics).
type Service interface {
// Add adds new Client Config to the user identified by the provided token.
Add(ctx context.Context, session smqauthn.Session, token string, cfg Config) (Config, error)
// View returns Client Config with given ID belonging to the user identified by the given token.
View(ctx context.Context, session smqauthn.Session, id string) (Config, error)
// Update updates editable fields of the provided Config.
Update(ctx context.Context, session smqauthn.Session, cfg Config) error
// UpdateCert updates an existing Config certificate and token.
// A non-nil error is returned to indicate operation failure.
UpdateCert(ctx context.Context, session smqauthn.Session, clientID, clientCert, clientKey, caCert string) (Config, error)
// UpdateConnections updates list of Channels related to given Config.
UpdateConnections(ctx context.Context, session smqauthn.Session, token, id string, connections []string) error
// List returns subset of Configs with given search params that belong to the
// user identified by the given token.
List(ctx context.Context, session smqauthn.Session, filter Filter, offset, limit uint64) (ConfigsPage, error)
// Remove removes Config with specified token that belongs to the user identified by the given token.
Remove(ctx context.Context, session smqauthn.Session, id string) error
// Bootstrap returns Config to the Client with provided external ID using external key.
Bootstrap(ctx context.Context, externalKey, externalID string, secure bool) (Config, error)
// ChangeState changes state of the Client with given client ID and domain ID.
ChangeState(ctx context.Context, session smqauthn.Session, token, id string, state State) error
// Methods RemoveConfig, UpdateChannel, and RemoveChannel are used as
// handlers for events. That's why these methods surpass ownership check.
// UpdateChannelHandler updates Channel with data received from an event.
UpdateChannelHandler(ctx context.Context, channel Channel) error
// RemoveConfigHandler removes Configuration with id received from an event.
RemoveConfigHandler(ctx context.Context, id string) error
// RemoveChannelHandler removes Channel with id received from an event.
RemoveChannelHandler(ctx context.Context, id string) error
// ConnectClientHandler changes state of the Config to active when connect event occurs.
ConnectClientHandler(ctx context.Context, channelID, clientID string) error
// DisconnectClientHandler changes state of the Config to inactive when disconnect event occurs.
DisconnectClientHandler(ctx context.Context, channelID, clientID string) error
}
// ConfigReader is used to parse Config into format which will be encoded
// as a JSON and consumed from the client side. The purpose of this interface
// is to provide convenient way to generate custom configuration response
// based on the specific Config which will be consumed by the client.
type ConfigReader interface {
ReadConfig(Config, bool) (any, error)
}
type bootstrapService struct {
policies policies.Service
configs ConfigRepository
sdk mgsdk.SDK
encKey []byte
idProvider magistrala.IDProvider
}
// New returns new Bootstrap service.
func New(policyService policies.Service, configs ConfigRepository, sdk mgsdk.SDK, encKey []byte, idp magistrala.IDProvider) Service {
return &bootstrapService{
configs: configs,
sdk: sdk,
policies: policyService,
encKey: encKey,
idProvider: idp,
}
}
func (bs bootstrapService) Add(ctx context.Context, session smqauthn.Session, token string, cfg Config) (Config, error) {
toConnect := bs.toIDList(cfg.Channels)
// Check if channels exist. This is the way to prevent fetching channels that already exist.
existing, err := bs.configs.ListExisting(ctx, session.DomainID, toConnect)
if err != nil {
return Config{}, errors.Wrap(errCheckChannels, err)
}
cfg.Channels, err = bs.connectionChannels(ctx, toConnect, bs.toIDList(existing), session.DomainID, token)
if err != nil {
return Config{}, errors.Wrap(errConnectionChannels, err)
}
id := cfg.ClientID
mgClient, err := bs.client(ctx, session.DomainID, id, token)
if err != nil {
return Config{}, errors.Wrap(errClientNotFound, err)
}
for _, channel := range cfg.Channels {
if channel.DomainID != mgClient.DomainID {
return Config{}, errors.Wrap(svcerr.ErrMalformedEntity, errNotInSameDomain)
}
}
cfg.ClientID = mgClient.ID
cfg.DomainID = session.DomainID
cfg.State = Inactive
cfg.ClientSecret = mgClient.Credentials.Secret
saved, err := bs.configs.Save(ctx, cfg, toConnect)
if err != nil {
// If id is empty, then a new client has been created function - bs.client(id, token)
// So, on bootstrap config save error , delete the newly created client.
if id == "" {
if errT := bs.sdk.DeleteClient(ctx, cfg.ClientID, cfg.DomainID, token); errT != nil {
err = errors.Wrap(err, errT)
}
}
return Config{}, errors.Wrap(ErrAddBootstrap, err)
}
cfg.ClientID = saved
cfg.Channels = append(cfg.Channels, existing...)
return cfg, nil
}
func (bs bootstrapService) View(ctx context.Context, session smqauthn.Session, id string) (Config, error) {
cfg, err := bs.configs.RetrieveByID(ctx, session.DomainID, id)
if err != nil {
return Config{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
return cfg, nil
}
func (bs bootstrapService) Update(ctx context.Context, session smqauthn.Session, cfg Config) error {
cfg.DomainID = session.DomainID
if err := bs.configs.Update(ctx, cfg); err != nil {
return errors.Wrap(errUpdateConnections, err)
}
return nil
}
func (bs bootstrapService) UpdateCert(ctx context.Context, session smqauthn.Session, clientID, clientCert, clientKey, caCert string) (Config, error) {
cfg, err := bs.configs.UpdateCert(ctx, session.DomainID, clientID, clientCert, clientKey, caCert)
if err != nil {
return Config{}, errors.Wrap(errUpdateCert, err)
}
return cfg, nil
}
func (bs bootstrapService) UpdateConnections(ctx context.Context, session smqauthn.Session, token, id string, connections []string) error {
cfg, err := bs.configs.RetrieveByID(ctx, session.DomainID, id)
if err != nil {
return errors.Wrap(errUpdateConnections, err)
}
add, remove := bs.updateList(cfg, connections)
// Check if channels exist. This is the way to prevent fetching channels that already exist.
existing, err := bs.configs.ListExisting(ctx, session.DomainID, connections)
if err != nil {
return errors.Wrap(errUpdateConnections, err)
}
channels, err := bs.connectionChannels(ctx, connections, bs.toIDList(existing), session.DomainID, token)
if err != nil {
return errors.Wrap(errUpdateConnections, err)
}
cfg.Channels = channels
var connect, disconnect []string
if cfg.State == Active {
connect = add
disconnect = remove
}
for _, c := range disconnect {
if err := bs.sdk.DisconnectClients(ctx, c, []string{id}, []string{"Publish", "Subscribe"}, session.DomainID, token); err != nil {
if errors.Contains(err, repoerr.ErrNotFound) {
continue
}
return ErrClients
}
}
for _, c := range connect {
conIDs := mgsdk.Connection{
ChannelIDs: []string{c},
ClientIDs: []string{id},
Types: []string{"Publish", "Subscribe"},
}
if err := bs.sdk.Connect(ctx, conIDs, session.DomainID, token); err != nil {
return ErrClients
}
}
if err := bs.configs.UpdateConnections(ctx, session.DomainID, id, channels, connections); err != nil {
return errors.Wrap(errUpdateConnections, err)
}
return nil
}
func (bs bootstrapService) listClientIDs(ctx context.Context, userID string) ([]string, error) {
tids, err := bs.policies.ListAllObjects(ctx, policies.Policy{
SubjectType: policies.UserType,
Subject: userID,
Permission: policies.ViewPermission,
ObjectType: policies.ClientType,
})
if err != nil {
return nil, errors.Wrap(svcerr.ErrNotFound, err)
}
return tids.Policies, nil
}
func (bs bootstrapService) List(ctx context.Context, session smqauthn.Session, filter Filter, offset, limit uint64) (ConfigsPage, error) {
if session.SuperAdmin {
return bs.configs.RetrieveAll(ctx, session.DomainID, []string{}, filter, offset, limit), nil
}
// Handle non-admin users
clientIDs, err := bs.listClientIDs(ctx, session.DomainUserID)
if err != nil {
return ConfigsPage{}, errors.Wrap(svcerr.ErrNotFound, err)
}
if len(clientIDs) == 0 {
return ConfigsPage{
Total: 0,
Offset: offset,
Limit: limit,
Configs: []Config{},
}, nil
}
return bs.configs.RetrieveAll(ctx, session.DomainID, clientIDs, filter, offset, limit), nil
}
func (bs bootstrapService) Remove(ctx context.Context, session smqauthn.Session, id string) error {
if err := bs.configs.Remove(ctx, session.DomainID, id); err != nil {
return errors.Wrap(errRemoveBootstrap, err)
}
return nil
}
func (bs bootstrapService) Bootstrap(ctx context.Context, externalKey, externalID string, secure bool) (Config, error) {
cfg, err := bs.configs.RetrieveByExternalID(ctx, externalID)
if err != nil {
return cfg, errors.Wrap(ErrBootstrap, err)
}
if secure {
dec, err := bs.dec(externalKey)
if err != nil {
return Config{}, errors.Wrap(ErrExternalKeySecure, err)
}
externalKey = dec
}
if cfg.ExternalKey != externalKey {
return Config{}, ErrExternalKey
}
return cfg, nil
}
func (bs bootstrapService) ChangeState(ctx context.Context, session smqauthn.Session, token, id string, state State) error {
cfg, err := bs.configs.RetrieveByID(ctx, session.DomainID, id)
if err != nil {
return errors.Wrap(errChangeState, err)
}
if cfg.State == state {
return nil
}
switch state {
case Active:
for _, c := range cfg.Channels {
if err := bs.sdk.ConnectClients(ctx, c.ID, []string{cfg.ClientID}, []string{"Publish", "Subscribe"}, session.DomainID, token); err != nil {
// Ignore conflict errors as they indicate the connection already exists.
if errors.Contains(err, svcerr.ErrConflict) {
continue
}
return ErrClients
}
}
case Inactive:
for _, c := range cfg.Channels {
if err := bs.sdk.DisconnectClients(ctx, c.ID, []string{cfg.ClientID}, []string{"Publish", "Subscribe"}, session.DomainID, token); err != nil {
if errors.Contains(err, repoerr.ErrNotFound) {
continue
}
return ErrClients
}
}
}
if err := bs.configs.ChangeState(ctx, session.DomainID, id, state); err != nil {
return errors.Wrap(errChangeState, err)
}
return nil
}
func (bs bootstrapService) UpdateChannelHandler(ctx context.Context, channel Channel) error {
if err := bs.configs.UpdateChannel(ctx, channel); err != nil {
return errors.Wrap(errUpdateChannel, err)
}
return nil
}
func (bs bootstrapService) RemoveConfigHandler(ctx context.Context, id string) error {
if err := bs.configs.RemoveClient(ctx, id); err != nil {
return errors.Wrap(errRemoveConfig, err)
}
return nil
}
func (bs bootstrapService) RemoveChannelHandler(ctx context.Context, id string) error {
if err := bs.configs.RemoveChannel(ctx, id); err != nil {
return errors.Wrap(errRemoveChannel, err)
}
return nil
}
func (bs bootstrapService) ConnectClientHandler(ctx context.Context, channelID, clientID string) error {
if err := bs.configs.ConnectClient(ctx, channelID, clientID); err != nil {
return errors.Wrap(errConnectClient, err)
}
return nil
}
func (bs bootstrapService) DisconnectClientHandler(ctx context.Context, channelID, clientID string) error {
if err := bs.configs.DisconnectClient(ctx, channelID, clientID); err != nil {
return errors.Wrap(errDisconnectClient, err)
}
return nil
}
// Method client retrieves Magistrala Client creating one if an empty ID is passed.
func (bs bootstrapService) client(ctx context.Context, domainID, id, token string) (mgsdk.Client, error) {
// If Client ID is not provided, then create new client.
if id == "" {
id, err := bs.idProvider.ID()
if err != nil {
return mgsdk.Client{}, errors.Wrap(errCreateClient, err)
}
client, sdkErr := bs.sdk.CreateClient(ctx, mgsdk.Client{ID: id, Name: "Bootstrapped Client " + id}, domainID, token)
if sdkErr != nil {
return mgsdk.Client{}, errors.Wrap(errCreateClient, sdkErr)
}
return client, nil
}
// If Client ID is provided, then retrieve client
client, sdkErr := bs.sdk.Client(ctx, id, domainID, token)
if sdkErr != nil {
return mgsdk.Client{}, errors.Wrap(ErrClients, sdkErr)
}
return client, nil
}
func (bs bootstrapService) connectionChannels(ctx context.Context, channels, existing []string, domainID, token string) ([]Channel, error) {
add := make(map[string]bool, len(channels))
for _, ch := range channels {
add[ch] = true
}
for _, ch := range existing {
if add[ch] {
delete(add, ch)
}
}
var ret []Channel
for id := range add {
ch, err := bs.sdk.Channel(ctx, id, domainID, token)
if err != nil {
return nil, errors.Wrap(errors.ErrMalformedEntity, err)
}
ret = append(ret, Channel{
ID: ch.ID,
Name: ch.Name,
Metadata: ch.Metadata,
DomainID: ch.DomainID,
})
}
return ret, nil
}
// Method updateList accepts config and channel IDs and returns three lists:
// 1) IDs of Channels to be added
// 2) IDs of Channels to be removed
// 3) IDs of common Channels for these two configs.
func (bs bootstrapService) updateList(cfg Config, connections []string) (add, remove []string) {
disconnect := make(map[string]bool, len(cfg.Channels))
for _, c := range cfg.Channels {
disconnect[c.ID] = true
}
for _, c := range connections {
if disconnect[c] {
// Don't disconnect common elements.
delete(disconnect, c)
continue
}
// Connect new elements.
add = append(add, c)
}
for v := range disconnect {
remove = append(remove, v)
}
return
}
func (bs bootstrapService) toIDList(channels []Channel) []string {
var ret []string
for _, ch := range channels {
ret = append(ret, ch.ID)
}
return ret
}
func (bs bootstrapService) dec(in string) (string, error) {
ciphertext, err := hex.DecodeString(in)
if err != nil {
return "", err
}
block, err := aes.NewCipher(bs.encKey)
if err != nil {
return "", err
}
if len(ciphertext) < aes.BlockSize {
return "", err
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)
return string(ciphertext), nil
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package bootstrap
import "strconv"
const (
// Inactive Client is created, but not able to exchange messages using Magistrala.
Inactive State = iota
// Active Client is created, configured, and whitelisted.
Active
)
// State represents corresponding Magistrala Client state. The possible Config States
// as well as description of what that State represents are given in the table:
// | State | What it means |
// |----------+--------------------------------------------------------------------------------|
// | Inactive | Client is created, but isn't able to communicate over Magistrala |
// | Active | Client is able to communicate using Magistrala |.
type State int
// String returns string representation of State.
func (s State) String() string {
return strconv.Itoa(int(s))
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package tracing provides tracing instrumentation for Magistrala Users service.
//
// This package provides tracing middleware for Magistrala Users service.
// It can be used to trace incoming requests and add tracing capabilities to
// Magistrala Users service.
//
// For more details about tracing instrumentation for Magistrala messaging refer
// to the documentation at https://magistrala.absmach.eu/docs/.
package tracing
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package tracing
import (
"context"
"github.com/absmach/magistrala/bootstrap"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
var _ bootstrap.Service = (*tracingMiddleware)(nil)
type tracingMiddleware struct {
tracer trace.Tracer
svc bootstrap.Service
}
// New returns a new bootstrap service with tracing capabilities.
func New(svc bootstrap.Service, tracer trace.Tracer) bootstrap.Service {
return &tracingMiddleware{tracer, svc}
}
// Add traces the "Add" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) Add(ctx context.Context, session smqauthn.Session, token string, cfg bootstrap.Config) (bootstrap.Config, error) {
ctx, span := tm.tracer.Start(ctx, "svc_register_user", trace.WithAttributes(
attribute.String("client_id", cfg.ClientID),
attribute.String("domain_id ", cfg.DomainID),
attribute.String("name", cfg.Name),
attribute.String("external_id", cfg.ExternalID),
attribute.String("content", cfg.Content),
attribute.String("state", cfg.State.String()),
))
defer span.End()
return tm.svc.Add(ctx, session, token, cfg)
}
// View traces the "View" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) View(ctx context.Context, session smqauthn.Session, id string) (bootstrap.Config, error) {
ctx, span := tm.tracer.Start(ctx, "svc_view_user", trace.WithAttributes(
attribute.String("id", id),
))
defer span.End()
return tm.svc.View(ctx, session, id)
}
// Update traces the "Update" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) Update(ctx context.Context, session smqauthn.Session, cfg bootstrap.Config) error {
ctx, span := tm.tracer.Start(ctx, "svc_update_user", trace.WithAttributes(
attribute.String("name", cfg.Name),
attribute.String("content", cfg.Content),
attribute.String("client_id", cfg.ClientID),
attribute.String("domain_id ", cfg.DomainID),
))
defer span.End()
return tm.svc.Update(ctx, session, cfg)
}
// UpdateCert traces the "UpdateCert" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) UpdateCert(ctx context.Context, session smqauthn.Session, clientID, clientCert, clientKey, caCert string) (bootstrap.Config, error) {
ctx, span := tm.tracer.Start(ctx, "svc_update_cert", trace.WithAttributes(
attribute.String("client_id", clientID),
))
defer span.End()
return tm.svc.UpdateCert(ctx, session, clientID, clientCert, clientKey, caCert)
}
// UpdateConnections traces the "UpdateConnections" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) UpdateConnections(ctx context.Context, session smqauthn.Session, token, id string, connections []string) error {
ctx, span := tm.tracer.Start(ctx, "svc_update_connections", trace.WithAttributes(
attribute.String("id", id),
attribute.StringSlice("connections", connections),
))
defer span.End()
return tm.svc.UpdateConnections(ctx, session, token, id, connections)
}
// List traces the "List" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) List(ctx context.Context, session smqauthn.Session, filter bootstrap.Filter, offset, limit uint64) (bootstrap.ConfigsPage, error) {
ctx, span := tm.tracer.Start(ctx, "svc_list_users", trace.WithAttributes(
attribute.Int64("offset", int64(offset)),
attribute.Int64("limit", int64(limit)),
))
defer span.End()
return tm.svc.List(ctx, session, filter, offset, limit)
}
// Remove traces the "Remove" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) Remove(ctx context.Context, session smqauthn.Session, id string) error {
ctx, span := tm.tracer.Start(ctx, "svc_remove_user", trace.WithAttributes(
attribute.String("id", id),
))
defer span.End()
return tm.svc.Remove(ctx, session, id)
}
// Bootstrap traces the "Bootstrap" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) Bootstrap(ctx context.Context, externalKey, externalID string, secure bool) (bootstrap.Config, error) {
ctx, span := tm.tracer.Start(ctx, "svc_bootstrap_user", trace.WithAttributes(
attribute.String("external_key", externalKey),
attribute.String("external_id", externalID),
attribute.Bool("secure", secure),
))
defer span.End()
return tm.svc.Bootstrap(ctx, externalKey, externalID, secure)
}
// ChangeState traces the "ChangeState" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) ChangeState(ctx context.Context, session smqauthn.Session, token, id string, state bootstrap.State) error {
ctx, span := tm.tracer.Start(ctx, "svc_change_state", trace.WithAttributes(
attribute.String("id", id),
attribute.String("state", state.String()),
))
defer span.End()
return tm.svc.ChangeState(ctx, session, token, id, state)
}
// UpdateChannelHandler traces the "UpdateChannelHandler" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) UpdateChannelHandler(ctx context.Context, channel bootstrap.Channel) error {
ctx, span := tm.tracer.Start(ctx, "svc_update_channel_handler", trace.WithAttributes(
attribute.String("id", channel.ID),
attribute.String("name", channel.Name),
attribute.String("description", channel.Description),
))
defer span.End()
return tm.svc.UpdateChannelHandler(ctx, channel)
}
// RemoveConfigHandler traces the "RemoveConfigHandler" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) RemoveConfigHandler(ctx context.Context, id string) error {
ctx, span := tm.tracer.Start(ctx, "svc_remove_config_handler", trace.WithAttributes(
attribute.String("id", id),
))
defer span.End()
return tm.svc.RemoveConfigHandler(ctx, id)
}
// RemoveChannelHandler traces the "RemoveChannelHandler" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) RemoveChannelHandler(ctx context.Context, id string) error {
ctx, span := tm.tracer.Start(ctx, "svc_remove_channel_handler", trace.WithAttributes(
attribute.String("id", id),
))
defer span.End()
return tm.svc.RemoveChannelHandler(ctx, id)
}
// ConnectClientHandler traces the "ConnectClientHandler" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) ConnectClientHandler(ctx context.Context, channelID, clientID string) error {
ctx, span := tm.tracer.Start(ctx, "svc_connect_client_handler", trace.WithAttributes(
attribute.String("channel_id", channelID),
attribute.String("client_id", clientID),
))
defer span.End()
return tm.svc.ConnectClientHandler(ctx, channelID, clientID)
}
// DisconnectClientHandler traces the "DisconnectClientHandler" operation of the wrapped bootstrap.Service.
func (tm *tracingMiddleware) DisconnectClientHandler(ctx context.Context, channelID, clientID string) error {
ctx, span := tm.tracer.Start(ctx, "svc_disconnect_client_handler", trace.WithAttributes(
attribute.String("channel_id", channelID),
attribute.String("client_id", clientID),
))
defer span.End()
return tm.svc.DisconnectClientHandler(ctx, channelID, clientID)
}
+1 -1
View File
@@ -20,7 +20,7 @@ type loggingMiddleware struct {
svc certs.Service
}
// LoggingMiddleware adds logging facilities to the service.
// LoggingMiddleware adds logging facilities to the core service.
func LoggingMiddleware(svc certs.Service, logger *slog.Logger) certs.Service {
return &loggingMiddleware{logger, svc}
}
+1 -1
View File
@@ -20,7 +20,7 @@ type metricsMiddleware struct {
svc certs.Service
}
// MetricsMiddleware instruments service by tracking request count and latency.
// MetricsMiddleware instruments core service by tracking request count and latency.
func MetricsMiddleware(svc certs.Service, counter metrics.Counter, latency metrics.Histogram) certs.Service {
return &metricsMiddleware{
counter: counter,
+214
View File
@@ -0,0 +1,214 @@
# Channels
The Channels service is a core component of Magistrala that manages communication channels between devices and applications. It handles channel creation, configuration, access control and message routing within the Magistrala ecosystem.
## Configuration
The service is configured using the following environment variables (unset variables use default values):
| Variable | Description | Default |
| ------------------------- | --------------------------------------------- | ------------------------------ |
| `MG_CHANNELS_LOG_LEVEL` | Log level (debug, info, warn, error) | info |
| `MG_CHANNELS_HTTP_HOST` | HTTP host for Channels service | localhost |
| `MG_CHANNELS_HTTP_PORT` | HTTP port for Channels service | 9005 |
| `MG_CHANNELS_SERVER_CERT` | Path to PEM encoded server certificate | "" |
| `MG_CHANNELS_SERVER_KEY` | Path to PEM encoded server key file | "" |
| `MG_CHANNELS_GRPC_HOST` | gRPC host for Channels service | localhost |
| `MG_CHANNELS_GRPC_PORT` | gRPC port for Channels service | 7005 |
| `MG_CHANNELS_DB_HOST` | Database host address | localhost |
| `MG_CHANNELS_DB_PORT` | Database port | 5432 |
| `MG_CHANNELS_DB_USER` | Database user | magistrala |
| `MG_CHANNELS_DB_PASS` | Database password | magistrala |
| `MG_CHANNELS_DB_NAME` | Name of the database used by the service | channels |
| `MG_CHANNELS_DB_SSL_MODE` | Database connection SSL mode | disable |
| `MG_CHANNELS_CACHE_URL` | Cache database URL | <redis://localhost:6379/0> |
| `MG_JAEGER_URL` | Jaeger tracing server URL | <http://jaeger:4318/v1/traces> |
| `MG_SEND_TELEMETRY` | Send telemetry to Magistrala call-home server | true |
## Features
- **Channel Management**: Create, update, delete and list channels
- **Access Control**: Manage channel permissions and user access
- **Message Routing**: Route messages between connected devices and services
- **Channel Groups**: Organize channels into logical groups
- **Metadata Support**: Attach custom metadata to channels
- **Real-time Updates**: Live channel state synchronization
## Architecture
The service is built using:
- **Go**: Core service implementation
- **gRPC**: Inter-service communication
- **PostgreSQL**: Primary data storage
- **Redis**: Caching and pub/sub messaging
- **Docker**: Containerized deployment
### Channels Table
| Column | Type | Description |
| ----------------- | ------------- | ----------------------------------------------------- |
| `id` | VARCHAR(36) | UUID of the channel (primary key) |
| `name` | VARCHAR(1024) | Human-readable name |
| `domain_id` | VARCHAR(36) | Domain to which the channel belongs |
| `parent_group_id` | VARCHAR(36) | Optional group parent |
| `tags` | TEXT[] | Array of tags |
| `metadata` | JSONB | Free-form structured metadata |
| `created_by` | VARCHAR(254) | User that created the channel |
| `created_at` | TIMESTAMPTZ | Timestamp of creation |
| `updated_at` | TIMESTAMPTZ | Timestamp of last update |
| `updated_by` | VARCHAR(254) | User that performed last update |
| `status` | SMALLINT | 0 = enabled, 1 = disabled |
| `route` | VARCHAR(36) | Optional route identifier unique within domain if set |
### Connections Table
| Column | Type | Description |
| ------------ | ----------- | ----------------------------------------------- |
| `channel_id` | VARCHAR(36) | Channel UUID |
| `domain_id` | VARCHAR(36) | Domain of channel and client |
| `client_id` | VARCHAR(36) | Client UUID |
| `type` | SMALLINT | Connection type: `1 = Publish`, `2 = Subscribe` |
## Deployment
The service is available as a Docker container. Refer to the Docker Compose section for the `channels` service in `docker-compose.yaml` for deployment configuration.
To build and run locally:
```bash
# download the latest version of the service
git clone https://github.com/absmach/magistrala
cd magistrala
# compile the channels
make channels
make install
MG_CHANNELS_HTTP_HOST=localhost \
MG_CHANNELS_HTTP_PORT=9005 \
MG_CHANNELS_DB_HOST=localhost \
MG_CHANNELS_DB_PORT=5432 \
MG_CHANNELS_DB_USER=magistrala \MG_CHANNELS_DB_PASS=magistrala \MG_CHANNELS_DB_NAME=channels \
$GOBIN/magistrala-channels
```
### Running the Service
```bash
# Set environment variables
export MQ_CHANNELS_DB_HOST=localhost
export MQ_CHANNELS_DB_PORT=5432
# Run the service
go run cmd/main.go
```
### Docker Deployment
```bash
docker run -p 8180:8180 magistrala/channels
```
## Testing
```bash
# Run unit tests
go test ./...
# Run integration tests
make test-integration
```
## Usage
The Channels service supports the following operations:
| Operation | Description |
| --------------- | -------------------------------------------- |
| `create` | Create a new channel |
| `list` | Retrieve all channels (paged) |
| `get` | Retrieve a single channel by ID |
| `update` | Update a channels name & metadata |
| `delete` | Permanently delete a channel |
| `enable` | Enable a previously disabled channel |
| `disable` | Disable an active channel |
| `set-parent` | Assign a parent group to a channel |
| `remove-parent` | Remove parent group from a channel |
| `connect` | Connect one or more clients to channels |
| `disconnect` | Disconnect one or more clients from channels |
### Example: Create a Channel
```bash
curl -X POST http://localhost:9005/<domainID>/channels \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"name": "myChannel",
"metadata": { "location": "lab" },
"route": "sensor-data",
"tags": ["sensor","edge"],
"status": "enabled"
}'
```
### Example: Connect Clients & Channels
```bash
curl -X POST http://localhost:9005/<domainID>/channels/connect \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"channel_ids": ["<chanID1>", "<chanID2>"],
"client_ids": ["<clientID1>", "<clientID2>"],
"types": ["publish", "subscribe"]
}'
```
### Example: Disconnect Clients from a Channel
```bash
curl -X POST http://localhost:9005/<domainID>/channels/disconnect \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"channel_ids": ["<chanID>"],
"client_ids": ["<clientID>"],
"types": ["publish"]
}'
```
## Best Practices
- Use tags and metadata to manage and categorize channels (e.g., environment, region, purpose).
- Assign `route` thoughtfully when channels need a predictable identifier.
- Keep channel hierarchies shallow for easier navigation (avoid deep nesting unless required).
- Use `disable` rather than immediate delete when you want to suspend a channel temporarily.
- Clean up unused connections: regularly review which clients are connected to channels and remove stale links.
- Enforce minimal privileges: only allow clients to connect to channels they truly need.
- Monitoring: use the `/health` endpoint and version metadata for service stability.
## Versioning & Health Check
The Channels service exposes a `/health` endpoint to provide operational status and version info.
### Health Check Request
```bash
curl -X GET http://localhost:9005/health \
-H "accept: application/health+json"
```
### Example Response
```json
{
"status": "pass",
"version": "0.18.0",
"commit": "<commit-hash>",
"description": "channels service",
"build_time": "2025-11-19T..."
}
```
+224
View File
@@ -0,0 +1,224 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc
import (
"context"
"fmt"
"time"
grpcChannelsV1 "github.com/absmach/magistrala/api/grpc/channels/v1"
grpcCommonV1 "github.com/absmach/magistrala/api/grpc/common/v1"
"github.com/absmach/magistrala/pkg/connections"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/go-kit/kit/endpoint"
kitgrpc "github.com/go-kit/kit/transport/grpc"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const svcName = "channels.v1.ChannelsService"
var _ grpcChannelsV1.ChannelsServiceClient = (*grpcClient)(nil)
type grpcClient struct {
timeout time.Duration
authorize endpoint.Endpoint
removeClientConnections endpoint.Endpoint
unsetParentGroupFromChannels endpoint.Endpoint
retrieveEntity endpoint.Endpoint
retrieveIDByRoute endpoint.Endpoint
}
// NewClient returns new gRPC client instance.
func NewClient(conn *grpc.ClientConn, timeout time.Duration) grpcChannelsV1.ChannelsServiceClient {
return &grpcClient{
authorize: kitgrpc.NewClient(
conn,
svcName,
"Authorize",
encodeAuthorizeRequest,
decodeAuthorizeResponse,
grpcChannelsV1.AuthzRes{},
).Endpoint(),
removeClientConnections: kitgrpc.NewClient(
conn,
svcName,
"RemoveClientConnections",
encodeRemoveClientConnectionsRequest,
decodeRemoveClientConnectionsResponse,
grpcChannelsV1.RemoveClientConnectionsRes{},
).Endpoint(),
unsetParentGroupFromChannels: kitgrpc.NewClient(
conn,
svcName,
"UnsetParentGroupFromChannels",
encodeUnsetParentGroupFromChannelsRequest,
decodeUnsetParentGroupFromChannelsResponse,
grpcChannelsV1.UnsetParentGroupFromChannelsRes{},
).Endpoint(),
retrieveEntity: kitgrpc.NewClient(
conn,
svcName,
"RetrieveEntity",
encodeRetrieveEntityRequest,
decodeRetrieveEntityResponse,
grpcCommonV1.RetrieveEntityRes{},
).Endpoint(),
retrieveIDByRoute: kitgrpc.NewClient(
conn,
svcName,
"RetrieveIDByRoute",
encodeRetrieveIDByRouteRequest,
decodeRetrieveIDByRouteResponse,
grpcCommonV1.RetrieveEntityRes{},
).Endpoint(),
timeout: timeout,
}
}
func (client grpcClient) Authorize(ctx context.Context, req *grpcChannelsV1.AuthzReq, _ ...grpc.CallOption) (r *grpcChannelsV1.AuthzRes, err error) {
ctx, cancel := context.WithTimeout(ctx, client.timeout)
defer cancel()
res, err := client.authorize(ctx, authorizeReq{
domainID: req.GetDomainId(),
clientID: req.GetClientId(),
clientType: req.GetClientType(),
channelID: req.GetChannelId(),
connType: connections.ConnType(req.GetType()),
})
if err != nil {
return &grpcChannelsV1.AuthzRes{}, decodeError(err)
}
ar := res.(authorizeRes)
return &grpcChannelsV1.AuthzRes{Authorized: ar.authorized}, nil
}
func encodeAuthorizeRequest(_ context.Context, grpcReq any) (any, error) {
req := grpcReq.(authorizeReq)
return &grpcChannelsV1.AuthzReq{
DomainId: req.domainID,
ClientId: req.clientID,
ClientType: req.clientType,
ChannelId: req.channelID,
Type: uint32(req.connType),
}, nil
}
func decodeAuthorizeResponse(_ context.Context, grpcRes any) (any, error) {
res := grpcRes.(*grpcChannelsV1.AuthzRes)
return authorizeRes{authorized: res.GetAuthorized()}, nil
}
func (client grpcClient) RemoveClientConnections(ctx context.Context, req *grpcChannelsV1.RemoveClientConnectionsReq, _ ...grpc.CallOption) (r *grpcChannelsV1.RemoveClientConnectionsRes, err error) {
ctx, cancel := context.WithTimeout(ctx, client.timeout)
defer cancel()
if _, err := client.removeClientConnections(ctx, req); err != nil {
return &grpcChannelsV1.RemoveClientConnectionsRes{}, decodeError(err)
}
return &grpcChannelsV1.RemoveClientConnectionsRes{}, nil
}
func encodeRemoveClientConnectionsRequest(_ context.Context, grpcReq any) (any, error) {
return grpcReq.(*grpcChannelsV1.RemoveClientConnectionsReq), nil
}
func decodeRemoveClientConnectionsResponse(_ context.Context, grpcRes any) (any, error) {
return grpcRes.(*grpcChannelsV1.RemoveClientConnectionsRes), nil
}
func (client grpcClient) UnsetParentGroupFromChannels(ctx context.Context, req *grpcChannelsV1.UnsetParentGroupFromChannelsReq, _ ...grpc.CallOption) (r *grpcChannelsV1.UnsetParentGroupFromChannelsRes, err error) {
ctx, cancel := context.WithTimeout(ctx, client.timeout)
defer cancel()
if _, err := client.unsetParentGroupFromChannels(ctx, req); err != nil {
return &grpcChannelsV1.UnsetParentGroupFromChannelsRes{}, decodeError(err)
}
return &grpcChannelsV1.UnsetParentGroupFromChannelsRes{}, nil
}
func encodeUnsetParentGroupFromChannelsRequest(_ context.Context, grpcReq any) (any, error) {
return grpcReq.(*grpcChannelsV1.UnsetParentGroupFromChannelsReq), nil
}
func decodeUnsetParentGroupFromChannelsResponse(_ context.Context, grpcRes any) (any, error) {
return grpcRes.(*grpcChannelsV1.UnsetParentGroupFromChannelsRes), nil
}
func (client grpcClient) RetrieveEntity(ctx context.Context, req *grpcCommonV1.RetrieveEntityReq, _ ...grpc.CallOption) (r *grpcCommonV1.RetrieveEntityRes, err error) {
ctx, cancel := context.WithTimeout(ctx, client.timeout)
defer cancel()
res, err := client.retrieveEntity(ctx, req)
if err != nil {
return &grpcCommonV1.RetrieveEntityRes{}, decodeError(err)
}
return res.(*grpcCommonV1.RetrieveEntityRes), nil
}
func encodeRetrieveEntityRequest(_ context.Context, grpcReq any) (any, error) {
return grpcReq.(*grpcCommonV1.RetrieveEntityReq), nil
}
func decodeRetrieveEntityResponse(_ context.Context, grpcRes any) (any, error) {
return grpcRes.(*grpcCommonV1.RetrieveEntityRes), nil
}
func (client grpcClient) RetrieveIDByRoute(ctx context.Context, req *grpcCommonV1.RetrieveIDByRouteReq, _ ...grpc.CallOption) (r *grpcCommonV1.RetrieveEntityRes, err error) {
ctx, cancel := context.WithTimeout(ctx, client.timeout)
defer cancel()
res, err := client.retrieveIDByRoute(ctx, req)
if err != nil {
return &grpcCommonV1.RetrieveEntityRes{}, decodeError(err)
}
return res.(*grpcCommonV1.RetrieveEntityRes), nil
}
func encodeRetrieveIDByRouteRequest(_ context.Context, grpcReq any) (any, error) {
return grpcReq.(*grpcCommonV1.RetrieveIDByRouteReq), nil
}
func decodeRetrieveIDByRouteResponse(_ context.Context, grpcRes any) (any, error) {
return grpcRes.(*grpcCommonV1.RetrieveEntityRes), nil
}
func decodeError(err error) error {
if st, ok := status.FromError(err); ok {
switch st.Code() {
case codes.Unauthenticated:
return errors.Wrap(svcerr.ErrAuthentication, errors.New(st.Message()))
case codes.PermissionDenied:
return errors.Wrap(svcerr.ErrAuthorization, errors.New(st.Message()))
case codes.InvalidArgument:
return errors.Wrap(errors.ErrMalformedEntity, errors.New(st.Message()))
case codes.FailedPrecondition:
return errors.Wrap(errors.ErrMalformedEntity, errors.New(st.Message()))
case codes.NotFound:
return errors.Wrap(svcerr.ErrNotFound, errors.New(st.Message()))
case codes.AlreadyExists:
return errors.Wrap(svcerr.ErrConflict, errors.New(st.Message()))
case codes.OK:
if msg := st.Message(); msg != "" {
return errors.Wrap(errors.ErrUnidentified, errors.New(msg))
}
return nil
default:
return errors.Wrap(fmt.Errorf("unexpected gRPC status: %s (status code:%v)", st.Code().String(), st.Code()), errors.New(st.Message()))
}
}
return err
}
+5
View File
@@ -0,0 +1,5 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package grpc contains implementation of Auth service gRPC API.
package grpc
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc
import (
"context"
ch "github.com/absmach/magistrala/channels"
channels "github.com/absmach/magistrala/channels/private"
"github.com/go-kit/kit/endpoint"
)
func authorizeEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(authorizeReq)
if err := req.validate(); err != nil {
return authorizeRes{}, err
}
if err := svc.Authorize(ctx, ch.AuthzReq{
DomainID: req.domainID,
ClientID: req.clientID,
ClientType: req.clientType,
ChannelID: req.channelID,
Type: req.connType,
}); err != nil {
return authorizeRes{}, err
}
return authorizeRes{authorized: true}, nil
}
}
func removeClientConnectionsEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(removeClientConnectionsReq)
if err := svc.RemoveClientConnections(ctx, req.clientID); err != nil {
return removeClientConnectionsRes{}, err
}
return removeClientConnectionsRes{}, nil
}
}
func unsetParentGroupFromChannelsEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(unsetParentGroupFromChannelsReq)
if err := svc.UnsetParentGroupFromChannels(ctx, req.parentGroupID); err != nil {
return unsetParentGroupFromChannelsRes{}, err
}
return unsetParentGroupFromChannelsRes{}, nil
}
}
func retrieveEntityEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(retrieveEntityReq)
channel, err := svc.RetrieveByID(ctx, req.Id)
if err != nil {
return retrieveEntityRes{}, err
}
return retrieveEntityRes{id: channel.ID, domain: channel.Domain, parentGroup: channel.ParentGroup, status: uint8(channel.Status)}, nil
}
}
func retrieveIDByRouteEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(retrieveIDByRouteReq)
if err := req.validate(); err != nil {
return retrieveIDByRouteRes{}, err
}
id, err := svc.RetrieveIDByRoute(ctx, req.route, req.domainID)
if err != nil {
return retrieveIDByRouteRes{}, err
}
return retrieveIDByRouteRes{id: id}, nil
}
}
+345
View File
@@ -0,0 +1,345 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc_test
import (
"context"
"fmt"
"net"
"testing"
"time"
grpcChannelsV1 "github.com/absmach/magistrala/api/grpc/channels/v1"
grpcCommonV1 "github.com/absmach/magistrala/api/grpc/common/v1"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/channels"
ch "github.com/absmach/magistrala/channels"
grpcapi "github.com/absmach/magistrala/channels/api/grpc"
"github.com/absmach/magistrala/channels/private/mocks"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/connections"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/policies"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
)
const port = 7005
var (
validID = testsutil.GenerateUUID(&testing.T{})
validChannel = ch.Channel{
ID: validID,
Domain: testsutil.GenerateUUID(&testing.T{}),
Status: channels.EnabledStatus,
}
)
func startGRPCServer(svc *mocks.Service, port int) *grpc.Server {
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
panic(fmt.Sprintf("failed to obtain port: %s", err))
}
server := grpc.NewServer()
grpcChannelsV1.RegisterChannelsServiceServer(server, grpcapi.NewServer(svc))
go func() {
if err := server.Serve(listener); err != nil {
panic(fmt.Sprintf("failed to serve: %s", err))
}
}()
return server
}
func TestAuthorize(t *testing.T) {
svc := new(mocks.Service)
server := startGRPCServer(svc, port)
defer server.GracefulStop()
authAddr := fmt.Sprintf("localhost:%d", port)
conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
client := grpcapi.NewClient(conn, time.Second)
cases := []struct {
desc string
domainID string
clientID string
clientType string
channelID string
connType connections.ConnType
err error
authzErr error
res *grpcChannelsV1.AuthzRes
code codes.Code
}{
{
desc: "authorize successfully",
domainID: validID,
clientID: validID,
clientType: policies.UserType,
channelID: validID,
connType: connections.Publish,
res: &grpcChannelsV1.AuthzRes{Authorized: true},
err: nil,
},
{
desc: "authorize with authorization error",
domainID: validID,
clientID: validID,
clientType: policies.UserType,
channelID: validID,
connType: connections.Publish,
res: &grpcChannelsV1.AuthzRes{Authorized: false},
authzErr: svcerr.ErrAuthorization,
err: svcerr.ErrAuthorization,
},
{
desc: "authorize withnot found error",
domainID: validID,
clientID: validID,
clientType: policies.UserType,
channelID: validID,
connType: connections.Publish,
res: &grpcChannelsV1.AuthzRes{Authorized: false},
authzErr: svcerr.ErrNotFound,
err: svcerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
authReq := ch.AuthzReq{
DomainID: tc.domainID,
ClientID: tc.clientID,
ClientType: tc.clientType,
ChannelID: tc.channelID,
Type: tc.connType,
}
svcCall := svc.On("Authorize", mock.Anything, authReq).Return(tc.authzErr)
res, err := client.Authorize(context.Background(), &grpcChannelsV1.AuthzReq{
DomainId: tc.domainID,
ClientId: tc.clientID,
ClientType: tc.clientType,
ChannelId: tc.channelID,
Type: uint32(tc.connType),
})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s", tc.desc, tc.err, err))
assert.Equal(t, tc.res, res, fmt.Sprintf("%s: expected %s got %s", tc.desc, tc.res, res))
svcCall.Unset()
})
}
}
func TestRemoveClientConnections(t *testing.T) {
svc := new(mocks.Service)
server := startGRPCServer(svc, port)
defer server.GracefulStop()
authAddr := fmt.Sprintf("localhost:%d", port)
conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
client := grpcapi.NewClient(conn, time.Second)
cases := []struct {
desc string
clientID string
err error
code codes.Code
}{
{
desc: "remove client connections successfully",
clientID: validID,
err: nil,
},
{
desc: "remove client connections with error",
clientID: validID,
err: svcerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
svcCall := svc.On("RemoveClientConnections", mock.Anything, tc.clientID).Return(tc.err)
res, err := client.RemoveClientConnections(context.Background(), &grpcChannelsV1.RemoveClientConnectionsReq{
ClientId: tc.clientID,
})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s", tc.desc, tc.err, err))
assert.Equal(t, &grpcChannelsV1.RemoveClientConnectionsRes{}, res)
svcCall.Unset()
})
}
}
func TestUnsetParentGroupFromChannelsEndpoint(t *testing.T) {
svc := new(mocks.Service)
server := startGRPCServer(svc, port)
defer server.GracefulStop()
authAddr := fmt.Sprintf("localhost:%d", port)
conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
client := grpcapi.NewClient(conn, time.Second)
cases := []struct {
desc string
parentGroupID string
err error
code codes.Code
}{
{
desc: "unset parent group from channels successfully",
parentGroupID: validID,
err: nil,
},
{
desc: "unset parent group from channels authorization error",
parentGroupID: validID,
err: svcerr.ErrAuthorization,
},
{
desc: "unset parent group from channels with not found error",
parentGroupID: validID,
err: svcerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
svcCall := svc.On("UnsetParentGroupFromChannels", mock.Anything, tc.parentGroupID).Return(tc.err)
res, err := client.UnsetParentGroupFromChannels(context.Background(), &grpcChannelsV1.UnsetParentGroupFromChannelsReq{
ParentGroupId: tc.parentGroupID,
})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s", tc.desc, tc.err, err))
assert.Equal(t, &grpcChannelsV1.UnsetParentGroupFromChannelsRes{}, res)
svcCall.Unset()
})
}
}
func TestRetrieveEntity(t *testing.T) {
svc := new(mocks.Service)
server := startGRPCServer(svc, port)
defer server.GracefulStop()
authAddr := fmt.Sprintf("localhost:%d", port)
conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
client := grpcapi.NewClient(conn, time.Second)
cases := []struct {
desc string
id string
svcRes ch.Channel
resp *grpcCommonV1.RetrieveEntityRes
code codes.Code
err error
}{
{
desc: "retrieve entity successfully",
id: validID,
svcRes: validChannel,
resp: &grpcCommonV1.RetrieveEntityRes{
Entity: &grpcCommonV1.EntityBasic{
Id: validChannel.ID,
DomainId: validChannel.Domain,
ParentGroupId: validChannel.ParentGroup,
Status: uint32(validChannel.Status),
},
},
err: nil,
},
{
desc: "retrieve entity with error",
id: validID,
resp: &grpcCommonV1.RetrieveEntityRes{},
err: svcerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
svcCall := svc.On("RetrieveByID", mock.Anything, tc.id).Return(tc.svcRes, tc.err)
res, err := client.RetrieveEntity(context.Background(), &grpcCommonV1.RetrieveEntityReq{
Id: tc.id,
})
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s", tc.desc, tc.err, err))
assert.Equal(t, tc.resp.Entity, res.Entity)
svcCall.Unset()
})
}
}
func TestRetrieveIDByRoute(t *testing.T) {
svc := new(mocks.Service)
server := startGRPCServer(svc, port)
defer server.GracefulStop()
authAddr := fmt.Sprintf("localhost:%d", port)
conn, _ := grpc.NewClient(authAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
client := grpcapi.NewClient(conn, time.Second)
validRoute := "validRoute"
domainID := testsutil.GenerateUUID(t)
cases := []struct {
desc string
retrieveReq *grpcCommonV1.RetrieveIDByRouteReq
svcRes string
svcErr error
retrieveRes *grpcCommonV1.RetrieveEntityRes
err error
}{
{
desc: "retrieve entity by route successfully",
retrieveReq: &grpcCommonV1.RetrieveIDByRouteReq{
Route: validRoute,
DomainId: domainID,
},
svcRes: validID,
retrieveRes: &grpcCommonV1.RetrieveEntityRes{
Entity: &grpcCommonV1.EntityBasic{
Id: validID,
},
},
err: nil,
},
{
desc: "retrieve entity by route with empty route",
retrieveReq: &grpcCommonV1.RetrieveIDByRouteReq{
Route: "",
DomainId: domainID,
},
svcRes: "",
retrieveRes: &grpcCommonV1.RetrieveEntityRes{},
err: apiutil.ErrMissingRoute,
},
{
desc: "retrieve entity by route with empty domain ID",
retrieveReq: &grpcCommonV1.RetrieveIDByRouteReq{
Route: validRoute,
DomainId: "",
},
svcRes: "",
retrieveRes: &grpcCommonV1.RetrieveEntityRes{},
err: apiutil.ErrMissingDomainID,
},
{
desc: "retrieve entity by route with invalid route",
retrieveReq: &grpcCommonV1.RetrieveIDByRouteReq{
Route: "invalidRoute",
DomainId: domainID,
},
svcRes: "",
svcErr: svcerr.ErrNotFound,
retrieveRes: &grpcCommonV1.RetrieveEntityRes{},
err: svcerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
svcCall := svc.On("RetrieveIDByRoute", mock.Anything, tc.retrieveReq.Route, tc.retrieveReq.DomainId).Return(tc.svcRes, tc.svcErr)
res, err := client.RetrieveIDByRoute(context.Background(), tc.retrieveReq)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s", tc.desc, tc.err, err))
assert.Equal(t, tc.retrieveRes.Entity, res.Entity)
svcCall.Unset()
})
}
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc
import (
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/pkg/connections"
"github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/pkg/policies"
)
var errDomainID = errors.New("domain id required for users")
type authorizeReq struct {
domainID string
channelID string
clientID string
clientType string
connType connections.ConnType
}
func (req authorizeReq) validate() error {
if req.clientType == policies.UserType && req.domainID == "" {
return errDomainID
}
return nil
}
type removeClientConnectionsReq struct {
clientID string
}
type unsetParentGroupFromChannelsReq struct {
parentGroupID string
}
type retrieveEntityReq struct {
Id string
}
type retrieveIDByRouteReq struct {
route string
domainID string
}
func (req retrieveIDByRouteReq) validate() error {
if req.route == "" {
return apiutil.ErrMissingRoute
}
if req.domainID == "" {
return apiutil.ErrMissingDomainID
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc
type authorizeRes struct {
authorized bool
}
type removeClientConnectionsRes struct{}
type unsetParentGroupFromChannelsRes struct{}
type channelBasic struct {
id string
domain string
parentGroup string
status uint8
}
type retrieveEntityRes channelBasic
type retrieveIDByRouteRes struct {
id string
}
+211
View File
@@ -0,0 +1,211 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc
import (
"context"
grpcChannelsV1 "github.com/absmach/magistrala/api/grpc/channels/v1"
grpcCommonV1 "github.com/absmach/magistrala/api/grpc/common/v1"
apiutil "github.com/absmach/magistrala/api/http/util"
smqauth "github.com/absmach/magistrala/auth"
channels "github.com/absmach/magistrala/channels/private"
"github.com/absmach/magistrala/pkg/connections"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
kitgrpc "github.com/go-kit/kit/transport/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var _ grpcChannelsV1.ChannelsServiceServer = (*grpcServer)(nil)
type grpcServer struct {
grpcChannelsV1.UnimplementedChannelsServiceServer
authorize kitgrpc.Handler
removeClientConnections kitgrpc.Handler
unsetParentGroupFromChannels kitgrpc.Handler
retrieveEntity kitgrpc.Handler
retrieveIDByRoute kitgrpc.Handler
}
// NewServer returns new AuthServiceServer instance.
func NewServer(svc channels.Service) grpcChannelsV1.ChannelsServiceServer {
return &grpcServer{
authorize: kitgrpc.NewServer(
authorizeEndpoint(svc),
decodeAuthorizeRequest,
encodeAuthorizeResponse,
),
removeClientConnections: kitgrpc.NewServer(
removeClientConnectionsEndpoint(svc),
decodeRemoveClientConnectionsRequest,
encodeRemoveClientConnectionsResponse,
),
unsetParentGroupFromChannels: kitgrpc.NewServer(
unsetParentGroupFromChannelsEndpoint(svc),
decodeUnsetParentGroupFromChannelsRequest,
encodeUnsetParentGroupFromChannelsResponse,
),
retrieveEntity: kitgrpc.NewServer(
retrieveEntityEndpoint(svc),
decodeRetrieveEntityRequest,
encodeRetrieveEntityResponse,
),
retrieveIDByRoute: kitgrpc.NewServer(
retrieveIDByRouteEndpoint(svc),
decodeRetrieveIDByRouteRequest,
encodeRetrieveIDByRouteResponse,
),
}
}
func (s *grpcServer) Authorize(ctx context.Context, req *grpcChannelsV1.AuthzReq) (*grpcChannelsV1.AuthzRes, error) {
_, res, err := s.authorize.ServeGRPC(ctx, req)
if err != nil {
return nil, encodeError(err)
}
return res.(*grpcChannelsV1.AuthzRes), nil
}
func decodeAuthorizeRequest(_ context.Context, grpcReq any) (any, error) {
req := grpcReq.(*grpcChannelsV1.AuthzReq)
connType := connections.ConnType(req.GetType())
if err := connections.CheckConnType(connType); err != nil {
return nil, err
}
return authorizeReq{
domainID: req.GetDomainId(),
clientID: req.GetClientId(),
clientType: req.GetClientType(),
channelID: req.GetChannelId(),
connType: connType,
}, nil
}
func encodeAuthorizeResponse(_ context.Context, grpcRes any) (any, error) {
res := grpcRes.(authorizeRes)
return &grpcChannelsV1.AuthzRes{Authorized: res.authorized}, nil
}
func (s *grpcServer) RemoveClientConnections(ctx context.Context, req *grpcChannelsV1.RemoveClientConnectionsReq) (*grpcChannelsV1.RemoveClientConnectionsRes, error) {
_, res, err := s.removeClientConnections.ServeGRPC(ctx, req)
if err != nil {
return nil, encodeError(err)
}
return res.(*grpcChannelsV1.RemoveClientConnectionsRes), nil
}
func decodeRemoveClientConnectionsRequest(_ context.Context, grpcReq any) (any, error) {
req := grpcReq.(*grpcChannelsV1.RemoveClientConnectionsReq)
return removeClientConnectionsReq{
clientID: req.GetClientId(),
}, nil
}
func encodeRemoveClientConnectionsResponse(_ context.Context, grpcRes any) (any, error) {
_ = grpcRes.(removeClientConnectionsRes)
return &grpcChannelsV1.RemoveClientConnectionsRes{}, nil
}
func (s *grpcServer) UnsetParentGroupFromChannels(ctx context.Context, req *grpcChannelsV1.UnsetParentGroupFromChannelsReq) (*grpcChannelsV1.UnsetParentGroupFromChannelsRes, error) {
_, res, err := s.unsetParentGroupFromChannels.ServeGRPC(ctx, req)
if err != nil {
return nil, encodeError(err)
}
return res.(*grpcChannelsV1.UnsetParentGroupFromChannelsRes), nil
}
func decodeUnsetParentGroupFromChannelsRequest(_ context.Context, grpcReq any) (any, error) {
req := grpcReq.(*grpcChannelsV1.UnsetParentGroupFromChannelsReq)
return unsetParentGroupFromChannelsReq{
parentGroupID: req.GetParentGroupId(),
}, nil
}
func encodeUnsetParentGroupFromChannelsResponse(_ context.Context, grpcRes any) (any, error) {
_ = grpcRes.(unsetParentGroupFromChannelsRes)
return &grpcChannelsV1.UnsetParentGroupFromChannelsRes{}, nil
}
func (s *grpcServer) RetrieveEntity(ctx context.Context, req *grpcCommonV1.RetrieveEntityReq) (*grpcCommonV1.RetrieveEntityRes, error) {
_, res, err := s.retrieveEntity.ServeGRPC(ctx, req)
if err != nil {
return nil, encodeError(err)
}
return res.(*grpcCommonV1.RetrieveEntityRes), nil
}
func decodeRetrieveEntityRequest(_ context.Context, grpcReq any) (any, error) {
req := grpcReq.(*grpcCommonV1.RetrieveEntityReq)
return retrieveEntityReq{
Id: req.GetId(),
}, nil
}
func encodeRetrieveEntityResponse(_ context.Context, grpcRes any) (any, error) {
res := grpcRes.(retrieveEntityRes)
return &grpcCommonV1.RetrieveEntityRes{
Entity: &grpcCommonV1.EntityBasic{
Id: res.id,
DomainId: res.domain,
ParentGroupId: res.parentGroup,
Status: uint32(res.status),
},
}, nil
}
func decodeRetrieveIDByRouteRequest(_ context.Context, grpcReq any) (any, error) {
req := grpcReq.(*grpcCommonV1.RetrieveIDByRouteReq)
return retrieveIDByRouteReq{
route: req.GetRoute(),
domainID: req.GetDomainId(),
}, nil
}
func encodeRetrieveIDByRouteResponse(_ context.Context, grpcRes any) (any, error) {
res := grpcRes.(retrieveIDByRouteRes)
return &grpcCommonV1.RetrieveEntityRes{
Entity: &grpcCommonV1.EntityBasic{
Id: res.id,
},
}, nil
}
func (s *grpcServer) RetrieveIDByRoute(ctx context.Context, req *grpcCommonV1.RetrieveIDByRouteReq) (*grpcCommonV1.RetrieveEntityRes, error) {
_, res, err := s.retrieveIDByRoute.ServeGRPC(ctx, req)
if err != nil {
return nil, encodeError(err)
}
return res.(*grpcCommonV1.RetrieveEntityRes), nil
}
func encodeError(err error) error {
switch {
case errors.Contains(err, nil):
return nil
case errors.Contains(err, errors.ErrMalformedEntity),
err == apiutil.ErrInvalidAuthKey,
err == apiutil.ErrMissingID,
err == apiutil.ErrMissingMemberType,
err == apiutil.ErrMissingPolicySub,
err == apiutil.ErrMissingPolicyObj,
err == apiutil.ErrMalformedPolicyAct:
return status.Error(codes.InvalidArgument, err.Error())
case errors.Contains(err, svcerr.ErrAuthentication),
errors.Contains(err, smqauth.ErrKeyExpired),
err == apiutil.ErrMissingEmail,
err == apiutil.ErrBearerToken:
return status.Error(codes.Unauthenticated, err.Error())
case errors.Contains(err, svcerr.ErrAuthorization):
return status.Error(codes.PermissionDenied, err.Error())
default:
return status.Error(codes.Internal, err.Error())
}
}
+329
View File
@@ -0,0 +1,329 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/channels"
"github.com/absmach/magistrala/internal/nullable"
"github.com/absmach/magistrala/pkg/errors"
"github.com/go-chi/chi/v5"
)
func decodeViewChannel(_ context.Context, r *http.Request) (any, error) {
roles, err := apiutil.ReadBoolQuery(r, api.RolesKey, false)
if err != nil {
return viewChannelReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
req := viewChannelReq{
id: chi.URLParam(r, "channelID"),
roles: roles,
}
return req, nil
}
func decodeCreateChannelReq(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := createChannelReq{}
if err := json.NewDecoder(r.Body).Decode(&req.Channel); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeCreateChannelsReq(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := createChannelsReq{}
if err := json.NewDecoder(r.Body).Decode(&req.Channels); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeListChannels(_ context.Context, r *http.Request) (any, error) {
name, err := apiutil.ReadStringQuery(r, api.NameKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
tags, err := apiutil.ReadStringQuery(r, api.TagsKey, "")
if err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
var tq channels.TagsQuery
if tags != "" {
tq = channels.ToTagsQuery(tags)
}
s, err := apiutil.ReadStringQuery(r, api.StatusKey, api.DefGroupStatus)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
status, err := channels.ToStatus(s)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
meta, err := apiutil.ReadMetadataQuery(r, api.MetadataKey, nil)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
offset, err := apiutil.ReadNumQuery[uint64](r, api.OffsetKey, api.DefOffset)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
limit, err := apiutil.ReadNumQuery[uint64](r, api.LimitKey, api.DefLimit)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
dir, err := apiutil.ReadStringQuery(r, api.DirKey, api.DefDir)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
order, err := apiutil.ReadStringQuery(r, api.OrderKey, api.DefOrder)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
allActions, err := apiutil.ReadStringQuery(r, api.ActionsKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
actions := []string{}
allActions = strings.TrimSpace(allActions)
if allActions != "" {
actions = strings.Split(allActions, ",")
}
roleID, err := apiutil.ReadStringQuery(r, api.RoleIDKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
roleName, err := apiutil.ReadStringQuery(r, api.RoleNameKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
accessType, err := apiutil.ReadStringQuery(r, api.AccessTypeKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
userID, err := apiutil.ReadStringQuery(r, api.UserKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
groupID, err := nullable.Parse(r.URL.Query(), api.GroupKey, nullable.ParseString)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
clientID, err := apiutil.ReadStringQuery(r, api.ClientKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
id, err := apiutil.ReadStringQuery(r, api.IDOrder, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
ot, err := apiutil.ReadBoolQuery(r, api.OnlyTotal, false)
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
connectionType, err := apiutil.ReadStringQuery(r, api.ConnTypeKey, "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
cfrom, err := apiutil.ReadStringQuery(r, "created_from", "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
cto, err := apiutil.ReadStringQuery(r, "created_to", "")
if err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
var createdFrom, createdTo time.Time
if cfrom != "" {
if createdFrom, err = time.Parse(time.RFC3339, cfrom); err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err)
}
}
if cto != "" {
if createdTo, err = time.Parse(time.RFC3339, cto); err != nil {
return listChannelsReq{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err)
}
}
req := listChannelsReq{
Page: channels.Page{
Name: name,
Tags: tq,
Status: status,
Metadata: meta,
RoleName: roleName,
RoleID: roleID,
Actions: actions,
AccessType: accessType,
Order: order,
Dir: dir,
Offset: offset,
Limit: limit,
Group: groupID,
Client: clientID,
ConnectionType: connectionType,
ID: id,
OnlyTotal: ot,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
},
userID: userID,
}
return req, nil
}
func decodeUpdateChannel(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := updateChannelReq{
id: chi.URLParam(r, "channelID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeUpdateChannelTags(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := updateChannelTagsReq{
id: chi.URLParam(r, "channelID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeSetChannelParentGroupStatus(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := setChannelParentGroupReq{
id: chi.URLParam(r, "channelID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeRemoveChannelParentGroupStatus(_ context.Context, r *http.Request) (any, error) {
req := removeChannelParentGroupReq{
id: chi.URLParam(r, "channelID"),
}
return req, nil
}
func decodeChangeChannelStatus(_ context.Context, r *http.Request) (any, error) {
req := changeChannelStatusReq{
id: chi.URLParam(r, "channelID"),
}
return req, nil
}
func decodeDeleteChannelReq(_ context.Context, r *http.Request) (any, error) {
req := deleteChannelReq{
id: chi.URLParam(r, "channelID"),
}
return req, nil
}
func decodeConnectChannelClientRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := connectChannelClientsRequest{
channelID: chi.URLParam(r, "channelID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeDisconnectChannelClientsRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := disconnectChannelClientsRequest{
channelID: chi.URLParam(r, "channelID"),
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeConnectRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := connectRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
func decodeDisconnectRequest(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := disconnectRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
return req, nil
}
File diff suppressed because it is too large Load Diff
+364
View File
@@ -0,0 +1,364 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"context"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/channels"
"github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/go-kit/kit/endpoint"
)
func createChannelEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(createChannelReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
channels, _, err := svc.CreateChannels(ctx, session, req.Channel)
if err != nil {
return nil, err
}
return createChannelRes{
Channel: channels[0],
created: true,
}, nil
}
}
func createChannelsEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(createChannelsReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
channels, _, err := svc.CreateChannels(ctx, session, req.Channels...)
if err != nil {
return nil, err
}
res := channelsPageRes{
pageRes: pageRes{
Total: uint64(len(channels)),
},
Channels: []viewChannelRes{},
}
for _, c := range channels {
res.Channels = append(res.Channels, viewChannelRes{Channel: c})
}
return res, nil
}
}
func viewChannelEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(viewChannelReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
c, err := svc.ViewChannel(ctx, session, req.id, req.roles)
if err != nil {
return nil, err
}
return viewChannelRes{Channel: c}, nil
}
}
func listChannelsEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(listChannelsReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
var page channels.ChannelsPage
var err error
switch req.userID != "" {
case true:
page, err = svc.ListUserChannels(ctx, session, req.userID, req.Page)
default:
page, err = svc.ListChannels(ctx, session, req.Page)
}
if err != nil {
return channelsPageRes{}, err
}
res := channelsPageRes{
pageRes: pageRes{
Total: page.Total,
Offset: page.Offset,
Limit: page.Limit,
},
Channels: []viewChannelRes{},
}
for _, c := range page.Channels {
res.Channels = append(res.Channels, viewChannelRes{Channel: c})
}
return res, nil
}
}
func updateChannelEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(updateChannelReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
ch := channels.Channel{
ID: req.id,
Name: req.Name,
Metadata: req.Metadata,
}
ch, err := svc.UpdateChannel(ctx, session, ch)
if err != nil {
return nil, err
}
return updateChannelRes{Channel: ch}, nil
}
}
func updateChannelTagsEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(updateChannelTagsReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
ch := channels.Channel{
ID: req.id,
Tags: req.Tags,
}
ch, err := svc.UpdateChannelTags(ctx, session, ch)
if err != nil {
return nil, err
}
return updateChannelRes{Channel: ch}, nil
}
}
func setChannelParentGroupEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(setChannelParentGroupReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
if err := svc.SetParentGroup(ctx, session, req.ParentGroupID, req.id); err != nil {
return nil, err
}
return setChannelParentGroupRes{}, nil
}
}
func removeChannelParentGroupEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(removeChannelParentGroupReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
if err := svc.RemoveParentGroup(ctx, session, req.id); err != nil {
return nil, err
}
return removeChannelParentGroupRes{}, nil
}
}
func enableChannelEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(changeChannelStatusReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
ch, err := svc.EnableChannel(ctx, session, req.id)
if err != nil {
return nil, err
}
return changeChannelStatusRes{Channel: ch}, nil
}
}
func disableChannelEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(changeChannelStatusReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
ch, err := svc.DisableChannel(ctx, session, req.id)
if err != nil {
return nil, err
}
return changeChannelStatusRes{Channel: ch}, nil
}
}
func connectChannelClientEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(connectChannelClientsRequest)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
if err := svc.Connect(ctx, session, []string{req.channelID}, req.ClientIDs, req.Types); err != nil {
return nil, err
}
return connectChannelClientsRes{}, nil
}
}
func disconnectChannelClientsEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(disconnectChannelClientsRequest)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
if err := svc.Disconnect(ctx, session, []string{req.channelID}, req.ClientIds, req.Types); err != nil {
return nil, err
}
return disconnectChannelClientsRes{}, nil
}
}
func connectEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(connectRequest)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
if err := svc.Connect(ctx, session, req.ChannelIds, req.ClientIds, req.Types); err != nil {
return nil, err
}
return connectRes{}, nil
}
}
func disconnectEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(disconnectRequest)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
if err := svc.Disconnect(ctx, session, req.ChannelIds, req.ClientIds, req.Types); err != nil {
return nil, err
}
return disconnectRes{}, nil
}
}
func deleteChannelEndpoint(svc channels.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(deleteChannelReq)
if err := req.validate(); err != nil {
return nil, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthentication
}
if err := svc.RemoveChannel(ctx, session, req.id); err != nil {
return nil, err
}
return deleteChannelRes{}, nil
}
}
+321
View File
@@ -0,0 +1,321 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"strings"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/channels"
"github.com/absmach/magistrala/pkg/connections"
)
type createChannelReq struct {
Channel channels.Channel
}
func (req createChannelReq) validate() error {
if len(req.Channel.Name) > api.MaxNameSize {
return apiutil.ErrNameSize
}
if req.Channel.ID != "" {
if strings.TrimSpace(req.Channel.ID) == "" {
return apiutil.ErrMissingChannelID
}
}
if req.Channel.Route != "" {
if err := api.ValidateRoute(req.Channel.Route); err != nil {
return err
}
if err := api.ValidateUUID(req.Channel.Route); err == nil {
return apiutil.ErrInvalidRouteFormat
}
}
return nil
}
type createChannelsReq struct {
Channels []channels.Channel
}
func (req createChannelsReq) validate() error {
if len(req.Channels) == 0 {
return apiutil.ErrEmptyList
}
for _, channel := range req.Channels {
if channel.ID != "" {
if strings.TrimSpace(channel.ID) == "" {
return apiutil.ErrMissingChannelID
}
}
if len(channel.Name) > api.MaxNameSize {
return apiutil.ErrNameSize
}
if channel.Route != "" {
if err := api.ValidateRoute(channel.Route); err != nil {
return err
}
if err := api.ValidateUUID(channel.Route); err == nil {
return apiutil.ErrInvalidRouteFormat
}
}
}
return nil
}
type viewChannelReq struct {
id string
roles bool
}
func (req viewChannelReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type listChannelsReq struct {
channels.Page
userID string
}
func (req listChannelsReq) validate() error {
if req.Limit > api.MaxLimitSize || req.Limit < 1 {
return apiutil.ErrLimitSize
}
if len(req.Name) > api.MaxNameSize {
return apiutil.ErrNameSize
}
switch req.Order {
case "", api.NameOrder, api.CreatedAtOrder, api.UpdatedAtOrder:
default:
return apiutil.ErrInvalidOrder
}
if req.Dir != "" && (req.Dir != api.DescDir && req.Dir != api.AscDir) {
return apiutil.ErrInvalidDirection
}
if req.ConnectionType != "" {
if _, err := connections.ParseConnType(req.ConnectionType); err != nil {
return apiutil.ErrValidation
}
}
return nil
}
type updateChannelReq struct {
id string
Name string `json:"name,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Tags []string `json:"tags,omitempty"`
}
func (req updateChannelReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
if len(req.Name) > api.MaxNameSize {
return apiutil.ErrNameSize
}
return nil
}
type updateChannelTagsReq struct {
id string
Tags []string `json:"tags,omitempty"`
}
func (req updateChannelTagsReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type setChannelParentGroupReq struct {
id string
ParentGroupID string `json:"parent_group_id"`
}
func (req setChannelParentGroupReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
if req.ParentGroupID == "" {
return apiutil.ErrMissingParentGroupID
}
return nil
}
type removeChannelParentGroupReq struct {
id string
}
func (req removeChannelParentGroupReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type changeChannelStatusReq struct {
id string
}
func (req changeChannelStatusReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
type connectChannelClientsRequest struct {
channelID string
ClientIDs []string `json:"client_ids,omitempty"`
Types []connections.ConnType `json:"types,omitempty"`
}
func (req *connectChannelClientsRequest) validate() error {
if req.channelID == "" || strings.TrimSpace(req.channelID) == "" {
return apiutil.ErrMissingID
}
if len(req.ClientIDs) == 0 {
return apiutil.ErrMissingID
}
for _, tid := range req.ClientIDs {
if err := api.ValidateUUID(tid); err != nil {
return err
}
}
if len(req.Types) == 0 {
return apiutil.ErrMissingConnectionType
}
return nil
}
type disconnectChannelClientsRequest struct {
channelID string
ClientIds []string `json:"client_ids,omitempty"`
Types []connections.ConnType `json:"types,omitempty"`
}
func (req *disconnectChannelClientsRequest) validate() error {
if req.channelID == "" {
return apiutil.ErrMissingID
}
if err := api.ValidateUUID(req.channelID); err != nil {
return err
}
if len(req.ClientIds) == 0 {
return apiutil.ErrMissingID
}
for _, tid := range req.ClientIds {
if err := api.ValidateUUID(tid); err != nil {
return err
}
}
if len(req.Types) == 0 {
return apiutil.ErrMissingConnectionType
}
return nil
}
type connectRequest struct {
ChannelIds []string `json:"channel_ids,omitempty"`
ClientIds []string `json:"client_ids,omitempty"`
Types []connections.ConnType `json:"types,omitempty"`
}
func (req *connectRequest) validate() error {
if len(req.ChannelIds) == 0 {
return apiutil.ErrMissingID
}
for _, cid := range req.ChannelIds {
if strings.TrimSpace(cid) == "" {
return apiutil.ErrMissingChannelID
}
}
if len(req.ClientIds) == 0 {
return apiutil.ErrMissingID
}
for _, tid := range req.ClientIds {
if strings.TrimSpace(tid) == "" {
return apiutil.ErrMissingChannelID
}
}
if len(req.Types) == 0 {
return apiutil.ErrMissingConnectionType
}
return nil
}
type disconnectRequest struct {
ChannelIds []string `json:"channel_ids,omitempty"`
ClientIds []string `json:"client_ids,omitempty"`
Types []connections.ConnType `json:"types,omitempty"`
}
func (req *disconnectRequest) validate() error {
if len(req.ChannelIds) == 0 {
return apiutil.ErrMissingID
}
for _, cid := range req.ChannelIds {
if err := api.ValidateUUID(cid); err != nil {
return err
}
}
if len(req.ClientIds) == 0 {
return apiutil.ErrMissingID
}
for _, tid := range req.ClientIds {
if err := api.ValidateUUID(tid); err != nil {
return err
}
}
if len(req.Types) == 0 {
return apiutil.ErrMissingConnectionType
}
return nil
}
type deleteChannelReq struct {
id string
}
func (req deleteChannelReq) validate() error {
if req.id == "" {
return apiutil.ErrMissingID
}
return nil
}
+628
View File
@@ -0,0 +1,628 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"fmt"
"strings"
"testing"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/channels"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/connections"
"github.com/stretchr/testify/assert"
)
func TestCreateChannelReqValidation(t *testing.T) {
cases := []struct {
desc string
req createChannelReq
err error
}{
{
desc: "valid request",
req: createChannelReq{
Channel: channels.Channel{
Name: valid,
Route: valid,
},
},
err: nil,
},
{
desc: "long name",
req: createChannelReq{
Channel: channels.Channel{
Name: strings.Repeat("a", api.MaxNameSize+1),
Route: valid,
},
},
err: apiutil.ErrNameSize,
},
{
desc: "invalid route",
req: createChannelReq{
Channel: channels.Channel{
Name: valid,
Route: "__invalid",
},
},
err: apiutil.ErrInvalidRouteFormat,
},
{
desc: "uuid as route",
req: createChannelReq{
Channel: channels.Channel{
Name: valid,
Route: testsutil.GenerateUUID(t),
},
},
err: apiutil.ErrInvalidRouteFormat,
},
{
desc: "missing channel ID",
req: createChannelReq{
Channel: channels.Channel{
ID: " ",
},
},
err: apiutil.ErrMissingChannelID,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestCreateChannelsReqValidation(t *testing.T) {
cases := []struct {
desc string
req createChannelsReq
err error
}{
{
desc: "valid request",
req: createChannelsReq{
Channels: []channels.Channel{
{
Name: valid,
Route: valid,
},
},
},
err: nil,
},
{
desc: "long name",
req: createChannelsReq{
Channels: []channels.Channel{
{
Name: strings.Repeat("a", api.MaxNameSize+1),
Route: valid,
},
},
},
err: apiutil.ErrNameSize,
},
{
desc: "missing channel ID",
req: createChannelsReq{
Channels: []channels.Channel{
{
ID: " ",
},
},
},
err: apiutil.ErrMissingChannelID,
},
{
desc: "empty list",
req: createChannelsReq{
Channels: []channels.Channel{},
},
err: apiutil.ErrEmptyList,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestViewChannelReqValidation(t *testing.T) {
cases := []struct {
desc string
req viewChannelReq
err error
}{
{
desc: "valid request",
req: viewChannelReq{
id: valid,
},
err: nil,
},
{
desc: "missing ID",
req: viewChannelReq{
id: "",
},
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestListChannelsReqValidation(t *testing.T) {
cases := []struct {
desc string
req listChannelsReq
err error
}{
{
desc: "valid request",
req: listChannelsReq{
Page: channels.Page{Limit: 10},
},
err: nil,
},
{
desc: "limit is 0",
req: listChannelsReq{
Page: channels.Page{Limit: 0},
},
err: apiutil.ErrLimitSize,
},
{
desc: "limit is greater than max limit",
req: listChannelsReq{
Page: channels.Page{Limit: api.MaxLimitSize + 1},
},
err: apiutil.ErrLimitSize,
},
{
desc: "name is too long",
req: listChannelsReq{
Page: channels.Page{Limit: 10, Name: strings.Repeat("a", api.MaxNameSize+1)},
},
err: apiutil.ErrNameSize,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestUpdateChannelReqValidate(t *testing.T) {
cases := []struct {
desc string
req updateChannelReq
err error
}{
{
desc: "valid request",
req: updateChannelReq{
id: valid,
},
err: nil,
},
{
desc: "missing ID",
req: updateChannelReq{
id: "",
},
err: apiutil.ErrMissingID,
},
{
desc: "name is too long",
req: updateChannelReq{
id: valid,
Name: strings.Repeat("a", api.MaxNameSize+1),
},
err: apiutil.ErrNameSize,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestUpdateChannelTagsReqValidate(t *testing.T) {
cases := []struct {
desc string
req updateChannelTagsReq
err error
}{
{
desc: "valid request",
req: updateChannelTagsReq{
id: valid,
Tags: []string{"tag1", "tag2"},
},
err: nil,
},
{
desc: "missing ID",
req: updateChannelTagsReq{
id: "",
Tags: []string{"tag1", "tag2"},
},
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestSetChannelsParentGroupReqValidate(t *testing.T) {
cases := []struct {
desc string
req setChannelParentGroupReq
err error
}{
{
desc: "valid request",
req: setChannelParentGroupReq{
id: valid,
ParentGroupID: valid,
},
err: nil,
},
{
desc: "missing ID",
req: setChannelParentGroupReq{
id: "",
ParentGroupID: valid,
},
err: apiutil.ErrMissingID,
},
{
desc: "missing parent group ID",
req: setChannelParentGroupReq{
id: valid,
ParentGroupID: "",
},
err: apiutil.ErrMissingParentGroupID,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestRemoveChannelParentGroupReqValidate(t *testing.T) {
cases := []struct {
desc string
req removeChannelParentGroupReq
err error
}{
{
desc: "valid request",
req: removeChannelParentGroupReq{
id: valid,
},
err: nil,
},
{
desc: "missing ID",
req: removeChannelParentGroupReq{
id: "",
},
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestChangeChannelStatusReqValidate(t *testing.T) {
cases := []struct {
desc string
req changeChannelStatusReq
err error
}{
{
desc: "valid request",
req: changeChannelStatusReq{
id: valid,
},
err: nil,
},
{
desc: "missing ID",
req: changeChannelStatusReq{
id: "",
},
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestConnectChannelClientsReqValidate(t *testing.T) {
cases := []struct {
desc string
req connectChannelClientsRequest
err error
}{
{
desc: "valid request",
req: connectChannelClientsRequest{
channelID: valid,
ClientIDs: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: nil,
},
{
desc: "missing channel ID",
req: connectChannelClientsRequest{
channelID: "",
ClientIDs: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "missing client IDs",
req: connectChannelClientsRequest{
channelID: valid,
ClientIDs: []string{},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "missing connection types",
req: connectChannelClientsRequest{
channelID: valid,
ClientIDs: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{},
},
err: apiutil.ErrMissingConnectionType,
},
{
desc: "invalid client ID",
req: connectChannelClientsRequest{
channelID: valid,
ClientIDs: []string{"client1", "invalid"},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrInvalidIDFormat,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestDisconnectChannelClientReqValidate(t *testing.T) {
cases := []struct {
desc string
req disconnectChannelClientsRequest
err error
}{
{
desc: "valid request",
req: disconnectChannelClientsRequest{
channelID: testsutil.GenerateUUID(t),
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: nil,
},
{
desc: "missing channel ID",
req: disconnectChannelClientsRequest{
channelID: "",
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "invalid channel ID",
req: disconnectChannelClientsRequest{
channelID: "invalid",
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrInvalidIDFormat,
},
{
desc: "missing client IDs",
req: disconnectChannelClientsRequest{
channelID: testsutil.GenerateUUID(t),
ClientIds: []string{},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "missing connection types",
req: disconnectChannelClientsRequest{
channelID: testsutil.GenerateUUID(t),
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{},
},
err: apiutil.ErrMissingConnectionType,
},
{
desc: "invalid client ID",
req: disconnectChannelClientsRequest{
channelID: testsutil.GenerateUUID(t),
ClientIds: []string{"client1", "invalid"},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrInvalidIDFormat,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestConnectReqValidate(t *testing.T) {
cases := []struct {
desc string
req connectRequest
err error
}{
{
desc: "valid request",
req: connectRequest{
ChannelIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: nil,
},
{
desc: "missing channel IDs",
req: connectRequest{
ChannelIds: []string{},
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "missing client IDs",
req: connectRequest{
ChannelIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
ClientIds: []string{},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "missing connection types",
req: connectRequest{
ChannelIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{},
},
err: apiutil.ErrMissingConnectionType,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestDisconnectReqValidate(t *testing.T) {
cases := []struct {
desc string
req disconnectRequest
err error
}{
{
desc: "valid request",
req: disconnectRequest{
ChannelIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: nil,
},
{
desc: "missing channel IDs",
req: disconnectRequest{
ChannelIds: []string{},
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "missing client IDs",
req: disconnectRequest{
ChannelIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
ClientIds: []string{},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrMissingID,
},
{
desc: "missing connection types",
req: disconnectRequest{
ChannelIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{},
},
err: apiutil.ErrMissingConnectionType,
},
{
desc: "invalid client ID",
req: disconnectRequest{
ChannelIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
ClientIds: []string{"client1", "invalid"},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrInvalidIDFormat,
},
{
desc: "invalid channel ID",
req: disconnectRequest{
ChannelIds: []string{"invalid", testsutil.GenerateUUID(t)},
ClientIds: []string{testsutil.GenerateUUID(t), testsutil.GenerateUUID(t)},
Types: []connections.ConnType{connections.Publish},
},
err: apiutil.ErrInvalidIDFormat,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestDeleteChannelReqValidate(t *testing.T) {
cases := []struct {
desc string
req deleteChannelReq
err error
}{
{
desc: "valid request",
req: deleteChannelReq{
id: valid,
},
err: nil,
},
{
desc: "missing ID",
req: deleteChannelReq{
id: "",
},
err: apiutil.ErrMissingID,
},
}
for _, tc := range cases {
err := tc.req.validate()
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"fmt"
"net/http"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/channels"
)
var (
_ magistrala.Response = (*createChannelRes)(nil)
_ magistrala.Response = (*viewChannelRes)(nil)
_ magistrala.Response = (*channelsPageRes)(nil)
_ magistrala.Response = (*updateChannelRes)(nil)
_ magistrala.Response = (*deleteChannelRes)(nil)
_ magistrala.Response = (*connectChannelClientsRes)(nil)
_ magistrala.Response = (*disconnectChannelClientsRes)(nil)
_ magistrala.Response = (*connectRes)(nil)
_ magistrala.Response = (*disconnectRes)(nil)
_ magistrala.Response = (*changeChannelStatusRes)(nil)
)
type pageRes struct {
Limit uint64 `json:"limit,omitempty"`
Offset uint64 `json:"offset,omitempty"`
Total uint64 `json:"total"`
}
type createChannelRes struct {
channels.Channel
created bool
}
func (res createChannelRes) Code() int {
if res.created {
return http.StatusCreated
}
return http.StatusOK
}
func (res createChannelRes) Headers() map[string]string {
if res.created {
return map[string]string{
"Location": fmt.Sprintf("/channels/%s", res.ID),
}
}
return map[string]string{}
}
func (res createChannelRes) Empty() bool {
return false
}
type viewChannelRes struct {
channels.Channel
}
func (res viewChannelRes) Code() int {
return http.StatusOK
}
func (res viewChannelRes) Headers() map[string]string {
return map[string]string{}
}
func (res viewChannelRes) Empty() bool {
return false
}
type channelsPageRes struct {
pageRes
Channels []viewChannelRes `json:"channels,omitempty"`
}
func (res channelsPageRes) Code() int {
return http.StatusOK
}
func (res channelsPageRes) Headers() map[string]string {
return map[string]string{}
}
func (res channelsPageRes) Empty() bool {
return false
}
type changeChannelStatusRes struct {
channels.Channel
}
func (res changeChannelStatusRes) Code() int {
return http.StatusOK
}
func (res changeChannelStatusRes) Headers() map[string]string {
return map[string]string{}
}
func (res changeChannelStatusRes) Empty() bool {
return false
}
type updateChannelRes struct {
channels.Channel
}
func (res updateChannelRes) Code() int {
return http.StatusOK
}
func (res updateChannelRes) Headers() map[string]string {
return map[string]string{}
}
func (res updateChannelRes) Empty() bool {
return false
}
type setChannelParentGroupRes struct{}
func (res setChannelParentGroupRes) Code() int {
return http.StatusOK
}
func (res setChannelParentGroupRes) Headers() map[string]string {
return map[string]string{}
}
func (res setChannelParentGroupRes) Empty() bool {
return true
}
type removeChannelParentGroupRes struct{}
func (res removeChannelParentGroupRes) Code() int {
return http.StatusNoContent
}
func (res removeChannelParentGroupRes) Headers() map[string]string {
return map[string]string{}
}
func (res removeChannelParentGroupRes) Empty() bool {
return true
}
type deleteChannelRes struct{}
func (res deleteChannelRes) Code() int {
return http.StatusNoContent
}
func (res deleteChannelRes) Headers() map[string]string {
return map[string]string{}
}
func (res deleteChannelRes) Empty() bool {
return true
}
type connectChannelClientsRes struct{}
func (res connectChannelClientsRes) Code() int {
return http.StatusCreated
}
func (res connectChannelClientsRes) Headers() map[string]string {
return map[string]string{}
}
func (res connectChannelClientsRes) Empty() bool {
return true
}
type disconnectChannelClientsRes struct{}
func (res disconnectChannelClientsRes) Code() int {
return http.StatusNoContent
}
func (res disconnectChannelClientsRes) Headers() map[string]string {
return map[string]string{}
}
func (res disconnectChannelClientsRes) Empty() bool {
return true
}
type connectRes struct{}
func (res connectRes) Code() int {
return http.StatusCreated
}
func (res connectRes) Headers() map[string]string {
return map[string]string{}
}
func (res connectRes) Empty() bool {
return true
}
type disconnectRes struct{}
func (res disconnectRes) Code() int {
return http.StatusNoContent
}
func (res disconnectRes) Headers() map[string]string {
return map[string]string{}
}
func (res disconnectRes) Empty() bool {
return true
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"log/slog"
"github.com/absmach/magistrala"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/channels"
smqauthn "github.com/absmach/magistrala/pkg/authn"
roleManagerHttp "github.com/absmach/magistrala/pkg/roles/rolemanager/api"
"github.com/go-chi/chi/v5"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// MakeHandler returns a HTTP handler for Channels API endpoints.
func MakeHandler(svc channels.Service, authn smqauthn.AuthNMiddleware, mux *chi.Mux, logger *slog.Logger, instanceID string, idp magistrala.IDProvider) *chi.Mux {
opts := []kithttp.ServerOption{
kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, api.EncodeError)),
}
d := roleManagerHttp.NewDecoder("channelID")
mux.Route("/{domainID}/channels", func(r chi.Router) {
r.Use(authn.Middleware())
r.Use(api.RequestIDMiddleware(idp))
r.Post("/", otelhttp.NewHandler(kithttp.NewServer(
createChannelEndpoint(svc),
decodeCreateChannelReq,
api.EncodeResponse,
opts...,
), "create_channel").ServeHTTP)
r.Post("/bulk", otelhttp.NewHandler(kithttp.NewServer(
createChannelsEndpoint(svc),
decodeCreateChannelsReq,
api.EncodeResponse,
opts...,
), "create_channels").ServeHTTP)
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
listChannelsEndpoint(svc),
decodeListChannels,
api.EncodeResponse,
opts...,
), "list_channels").ServeHTTP)
r.Post("/connect", otelhttp.NewHandler(kithttp.NewServer(
connectEndpoint(svc),
decodeConnectRequest,
api.EncodeResponse,
opts...,
), "connect").ServeHTTP)
r.Post("/disconnect", otelhttp.NewHandler(kithttp.NewServer(
disconnectEndpoint(svc),
decodeDisconnectRequest,
api.EncodeResponse,
opts...,
), "disconnect").ServeHTTP)
r = roleManagerHttp.EntityAvailableActionsRouter(svc, d, r, opts)
r.Route("/{channelID}", func(r chi.Router) {
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
viewChannelEndpoint(svc),
decodeViewChannel,
api.EncodeResponse,
opts...,
), "view_channel").ServeHTTP)
r.Patch("/", otelhttp.NewHandler(kithttp.NewServer(
updateChannelEndpoint(svc),
decodeUpdateChannel,
api.EncodeResponse,
opts...,
), "update_channel_name_and_metadata").ServeHTTP)
r.Patch("/tags", otelhttp.NewHandler(kithttp.NewServer(
updateChannelTagsEndpoint(svc),
decodeUpdateChannelTags,
api.EncodeResponse,
opts...,
), "update_channel_tag").ServeHTTP)
r.Delete("/", otelhttp.NewHandler(kithttp.NewServer(
deleteChannelEndpoint(svc),
decodeDeleteChannelReq,
api.EncodeResponse,
opts...,
), "delete_channel").ServeHTTP)
r.Post("/enable", otelhttp.NewHandler(kithttp.NewServer(
enableChannelEndpoint(svc),
decodeChangeChannelStatus,
api.EncodeResponse,
opts...,
), "enable_channel").ServeHTTP)
r.Post("/disable", otelhttp.NewHandler(kithttp.NewServer(
disableChannelEndpoint(svc),
decodeChangeChannelStatus,
api.EncodeResponse,
opts...,
), "disable_channel").ServeHTTP)
r.Post("/parent", otelhttp.NewHandler(kithttp.NewServer(
setChannelParentGroupEndpoint(svc),
decodeSetChannelParentGroupStatus,
api.EncodeResponse,
opts...,
), "set_channel_parent_group").ServeHTTP)
r.Delete("/parent", otelhttp.NewHandler(kithttp.NewServer(
removeChannelParentGroupEndpoint(svc),
decodeRemoveChannelParentGroupStatus,
api.EncodeResponse,
opts...,
), "remove_channel_parent_group").ServeHTTP)
r.Post("/connect", otelhttp.NewHandler(kithttp.NewServer(
connectChannelClientEndpoint(svc),
decodeConnectChannelClientRequest,
api.EncodeResponse,
opts...,
), "connect_channel_client").ServeHTTP)
r.Post("/disconnect", otelhttp.NewHandler(kithttp.NewServer(
disconnectChannelClientsEndpoint(svc),
decodeDisconnectChannelClientsRequest,
api.EncodeResponse,
opts...,
), "disconnect_channel_client").ServeHTTP)
roleManagerHttp.EntityRoleMangerRouter(svc, d, r, opts)
})
})
mux.Get("/health", magistrala.Health("channels", instanceID))
mux.Handle("/metrics", promhttp.Handler())
return mux
}
+7
View File
@@ -0,0 +1,7 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package channels
import "github.com/absmach/magistrala/pkg/roles"
const BuiltInRoleAdmin roles.BuiltInRoleName = "admin"
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package cache
import (
"context"
"time"
"github.com/absmach/magistrala/channels"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/redis/go-redis/v9"
)
var (
ErrEmptyDomainID = errors.New("domain ID is empty")
ErrEmptyChannelID = errors.New("channel ID is empty")
ErrEmptyChannelRoute = errors.New("channel route is empty")
)
type channelsCache struct {
client *redis.Client
duration time.Duration
}
func NewChannelsCache(client *redis.Client, duration time.Duration) channels.Cache {
return &channelsCache{
client: client,
duration: duration,
}
}
func (cc *channelsCache) Save(ctx context.Context, route, domainID, channelID string) error {
key, err := encodeKey(domainID, route)
if err != nil {
return errors.Wrap(repoerr.ErrCreateEntity, err)
}
if channelID == "" {
return errors.Wrap(repoerr.ErrCreateEntity, ErrEmptyChannelID)
}
if err := cc.client.Set(ctx, key, channelID, cc.duration).Err(); err != nil {
return errors.Wrap(repoerr.ErrCreateEntity, err)
}
return nil
}
func (cc *channelsCache) ID(ctx context.Context, channelRoute, domainID string) (string, error) {
key, err := encodeKey(domainID, channelRoute)
if err != nil {
return "", errors.Wrap(repoerr.ErrNotFound, err)
}
id, err := cc.client.Get(ctx, key).Result()
if err != nil {
return "", errors.Wrap(repoerr.ErrNotFound, err)
}
return id, nil
}
func (cc *channelsCache) Remove(ctx context.Context, channelRoute, domainID string) error {
key, err := encodeKey(domainID, channelRoute)
if err != nil {
return errors.Wrap(repoerr.ErrRemoveEntity, err)
}
if err := cc.client.Del(ctx, key).Err(); err != nil {
return errors.Wrap(repoerr.ErrRemoveEntity, err)
}
return nil
}
func encodeKey(domainID, channelRoute string) (string, error) {
if domainID == "" {
return "", ErrEmptyDomainID
}
if channelRoute == "" {
return "", ErrEmptyChannelRoute
}
return domainID + ":" + channelRoute, nil
}

Some files were not shown because too many files have changed in this diff Show More