mirror of
https://github.com/absmach/magistrala.git
synced 2026-08-07 07:14:46 +00:00
auto provision of ATOM token
Signed-off-by: Arvindh <arvindh91@gmail.com>
This commit is contained in:
@@ -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/*
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user