auto provision of ATOM token

Signed-off-by: Arvindh <arvindh91@gmail.com>
This commit is contained in:
Arvindh
2026-06-25 17:46:46 +05:30
committed by dusan
parent 601bbacd17
commit eed4d4aa00
11 changed files with 657 additions and 60 deletions
+3
View File
@@ -19,6 +19,9 @@ 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/*
+33 -9
View File
@@ -23,6 +23,12 @@ 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
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
@@ -79,7 +85,15 @@ define make_docker_dev
-f docker/Dockerfile.dev ./build
endef
define require_atom_tokens_env
@if [ -z "$(filter down,$(DOCKER_COMPOSE_COMMAND))" ] && [ ! -f "$(ATOM_TOKENS_ENV)" ]; then \
echo "Missing $(ATOM_TOKENS_ENV). Run 'make provision_atom_tokens' before starting the Docker Compose stack."; \
exit 2; \
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."; \
@@ -89,12 +103,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 --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args); \
docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -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 --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args); \
docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args); \
fi
endef
@@ -144,7 +158,7 @@ FILTERED_SERVICES = $(filter-out $(RUN_ADDON_ARGS), $(SERVICES))
all: $(SERVICES)
.PHONY: all $(SERVICES) dockers dockers_dev latest release run_latest run_latest_ci run_tls run_stable run_addons grpc_mtls_certs check_mtls check_certs test_api mocks
.PHONY: all $(SERVICES) dockers dockers_dev latest release provision_atom_tokens run_latest run_latest_ci run_tls run_stable run_addons grpc_mtls_certs check_mtls check_certs test_api mocks
clean:
rm -rf ${BUILD_DIR}
@@ -263,6 +277,11 @@ rundev:
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 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
check_tls:
ifeq ($(GRPC_TLS),true)
@echo "gRPC TLS is enabled"
@@ -289,12 +308,14 @@ endif
endif
run_latest: 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 --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
$(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 --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
$(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)
@@ -308,17 +329,20 @@ run_tls:
./docker/setup-tls.sh
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 --env-file docker/.env -p $(DOCKER_PROJECT) $(DOCKER_COMPOSE_COMMAND) $(args)
$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -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 --env-file ./docker/.env -p $(DOCKER_PROJECT) up -d atom jaeger
@$(DOCKER_PLATFORM) docker compose -f docker/docker-compose.yaml $(DOCKER_ENV_FILES) -p $(DOCKER_PROJECT) up -d atom 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) --env-file ./docker/.env $(DOCKER_COMPOSE_COMMAND) $(args) & \
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) & \
done
run_live: check_certs
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)
$(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)
+62
View File
@@ -6,6 +6,7 @@ package main
import (
"context"
"flag"
"fmt"
"log"
"os"
@@ -31,6 +32,22 @@ func main() {
}
client := atom.NewClient(cfg)
if len(os.Args) > 1 {
switch os.Args[1] {
case "bootstrap-actions":
runBootstrapActions(client)
return
case "provision-tokens":
runProvisionTokens(client, os.Args[2:])
return
default:
log.Fatalf("unknown command %q", os.Args[1])
}
}
runBootstrapActions(client)
}
func runBootstrapActions(client *atom.Client) {
retries := envInt("MG_ATOM_BOOTSTRAP_RETRIES", defaultRetries)
retryInterval := envDuration("MG_ATOM_BOOTSTRAP_RETRY_INTERVAL", defaultRetryInterval)
timeout := envDuration("MG_ATOM_BOOTSTRAP_TIMEOUT", defaultTimeout)
@@ -54,6 +71,43 @@ func main() {
log.Fatalf("Magistrala Atom action bootstrap failed after %d attempts: %v", retries, lastErr)
}
func runProvisionTokens(client *atom.Client, args []string) {
fs := flag.NewFlagSet("provision-tokens", flag.ExitOnError)
output := fs.String("output", envString("MG_ATOM_TOKENS_OUTPUT", "docker/.env.tokens"), "path to write generated token env file")
rotate := fs.String("rotate", "", "rotate one token by name/env var, or all")
entityID := fs.String("entity-id", envString("ATOM_SERVICE_ENTITY_ID", atom.DefaultServiceEntityID), "Atom service entity ID to receive API keys")
if err := fs.Parse(args); err != nil {
log.Fatal(err)
}
retries := envInt("MG_ATOM_BOOTSTRAP_RETRIES", defaultRetries)
retryInterval := envDuration("MG_ATOM_BOOTSTRAP_RETRY_INTERVAL", defaultRetryInterval)
timeout := envDuration("MG_ATOM_BOOTSTRAP_TIMEOUT", defaultTimeout)
var lastErr error
for attempt := 1; attempt <= retries; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
result, err := atom.ProvisionServiceTokens(ctx, client, atom.TokenProvisionOptions{
OutputPath: *output,
ServiceEntityID: *entityID,
Rotate: *rotate,
})
cancel()
if err == nil {
log.Printf("Magistrala Atom token provisioning completed: output=%s preserved=%d created=%d rotated=%d",
result.OutputPath, len(result.Preserved), len(result.Created), len(result.Rotated))
return
}
lastErr = err
if attempt < retries {
log.Printf("Magistrala Atom token provisioning attempt %d/%d failed: %v; retrying in %s", attempt, retries, err, retryInterval)
time.Sleep(retryInterval)
}
}
log.Fatalf("Magistrala Atom token provisioning failed after %d attempts: %v", retries, lastErr)
}
func envInt(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
@@ -82,3 +136,11 @@ func envDuration(key string, fallback time.Duration) time.Duration {
fmt.Fprintf(os.Stderr, "invalid %s=%q, using %s\n", key, raw, fallback)
return fallback
}
func envString(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
+6 -2
View File
@@ -31,7 +31,7 @@ MG_FLUXMQ_API_PORT_2=9082
MG_FLUXMQ_API_PORT_3=9083
## Message Broker
MG_MESSAGE_BROKER_URL=amqp://guest:guest@nginx:${MG_FLUXMQ_AMQP091_PORT}/
MG_MESSAGE_BROKER_URL=amqp://guest:guest@nginx:${MG_NGINX_AMQP_PORT}/
## Redis
MG_REDIS_TCP_PORT=6379
@@ -39,7 +39,7 @@ MG_REDIS_URL=redis://es-redis:${MG_REDIS_TCP_PORT}/0
## Event Store
MG_ES_TYPE=es_fluxmq
MG_ES_URL=amqp://guest:guest@nginx:5682/
MG_ES_URL=amqp://guest:guest@nginx:${MG_NGINX_AMQP_PORT}/
## Jaeger
MG_JAEGER_COLLECTOR_OTLP_ENABLED=true
@@ -174,6 +174,10 @@ ATOM_CERTS_ROOT_CA_KEY_PATH=/certs/ca.key
ATOM_CERTS_CA_DIR=./ssl/certs
ATOM_CERTS_LEAF_DEFAULT_TTL_SECS=2592000
ATOM_CERTS_LEAF_MAX_TTL_SECS=2592000
# Local compose runs the server-rendered UI and GraphQL traffic through shared
# Docker peer IPs, which can trip Atom's default per-IP GraphQL limits.
ATOM_RATE_LIMIT_ENABLED=false
ATOM_TRUSTED_PROXY_CIDRS=
ATOM_RUST_LOG=info
ATOM_INVITATION_REDIRECT=http://localhost:3000/invitations/accept
ATOM_INVITATION_EXPIRY_SECS=604800
+13 -3
View File
@@ -13,13 +13,22 @@ Follow the [official Docker Compose installation guide](https://docs.docker.com/
Run the following commands from the project root directory.
```bash
docker compose -f docker/docker-compose.yaml up
make provision_atom_tokens
make run_latest
```
`make provision_atom_tokens` starts Atom, creates per-service Atom API keys, and writes them to the generated `docker/.env.tokens` file. That file is local-only and must not be committed.
If you use `docker compose` directly instead of the Makefile, pass both env files:
```bash
docker compose -f docker/docker-compose.yaml --env-file docker/.env --env-file docker/.env.tokens up
```
To start additional addon services:
```bash
docker compose -f docker/addons/<path>/docker-compose.yaml up
docker compose -f docker/addons/<path>/docker-compose.yaml --env-file docker/.env --env-file docker/.env.tokens up
```
To pull images from a specific release in `ghcr.io/absmach/magistrala`, change `MG_RELEASE_TAG` in `.env` before running these commands.
@@ -215,7 +224,8 @@ make dockers # builds all Docker images
Start services with Docker compose:
```bash
docker compose -f docker/docker-compose.yaml up
make provision_atom_tokens
make run_latest
```
To clean up:
@@ -32,13 +32,8 @@ services:
MG_POSTGRES_SSL_KEY: ${MG_POSTGRES_SSL_KEY}
MG_POSTGRES_SSL_ROOT_CERT: ${MG_POSTGRES_SSL_ROOT_CERT}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_POSTGRES_READER}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
ATOM_ADMIN_USERNAME: ${ATOM_ADMIN_USERNAME}
ATOM_ADMIN_SECRET: ${ATOM_ADMIN_SECRET}
ATOM_TIMEOUT: ${ATOM_TIMEOUT}
MG_POSTGRES_READER_GRPC_URL: ${MG_POSTGRES_READER_GRPC_URL}
MG_POSTGRES_READER_GRPC_PORT: ${MG_POSTGRES_READER_GRPC_PORT}
+11 -40
View File
@@ -83,6 +83,8 @@ services:
ATOM_CERTS_ROOT_CA_KEY_PATH: ${ATOM_CERTS_ROOT_CA_KEY_PATH:-/certs/ca.key}
ATOM_CERTS_LEAF_DEFAULT_TTL_SECS: ${ATOM_CERTS_LEAF_DEFAULT_TTL_SECS:-2592000}
ATOM_CERTS_LEAF_MAX_TTL_SECS: ${ATOM_CERTS_LEAF_MAX_TTL_SECS:-2592000}
ATOM_RATE_LIMIT_ENABLED: ${ATOM_RATE_LIMIT_ENABLED:-true}
ATOM_TRUSTED_PROXY_CIDRS: ${ATOM_TRUSTED_PROXY_CIDRS:-}
ATOM_MIN_PASSWORD_CHARS: ${ATOM_MIN_PASSWORD_CHARS}
RUST_LOG: ${ATOM_RUST_LOG}
ports:
@@ -113,7 +115,6 @@ services:
- atom
environment:
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
@@ -168,15 +169,10 @@ services:
MG_JOURNAL_DB_SSL_KEY: ${MG_JOURNAL_DB_SSL_KEY}
MG_JOURNAL_DB_SSL_ROOT_CERT: ${MG_JOURNAL_DB_SSL_ROOT_CERT}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_JOURNAL}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
ATOM_ADMIN_USERNAME: ${ATOM_ADMIN_USERNAME}
ATOM_ADMIN_SECRET: ${ATOM_ADMIN_SECRET}
ATOM_TIMEOUT: ${ATOM_TIMEOUT}
MG_ES_URL: ${MG_ES_URL}
MG_JAEGER_URL: ${MG_JAEGER_URL}
@@ -313,12 +309,7 @@ services:
MG_EMAIL_ACCEPTANCE_TEMPLATE: ${MG_EMAIL_ACCEPTANCE_TEMPLATE}
MG_EMAIL_REJECTION_TEMPLATE: ${MG_EMAIL_REJECTION_TEMPLATE}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
ATOM_ADMIN_USERNAME: ${ATOM_ADMIN_USERNAME}
ATOM_ADMIN_SECRET: ${ATOM_ADMIN_SECRET}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_NOTIFICATIONS}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
@@ -351,7 +342,7 @@ services:
atom-bootstrap:
condition: service_completed_successfully
environment:
FLUXMQ_ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
FLUXMQ_ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_FLUXMQ_NODE1}
restart: on-failure
ports:
- ${MG_COAP_PORT}:5683/udp
@@ -374,7 +365,7 @@ services:
atom-bootstrap:
condition: service_completed_successfully
environment:
FLUXMQ_ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
FLUXMQ_ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_FLUXMQ_NODE2}
restart: on-failure
ports:
- ${MG_FLUXMQ_API_PORT_2}:8082
@@ -396,7 +387,7 @@ services:
atom-bootstrap:
condition: service_completed_successfully
environment:
FLUXMQ_ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
FLUXMQ_ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_FLUXMQ_NODE3}
restart: on-failure
ports:
- ${MG_FLUXMQ_API_PORT_3}:8082
@@ -686,15 +677,10 @@ services:
MG_TIMESCALE_SSL_KEY: ${MG_TIMESCALE_SSL_KEY}
MG_TIMESCALE_SSL_ROOT_CERT: ${MG_TIMESCALE_SSL_ROOT_CERT}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_TIMESCALE_READER}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
ATOM_ADMIN_USERNAME: ${ATOM_ADMIN_USERNAME}
ATOM_ADMIN_SECRET: ${ATOM_ADMIN_SECRET}
ATOM_TIMEOUT: ${ATOM_TIMEOUT}
MG_TIMESCALE_READER_GRPC_URL: ${MG_TIMESCALE_READER_GRPC_URL}
MG_TIMESCALE_READER_GRPC_PORT: ${MG_TIMESCALE_READER_GRPC_PORT}
@@ -809,12 +795,7 @@ services:
environment:
MG_RE_LOG_LEVEL: ${MG_RE_LOG_LEVEL}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
ATOM_ADMIN_USERNAME: ${ATOM_ADMIN_USERNAME}
ATOM_ADMIN_SECRET: ${ATOM_ADMIN_SECRET}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_RE}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
@@ -898,12 +879,7 @@ services:
environment:
MG_ALARMS_LOG_LEVEL: ${MG_ALARMS_LOG_LEVEL}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
ATOM_ADMIN_USERNAME: ${ATOM_ADMIN_USERNAME}
ATOM_ADMIN_SECRET: ${ATOM_ADMIN_SECRET}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_ALARMS}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
@@ -966,12 +942,7 @@ services:
environment:
MG_REPORTS_LOG_LEVEL: ${MG_REPORTS_LOG_LEVEL}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${ATOM_SERVICE_TOKEN}
ATOM_SERVICE_USERNAME: ${ATOM_SERVICE_USERNAME}
ATOM_SERVICE_SECRET: ${ATOM_SERVICE_SECRET}
ATOM_ADMIN_TOKEN: ${ATOM_ADMIN_TOKEN}
ATOM_ADMIN_USERNAME: ${ATOM_ADMIN_USERNAME}
ATOM_ADMIN_SECRET: ${ATOM_ADMIN_SECRET}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_REPORTS}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
+30
View File
@@ -487,6 +487,31 @@ func (c *Client) CreatePassword(ctx context.Context, entityID, password string)
}`, map[string]any{"entityId": entityID, "password": password}, nil)
}
func (c *Client) CreateAPIKey(ctx context.Context, entityID, description string) (APIKeyResponse, error) {
var out struct {
CreateAPIKey APIKeyResponse `json:"createApiKey"`
}
err := c.graphQL(ctx, `mutation CreateAPIKey($entityId: ID!, $input: CreateApiKeyInput!) {
createApiKey(entityId: $entityId, input: $input) {
credentialId
key
expiresAt
}
}`, map[string]any{
"entityId": entityID,
"input": map[string]any{
"description": description,
},
}, &out)
return out.CreateAPIKey, err
}
func (c *Client) RevokeCredential(ctx context.Context, entityID, credentialID string) error {
return c.graphQL(ctx, `mutation RevokeCredential($entityId: ID!, $credentialId: ID!) {
revokeCredential(entityId: $entityId, credentialId: $credentialId)
}`, map[string]any{"entityId": entityID, "credentialId": credentialID}, nil)
}
func (c *Client) ListEntities(ctx context.Context, q Query) (EntityList, error) {
var out struct {
Entities EntityList `json:"entities"`
@@ -892,3 +917,8 @@ func IsConflict(err error) bool {
ae, ok := err.(Error)
return ok && ae.StatusCode == http.StatusConflict
}
func IsNotFound(err error) bool {
ae, ok := err.(Error)
return ok && ae.StatusCode == http.StatusNotFound
}
+244
View File
@@ -0,0 +1,244 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package atom
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
const DefaultServiceEntityID = "00000000-0000-0000-0000-000000000003"
type ServiceTokenSpec struct {
Name string
Env string
Description string
}
type TokenProvisionOptions struct {
OutputPath string
ServiceEntityID string
Rotate string
Specs []ServiceTokenSpec
}
type TokenProvisionResult struct {
OutputPath string
Preserved []string
Created []string
Rotated []string
}
func DefaultServiceTokenSpecs() []ServiceTokenSpec {
return []ServiceTokenSpec{
{Name: "fluxmq-node1", Env: "MG_ATOM_TOKEN_FLUXMQ_NODE1", Description: "Magistrala Docker Compose token for fluxmq-node1"},
{Name: "fluxmq-node2", Env: "MG_ATOM_TOKEN_FLUXMQ_NODE2", Description: "Magistrala Docker Compose token for fluxmq-node2"},
{Name: "fluxmq-node3", Env: "MG_ATOM_TOKEN_FLUXMQ_NODE3", Description: "Magistrala Docker Compose token for fluxmq-node3"},
{Name: "journal", Env: "MG_ATOM_TOKEN_JOURNAL", Description: "Magistrala Docker Compose token for journal"},
{Name: "notifications", Env: "MG_ATOM_TOKEN_NOTIFICATIONS", Description: "Magistrala Docker Compose token for notifications"},
{Name: "timescale-reader", Env: "MG_ATOM_TOKEN_TIMESCALE_READER", Description: "Magistrala Docker Compose token for timescale-reader"},
{Name: "re", Env: "MG_ATOM_TOKEN_RE", Description: "Magistrala Docker Compose token for rule engine"},
{Name: "alarms", Env: "MG_ATOM_TOKEN_ALARMS", Description: "Magistrala Docker Compose token for alarms"},
{Name: "reports", Env: "MG_ATOM_TOKEN_REPORTS", Description: "Magistrala Docker Compose token for reports"},
{Name: "postgres-reader", Env: "MG_ATOM_TOKEN_POSTGRES_READER", Description: "Magistrala Docker Compose token for postgres-reader"},
}
}
func ProvisionServiceTokens(ctx context.Context, client *Client, opts TokenProvisionOptions) (TokenProvisionResult, error) {
if client == nil {
return TokenProvisionResult{}, fmt.Errorf("atom client is nil")
}
if strings.TrimSpace(opts.OutputPath) == "" {
return TokenProvisionResult{}, fmt.Errorf("token output path is required")
}
entityID := strings.TrimSpace(opts.ServiceEntityID)
if entityID == "" {
entityID = DefaultServiceEntityID
}
specs := opts.Specs
if len(specs) == 0 {
specs = DefaultServiceTokenSpecs()
}
rotate, err := normalizeRotation(opts.Rotate, specs)
if err != nil {
return TokenProvisionResult{}, err
}
existing, err := readTokenEnvFile(opts.OutputPath)
if err != nil {
return TokenProvisionResult{}, err
}
values := make(map[string]string, len(specs))
result := TokenProvisionResult{OutputPath: opts.OutputPath}
for _, spec := range specs {
token := strings.TrimSpace(existing[spec.Env])
shouldRotate := rotate["all"] || rotate[spec.Env]
if token != "" && !shouldRotate {
active, err := client.TokenActive(ctx, token)
if err == nil && active {
values[spec.Env] = token
result.Preserved = append(result.Preserved, spec.Env)
continue
}
}
if token != "" && shouldRotate {
credentialID, ok := CredentialIDFromAPIKey(token)
if ok {
if err := client.RevokeCredential(ctx, entityID, credentialID); err != nil && !IsNotFound(err) {
return TokenProvisionResult{}, fmt.Errorf("revoke %s credential %s: %w", spec.Env, credentialID, err)
}
}
}
created, err := client.CreateAPIKey(ctx, entityID, spec.Description)
if err != nil {
return TokenProvisionResult{}, fmt.Errorf("create %s token: %w", spec.Env, err)
}
if strings.TrimSpace(created.Key) == "" {
return TokenProvisionResult{}, fmt.Errorf("create %s token: atom returned an empty key", spec.Env)
}
values[spec.Env] = created.Key
if shouldRotate {
result.Rotated = append(result.Rotated, spec.Env)
} else {
result.Created = append(result.Created, spec.Env)
}
}
if err := writeTokenEnvFile(opts.OutputPath, specs, values); err != nil {
return TokenProvisionResult{}, err
}
return result, nil
}
func (c *Client) TokenActive(ctx context.Context, token string) (bool, error) {
res, err := c.Introspect(ctx, token)
if err != nil {
return false, err
}
return res.Active, nil
}
func CredentialIDFromAPIKey(token string) (string, bool) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), "atom_")
if !ok {
return "", false
}
idHex, secretHex, ok := strings.Cut(rest, "_")
if !ok || len(idHex) != 32 || len(secretHex) != 64 || !isLowerHex(idHex) || !isLowerHex(secretHex) {
return "", false
}
return fmt.Sprintf("%s-%s-%s-%s-%s", idHex[0:8], idHex[8:12], idHex[12:16], idHex[16:20], idHex[20:32]), true
}
func normalizeRotation(raw string, specs []ServiceTokenSpec) (map[string]bool, error) {
rotation := map[string]bool{}
raw = strings.TrimSpace(raw)
if raw == "" {
return rotation, nil
}
if strings.EqualFold(raw, "all") {
rotation["all"] = true
return rotation, nil
}
lookup := map[string]string{}
for _, spec := range specs {
lookup[strings.ToLower(spec.Env)] = spec.Env
lookup[strings.ToLower(strings.TrimPrefix(spec.Env, "MG_ATOM_TOKEN_"))] = spec.Env
lookup[strings.ToLower(strings.ReplaceAll(spec.Name, "-", "_"))] = spec.Env
}
key := strings.ToLower(strings.ReplaceAll(raw, "-", "_"))
env, ok := lookup[key]
if !ok {
return nil, fmt.Errorf("unknown token rotation target %q", raw)
}
rotation[env] = true
return rotation, nil
}
func readTokenEnvFile(path string) (map[string]string, error) {
values := map[string]string{}
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return values, nil
}
return nil, fmt.Errorf("read token env file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key != "" {
values[key] = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan token env file: %w", err)
}
return values, nil
}
func writeTokenEnvFile(path string, specs []ServiceTokenSpec, values map[string]string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create token env directory: %w", err)
}
tmp, err := os.CreateTemp(dir, ".env.tokens-*")
if err != nil {
return fmt.Errorf("create token env temp file: %w", err)
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("secure token env temp file: %w", err)
}
if _, err := fmt.Fprintln(tmp, "# Generated by atom-bootstrap provision-tokens. Do not commit."); err != nil {
_ = tmp.Close()
return err
}
for _, spec := range specs {
value := strings.TrimSpace(values[spec.Env])
if value == "" {
_ = tmp.Close()
return fmt.Errorf("missing generated token for %s", spec.Env)
}
if _, err := fmt.Fprintf(tmp, "%s=%s\n", spec.Env, value); err != nil {
_ = tmp.Close()
return fmt.Errorf("write token env file: %w", err)
}
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close token env temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("replace token env file: %w", err)
}
return nil
}
func isLowerHex(value string) bool {
for _, r := range value {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package atom
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestProvisionServiceTokensCreatesMissingToken(t *testing.T) {
fake := newFakeAtomTokenServer(t, nil)
defer fake.Close()
output := filepath.Join(t.TempDir(), ".env.tokens")
result, err := ProvisionServiceTokens(context.Background(), fake.Client(), TokenProvisionOptions{
OutputPath: output,
Specs: []ServiceTokenSpec{testTokenSpec()},
})
if err != nil {
t.Fatalf("provision tokens failed: %v", err)
}
if !containsString(result.Created, testTokenSpec().Env) {
t.Fatalf("expected token to be created, got result %+v", result)
}
if len(fake.created) != 1 {
t.Fatalf("unexpected create count: %d", len(fake.created))
}
values, err := readTokenEnvFile(output)
if err != nil {
t.Fatalf("read token env file: %v", err)
}
if values[testTokenSpec().Env] == "" {
t.Fatalf("expected generated token in env file")
}
info, err := os.Stat(output)
if err != nil {
t.Fatalf("stat token env file: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("unexpected token env permissions: got %s want -rw-------", got)
}
assertNoTempTokenFiles(t, filepath.Dir(output))
}
func TestProvisionServiceTokensPreservesExistingActiveToken(t *testing.T) {
token := apiKeyForCredentialID("11111111-1111-1111-1111-111111111111")
fake := newFakeAtomTokenServer(t, map[string]bool{token: true})
defer fake.Close()
output := filepath.Join(t.TempDir(), ".env.tokens")
if err := os.WriteFile(output, []byte(testTokenSpec().Env+"="+token+"\n"), 0o600); err != nil {
t.Fatalf("write existing token file: %v", err)
}
result, err := ProvisionServiceTokens(context.Background(), fake.Client(), TokenProvisionOptions{
OutputPath: output,
Specs: []ServiceTokenSpec{testTokenSpec()},
})
if err != nil {
t.Fatalf("provision tokens failed: %v", err)
}
if !containsString(result.Preserved, testTokenSpec().Env) {
t.Fatalf("expected token to be preserved, got result %+v", result)
}
if len(fake.created) != 0 {
t.Fatalf("expected no new API key, got %d", len(fake.created))
}
values, err := readTokenEnvFile(output)
if err != nil {
t.Fatalf("read token env file: %v", err)
}
if got := values[testTokenSpec().Env]; got != token {
t.Fatalf("expected preserved token, got %q", got)
}
}
func TestProvisionServiceTokensRotatesToken(t *testing.T) {
oldCredentialID := "11111111-1111-1111-1111-111111111111"
token := apiKeyForCredentialID(oldCredentialID)
fake := newFakeAtomTokenServer(t, map[string]bool{token: true})
defer fake.Close()
output := filepath.Join(t.TempDir(), ".env.tokens")
if err := os.WriteFile(output, []byte(testTokenSpec().Env+"="+token+"\n"), 0o600); err != nil {
t.Fatalf("write existing token file: %v", err)
}
result, err := ProvisionServiceTokens(context.Background(), fake.Client(), TokenProvisionOptions{
OutputPath: output,
Rotate: "journal",
Specs: []ServiceTokenSpec{testTokenSpec()},
})
if err != nil {
t.Fatalf("provision tokens failed: %v", err)
}
if !containsString(result.Rotated, testTokenSpec().Env) {
t.Fatalf("expected token to be rotated, got result %+v", result)
}
if !containsString(fake.revoked, oldCredentialID) {
t.Fatalf("expected old credential to be revoked, got %v", fake.revoked)
}
values, err := readTokenEnvFile(output)
if err != nil {
t.Fatalf("read token env file: %v", err)
}
if got := values[testTokenSpec().Env]; got == "" || got == token {
t.Fatalf("expected rotated token, got %q", got)
}
}
func TestCredentialIDFromAPIKey(t *testing.T) {
want := "11111111-2222-3333-4444-555555555555"
got, ok := CredentialIDFromAPIKey(apiKeyForCredentialID(want))
if !ok {
t.Fatalf("expected credential id to parse")
}
if got != want {
t.Fatalf("unexpected credential id: got %s want %s", got, want)
}
if _, ok := CredentialIDFromAPIKey("not-an-api-key"); ok {
t.Fatalf("expected invalid token to be rejected")
}
}
type fakeAtomTokenServer struct {
t *testing.T
server *httptest.Server
active map[string]bool
created []map[string]any
revoked []string
nextID int
}
func newFakeAtomTokenServer(t *testing.T, active map[string]bool) *fakeAtomTokenServer {
t.Helper()
fake := &fakeAtomTokenServer{
t: t,
active: active,
}
if fake.active == nil {
fake.active = map[string]bool{}
}
fake.server = httptest.NewServer(http.HandlerFunc(fake.handle))
return fake
}
func (f *fakeAtomTokenServer) Close() {
f.server.Close()
}
func (f *fakeAtomTokenServer) Client() *Client {
return NewClient(Config{URL: f.server.URL, Token: "admin-token", Timeout: time.Second})
}
func (f *fakeAtomTokenServer) handle(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/auth/introspect":
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
_ = json.NewEncoder(w).Encode(IntrospectionResponse{Active: f.active[token], EntityID: "entity-1"})
case atomGraphQLPath:
f.handleGraphQL(w, r)
default:
f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}
func (f *fakeAtomTokenServer) handleGraphQL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
f.t.Fatalf("unexpected GraphQL method: %s", r.Method)
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
f.t.Fatalf("decode GraphQL request: %v", err)
}
switch {
case strings.Contains(payload.Query, "createApiKey"):
input := payload.Variables["input"].(map[string]any)
f.created = append(f.created, input)
f.nextID++
credentialID := credentialIDForIndex(f.nextID)
key := apiKeyForCredentialID(credentialID)
f.active[key] = true
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"createApiKey": APIKeyResponse{
CredentialID: credentialID,
Key: key,
},
},
})
case strings.Contains(payload.Query, "revokeCredential"):
credentialID := payload.Variables["credentialId"].(string)
f.revoked = append(f.revoked, credentialID)
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{"revokeCredential": true},
})
default:
f.t.Fatalf("unexpected GraphQL payload: %s", payload.Query)
}
}
func testTokenSpec() ServiceTokenSpec {
return ServiceTokenSpec{Name: "journal", Env: "MG_ATOM_TOKEN_JOURNAL", Description: "test journal token"}
}
func apiKeyForCredentialID(id string) string {
return "atom_" + strings.ReplaceAll(id, "-", "") + "_" + strings.Repeat("a", 64)
}
func credentialIDForIndex(index int) string {
return fmt.Sprintf("aaaaaaaa-aaaa-aaaa-aaaa-%012d", index)
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func assertNoTempTokenFiles(t *testing.T, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read output directory: %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".env.tokens-") {
t.Fatalf("temporary token file was not removed: %s", entry.Name())
}
}
}
+6
View File
@@ -232,6 +232,12 @@ type LoginResponse struct {
ExpiresAt time.Time `json:"expires_at"`
}
type APIKeyResponse struct {
CredentialID string `json:"credentialId"`
Key string `json:"key"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type ResourceList struct {
Items []Resource `json:"items"`
Total uint64 `json:"total"`