From d60358b73930b42bfe91a83cc5712636243176a7 Mon Sep 17 00:00:00 2001 From: Mohammed Junaid Quraishi Date: Sun, 27 Sep 2026 01:31:45 -0400 Subject: [PATCH] revert: undo PR 1 merge Revert merge 388693a53a23fe5ff7e6722f947d6fbfefc360cd relative to its main parent, restoring the source tree at 3afbbb53e5c114ef53de7e038521a4a14db4330e. --- Makefile | 78 ++++--- README.md | 27 +-- central/auth/.env.example | 14 +- central/auth/package-lock.json | 212 +++++------------- central/auth/package.json | 3 +- central/auth/src/config/env.ts | 23 +- central/auth/src/db/auth-store.ts | 171 -------------- .../auth/src/db/legacy-mongodb-migration.ts | 121 ---------- central/auth/src/db/mongoose.ts | 65 ++++++ central/auth/src/db/postgres.ts | 71 ------ central/auth/src/index.ts | 15 +- central/auth/src/lib/auth.ts | 32 +-- central/auth/src/lib/bootstrap-admin.ts | 104 +++++---- central/auth/src/models/account.model.ts | 47 ++++ central/auth/src/models/session.model.ts | 37 +++ central/auth/src/models/user.model.ts | 41 ++++ central/auth/src/models/verification.model.ts | 34 +++ central/auth/test-support/postgres.ts | 34 --- central/auth/tests/integration/auth.spec.ts | 134 +++-------- .../tests/integration/migration-guard.spec.ts | 94 -------- .../tests/integration/postgres-schema.spec.ts | 30 --- central/auth/tests/setup/app.ts | 11 +- central/auth/tests/setup/mongoose.ts | 53 +++++ central/auth/tests/unit/env.spec.ts | 8 - deploy/standalone/docker-compose.yml | 11 - ops/appliance/Dockerfile | 2 - ops/appliance/manager/config.go | 18 +- ops/appliance/manager/main.go | 12 - ops/appliance/manager/preflight.go | 107 --------- ops/appliance/manager/preflight_test.go | 164 -------------- ops/appliance/manager/process.go | 52 +---- ops/appliance/manager/services.go | 20 +- ops/appliance/manager/setup.go | 15 +- ops/appliance/manager/supervise.go | 17 +- ...docker-compose.central-auth-test-mongo.yml | 27 +++ ...ker-compose.central-auth-test-postgres.yml | 27 --- ops/testing/test-central-auth-compose.py | 152 ------------- ops/unicron/.env.example | 7 +- ops/unicron/.env.production.example | 3 +- ops/unicron/docker-compose.unicron.yaml | 40 +--- ops/unicron/scripts/legacy-auth-mongodb.sh | 36 --- 41 files changed, 569 insertions(+), 1600 deletions(-) delete mode 100644 central/auth/src/db/auth-store.ts delete mode 100644 central/auth/src/db/legacy-mongodb-migration.ts create mode 100644 central/auth/src/db/mongoose.ts delete mode 100644 central/auth/src/db/postgres.ts create mode 100644 central/auth/src/models/account.model.ts create mode 100644 central/auth/src/models/session.model.ts create mode 100644 central/auth/src/models/user.model.ts create mode 100644 central/auth/src/models/verification.model.ts delete mode 100644 central/auth/test-support/postgres.ts delete mode 100644 central/auth/tests/integration/migration-guard.spec.ts delete mode 100644 central/auth/tests/integration/postgres-schema.spec.ts create mode 100644 central/auth/tests/setup/mongoose.ts delete mode 100644 ops/appliance/manager/preflight.go delete mode 100644 ops/appliance/manager/preflight_test.go create mode 100644 ops/testing/docker-compose.central-auth-test-mongo.yml delete mode 100644 ops/testing/docker-compose.central-auth-test-postgres.yml delete mode 100644 ops/testing/test-central-auth-compose.py delete mode 100644 ops/unicron/scripts/legacy-auth-mongodb.sh diff --git a/Makefile b/Makefile index e268618..9500927 100644 --- a/Makefile +++ b/Makefile @@ -10,12 +10,11 @@ LOCAL_BUILD_ENV ?= BUILDX_BUILDER=default DIND_COMPOSE_FILE ?= ops/dind/docker-compose.dind.yaml CENTRAL_COMPOSE_FILE ?= ops/unicron/docker-compose.unicron.yaml CENTRAL_ENV_FILE ?= ops/unicron/.env -CENTRAL_AUTH_TEST_POSTGRES_COMPOSE_FILE ?= ops/testing/docker-compose.central-auth-test-postgres.yml -CENTRAL_AUTH_TEST_POSTGRES_PORT ?= 5433 -CENTRAL_AUTH_TEST_POSTGRES_USER ?= postgres -CENTRAL_AUTH_TEST_POSTGRES_PASSWORD ?= password -CENTRAL_AUTH_TEST_POSTGRES_DB ?= central_auth_test -CENTRAL_AUTH_TEST_POSTGRES_SCHEMA ?= central_auth_test +CENTRAL_AUTH_TEST_MONGO_COMPOSE_FILE ?= ops/testing/docker-compose.central-auth-test-mongo.yml +CENTRAL_AUTH_TEST_MONGO_PORT ?= 27018 +CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME ?= root +CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD ?= password +CENTRAL_AUTH_TEST_MONGODB_DB_NAME ?= central_auth_test REGISTRY ?= localhost:5000 TAG ?= latest @@ -24,7 +23,8 @@ RELEASE_PLATFORMS ?= linux/amd64,linux/arm64 CENTRAL_ENV_ARG = $(if $(wildcard $(CENTRAL_ENV_FILE)),--env-file $(CENTRAL_ENV_FILE),) CENTRAL_COMPOSE = $(LOCAL_BUILD_ENV) $(COMPOSE) -f $(CENTRAL_COMPOSE_FILE) $(CENTRAL_ENV_ARG) -CENTRAL_AUTH_TEST_POSTGRES_COMPOSE = $(LOCAL_BUILD_ENV) $(COMPOSE) -f $(CENTRAL_AUTH_TEST_POSTGRES_COMPOSE_FILE) +CENTRAL_AUTH_TEST_MONGODB_URI ?= mongodb://$(CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME):$(CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD)@127.0.0.1:$(CENTRAL_AUTH_TEST_MONGO_PORT)/?authSource=admin +CENTRAL_AUTH_TEST_MONGO_COMPOSE = $(LOCAL_BUILD_ENV) $(COMPOSE) -f $(CENTRAL_AUTH_TEST_MONGO_COMPOSE_FILE) DIND_COMPOSE = $(LOCAL_BUILD_ENV) $(COMPOSE) -f $(DIND_COMPOSE_FILE) OTEL_MIN_IMAGE ?= unicron-otel-min @@ -71,9 +71,9 @@ APPLIANCE_NETWORK_ALIAS ?= unicron.central central-down \ central-destroy \ central-migrate \ - central-auth-test-postgres-up \ - central-auth-test-postgres-down \ - central-auth-test-postgres-destroy \ + central-auth-test-mongo-up \ + central-auth-test-mongo-down \ + central-auth-test-mongo-destroy \ test-central-auth \ build-otel-min \ build-fluent-bit-min \ @@ -185,40 +185,38 @@ central-migrate: ## Run central backend migrations @$(CENTRAL_COMPOSE) run --rm --no-deps backend \ python /app/backend/scripts/migrate_or_bootstrap.py -central-auth-test-postgres-up: ## Start disposable central-auth test Postgres and wait for readiness +central-auth-test-mongo-up: ## Start disposable central-auth test Mongo and wait for readiness @echo "" - @echo "Starting central-auth test Postgres on 127.0.0.1:$(CENTRAL_AUTH_TEST_POSTGRES_PORT)..." - @CENTRAL_AUTH_TEST_POSTGRES_PORT="$(CENTRAL_AUTH_TEST_POSTGRES_PORT)" \ - CENTRAL_AUTH_TEST_POSTGRES_USER="$(CENTRAL_AUTH_TEST_POSTGRES_USER)" \ - CENTRAL_AUTH_TEST_POSTGRES_PASSWORD="$(CENTRAL_AUTH_TEST_POSTGRES_PASSWORD)" \ - CENTRAL_AUTH_TEST_POSTGRES_DB="$(CENTRAL_AUTH_TEST_POSTGRES_DB)" \ - $(CENTRAL_AUTH_TEST_POSTGRES_COMPOSE) up -d --wait --wait-timeout $(WAIT_TIMEOUT) - -central-auth-test-postgres-down: ## Stop central-auth test Postgres while preserving its disposable volume + @echo "Starting central-auth test Mongo on 127.0.0.1:$(CENTRAL_AUTH_TEST_MONGO_PORT)..." + @CENTRAL_AUTH_TEST_MONGO_PORT="$(CENTRAL_AUTH_TEST_MONGO_PORT)" \ + CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME="$(CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME)" \ + CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD="$(CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD)" \ + CENTRAL_AUTH_TEST_MONGODB_DB_NAME="$(CENTRAL_AUTH_TEST_MONGODB_DB_NAME)" \ + $(CENTRAL_AUTH_TEST_MONGO_COMPOSE) up -d --wait --wait-timeout $(WAIT_TIMEOUT) + +central-auth-test-mongo-down: ## Stop central-auth test Mongo while preserving its disposable volume @echo "" - @CENTRAL_AUTH_TEST_POSTGRES_PORT="$(CENTRAL_AUTH_TEST_POSTGRES_PORT)" \ - CENTRAL_AUTH_TEST_POSTGRES_USER="$(CENTRAL_AUTH_TEST_POSTGRES_USER)" \ - CENTRAL_AUTH_TEST_POSTGRES_PASSWORD="$(CENTRAL_AUTH_TEST_POSTGRES_PASSWORD)" \ - CENTRAL_AUTH_TEST_POSTGRES_DB="$(CENTRAL_AUTH_TEST_POSTGRES_DB)" \ - $(CENTRAL_AUTH_TEST_POSTGRES_COMPOSE) down --remove-orphans || true - -central-auth-test-postgres-destroy: ## Destructively remove central-auth test Postgres and its disposable volume + @echo "Stopping central-auth test Mongo..." + @CENTRAL_AUTH_TEST_MONGO_PORT="$(CENTRAL_AUTH_TEST_MONGO_PORT)" \ + CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME="$(CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME)" \ + CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD="$(CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD)" \ + CENTRAL_AUTH_TEST_MONGODB_DB_NAME="$(CENTRAL_AUTH_TEST_MONGODB_DB_NAME)" \ + $(CENTRAL_AUTH_TEST_MONGO_COMPOSE) down --remove-orphans || true + +central-auth-test-mongo-destroy: ## Destructively remove central-auth test Mongo and its disposable volume @echo "" - @CENTRAL_AUTH_TEST_POSTGRES_PORT="$(CENTRAL_AUTH_TEST_POSTGRES_PORT)" \ - CENTRAL_AUTH_TEST_POSTGRES_USER="$(CENTRAL_AUTH_TEST_POSTGRES_USER)" \ - CENTRAL_AUTH_TEST_POSTGRES_PASSWORD="$(CENTRAL_AUTH_TEST_POSTGRES_PASSWORD)" \ - CENTRAL_AUTH_TEST_POSTGRES_DB="$(CENTRAL_AUTH_TEST_POSTGRES_DB)" \ - $(CENTRAL_AUTH_TEST_POSTGRES_COMPOSE) down -v --remove-orphans || true - -test-central-auth: central-auth-test-postgres-up ## Run central-auth unit and integration tests against disposable Postgres + @echo "Destroying central-auth test Mongo and volume..." + @CENTRAL_AUTH_TEST_MONGO_PORT="$(CENTRAL_AUTH_TEST_MONGO_PORT)" \ + CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME="$(CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME)" \ + CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD="$(CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD)" \ + CENTRAL_AUTH_TEST_MONGODB_DB_NAME="$(CENTRAL_AUTH_TEST_MONGODB_DB_NAME)" \ + $(CENTRAL_AUTH_TEST_MONGO_COMPOSE) down -v --remove-orphans || true + +test-central-auth: central-auth-test-mongo-up ## Run central-auth unit and integration tests against disposable Mongo @echo "" - @echo "Running central-auth tests against Postgres on 127.0.0.1:$(CENTRAL_AUTH_TEST_POSTGRES_PORT)" - @CENTRAL_AUTH_TEST_POSTGRES_HOST="127.0.0.1" \ - CENTRAL_AUTH_TEST_POSTGRES_PORT="$(CENTRAL_AUTH_TEST_POSTGRES_PORT)" \ - CENTRAL_AUTH_TEST_POSTGRES_USER="$(CENTRAL_AUTH_TEST_POSTGRES_USER)" \ - CENTRAL_AUTH_TEST_POSTGRES_PASSWORD="$(CENTRAL_AUTH_TEST_POSTGRES_PASSWORD)" \ - CENTRAL_AUTH_TEST_POSTGRES_DB="$(CENTRAL_AUTH_TEST_POSTGRES_DB)" \ - CENTRAL_AUTH_TEST_POSTGRES_SCHEMA="$(CENTRAL_AUTH_TEST_POSTGRES_SCHEMA)" \ + @echo "Running central-auth tests against $(CENTRAL_AUTH_TEST_MONGODB_URI)" + @CENTRAL_AUTH_TEST_MONGODB_URI="$(CENTRAL_AUTH_TEST_MONGODB_URI)" \ + CENTRAL_AUTH_TEST_MONGODB_DB_NAME="$(CENTRAL_AUTH_TEST_MONGODB_DB_NAME)" \ npm --prefix central/auth run test:all define docker_build_only diff --git a/README.md b/README.md index f6cd056..3a373af 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,8 @@ docker compose -f deploy/standalone/docker-compose.yml up -d - Single-image appliance runtime for standalone deployment. - Local administrator auth with explicit recovery mode. - Central backend, React frontend, alert engine, notifier API, and notifier worker. -- Postgres, Redis, Step CA/RA, Traefik, VictoriaMetrics, VictoriaLogs, +- Postgres, MongoDB, Redis, Step CA/RA, Traefik, VictoriaMetrics, VictoriaLogs, and OTel collector inside the appliance. -- Automatic migration of Central Auth data from older MongoDB-backed appliance volumes. - Agent enrollment through tokens and mTLS identity. - Browser and API ingress under `/unicron`. - Agent mTLS and OTLP ingress on port `8443`. @@ -65,22 +64,6 @@ docker compose -f deploy/standalone/docker-compose.yml up -d ## Development -### Upgrading Multi-container Auth - -Compose retains `unicron-central-auth-mongo-data` and imports the existing login -before creating a new administrator. Keep the old `CENTRAL_AUTH_MONGO_ROOT_USERNAME`, -`CENTRAL_AUTH_MONGO_ROOT_PASSWORD`, and `CENTRAL_AUTH_MONGODB_DB_NAME` settings. -Back up both databases before upgrading. Missing legacy users, missing passwords, -or conflicting PostgreSQL and MongoDB accounts stop startup rather than replacing -the login. Restore the missing data or correct the connection settings before retrying. - -Fresh installs never start MongoDB. After migration, restarting the legacy helper -leaves it idle; it no longer needs a MongoDB-compatible CPU. The first migration -of an old database still requires a compatible CPU. Keep the new -`unicron-central-auth-migration` volume with PostgreSQL when backing up or restoring. - -### Local Checks - Useful commands: ```sh @@ -105,14 +88,6 @@ make test-central-auth (cd central/unicron/backend && poetry run python -m unittest tests.test_security_hardening tests.test_origin_policy tests.test_appliance_update) ``` -Test the production multi-container auth wiring with isolated containers and -volumes (including real legacy password migration and failure cases): - -```sh -docker build -f central/auth/Dockerfile -t unicron-central-auth:local-test . -python3 ops/testing/test-central-auth-compose.py unicron-central-auth:local-test -``` - ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/central/auth/.env.example b/central/auth/.env.example index 6dd6049..8f66a13 100644 --- a/central/auth/.env.example +++ b/central/auth/.env.example @@ -13,14 +13,12 @@ HEADERS_TIMEOUT_MS=35000 KEEPALIVE_TIMEOUT_MS=5000 SHUTDOWN_DRAIN_MS=5000 -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_DB=unicron -CENTRAL_AUTH_POSTGRES_SCHEMA=central_auth -POSTGRES_MAX_POOL_SIZE=10 -POSTGRES_SSL=false +MONGODB_URI=mongodb://root:password@mongodb:27017/?authSource=admin +MONGODB_DB_NAME=UnicronCentralAuthDev +MONGODB_MIN_POOL_SIZE=0 +MONGODB_MAX_POOL_SIZE=10 +MONGODB_TLS=true +MONGODB_RETRY_WRITES=true CENTRAL_AUTH_SECRET=changeme-central-auth-secret-min-32-chars CENTRAL_AUTH_BASE_URL=http://localhost:3020 diff --git a/central/auth/package-lock.json b/central/auth/package-lock.json index a3fa1f7..9ca2976 100644 --- a/central/auth/package-lock.json +++ b/central/auth/package-lock.json @@ -16,7 +16,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "mongodb": "^7.0.0", - "pg": "^8.23.0", + "mongoose": "^9.0.1", "pino": "^10.1.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.2", @@ -27,7 +27,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/node": "^24.10.0", - "@types/pg": "^8.23.1", "@types/supertest": "^6.0.3", "cross-env": "^10.1.0", "prettier": "^3.6.2", @@ -1546,18 +1545,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -2985,6 +2972,15 @@ "node": ">=10" } }, + "node_modules/kareem": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.3.0.tgz", + "integrity": "sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/kysely": { "version": "0.28.17", "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", @@ -3460,6 +3456,51 @@ "node": ">=20.19.0" } }, + "node_modules/mongoose": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.6.1.tgz", + "integrity": "sha512-3T8/b0plM3ZJPW3WjlzVMIGJEYYTjgDPQ05Qzru3xu3/wOPSFKWYxdwUF2dl8h3NG5dVkzIuOkZdLacnlLf/sA==", + "license": "MIT", + "dependencies": { + "kareem": "3.3.0", + "mongodb": "~7.2", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -3628,95 +3669,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.14.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3904,45 +3856,6 @@ } } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -4445,6 +4358,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5137,15 +5056,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/central/auth/package.json b/central/auth/package.json index a098ef2..a37dd33 100644 --- a/central/auth/package.json +++ b/central/auth/package.json @@ -25,7 +25,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "mongodb": "^7.0.0", - "pg": "^8.23.0", + "mongoose": "^9.0.1", "pino": "^10.1.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.2", @@ -36,7 +36,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/node": "^24.10.0", - "@types/pg": "^8.23.1", "@types/supertest": "^6.0.3", "cross-env": "^10.1.0", "prettier": "^3.6.2", diff --git a/central/auth/src/config/env.ts b/central/auth/src/config/env.ts index c1dc72f..dcac7de 100644 --- a/central/auth/src/config/env.ts +++ b/central/auth/src/config/env.ts @@ -45,23 +45,12 @@ export const EnvSchema = z.object({ KEEPALIVE_TIMEOUT_MS: z.coerce.number().int().min(0).default(5000), SHUTDOWN_DRAIN_MS: z.coerce.number().int().min(0).default(5000), - POSTGRES_HOST: z.string().min(1).default('localhost'), - POSTGRES_PORT: z.coerce.number().int().positive().default(5432), - POSTGRES_USER: z.string().min(1).default('postgres'), - POSTGRES_PASSWORD: z.string().default('postgres'), - POSTGRES_DB: z.string().min(1).default('unicron'), - CENTRAL_AUTH_POSTGRES_SCHEMA: z - .string() - .regex(/^[A-Za-z_][A-Za-z0-9_]*$/) - .default('central_auth'), - POSTGRES_MAX_POOL_SIZE: z.coerce.number().int().min(1).default(10), - POSTGRES_SSL: envBoolean(booleanEnvValue.default(false)), - POSTGRES_SSL_REJECT_UNAUTHORIZED: envBoolean(booleanEnvValue.default(true)), - - LEGACY_MONGODB_URI: z.preprocess((value) => (value === '' ? undefined : value), z.url().optional()), - LEGACY_MONGODB_DB_NAME: z.string().min(1).default('unicron_central_auth'), - LEGACY_MONGODB_MIGRATION_MARKER: z.preprocess((value) => (value === '' ? undefined : value), z.string().optional()), - LEGACY_MONGODB_SOURCE_STATE_FILE: z.preprocess((value) => (value === '' ? undefined : value), z.string().optional()), + MONGODB_URI: z.url().default('mongodb://localhost:27017'), + MONGODB_DB_NAME: z.string().min(1).default('central_auth'), + MONGODB_MIN_POOL_SIZE: z.coerce.number().int().min(0).default(0), + MONGODB_MAX_POOL_SIZE: z.coerce.number().int().min(1).default(10), + MONGODB_TLS: envBoolean(booleanEnvValue.default(true)), + MONGODB_RETRY_WRITES: envBoolean(booleanEnvValue.default(true)), CENTRAL_AUTH_SECRET: z.string().min(32).default('changeme-central-auth-secret-please-override-123456'), CENTRAL_AUTH_BASE_URL: z.url().default('http://localhost:3020'), diff --git a/central/auth/src/db/auth-store.ts b/central/auth/src/db/auth-store.ts deleted file mode 100644 index 6f0877a..0000000 --- a/central/auth/src/db/auth-store.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { getPostgresPool } from './postgres'; - -export interface AuthUser { - id: string; - email: string; - emailVerified: boolean; - name?: string | null; - username?: string | null; - displayUsername?: string | null; - image?: string | null; - requiresPasswordChange?: boolean | null; - createdAt: Date; - updatedAt: Date; -} - -export interface ImportedCredential { - id?: string; - accountId?: string; - password?: string | null; - createdAt?: Date; - updatedAt?: Date; -} - -type UserUpdates = Partial>; - -const userColumns: Record = { - email: 'email', - emailVerified: 'emailVerified', - name: 'name', - username: 'username', - displayUsername: 'displayUsername', - requiresPasswordChange: 'requiresPasswordChange', -}; - -export class AuthStore { - constructor(private readonly pool: Pool = getPostgresPool()) {} - - async listUsers(): Promise { - const result = await this.pool.query( - `SELECT id, email, "emailVerified", name, username, "displayUsername", image, - "requiresPasswordChange", "createdAt", "updatedAt" - FROM "user" - ORDER BY "createdAt", id`, - ); - return result.rows; - } - - async createAdmin(user: Omit & Partial>, passwordHash: string): Promise { - const now = new Date(); - const id = user.id ?? randomUUID(); - const createdAt = user.createdAt ?? now; - const updatedAt = user.updatedAt ?? now; - const client = await this.pool.connect(); - try { - await client.query('BEGIN'); - const created = await this.insertUser(client, { ...user, id, createdAt, updatedAt }); - await this.upsertCredentialWithClient(client, id, passwordHash); - await client.query('COMMIT'); - return created; - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } - } - - async importLegacyAdmin(user: AuthUser, credential?: ImportedCredential): Promise { - const client = await this.pool.connect(); - try { - await client.query('BEGIN'); - await this.insertUser(client, user); - if (credential?.password) { - await this.upsertCredentialWithClient(client, user.id, credential.password, credential); - } - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } - } - - async updateUser(userId: string, updates: UserUpdates): Promise { - const entries = Object.entries(updates) as Array<[keyof UserUpdates, UserUpdates[keyof UserUpdates]]>; - if (entries.length === 0) return; - - const assignments = entries.map(([key], index) => `"${userColumns[key]}" = $${index + 2}`); - const values = entries.map(([, value]) => value); - await this.pool.query(`UPDATE "user" SET ${assignments.join(', ')}, "updatedAt" = NOW() WHERE id = $1`, [userId, ...values]); - } - - async credentialExists(userId: string): Promise { - const result = await this.pool.query('SELECT 1 FROM account WHERE "userId" = $1 AND "providerId" = $2 LIMIT 1', [userId, 'credential']); - return result.rowCount === 1; - } - - async readCredentialPassword(userId: string): Promise { - const result = await this.pool.query<{ password: string | null }>('SELECT password FROM account WHERE "userId" = $1 AND "providerId" = $2 LIMIT 1', [userId, 'credential']); - return result.rows[0]?.password ?? null; - } - - async writeCredential(userId: string, passwordHash: string): Promise { - const client = await this.pool.connect(); - try { - await client.query('BEGIN'); - await this.upsertCredentialWithClient(client, userId, passwordHash); - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } - } - - async deleteSessions(userId: string): Promise { - await this.pool.query('DELETE FROM session WHERE "userId" = $1', [userId]); - } - - async clearPasswordChangeRequirement(userId: string): Promise { - await this.updateUser(userId, { requiresPasswordChange: false }); - } - - private async insertUser(client: PoolClient, user: AuthUser): Promise { - const result = await client.query( - `INSERT INTO "user" ( - id, email, "emailVerified", name, username, "displayUsername", image, - "requiresPasswordChange", "createdAt", "updatedAt" - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id, email, "emailVerified", name, username, "displayUsername", image, - "requiresPasswordChange", "createdAt", "updatedAt"`, - [ - user.id, - user.email, - user.emailVerified, - user.name ?? null, - user.username ?? null, - user.displayUsername ?? null, - user.image ?? null, - user.requiresPasswordChange ?? false, - user.createdAt, - user.updatedAt, - ], - ); - return result.rows[0]; - } - - private async upsertCredentialWithClient(client: PoolClient, userId: string, passwordHash: string, imported: ImportedCredential = {}): Promise { - const updated = await client.query( - `UPDATE account - SET password = $3, "updatedAt" = $4 - WHERE "userId" = $1 AND "providerId" = $2`, - [userId, 'credential', passwordHash, imported.updatedAt ?? new Date()], - ); - if (updated.rowCount && updated.rowCount > 0) return; - - await client.query( - `INSERT INTO account (id, "accountId", "providerId", "userId", password, "createdAt", "updatedAt") - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [imported.id ?? randomUUID(), imported.accountId ?? userId, 'credential', userId, passwordHash, imported.createdAt ?? new Date(), imported.updatedAt ?? new Date()], - ); - } -} - -export function getAuthStore(): AuthStore { - return new AuthStore(); -} diff --git a/central/auth/src/db/legacy-mongodb-migration.ts b/central/auth/src/db/legacy-mongodb-migration.ts deleted file mode 100644 index fcecb0d..0000000 --- a/central/auth/src/db/legacy-mongodb-migration.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { MongoClient, type Document } from 'mongodb'; -import { env } from '../config/env'; -import { getAuthStore, type AuthStore, type AuthUser, type ImportedCredential } from './auth-store'; -import { logger } from '../logging/logger'; - -function asDate(value: unknown, fallback: Date): Date { - if (value instanceof Date && !Number.isNaN(value.getTime())) return value; - if (typeof value === 'string' || typeof value === 'number') { - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) return parsed; - } - return fallback; -} - -function mapLegacyUser(document: Document): AuthUser { - const now = new Date(); - return { - id: String(document._id), - email: String(document.email), - emailVerified: document.emailVerified !== false, - name: typeof document.name === 'string' ? document.name : null, - username: typeof document.username === 'string' ? document.username : null, - displayUsername: typeof document.displayUsername === 'string' ? document.displayUsername : null, - image: typeof document.image === 'string' ? document.image : null, - requiresPasswordChange: document.requiresPasswordChange === true, - createdAt: asDate(document.createdAt, now), - updatedAt: asDate(document.updatedAt, now), - }; -} - -function mapLegacyCredential(document: Document | null): ImportedCredential | undefined { - if (!document) return undefined; - return { - id: String(document._id), - ...(typeof document.accountId === 'string' ? { accountId: document.accountId } : {}), - ...(typeof document.password === 'string' ? { password: document.password } : {}), - createdAt: asDate(document.createdAt, new Date()), - updatedAt: asDate(document.updatedAt, new Date()), - }; -} - -export async function importLegacyMongoDocuments(store: AuthStore, users: Document[], credential: Document | null): Promise { - if (users.length > 1) { - throw new Error(`central/auth migration found ${users.length} MongoDB users; local appliance auth supports exactly one administrator.`); - } - if (users.length === 0) return null; - if (typeof credential?.password !== 'string' || !credential.password.trim()) { - throw new Error('Legacy MongoDB administrator has no password credential; refusing to replace it with a bootstrap password.'); - } - - const user = mapLegacyUser(users[0]); - await store.importLegacyAdmin(user, mapLegacyCredential(credential)); - return user.id; -} - -async function writeMigrationMarker(path?: string): Promise { - if (!path) return; - await mkdir(dirname(path), { recursive: true }); - const temporaryPath = `${path}.tmp-${process.pid}`; - await writeFile(temporaryPath, `completed=${new Date().toISOString()}\n`, { mode: 0o600 }); - await rename(temporaryPath, path); -} - -export async function migrateLegacyMongoAuth(store: AuthStore = getAuthStore()): Promise { - let sourceRequiresMigration = false; - if (env.LEGACY_MONGODB_SOURCE_STATE_FILE) { - const state = (await readFile(env.LEGACY_MONGODB_SOURCE_STATE_FILE, 'utf8')).trim(); - if (state === 'empty') return; - if (state !== 'required' && state !== 'completed') throw new Error('Unknown legacy MongoDB source state; refusing to bootstrap.'); - sourceRequiresMigration = true; - } - if (!env.LEGACY_MONGODB_URI && !env.LEGACY_MONGODB_MIGRATION_MARKER && !sourceRequiresMigration) return; - - const existingUsers = await store.listUsers(); - if (existingUsers.length > 1) { - throw new Error(`central/auth migration found ${existingUsers.length} PostgreSQL users; local appliance auth supports exactly one administrator.`); - } - if (env.LEGACY_MONGODB_MIGRATION_MARKER) { - const marker = await readFile(env.LEGACY_MONGODB_MIGRATION_MARKER, 'utf8').catch((err: NodeJS.ErrnoException) => { - if (err.code === 'ENOENT') return ''; - throw err; - }); - if (marker.trim()) { - if (existingUsers.length !== 1) throw new Error('Legacy migration is marked complete but the PostgreSQL administrator is missing; restore PostgreSQL before starting auth.'); - if (!(await store.readCredentialPassword(existingUsers[0].id))?.trim()) throw new Error('Migrated PostgreSQL administrator has no password credential; restore it before starting auth.'); - return; - } - } - - if (!env.LEGACY_MONGODB_URI) { - if (sourceRequiresMigration) throw new Error('Legacy MongoDB migration is required but its connection URI is missing.'); - return; - } - - const client = new MongoClient(String(env.LEGACY_MONGODB_URI), { serverSelectionTimeoutMS: 8_000 }); - try { - await client.connect(); - const database = client.db(env.LEGACY_MONGODB_DB_NAME); - const users = await database.collection('user').find({}).sort({ createdAt: 1 }).limit(2).toArray(); - if (users.length === 1) { - const legacyUser = users[0]; - const credential = await database.collection('account').findOne({ userId: legacyUser._id, providerId: 'credential' }); - if (typeof credential?.password !== 'string' || !credential.password.trim()) throw new Error('Legacy MongoDB administrator has no password credential; refusing to bootstrap.'); - if (existingUsers.length === 1) { - // An import can commit before its filesystem marker is written. Only the same identity is a safe retry. - if (existingUsers[0].id !== String(legacyUser._id)) throw new Error('PostgreSQL and legacy MongoDB contain different administrators; refusing to replace either account.'); - if ((await store.readCredentialPassword(existingUsers[0].id)) !== credential.password) throw new Error('PostgreSQL and legacy MongoDB credentials differ without a completed migration; refusing to bootstrap.'); - } else { - await importLegacyMongoDocuments(store, users, credential); - } - logger.warn({ userId: String(legacyUser._id), sessionsMigrated: false }, 'Migrated local administrator from MongoDB to PostgreSQL; existing sessions were revoked'); - await writeMigrationMarker(env.LEGACY_MONGODB_MIGRATION_MARKER); - } else { - throw new Error(`Legacy MongoDB migration expected exactly one administrator but found ${users.length}; check the database name and restore missing auth data before starting auth.`); - } - } finally { - await client.close().catch(() => undefined); - } -} diff --git a/central/auth/src/db/mongoose.ts b/central/auth/src/db/mongoose.ts new file mode 100644 index 0000000..77b46a5 --- /dev/null +++ b/central/auth/src/db/mongoose.ts @@ -0,0 +1,65 @@ +import mongoose from 'mongoose'; +import { env } from '../config/env'; +import { logger } from '../logging/logger'; + +export interface ConnectMongooseOptions { + uri?: string; + dbName?: string; + memory?: boolean; + retryWrites?: boolean; + tls?: boolean; + minPoolSize?: number; + maxPoolSize?: number; +} + +export async function connectMongoose(options: ConnectMongooseOptions = {}) { + const effectiveUri = options.uri ?? String(env.MONGODB_URI); + const uri = new URL(effectiveUri); + const params = uri.searchParams; + + if (options.memory) { + if (!params.has('retryWrites')) params.set('retryWrites', String(options.retryWrites ?? false)); + if (params.has('tls')) params.set('tls', 'false'); + } else { + if (!params.has('retryWrites')) params.set('retryWrites', String(options.retryWrites ?? env.MONGODB_RETRY_WRITES)); + if (!params.has('tls') && uri.protocol === 'mongodb+srv:') params.set('tls', String(options.tls ?? env.MONGODB_TLS)); + } + + uri.search = params.toString(); + + await mongoose.connect(uri.toString(), { + dbName: options.dbName ?? env.MONGODB_DB_NAME, + minPoolSize: options.minPoolSize ?? env.MONGODB_MIN_POOL_SIZE, + maxPoolSize: options.maxPoolSize ?? env.MONGODB_MAX_POOL_SIZE, + serverSelectionTimeoutMS: 8_000, + }); + + logger.info({ host: mongoose.connection.host, db: mongoose.connection.name, memory: Boolean(options.memory) }, 'Mongoose connected'); +} + +export async function getMongoDBClient() { + if (mongoose.connection.readyState !== 1 || !mongoose.connection.db) { + throw new Error('Mongoose is not connected. Call connectMongoose() first.'); + } + return mongoose.connection.db; +} + +export async function disconnectMongoose() { + try { + await mongoose.disconnect(); + logger.info('Mongoose disconnected'); + } catch (err) { + logger.warn({ err }, 'Error while disconnecting Mongoose'); + } +} + +export async function mongoosePing(): Promise { + try { + const db = mongoose.connection.db; + if (!db) return false; + await db.admin().ping(); + return true; + } catch { + return false; + } +} diff --git a/central/auth/src/db/postgres.ts b/central/auth/src/db/postgres.ts deleted file mode 100644 index 4a52b5c..0000000 --- a/central/auth/src/db/postgres.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Pool, type PoolConfig } from 'pg'; -import { env } from '../config/env'; -import { logger } from '../logging/logger'; - -let pool: Pool | null = null; - -function quoteIdentifier(value: string): string { - return `"${value.replaceAll('"', '""')}"`; -} - -function poolConfig(overrides: Partial = {}): PoolConfig { - const schema = env.CENTRAL_AUTH_POSTGRES_SCHEMA; - return { - host: env.POSTGRES_HOST, - port: env.POSTGRES_PORT, - user: env.POSTGRES_USER, - password: env.POSTGRES_PASSWORD, - database: env.POSTGRES_DB, - max: env.POSTGRES_MAX_POOL_SIZE, - ssl: env.POSTGRES_SSL ? { rejectUnauthorized: env.POSTGRES_SSL_REJECT_UNAUTHORIZED } : false, - application_name: env.SERVICE_NAME, - options: `-c search_path=${quoteIdentifier(schema)}`, - ...overrides, - }; -} - -export async function connectPostgres(overrides: Partial = {}): Promise { - if (pool) return pool; - - const candidate = new Pool(poolConfig(overrides)); - candidate.on('error', (err) => logger.error({ err }, 'Unexpected PostgreSQL pool error')); - - try { - await candidate.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(env.CENTRAL_AUTH_POSTGRES_SCHEMA)}`); - await candidate.query('SELECT 1'); - } catch (err) { - await candidate.end().catch(() => undefined); - throw err; - } - - pool = candidate; - logger.info({ host: env.POSTGRES_HOST, database: env.POSTGRES_DB, schema: env.CENTRAL_AUTH_POSTGRES_SCHEMA }, 'PostgreSQL connected'); - return pool; -} - -export function getPostgresPool(): Pool { - if (!pool) throw new Error('PostgreSQL is not connected. Call connectPostgres() first.'); - return pool; -} - -export async function disconnectPostgres(): Promise { - const current = pool; - pool = null; - if (!current) return; - - try { - await current.end(); - logger.info('PostgreSQL disconnected'); - } catch (err) { - logger.warn({ err }, 'Error while disconnecting PostgreSQL'); - } -} - -export async function postgresPing(): Promise { - try { - await getPostgresPool().query('SELECT 1'); - return true; - } catch { - return false; - } -} diff --git a/central/auth/src/index.ts b/central/auth/src/index.ts index 0a9aae6..393ff26 100644 --- a/central/auth/src/index.ts +++ b/central/auth/src/index.ts @@ -1,20 +1,17 @@ import http, { type Server } from 'node:http'; import { env } from './config/env'; -import { connectPostgres, disconnectPostgres, postgresPing } from './db/postgres'; +import { connectMongoose, disconnectMongoose, getMongoDBClient, mongoosePing } from './db/mongoose'; import { createApp } from './app'; -import { createAuth, migrateAuthSchema } from './lib/auth'; +import { createAuth } from './lib/auth'; import { bootstrapLocalAdmin } from './lib/bootstrap-admin'; -import { migrateLegacyMongoAuth } from './db/legacy-mongodb-migration'; import { logger } from './logging/logger'; import { registerCheck, setDraining } from './utils/readiness'; async function main() { - const postgresPool = await connectPostgres(); - registerCheck('postgresql', postgresPing); + await connectMongoose(); + registerCheck('mongodb', mongoosePing); - await migrateAuthSchema({ postgresPool }); - await migrateLegacyMongoAuth(); - const auth = await createAuth({ postgresPool }); + const auth = await createAuth({ mongoDb: await getMongoDBClient() }); await bootstrapLocalAdmin(); const app = createApp(auth); @@ -34,7 +31,7 @@ async function main() { logger.info({ drainMs: env.SHUTDOWN_DRAIN_MS }, 'Drain window complete, closing server'); server.close(async (err) => { if (err) logger.error({ err }, 'Error closing server'); - await disconnectPostgres(); + await disconnectMongoose(); logger.info('HTTP server closed'); process.exit(err ? 1 : 0); }); diff --git a/central/auth/src/lib/auth.ts b/central/auth/src/lib/auth.ts index 87eda28..c724c20 100644 --- a/central/auth/src/lib/auth.ts +++ b/central/auth/src/lib/auth.ts @@ -1,12 +1,12 @@ import { betterAuth, type Auth, type BetterAuthOptions } from 'better-auth'; import { APIError, createAuthMiddleware } from 'better-auth/api'; -import { getMigrations } from 'better-auth/db/migration'; +import { mongodbAdapter } from 'better-auth/adapters/mongodb'; import { username } from 'better-auth/plugins/username'; -import type { Pool } from 'pg'; +import type { Db } from 'mongodb'; import { env } from '../config/env'; -import { getAuthStore } from '../db/auth-store'; -import { getPostgresPool } from '../db/postgres'; +import { getMongoDBClient } from '../db/mongoose'; import { logger } from '../logging/logger'; +import { UserModel } from '../models/user.model'; import { USERNAME_MAX, USERNAME_MIN, USERNAME_REGEX } from '../constants'; import { NewPasswordInput } from '../schemas/password.schemas'; import { normalizeUsername } from '../schemas/username.schemas'; @@ -43,10 +43,12 @@ export function getAuth() { } export interface CreateAuthOptions { - postgresPool?: Pool; + mongoDb?: Db; } -export function buildAuthConfig(authDb: Pool): BetterAuthOptions { +export async function createAuth(options: CreateAuthOptions = {}): Promise> { + const authDb = options.mongoDb ?? (await getMongoDBClient()); + const trustedOrigins: BetterAuthOptions['trustedOrigins'] = async (request) => { if (!request) return Array.from(baseAllowedOrigins).filter((origin) => origin !== '*'); @@ -60,12 +62,12 @@ export function buildAuthConfig(authDb: Pool): BetterAuthOptions { return Array.from(new Set([normalized, ...Array.from(baseAllowedOrigins).filter((origin) => origin !== '*')])); }; - return { + const config = { appName: 'Unicron Central Auth', baseURL: env.CENTRAL_AUTH_BASE_URL, basePath: '/api/auth', secret: env.CENTRAL_AUTH_SECRET, - database: authDb, + database: mongodbAdapter(authDb, { client: authDb.client }), logger: betterAuthLogger, onAPIError: { async onError(error) { @@ -139,7 +141,7 @@ export function buildAuthConfig(authDb: Pool): BetterAuthOptions { if (ctx.path === '/change-password') { const userId = (ctx.context.session as any)?.user?.id ?? (ctx.context.session as any)?.session?.userId; if (userId) { - await getAuthStore().clearPasswordChangeRequirement(String(userId)); + await UserModel.updateOne({ _id: userId }, { $set: { requiresPasswordChange: false } }); } } }), @@ -154,18 +156,6 @@ export function buildAuthConfig(authDb: Pool): BetterAuthOptions { }), ], } satisfies BetterAuthOptions; -} - -export async function migrateAuthSchema(options: CreateAuthOptions = {}): Promise { - const authDb = options.postgresPool ?? getPostgresPool(); - const migration = await getMigrations(buildAuthConfig(authDb)); - await migration.runMigrations(); - logger.info({ createdTables: migration.toBeCreated.length, alteredTables: migration.toBeAdded.length }, 'Central auth PostgreSQL schema is ready'); -} - -export async function createAuth(options: CreateAuthOptions = {}): Promise> { - const authDb = options.postgresPool ?? getPostgresPool(); - const config = buildAuthConfig(authDb); auth = betterAuth(config) as Auth; return auth; diff --git a/central/auth/src/lib/bootstrap-admin.ts b/central/auth/src/lib/bootstrap-admin.ts index 5bd43b5..7d5ede1 100644 --- a/central/auth/src/lib/bootstrap-admin.ts +++ b/central/auth/src/lib/bootstrap-admin.ts @@ -2,8 +2,10 @@ import { randomBytes } from 'node:crypto'; import { hashPassword } from 'better-auth/crypto'; import { DEFAULT_CENTRAL_ADMIN_PASSWORD, LOCAL_EMAIL_DOMAIN } from '../constants'; import { env } from '../config/env'; -import { getAuthStore, type AuthStore, type AuthUser } from '../db/auth-store'; import { logger } from '../logging/logger'; +import { AccountModel } from '../models/account.model'; +import { SessionModel } from '../models/session.model'; +import { UserModel, type IUser } from '../models/user.model'; import { PasswordSchema } from '../schemas/password.schemas'; import { UsernameSchema } from '../schemas/username.schemas'; @@ -34,43 +36,52 @@ function passwordPolicyError(password: string): string | null { return parsed.error.issues.map((issue) => issue.message).join('; '); } -function usernameForUser(user: Pick): string { +function usernameForUser(user: Pick): string { if (user.username) return String(user.username).trim().toLowerCase(); if (user.name) return String(user.name).trim().toLowerCase(); - const email = String(user.email ?? '') - .trim() - .toLowerCase(); - return email.endsWith(`@${LOCAL_EMAIL_DOMAIN}`) ? email.slice(0, -1 * `@${LOCAL_EMAIL_DOMAIN}`.length) : email; + const email = String(user.email ?? '').trim().toLowerCase(); + return email.endsWith(`@${LOCAL_EMAIL_DOMAIN}`) ? email.slice(0, -1 * (`@${LOCAL_EMAIL_DOMAIN}`).length) : email; } -async function writeCredential(store: AuthStore, user: AuthUser, password: string) { +async function writeCredential(user: IUser, password: string) { const passwordHash = await hashPassword(password); - await store.writeCredential(user.id, passwordHash); -} - -async function ensureCredentialIfMissing(store: AuthStore, user: AuthUser, password: string) { - if (await store.credentialExists(user.id)) return; - await writeCredential(store, user, password); -} -async function createAdminUser(store: AuthStore, username: string, password: string, requiresPasswordChange: boolean) { - const passwordHash = await hashPassword(password); - return store.createAdmin( + await AccountModel.updateOne( + { userId: user._id, providerId: 'credential' }, { - email: syntheticEmailForUsername(username), - emailVerified: true, - username, - displayUsername: username, - name: username, - requiresPasswordChange, - image: null, + $set: { + userId: user._id, + accountId: user._id.toString(), + providerId: 'credential', + password: passwordHash, + }, }, - passwordHash, + { upsert: true }, ); } -async function alignAdminIdentity(store: AuthStore, user: AuthUser, username: string, requiresPasswordChange?: boolean) { - const update: Partial = { +async function ensureCredentialIfMissing(user: IUser, password: string) { + const existingAccount = await AccountModel.findOne({ userId: user._id, providerId: 'credential' }); + if (existingAccount) return; + await writeCredential(user, password); +} + +async function createAdminUser(username: string, password: string, requiresPasswordChange: boolean) { + const admin = await UserModel.create({ + email: syntheticEmailForUsername(username), + emailVerified: true, + username, + displayUsername: username, + name: username, + requiresPasswordChange, + }); + + await writeCredential(admin, password); + return admin; +} + +async function alignAdminIdentity(user: IUser, username: string, requiresPasswordChange?: boolean) { + const update: Record = { email: syntheticEmailForUsername(username), emailVerified: true, username, @@ -82,15 +93,15 @@ async function alignAdminIdentity(store: AuthStore, user: AuthUser, username: st update.requiresPasswordChange = requiresPasswordChange; } - await store.updateUser(user.id, update); + await UserModel.updateOne({ _id: user._id }, { $set: update }); Object.assign(user, update); } -export async function bootstrapLocalAdmin(store: AuthStore = getAuthStore()): Promise { +export async function bootstrapLocalAdmin(): Promise { const username = configuredAdminUsername(); const configuredPassword = configuredAdminPassword(); - const users = await store.listUsers(); + const users = await UserModel.find().sort({ createdAt: 1 }); if (users.length > 1) { throw new Error(`central/auth bootstrap found ${users.length} existing users. Local appliance auth supports exactly one administrator account.`); @@ -106,12 +117,18 @@ export async function bootstrapLocalAdmin(store: AuthStore = getAuthStore()): Pr } const requiresPasswordChange = generated || isDefaultAdminBootstrapPassword(password); - const admin = await createAdminUser(store, username, password, requiresPasswordChange); + const admin = await createAdminUser(username, password, requiresPasswordChange); if (generated) { - logger.warn({ userId: admin.id, username, generatedPassword: password, requiresPasswordChange }, 'Local admin first-boot generated administrator credential'); + logger.warn( + { userId: admin._id.toString(), username, generatedPassword: password, requiresPasswordChange }, + 'Local admin first-boot generated administrator credential', + ); } else { - logger.info({ userId: admin.id, username, recoveryOverride: env.CENTRAL_ADMIN_RECOVERY_OVERRIDE, requiresPasswordChange }, 'Local admin bootstrap created administrator'); + logger.info( + { userId: admin._id.toString(), username, recoveryOverride: env.CENTRAL_ADMIN_RECOVERY_OVERRIDE, requiresPasswordChange }, + 'Local admin bootstrap created administrator', + ); } return; } @@ -128,10 +145,10 @@ export async function bootstrapLocalAdmin(store: AuthStore = getAuthStore()): Pr throw new Error(`CENTRAL_ADMIN_PASSWORD does not meet the password policy: ${passwordError}`); } - await alignAdminIdentity(store, existing, username, true); - await writeCredential(store, existing, configuredPassword); - await store.deleteSessions(existing.id); - logger.warn({ userId: existing.id, username }, 'Local admin recovery rotated administrator credential and revoked sessions'); + await alignAdminIdentity(existing, username, true); + await writeCredential(existing, configuredPassword); + await SessionModel.deleteMany({ userId: existing._id }); + logger.warn({ userId: existing._id.toString(), username }, 'Local admin recovery rotated administrator credential and revoked sessions'); return; } @@ -143,7 +160,7 @@ export async function bootstrapLocalAdmin(store: AuthStore = getAuthStore()): Pr ); } - const identityUpdates: Partial = {}; + const identityUpdates: Record = {}; if (existing.email !== syntheticEmailForUsername(username)) identityUpdates.email = syntheticEmailForUsername(username); if (!existing.emailVerified) identityUpdates.emailVerified = true; if (!existing.username) identityUpdates.username = username; @@ -151,10 +168,11 @@ export async function bootstrapLocalAdmin(store: AuthStore = getAuthStore()): Pr if (!existing.name) identityUpdates.name = username; if (Object.keys(identityUpdates).length > 0) { - await store.updateUser(existing.id, identityUpdates); + await UserModel.updateOne({ _id: existing._id }, { $set: identityUpdates }); } - if (!(await store.credentialExists(existing.id))) { + const existingAccount = await AccountModel.findOne({ userId: existing._id, providerId: 'credential' }); + if (!existingAccount) { if (!configuredPassword) { throw new Error('central/auth bootstrap found an existing administrator without a credential. Set CENTRAL_ADMIN_PASSWORD once to repair it.'); } @@ -164,12 +182,12 @@ export async function bootstrapLocalAdmin(store: AuthStore = getAuthStore()): Pr throw new Error(`CENTRAL_ADMIN_PASSWORD does not meet the password policy: ${passwordError}`); } - await ensureCredentialIfMissing(store, existing, configuredPassword); + await ensureCredentialIfMissing(existing, configuredPassword); } else if (configuredPassword) { logger.warn( - { userId: existing.id, username, recoveryOverride: false, sessionsRevoked: false }, + { userId: existing._id.toString(), username, recoveryOverride: false, sessionsRevoked: false }, 'Configured CENTRAL_ADMIN_PASSWORD is intentionally ignored on normal restart; use CENTRAL_ADMIN_RECOVERY_OVERRIDE=true for credential recovery.', ); } - logger.info({ userId: existing.id, username, recoveryOverride: false }, 'Local admin bootstrap reused existing administrator'); + logger.info({ userId: existing._id.toString(), username, recoveryOverride: false }, 'Local admin bootstrap reused existing administrator'); } diff --git a/central/auth/src/models/account.model.ts b/central/auth/src/models/account.model.ts new file mode 100644 index 0000000..708aec9 --- /dev/null +++ b/central/auth/src/models/account.model.ts @@ -0,0 +1,47 @@ +import mongoose, { Schema, type Document, type Model, type Types } from 'mongoose'; + +export interface IAccount extends Document { + userId: Types.ObjectId; + accountId: string; + providerId: string; + accessToken?: string; + refreshToken?: string; + accessTokenExpiresAt?: Date; + refreshTokenExpiresAt?: Date; + scope?: string; + idToken?: string; + password?: string; + createdAt: Date; + updatedAt: Date; + id?: string; +} + +const AccountSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'user', required: true, index: true }, + accountId: { type: String, required: true }, + providerId: { type: String, required: true, index: true }, + accessToken: { type: String, required: false, index: true }, + refreshToken: { type: String, required: false }, + accessTokenExpiresAt: { type: Date, required: false }, + refreshTokenExpiresAt: { type: Date, required: false }, + scope: { type: String, required: false }, + idToken: { type: String, required: false }, + password: { type: String, required: false }, + }, + { collection: 'account', timestamps: true }, +); + +AccountSchema.virtual('id').get(function () { + return this._id.toString(); +}); + +AccountSchema.set('toJSON', { + virtuals: true, + transform: (_doc, ret) => { + const { _id, __v, ...rest } = ret; + return { ...rest, id: _id?.toString() }; + }, +}); + +export const AccountModel: Model = (mongoose.models.account as Model) || mongoose.model('account', AccountSchema); diff --git a/central/auth/src/models/session.model.ts b/central/auth/src/models/session.model.ts new file mode 100644 index 0000000..27f2739 --- /dev/null +++ b/central/auth/src/models/session.model.ts @@ -0,0 +1,37 @@ +import mongoose, { Schema, type Document, type Model, type Types } from 'mongoose'; + +export interface ISession extends Document { + userId: Types.ObjectId; + token: string; + expiresAt: Date; + ipAddress?: string; + userAgent?: string; + createdAt: Date; + updatedAt: Date; + id?: string; +} + +const SessionSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'user', required: true, index: true }, + token: { type: String, required: true, unique: true }, + expiresAt: { type: Date, required: true, index: true }, + ipAddress: { type: String, required: false }, + userAgent: { type: String, required: false }, + }, + { collection: 'session', timestamps: true }, +); + +SessionSchema.virtual('id').get(function () { + return this._id.toString(); +}); + +SessionSchema.set('toJSON', { + virtuals: true, + transform: (_doc, ret) => { + const { _id, __v, ...rest } = ret; + return { ...rest, id: _id?.toString() }; + }, +}); + +export const SessionModel: Model = (mongoose.models.session as Model) || mongoose.model('session', SessionSchema); diff --git a/central/auth/src/models/user.model.ts b/central/auth/src/models/user.model.ts new file mode 100644 index 0000000..d8fdd81 --- /dev/null +++ b/central/auth/src/models/user.model.ts @@ -0,0 +1,41 @@ +import mongoose, { Schema, type Document, type Model, type Types } from 'mongoose'; + +export interface IUser extends Document { + email: string; + emailVerified: boolean; + name?: string; + username?: string; + displayUsername?: string; + image?: string; + requiresPasswordChange?: boolean; + createdAt: Date; + updatedAt: Date; + id?: string; +} + +const UserSchema = new Schema( + { + email: { type: String, required: true, unique: true, index: true, lowercase: true, trim: true }, + emailVerified: { type: Boolean, required: true, default: true }, + name: { type: String, required: false, trim: true }, + username: { type: String, required: false, unique: true, sparse: true, index: true, lowercase: true, trim: true }, + displayUsername: { type: String, required: false, trim: true }, + image: { type: String, required: false }, + requiresPasswordChange: { type: Boolean, required: false, default: false }, + }, + { collection: 'user', timestamps: true }, +); + +UserSchema.virtual('id').get(function () { + return this._id.toString(); +}); + +UserSchema.set('toJSON', { + virtuals: true, + transform: (_doc, ret) => { + const { _id, __v, ...rest } = ret; + return { ...rest, id: _id?.toString() }; + }, +}); + +export const UserModel: Model = (mongoose.models.user as Model) || mongoose.model('user', UserSchema); diff --git a/central/auth/src/models/verification.model.ts b/central/auth/src/models/verification.model.ts new file mode 100644 index 0000000..038a4de --- /dev/null +++ b/central/auth/src/models/verification.model.ts @@ -0,0 +1,34 @@ +import mongoose, { Schema, type Document, type Model, type Types } from 'mongoose'; + +export interface IVerification extends Document { + identifier: string; + value: string; + expiresAt: Date; + createdAt: Date; + updatedAt: Date; + id?: string; +} + +const VerificationSchema = new Schema( + { + identifier: { type: String, required: true, index: true }, + value: { type: String, required: true }, + expiresAt: { type: Date, required: true, index: true }, + }, + { collection: 'verification', timestamps: true }, +); + +VerificationSchema.virtual('id').get(function () { + return this._id.toString(); +}); + +VerificationSchema.set('toJSON', { + virtuals: true, + transform: (_doc, ret) => { + const { _id, __v, ...rest } = ret; + return { ...rest, id: _id?.toString() }; + }, +}); + +export const VerificationModel: Model = + (mongoose.models.verification as Model) || mongoose.model('verification', VerificationSchema); diff --git a/central/auth/test-support/postgres.ts b/central/auth/test-support/postgres.ts deleted file mode 100644 index 544f9d4..0000000 --- a/central/auth/test-support/postgres.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { env } from '../src/config/env'; -import { connectPostgres, disconnectPostgres, getPostgresPool } from '../src/db/postgres'; - -const DEFAULT_TEST_SCHEMA = 'central_auth_test'; - -export async function startTestPostgres() { - env.POSTGRES_HOST = process.env.CENTRAL_AUTH_TEST_POSTGRES_HOST || '127.0.0.1'; - env.POSTGRES_PORT = Number(process.env.CENTRAL_AUTH_TEST_POSTGRES_PORT || '5433'); - env.POSTGRES_USER = process.env.CENTRAL_AUTH_TEST_POSTGRES_USER || 'postgres'; - env.POSTGRES_PASSWORD = process.env.CENTRAL_AUTH_TEST_POSTGRES_PASSWORD || 'password'; - env.POSTGRES_DB = process.env.CENTRAL_AUTH_TEST_POSTGRES_DB || 'central_auth_test'; - env.CENTRAL_AUTH_POSTGRES_SCHEMA = process.env.CENTRAL_AUTH_TEST_POSTGRES_SCHEMA || DEFAULT_TEST_SCHEMA; - env.POSTGRES_SSL = false; - - if (!env.CENTRAL_AUTH_POSTGRES_SCHEMA.endsWith('_test')) { - throw new Error(`Refusing to use PostgreSQL schema "${env.CENTRAL_AUTH_POSTGRES_SCHEMA}"; test schemas must end in _test.`); - } - - await connectPostgres(); -} - -export async function dropTestDatabase() { - const schema = env.CENTRAL_AUTH_POSTGRES_SCHEMA; - if (!schema.endsWith('_test')) { - throw new Error(`Refusing to clear PostgreSQL schema "${schema}"; test schemas must end in _test.`); - } - const pool = getPostgresPool(); - await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); - await pool.query(`CREATE SCHEMA "${schema}"`); -} - -export async function stopTestPostgres() { - await disconnectPostgres(); -} diff --git a/central/auth/tests/integration/auth.spec.ts b/central/auth/tests/integration/auth.spec.ts index 07c8374..2d3c9b3 100644 --- a/central/auth/tests/integration/auth.spec.ts +++ b/central/auth/tests/integration/auth.spec.ts @@ -1,31 +1,14 @@ import request from 'supertest'; -import { hashPassword } from 'better-auth/crypto'; -import { ObjectId } from 'mongodb'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CENTRAL_ADMIN_PASSWORD } from '../../src/constants'; -import { createApp } from '../../src/app'; -import { getAuthStore, type AuthUser } from '../../src/db/auth-store'; -import { getPostgresPool } from '../../src/db/postgres'; +import { AccountModel } from '../../src/models/account.model'; +import { SessionModel } from '../../src/models/session.model'; +import { UserModel } from '../../src/models/user.model'; import { bootstrapLocalAdmin } from '../../src/lib/bootstrap-admin'; -import { createAuth, migrateAuthSchema } from '../../src/lib/auth'; -import { importLegacyMongoDocuments } from '../../src/db/legacy-mongodb-migration'; import { env, parseEnv } from '../../src/config/env'; import { logger } from '../../src/logging/logger'; import { buildBootstrappedApp, resetAuthEnv, TEST_PASSWORD, TEST_SECRET } from '../setup/app'; -import { dropTestDatabase, startTestPostgres, stopTestPostgres } from '../../test-support/postgres'; - -async function users(): Promise { - return getAuthStore().listUsers(); -} - -async function accounts(): Promise> { - return (await getPostgresPool().query<{ providerId: string }>('SELECT "providerId" FROM account ORDER BY "createdAt"')).rows; -} - -async function sessionCount(): Promise { - const result = await getPostgresPool().query<{ count: string }>('SELECT COUNT(*)::text AS count FROM session'); - return Number(result.rows[0].count); -} +import { dropTestDatabase, startTestMongo, stopTestMongo } from '../setup/mongoose'; async function signIn(agent: any, username = env.CENTRAL_ADMIN_USERNAME, password = env.CENTRAL_ADMIN_PASSWORD ?? TEST_PASSWORD) { return agent.post('/api/auth/sign-in/username').send({ username, password }).expect(200); @@ -43,20 +26,17 @@ function applyParsedAuthEnv(overrides: NodeJS.ProcessEnv = {}) { CENTRAL_ADMIN_PASSWORD: TEST_PASSWORD, CENTRAL_ADMIN_RECOVERY_OVERRIDE: 'false', CORS_ORIGINS: 'http://localhost:3000', - POSTGRES_HOST: env.POSTGRES_HOST, - POSTGRES_PORT: String(env.POSTGRES_PORT), - POSTGRES_USER: env.POSTGRES_USER, - POSTGRES_PASSWORD: env.POSTGRES_PASSWORD, - POSTGRES_DB: env.POSTGRES_DB, - CENTRAL_AUTH_POSTGRES_SCHEMA: env.CENTRAL_AUTH_POSTGRES_SCHEMA, - POSTGRES_SSL: 'false', + MONGODB_URI: env.MONGODB_URI, + MONGODB_DB_NAME: env.MONGODB_DB_NAME, + MONGODB_TLS: 'false', + MONGODB_RETRY_WRITES: 'false', ...overrides, }), ); } beforeAll(async () => { - await startTestPostgres(); + await startTestMongo(); }); beforeEach(async () => { @@ -66,68 +46,26 @@ beforeEach(async () => { }); afterAll(async () => { - await stopTestPostgres(); + await stopTestMongo(); }); describe('local admin bootstrap', () => { it('creates the configured admin and credential on a fresh database', async () => { await buildBootstrappedApp(); - const userRows = await users(); - const accountRows = await accounts(); + const users = await UserModel.find().lean(); + const accounts = await AccountModel.find().lean(); - expect(userRows).toHaveLength(1); - expect(userRows[0]).toMatchObject({ + expect(users).toHaveLength(1); + expect(users[0]).toMatchObject({ username: 'admin', displayUsername: 'admin', email: 'admin@local.unicron.invalid', emailVerified: true, requiresPasswordChange: false, }); - expect(accountRows).toHaveLength(1); - expect(accountRows[0]).toMatchObject({ providerId: 'credential' }); - }); - - it('preserves a legacy MongoDB administrator and password while revoking old sessions', async () => { - const legacyUserId = new ObjectId(); - const legacyAccountId = new ObjectId(); - const createdAt = new Date('2026-01-02T03:04:05Z'); - const postgresPool = getPostgresPool(); - await migrateAuthSchema({ postgresPool }); - await importLegacyMongoDocuments( - getAuthStore(), - [ - { - _id: legacyUserId, - email: 'admin@local.unicron.invalid', - emailVerified: true, - username: 'admin', - displayUsername: 'admin', - name: 'admin', - requiresPasswordChange: false, - createdAt, - updatedAt: createdAt, - }, - ], - { - _id: legacyAccountId, - userId: legacyUserId, - accountId: legacyUserId.toString(), - providerId: 'credential', - password: await hashPassword(TEST_PASSWORD), - createdAt, - updatedAt: createdAt, - }, - ); - - const auth = await createAuth({ postgresPool }); - await bootstrapLocalAdmin(); - const app = createApp(auth); - expect(await sessionCount()).toBe(0); - const login = await request(app).post('/api/auth/sign-in/username').send({ username: 'admin', password: TEST_PASSWORD }).expect(200); - - expect(login.body.user.id).toBe(legacyUserId.toString()); - expect(await sessionCount()).toBe(1); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ providerId: 'credential' }); }); it('auto-generates a first-boot admin credential only when no password is provided', async () => { @@ -140,7 +78,7 @@ describe('local admin bootstrap', () => { expect(generatedPassword).toEqual(expect.stringMatching(/^Unicron-.+-A1!$/)); - const [user] = await users(); + const user = await UserModel.findOne().lean(); expect(user).toMatchObject({ username: 'admin', requiresPasswordChange: true, @@ -154,12 +92,12 @@ describe('local admin bootstrap', () => { const agent = request.agent(app); await signIn(agent); await agent.get('/api/v1/profile').expect(200); - const originalSessionCount = await sessionCount(); + const sessionCount = await SessionModel.countDocuments(); env.CENTRAL_ADMIN_PASSWORD = undefined; await bootstrapLocalAdmin(); - expect(await sessionCount()).toBe(originalSessionCount); + expect(await SessionModel.countDocuments()).toBe(sessionCount); await agent.get('/api/v1/profile').expect(200); env.CENTRAL_ADMIN_PASSWORD = 'Replacement-Password1!'; @@ -168,7 +106,8 @@ describe('local admin bootstrap', () => { const ignoredPasswordWarning = warnSpy.mock.calls.find( ([, message]) => - message === 'Configured CENTRAL_ADMIN_PASSWORD is intentionally ignored on normal restart; use CENTRAL_ADMIN_RECOVERY_OVERRIDE=true for credential recovery.', + message === + 'Configured CENTRAL_ADMIN_PASSWORD is intentionally ignored on normal restart; use CENTRAL_ADMIN_RECOVERY_OVERRIDE=true for credential recovery.', ); expect(ignoredPasswordWarning).toBeDefined(); expect(ignoredPasswordWarning?.[0]).toMatchObject({ @@ -178,7 +117,7 @@ describe('local admin bootstrap', () => { sessionsRevoked: false, }); expect(JSON.stringify(ignoredPasswordWarning)).not.toContain('Replacement-Password1!'); - expect(await sessionCount()).toBe(originalSessionCount); + expect(await SessionModel.countDocuments()).toBe(sessionCount); await agent.get('/api/v1/profile').expect(200); await request(app).post('/api/auth/sign-in/username').send({ username: 'admin', password: 'Replacement-Password1!' }).expect(401); await request(app).post('/api/auth/sign-in/username').send({ username: 'admin', password: TEST_PASSWORD }).expect(200); @@ -233,9 +172,9 @@ describe('local admin bootstrap', () => { }); await bootstrapLocalAdmin(); - expect(await users()).toHaveLength(1); - expect(await sessionCount()).toBe(0); - const [user] = await users(); + expect(await UserModel.countDocuments()).toBe(1); + expect(await SessionModel.countDocuments()).toBe(0); + const user = await UserModel.findOne().lean(); expect(user).toMatchObject({ username: 'operator', email: 'operator@local.unicron.invalid', @@ -260,18 +199,13 @@ describe('local admin bootstrap', () => { it('fails bootstrap when more than one user exists even during recovery', async () => { await buildBootstrappedApp(); - await getAuthStore().createAdmin( - { - email: 'second@local.unicron.invalid', - emailVerified: true, - username: 'second', - displayUsername: 'second', - name: 'second', - image: null, - requiresPasswordChange: false, - }, - 'unused-test-password-hash', - ); + await UserModel.create({ + email: 'second@local.unicron.invalid', + emailVerified: true, + username: 'second', + displayUsername: 'second', + name: 'second', + }); env.CENTRAL_ADMIN_RECOVERY_OVERRIDE = true; await expect(bootstrapLocalAdmin()).rejects.toThrow(/supports exactly one administrator/); @@ -367,9 +301,7 @@ describe('disabled auth surfaces', () => { ]; for (const testCase of cases) { - const res = await request(app) - [testCase.method](testCase.path) - .send(testCase.body ?? {}); + const res = await request(app)[testCase.method](testCase.path).send(testCase.body ?? {}); expect(res.status, `${testCase.method.toUpperCase()} ${testCase.path}`).toBe(404); expect(res.body).toMatchObject({ code: 'NOT_FOUND' }); } diff --git a/central/auth/tests/integration/migration-guard.spec.ts b/central/auth/tests/integration/migration-guard.spec.ts deleted file mode 100644 index cb9dca9..0000000 --- a/central/auth/tests/integration/migration-guard.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest'; -import { env } from '../../src/config/env'; -import { getAuthStore } from '../../src/db/auth-store'; -import { getPostgresPool } from '../../src/db/postgres'; -import { importLegacyMongoDocuments, migrateLegacyMongoAuth } from '../../src/db/legacy-mongodb-migration'; -import { migrateAuthSchema } from '../../src/lib/auth'; -import { dropTestDatabase, startTestPostgres, stopTestPostgres } from '../../test-support/postgres'; -import { buildBootstrappedApp, resetAuthEnv } from '../setup/app'; - -let directory: string; -beforeAll(async () => { - directory = await mkdtemp(join(tmpdir(), 'auth-migration-test-')); - await startTestPostgres(); -}); -beforeEach(async () => { - resetAuthEnv(); - await dropTestDatabase(); - await migrateAuthSchema(); - env.LEGACY_MONGODB_SOURCE_STATE_FILE = join(directory, 'source-state'); - env.LEGACY_MONGODB_MIGRATION_MARKER = join(directory, 'completed'); - env.LEGACY_MONGODB_URI = 'mongodb://127.0.0.1:1'; - await rm(env.LEGACY_MONGODB_SOURCE_STATE_FILE, { force: true }); - await rm(env.LEGACY_MONGODB_MIGRATION_MARKER, { force: true }); -}); -afterAll(async () => { - await stopTestPostgres(); - await rm(directory, { recursive: true, force: true }); -}); - -it('skips MongoDB for a confirmed empty volume', async () => { - await writeFile(env.LEGACY_MONGODB_SOURCE_STATE_FILE!, 'empty\n'); - await expect(migrateLegacyMongoAuth()).resolves.toBeUndefined(); - await buildBootstrappedApp(); -}); - -it('fails closed when the volume inspection is missing or invalid', async () => { - await expect(migrateLegacyMongoAuth()).rejects.toThrow('ENOENT'); - await writeFile(env.LEGACY_MONGODB_SOURCE_STATE_FILE!, 'unexpected\n'); - await expect(migrateLegacyMongoAuth()).rejects.toThrow('refusing to bootstrap'); -}); - -it('requires a connection for legacy data', async () => { - await writeFile(env.LEGACY_MONGODB_SOURCE_STATE_FILE!, 'required\n'); - env.LEGACY_MONGODB_URI = undefined; - await expect(migrateLegacyMongoAuth()).rejects.toThrow('connection URI is missing'); -}); - -it.each([true, false])('does not contact MongoDB after a completed migration (URI configured: %s)', async (uriConfigured) => { - await buildBootstrappedApp(); - await writeFile(env.LEGACY_MONGODB_SOURCE_STATE_FILE!, 'completed\n'); - await writeFile(env.LEGACY_MONGODB_MIGRATION_MARKER!, 'completed=test\n'); - if (!uriConfigured) { - env.LEGACY_MONGODB_SOURCE_STATE_FILE = undefined; - env.LEGACY_MONGODB_URI = undefined; - } - await expect(migrateLegacyMongoAuth()).resolves.toBeUndefined(); -}); - -it.each([true, false])('does not recreate a lost migrated administrator (URI configured: %s)', async (uriConfigured) => { - await writeFile(env.LEGACY_MONGODB_SOURCE_STATE_FILE!, 'completed\n'); - await writeFile(env.LEGACY_MONGODB_MIGRATION_MARKER!, 'completed=test\n'); - if (!uriConfigured) { - env.LEGACY_MONGODB_SOURCE_STATE_FILE = undefined; - env.LEGACY_MONGODB_URI = undefined; - } - await expect(migrateLegacyMongoAuth()).rejects.toThrow('administrator is missing'); -}); - -it.each([true, false])('does not repair a missing migrated password (URI configured: %s)', async (uriConfigured) => { - await buildBootstrappedApp(); - await getPostgresPool().query('UPDATE account SET password = NULL'); - await writeFile(env.LEGACY_MONGODB_SOURCE_STATE_FILE!, 'completed\n'); - await writeFile(env.LEGACY_MONGODB_MIGRATION_MARKER!, 'completed=test\n'); - if (!uriConfigured) { - env.LEGACY_MONGODB_SOURCE_STATE_FILE = undefined; - env.LEGACY_MONGODB_URI = undefined; - } - await expect(migrateLegacyMongoAuth()).rejects.toThrow('no password credential'); -}); - -it('allows fresh appliance bootstrap when no migration marker exists', async () => { - env.LEGACY_MONGODB_SOURCE_STATE_FILE = undefined; - env.LEGACY_MONGODB_URI = undefined; - await expect(migrateLegacyMongoAuth()).resolves.toBeUndefined(); - await buildBootstrappedApp(); -}); - -it('refuses to import an administrator without their legacy password', async () => { - await expect(importLegacyMongoDocuments(getAuthStore(), [{ _id: 'legacy-admin' }], null)).rejects.toThrow('no password credential'); - expect(await getAuthStore().listUsers()).toHaveLength(0); -}); diff --git a/central/auth/tests/integration/postgres-schema.spec.ts b/central/auth/tests/integration/postgres-schema.spec.ts deleted file mode 100644 index 216c51f..0000000 --- a/central/auth/tests/integration/postgres-schema.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { afterAll, beforeAll, expect, it } from 'vitest'; -import { env } from '../../src/config/env'; -import { connectPostgres, disconnectPostgres, getPostgresPool } from '../../src/db/postgres'; -import { migrateAuthSchema } from '../../src/lib/auth'; -import { dropTestDatabase, startTestPostgres, stopTestPostgres } from '../../test-support/postgres'; - -beforeAll(async () => { - await startTestPostgres(); - await disconnectPostgres(); - env.CENTRAL_AUTH_POSTGRES_SCHEMA = 'CentralAuth_restart_test'; - await connectPostgres(); - await dropTestDatabase(); -}); - -afterAll(async () => { - await getPostgresPool().query('DROP SCHEMA IF EXISTS "CentralAuth_restart_test" CASCADE'); - await stopTestPostgres(); -}); - -it('keeps mixed-case schemas isolated and migrates again after reconnecting', async () => { - for (let boot = 0; boot < 2; boot++) { - const pool = await connectPostgres(); - expect((await pool.query('SELECT current_schema() AS schema')).rows[0].schema).toBe('CentralAuth_restart_test'); - await migrateAuthSchema({ postgresPool: pool }); - const tables = await pool.query("SELECT tablename FROM pg_tables WHERE schemaname = $1 ORDER BY tablename", [env.CENTRAL_AUTH_POSTGRES_SCHEMA]); - expect(tables.rows.map((row) => row.tablename)).toEqual(['account', 'session', 'user', 'verification']); - await disconnectPostgres(); - } - await connectPostgres(); -}); diff --git a/central/auth/tests/setup/app.ts b/central/auth/tests/setup/app.ts index 6c8e699..c504941 100644 --- a/central/auth/tests/setup/app.ts +++ b/central/auth/tests/setup/app.ts @@ -1,7 +1,7 @@ -import { getPostgresPool } from '../../src/db/postgres'; +import { getMongoDBClient } from '../../src/db/mongoose'; import { createApp } from '../../src/app'; import { bootstrapLocalAdmin } from '../../src/lib/bootstrap-admin'; -import { createAuth, migrateAuthSchema } from '../../src/lib/auth'; +import { createAuth } from '../../src/lib/auth'; import { env } from '../../src/config/env'; export const TEST_PASSWORD = 'Start-Password1!'; @@ -16,16 +16,11 @@ export function resetAuthEnv(overrides: Partial = {}) { env.CENTRAL_ADMIN_PASSWORD = TEST_PASSWORD; env.CENTRAL_ADMIN_RECOVERY_OVERRIDE = false; env.CORS_ORIGINS = 'http://localhost:3000'; - env.LEGACY_MONGODB_URI = undefined; - env.LEGACY_MONGODB_SOURCE_STATE_FILE = undefined; - env.LEGACY_MONGODB_MIGRATION_MARKER = undefined; Object.assign(env, overrides); } export async function buildBootstrappedApp() { - const postgresPool = getPostgresPool(); - await migrateAuthSchema({ postgresPool }); - const auth = await createAuth({ postgresPool }); + const auth = await createAuth({ mongoDb: await getMongoDBClient() }); await bootstrapLocalAdmin(); return createApp(auth); } diff --git a/central/auth/tests/setup/mongoose.ts b/central/auth/tests/setup/mongoose.ts new file mode 100644 index 0000000..92d796e --- /dev/null +++ b/central/auth/tests/setup/mongoose.ts @@ -0,0 +1,53 @@ +import mongoose from 'mongoose'; +import { connectMongoose, disconnectMongoose } from '../../src/db/mongoose'; +import { env } from '../../src/config/env'; + +const DEFAULT_TEST_DB_NAME = 'central_auth_test'; + +export async function startTestMongo() { + const uri = process.env.CENTRAL_AUTH_TEST_MONGODB_URI; + const dbName = process.env.CENTRAL_AUTH_TEST_MONGODB_DB_NAME || DEFAULT_TEST_DB_NAME; + + if (!uri) { + throw new Error( + 'CENTRAL_AUTH_TEST_MONGODB_URI is required for central-auth integration tests. Run `make test-central-auth` from the repository root.', + ); + } + + env.MONGODB_URI = uri; + env.MONGODB_DB_NAME = dbName; + env.MONGODB_TLS = false; + env.MONGODB_RETRY_WRITES = false; + + await connectMongoose({ + uri, + dbName: env.MONGODB_DB_NAME, + retryWrites: false, + tls: false, + minPoolSize: 1, + maxPoolSize: 5, + }); +} + +export async function dropTestDatabase() { + if (mongoose.connection.readyState !== 1) return; + const expectedDbName = process.env.CENTRAL_AUTH_TEST_MONGODB_DB_NAME || DEFAULT_TEST_DB_NAME; + const activeDbName = mongoose.connection.db?.databaseName; + + if (activeDbName !== expectedDbName) { + throw new Error( + `Refusing to clear MongoDB database "${activeDbName ?? 'unknown'}"; expected test database "${expectedDbName}".`, + ); + } + + const collections = await mongoose.connection.db?.collections(); + if (!collections) return; + + for (const collection of collections) { + await collection.deleteMany({}); + } +} + +export async function stopTestMongo() { + await disconnectMongoose(); +} diff --git a/central/auth/tests/unit/env.spec.ts b/central/auth/tests/unit/env.spec.ts index 0f8d484..ebe2b78 100644 --- a/central/auth/tests/unit/env.spec.ts +++ b/central/auth/tests/unit/env.spec.ts @@ -25,12 +25,4 @@ describe('environment parsing', () => { it('rejects invalid boolean values', () => { expect(() => parseEnv({ CENTRAL_ADMIN_RECOVERY_OVERRIDE: 'sure' })).toThrow(/Expected boolean environment value/); }); - - it('defaults Central Auth to its own PostgreSQL schema', () => { - expect(parseEnv({}).CENTRAL_AUTH_POSTGRES_SCHEMA).toBe('central_auth'); - }); - - it('rejects unsafe PostgreSQL schema names', () => { - expect(() => parseEnv({ CENTRAL_AUTH_POSTGRES_SCHEMA: 'central-auth; DROP SCHEMA public' })).toThrow(); - }); }); diff --git a/deploy/standalone/docker-compose.yml b/deploy/standalone/docker-compose.yml index 7aff3b8..d08cfdc 100644 --- a/deploy/standalone/docker-compose.yml +++ b/deploy/standalone/docker-compose.yml @@ -25,11 +25,6 @@ services: - unicron-stepca-ra:127.0.0.1 - unicron.central:127.0.0.1 - ${UNICRON_CENTRAL_FQDN:-localhost}:127.0.0.1 - networks: - default: - aliases: - # Local agents use this TLS hostname when they join the appliance network. - - unicron.central ports: - ${UNICRON_HTTP_PORT:-80}:80 - ${UNICRON_HTTPS_PORT:-443}:443 @@ -40,7 +35,6 @@ services: UNICRON_PUBLIC_CENTRAL_PORT: ${UNICRON_HTTPS_PORT:-443} UNICRON_PUBLIC_CENTRAL_MTLS_PORT: ${UNICRON_MTLS_PORT:-8443} UNICRON_APPLIANCE_CONTAINER_NAME: ${UNICRON_CONTAINER_NAME:-unicron-appliance} - LOCAL_AGENT_DOCKER_NETWORK: ${UNICRON_DOCKER_NETWORK:-logforge-unicron-network} TMPDIR: /run/pyinstaller CENTRAL_ADMIN_USERNAME: ${CENTRAL_ADMIN_USERNAME:-admin} CENTRAL_ADMIN_PASSWORD: ${CENTRAL_ADMIN_PASSWORD:-} @@ -56,8 +50,3 @@ services: volumes: unicron-data: name: unicron-data - -networks: - default: - # Keep this stable because locally generated agent enrollment commands join it. - name: ${UNICRON_DOCKER_NETWORK:-logforge-unicron-network} diff --git a/ops/appliance/Dockerfile b/ops/appliance/Dockerfile index bcfe4ec..a657a01 100644 --- a/ops/appliance/Dockerfile +++ b/ops/appliance/Dockerfile @@ -237,7 +237,6 @@ RUN groupadd --system mongodb && \ /home \ /opt/unicron/bin \ /var/lib/unicron/backend \ - /var/lib/unicron/central-auth \ /var/lib/unicron/mongo \ /var/lib/unicron/notifier \ /var/lib/unicron/otelcol \ @@ -261,7 +260,6 @@ RUN groupadd --system mongodb && \ chown -R unicron:unicron /opt/unicron && \ chown -R unicron:unicron \ /var/lib/unicron/backend \ - /var/lib/unicron/central-auth \ /var/lib/unicron/notifier \ /var/lib/unicron/otelcol \ /var/lib/unicron/victoria-logs \ diff --git a/ops/appliance/manager/config.go b/ops/appliance/manager/config.go index f2ab83e..007264f 100644 --- a/ops/appliance/manager/config.go +++ b/ops/appliance/manager/config.go @@ -44,12 +44,9 @@ type RuntimeConfig struct { PostgresUser string PostgresDB string - CentralAuthPostgresSchema string - CentralAuthCookieName string - CentralAuthPublicURL string - LegacyAuthMongoDBName string - LegacyAuthMigrationMarker string - LegacyAuthMigrationRequired bool + CentralAuthMongoDBName string + CentralAuthCookieName string + CentralAuthPublicURL string TraefikRouterHosts string TraefikCertSANs string @@ -82,9 +79,7 @@ func loadConfig() RuntimeConfig { postgresDB := setDefaultEnv("POSTGRES_DB", "unicron") setDefaultEnv("CENTRAL_ADMIN_USERNAME", "admin") setDefaultEnv("CENTRAL_ADMIN_RECOVERY_OVERRIDE", "false") - authPostgresSchema := setDefaultEnv("CENTRAL_AUTH_POSTGRES_SCHEMA", "central_auth") - legacyAuthDBName := envOrDefault("LEGACY_MONGODB_DB_NAME", envOrDefault("CENTRAL_AUTH_MONGODB_DB_NAME", "unicron_central_auth")) - legacyAuthMigrationMarker := envOrDefault("LEGACY_MONGODB_MIGRATION_MARKER", dataDir+"/central-auth/mongodb-migration-complete") + authDBName := setDefaultEnv("CENTRAL_AUTH_MONGODB_DB_NAME", "unicron_central_auth") authCookieName := setDefaultEnv("CENTRAL_AUTH_COOKIE_NAME", "unicron.central_auth.session") authPublicURL := setDefaultEnv("CENTRAL_AUTH_PUBLIC_BASE_URL", fmt.Sprintf("https://%s:%s", centralFQDN, publicCentralPort)) @@ -133,12 +128,9 @@ func loadConfig() RuntimeConfig { FrontendPort: frontendPort, PostgresUser: postgresUser, PostgresDB: postgresDB, - CentralAuthPostgresSchema: authPostgresSchema, + CentralAuthMongoDBName: authDBName, CentralAuthCookieName: authCookieName, CentralAuthPublicURL: authPublicURL, - LegacyAuthMongoDBName: legacyAuthDBName, - LegacyAuthMigrationMarker: legacyAuthMigrationMarker, - LegacyAuthMigrationRequired: parseBoolEnv(envOrDefault("UNICRON_LEGACY_AUTH_MIGRATION_REQUIRED", "false"), false), TraefikRouterHosts: routerHosts, TraefikCertSANs: certSANs, StepCADNS: stepCADNS, diff --git a/ops/appliance/manager/main.go b/ops/appliance/manager/main.go index 185af0e..6546485 100644 --- a/ops/appliance/manager/main.go +++ b/ops/appliance/manager/main.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strconv" ) func main() { @@ -45,17 +44,6 @@ func dispatch() error { } func runEntrypoint(cfg RuntimeConfig) error { - legacyMigrationRequired, err := legacyMongoMigrationRequired(cfg) - if err != nil { - return fmt.Errorf("appliance preflight failed: %w", err) - } - cfg.LegacyAuthMigrationRequired = legacyMigrationRequired - _ = os.Setenv("UNICRON_LEGACY_AUTH_MIGRATION_REQUIRED", strconv.FormatBool(legacyMigrationRequired)) - if legacyMigrationRequired { - if err := checkMongoRuntimeCompatibility(); err != nil { - return fmt.Errorf("appliance preflight failed: %w", err) - } - } if err := setupAppliance(cfg); err != nil { return err } diff --git a/ops/appliance/manager/preflight.go b/ops/appliance/manager/preflight.go deleted file mode 100644 index f6d1c83..0000000 --- a/ops/appliance/manager/preflight.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "os/exec" - "runtime" - "strings" - "syscall" - "time" -) - -const mongoCompatibilityCheckTimeout = 10 * time.Second - -func legacyMongoMigrationRequired(cfg RuntimeConfig) (bool, error) { - if fileNonEmpty(cfg.LegacyAuthMigrationMarker) { - return false, nil - } - - entries, err := os.ReadDir(cfg.DataDir + "/mongo") - if os.IsNotExist(err) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("inspect legacy MongoDB auth data: %w", err) - } - for _, entry := range entries { - name := entry.Name() - if name == "WiredTiger" || name == "storage.bson" || strings.HasPrefix(name, "collection-") || strings.HasPrefix(name, "index-") { - return true, nil - } - } - return false, nil -} - -func checkMongoRuntimeCompatibility() error { - if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil { - if err := checkMongoCPUFeatures(runtime.GOARCH, string(cpuInfo)); err != nil { - return err - } - } - - ctx, cancel := context.WithTimeout(context.Background(), mongoCompatibilityCheckTimeout) - defer cancel() - return probeMongoRuntime(ctx, "mongod", "--version") -} - -func checkMongoCPUFeatures(arch, cpuInfo string) error { - if arch != "amd64" { - return nil - } - fieldFound, avxAvailable := cpuInfoFeatureStatus(cpuInfo, "flags", "avx") - if fieldFound && !avxAvailable { - return errors.New("the existing Central Auth data needs a one-time MongoDB migration, but MongoDB 7 requires AVX on x86_64 and Docker does not expose the avx CPU flag; migrate this appliance volume on an AVX-capable host") - } - return nil -} - -func cpuInfoFeatureStatus(cpuInfo, field, feature string) (bool, bool) { - fieldFound := false - for _, line := range strings.Split(cpuInfo, "\n") { - key, value, found := strings.Cut(line, ":") - if !found || !strings.EqualFold(strings.TrimSpace(key), field) { - continue - } - fieldFound = true - featureFound := false - for _, candidate := range strings.Fields(value) { - if strings.EqualFold(candidate, feature) { - featureFound = true - break - } - } - if !featureFound { - return true, false - } - } - return fieldFound, fieldFound -} - -func probeMongoRuntime(ctx context.Context, command string, args ...string) error { - var output bytes.Buffer - cmd := exec.CommandContext(ctx, command, args...) - cmd.Stdout = &output - cmd.Stderr = &output - err := cmd.Run() - if err == nil { - return nil - } - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("MongoDB runtime compatibility check timed out after %s", mongoCompatibilityCheckTimeout) - } - if signal, ok := exitSignal(err); ok { - if signal == syscall.SIGILL { - return fmt.Errorf("the existing Central Auth data needs a one-time migration, but MongoDB terminated by %s because this CPU does not expose a supported instruction set; migrate this appliance volume on an AVX-capable host", formatSignal(signal)) - } - return fmt.Errorf("MongoDB runtime compatibility check terminated by %s", formatSignal(signal)) - } - detail := strings.TrimSpace(output.String()) - if detail != "" { - return fmt.Errorf("MongoDB runtime compatibility check failed: %w: %s", err, detail) - } - return fmt.Errorf("MongoDB runtime compatibility check failed: %w", err) -} diff --git a/ops/appliance/manager/preflight_test.go b/ops/appliance/manager/preflight_test.go deleted file mode 100644 index ff13348..0000000 --- a/ops/appliance/manager/preflight_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package main - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" -) - -func TestCheckMongoCPUFeaturesRejectsAMD64WithoutAVX(t *testing.T) { - cpuInfo := "flags : fpu sse sse2 sse4_1 sse4_2\n" - - err := checkMongoCPUFeatures("amd64", cpuInfo) - - if err == nil { - t.Fatal("checkMongoCPUFeatures() succeeded without AVX") - } - for _, want := range []string{"one-time MongoDB migration", "MongoDB 7 requires AVX", "AVX-capable host"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error %q does not contain %q", err, want) - } - } -} - -func TestCheckMongoCPUFeaturesAcceptsAMD64WithAVX(t *testing.T) { - cpuInfo := "flags : fpu sse sse2 avx avx2\n" - - if err := checkMongoCPUFeatures("amd64", cpuInfo); err != nil { - t.Fatal(err) - } -} - -func TestCheckMongoCPUFeaturesDefersWhenCPUFlagsAreUnavailable(t *testing.T) { - if err := checkMongoCPUFeatures("amd64", "processor : 0\n"); err != nil { - t.Fatal(err) - } -} - -func TestCheckMongoCPUFeaturesDoesNotApplyX86RuleToARM64(t *testing.T) { - if err := checkMongoCPUFeatures("arm64", "Features : fp asimd\n"); err != nil { - t.Fatal(err) - } -} - -func TestProbeMongoRuntimeReportsSIGILL(t *testing.T) { - err := probeMongoRuntime(context.Background(), "/bin/sh", "-c", "kill -ILL $$") - - if err == nil { - t.Fatal("probeMongoRuntime() succeeded after SIGILL") - } - for _, want := range []string{"one-time migration", "SIGILL", "CPU", "AVX-capable host"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error %q does not contain %q", err, want) - } - } -} - -func TestLegacyMongoMigrationRequiredOnlyForUnmigratedData(t *testing.T) { - dataDir := t.TempDir() - cfg := RuntimeConfig{ - DataDir: dataDir, - LegacyAuthMigrationMarker: filepath.Join(dataDir, "central-auth", "mongodb-migration-complete"), - } - - required, err := legacyMongoMigrationRequired(cfg) - if err != nil { - t.Fatal(err) - } - if required { - t.Fatal("empty appliance data unexpectedly requires MongoDB migration") - } - - if err := os.MkdirAll(filepath.Join(dataDir, "mongo"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dataDir, "mongo", "WiredTiger"), []byte("legacy-data"), 0o600); err != nil { - t.Fatal(err) - } - required, err = legacyMongoMigrationRequired(cfg) - if err != nil { - t.Fatal(err) - } - if !required { - t.Fatal("legacy MongoDB data was not detected") - } - - if err := os.MkdirAll(filepath.Dir(cfg.LegacyAuthMigrationMarker), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cfg.LegacyAuthMigrationMarker, []byte("completed\n"), 0o600); err != nil { - t.Fatal(err) - } - required, err = legacyMongoMigrationRequired(cfg) - if err != nil { - t.Fatal(err) - } - if required { - t.Fatal("completed migration still requires MongoDB") - } -} - -func TestSupervisedServiceSpecsOnlyIncludesMongoForLegacyMigration(t *testing.T) { - fresh := supervisedServiceSpecs(RuntimeConfig{}) - legacy := supervisedServiceSpecs(RuntimeConfig{LegacyAuthMigrationRequired: true}) - - for _, spec := range fresh { - if spec.Name == "mongo" { - t.Fatal("fresh appliance starts MongoDB") - } - } - found := false - for _, spec := range legacy { - if spec.Name == "mongo" { - found = true - } - } - if !found { - t.Fatal("legacy migration did not start MongoDB") - } -} - -func TestExitCodeAndSignalPreserveSIGILL(t *testing.T) { - err := exec.Command("/bin/sh", "-c", "kill -ILL $$").Run() - - if got := exitCode(err); got != 132 { - t.Fatalf("exitCode() = %d, want 132", got) - } - signal, ok := exitSignal(err) - if !ok { - t.Fatal("exitSignal() did not report a signal") - } - if signal != syscall.SIGILL { - t.Fatalf("exitSignal() = %v, want SIGILL", signal) - } - if got := formatSignal(signal); got != "SIGILL (illegal instruction)" { - t.Fatalf("formatSignal() = %q", got) - } -} - -func TestStoreExitRecordsSIGILL(t *testing.T) { - err := exec.Command("/bin/sh", "-c", "kill -ILL $$").Run() - statusPath := filepath.Join(t.TempDir(), "status.json") - store := newStatusStore(statusPath, []ServiceSpec{{Name: "mongo", Critical: true}}) - - storeExit("mongo", store, 42, 3, err) - - snapshot, readErr := readStatus(statusPath) - if readErr != nil { - t.Fatal(readErr) - } - status := snapshot.Services["mongo"] - if status.ExitCode != 132 { - t.Fatalf("ExitCode = %d, want 132", status.ExitCode) - } - if status.Message != "terminated by SIGILL (illegal instruction)" { - t.Fatalf("Message = %q", status.Message) - } - if status.Restarts != 3 { - t.Fatalf("Restarts = %d, want 3", status.Restarts) - } -} diff --git a/ops/appliance/manager/process.go b/ops/appliance/manager/process.go index 5a76371..292e3fa 100644 --- a/ops/appliance/manager/process.go +++ b/ops/appliance/manager/process.go @@ -138,59 +138,15 @@ func exitCode(err error) int { if err == nil { return 0 } - if status, ok := waitStatus(err); ok { - if status.Signaled() { - return 128 + int(status.Signal()) + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + return status.ExitStatus() } - return status.ExitStatus() } return 1 } -func exitSignal(err error) (syscall.Signal, bool) { - status, ok := waitStatus(err) - if !ok || !status.Signaled() { - return 0, false - } - return status.Signal(), true -} - -func waitStatus(err error) (syscall.WaitStatus, bool) { - var exitErr *exec.ExitError - if !errors.As(err, &exitErr) { - return 0, false - } - status, ok := exitErr.Sys().(syscall.WaitStatus) - return status, ok -} - -func formatSignal(signal syscall.Signal) string { - name := "" - switch signal { - case syscall.SIGABRT: - name = "SIGABRT" - case syscall.SIGBUS: - name = "SIGBUS" - case syscall.SIGFPE: - name = "SIGFPE" - case syscall.SIGILL: - name = "SIGILL" - case syscall.SIGINT: - name = "SIGINT" - case syscall.SIGKILL: - name = "SIGKILL" - case syscall.SIGSEGV: - name = "SIGSEGV" - case syscall.SIGTERM: - name = "SIGTERM" - } - description := signal.String() - if name == "" { - return fmt.Sprintf("signal %d (%s)", signal, description) - } - return fmt.Sprintf("%s (%s)", name, description) -} - func processAlive(pid int) bool { if pid <= 0 { return false diff --git a/ops/appliance/manager/services.go b/ops/appliance/manager/services.go index b544f8a..b076888 100644 --- a/ops/appliance/manager/services.go +++ b/ops/appliance/manager/services.go @@ -161,26 +161,16 @@ func traefikArgs(cfg RuntimeConfig) []string { } func runCentralAuth(cfg RuntimeConfig) error { - if err := waitPostgres(cfg); err != nil { + if err := waitTCP("127.0.0.1", 27017, "MongoDB", 120); err != nil { return err } publicOrigin := fmt.Sprintf("https://%s:%s", cfg.CentralFQDN, cfg.PublicCentralPort) setServiceEnv(map[string]string{ - "PORT": "3020", - "POSTGRES_HOST": "127.0.0.1", - "POSTGRES_PORT": "5432", - "CENTRAL_AUTH_POSTGRES_SCHEMA": cfg.CentralAuthPostgresSchema, - "LEGACY_MONGODB_MIGRATION_MARKER": cfg.LegacyAuthMigrationMarker, + "PORT": "3020", + "MONGODB_URI": "mongodb://127.0.0.1:27017", + "MONGODB_DB_NAME": cfg.CentralAuthMongoDBName, + "MONGODB_TLS": "false", }) - if cfg.LegacyAuthMigrationRequired { - if err := waitTCP("127.0.0.1", 27017, "legacy MongoDB", 120); err != nil { - return err - } - setServiceEnv(map[string]string{ - "LEGACY_MONGODB_URI": "mongodb://127.0.0.1:27017", - "LEGACY_MONGODB_DB_NAME": cfg.LegacyAuthMongoDBName, - }) - } setDefaultEnv("CENTRAL_AUTH_BASE_URL", cfg.CentralAuthPublicURL) setDefaultEnv( "CORS_ORIGINS", diff --git a/ops/appliance/manager/setup.go b/ops/appliance/manager/setup.go index 3e85a51..0a3e7bd 100644 --- a/ops/appliance/manager/setup.go +++ b/ops/appliance/manager/setup.go @@ -7,9 +7,9 @@ import ( ) func setupAppliance(cfg RuntimeConfig) error { - dirs := []string{ + for _, dir := range []string{ cfg.DataDir + "/backend", - cfg.DataDir + "/central-auth", + cfg.DataDir + "/mongo", cfg.DataDir + "/notifier", cfg.DataDir + "/otelcol", cfg.DataDir + "/postgres", @@ -27,11 +27,7 @@ func setupAppliance(cfg RuntimeConfig) error { cfg.StepPath + "/ra-provisioner", "/run/postgresql", cfg.TmpDir, - } - if cfg.LegacyAuthMigrationRequired { - dirs = append(dirs, cfg.DataDir+"/mongo") - } - for _, dir := range dirs { + } { if err := ensureDir(dir, 0o755); err != nil { return fmt.Errorf("create %s: %w", dir, err) } @@ -49,13 +45,10 @@ func setupAppliance(cfg RuntimeConfig) error { return err } chownR("redis:redis", cfg.DataDir+"/redis") - if cfg.LegacyAuthMigrationRequired { - chownR("mongodb:mongodb", cfg.DataDir+"/mongo") - } + chownR("mongodb:mongodb", cfg.DataDir+"/mongo") chownR( "unicron:unicron", cfg.DataDir+"/backend", - cfg.DataDir+"/central-auth", cfg.DataDir+"/notifier", cfg.DataDir+"/otelcol", cfg.DataDir+"/victoria-logs", diff --git a/ops/appliance/manager/supervise.go b/ops/appliance/manager/supervise.go index 6940b2f..0874253 100644 --- a/ops/appliance/manager/supervise.go +++ b/ops/appliance/manager/supervise.go @@ -14,6 +14,7 @@ func serviceSpecs() []ServiceSpec { return []ServiceSpec{ {Name: "postgres", StartSecs: 5 * time.Second, Critical: true}, {Name: "redis", StartSecs: 3 * time.Second, Critical: true}, + {Name: "mongo", StartSecs: 5 * time.Second, Critical: true}, {Name: "stepca", StartSecs: 5 * time.Second, Critical: true}, {Name: "stepca-ra", StartSecs: 5 * time.Second, Critical: true}, {Name: "traefik", StartSecs: 5 * time.Second, Critical: true}, @@ -31,10 +32,6 @@ func serviceSpecs() []ServiceSpec { func supervisedServiceSpecs(cfg RuntimeConfig) []ServiceSpec { specs := append([]ServiceSpec{}, serviceSpecs()...) - if cfg.LegacyAuthMigrationRequired { - mongo := ServiceSpec{Name: "mongo", StartSecs: 5 * time.Second, Critical: true} - specs = append(specs[:2], append([]ServiceSpec{mongo}, specs[2:]...)...) - } if cfg.SelfUpdateEnabled { specs = append(specs, ServiceSpec{Name: "appliance-updater", StartSecs: 2 * time.Second, Critical: false}) } @@ -176,16 +173,10 @@ func superviseService(ctx context.Context, cfg RuntimeConfig, spec ServiceSpec, func storeExit(name string, store *statusStore, pid, restarts int, err error) { code := exitCode(err) msg := "" - if signal, ok := exitSignal(err); ok { - formattedSignal := formatSignal(signal) - msg = "terminated by " + formattedSignal - logf("APPLIANCE-ENTRY", "Service %s terminated by %s (exit code %d)", name, formattedSignal, code) - } else { - if err != nil { - msg = err.Error() - } - logf("APPLIANCE-ENTRY", "Service %s exited with code %d", name, code) + if err != nil { + msg = err.Error() } + logf("APPLIANCE-ENTRY", "Service %s exited with code %d", name, code) store.update(name, func(status ServiceStatus) ServiceStatus { status.State = "exited" status.PID = pid diff --git a/ops/testing/docker-compose.central-auth-test-mongo.yml b/ops/testing/docker-compose.central-auth-test-mongo.yml new file mode 100644 index 0000000..797e7a8 --- /dev/null +++ b/ops/testing/docker-compose.central-auth-test-mongo.yml @@ -0,0 +1,27 @@ +services: + central-auth-test-mongodb: + image: mongo:7 + container_name: unicron-central-auth-test-mongodb + restart: unless-stopped + ports: + - "127.0.0.1:${CENTRAL_AUTH_TEST_MONGO_PORT:-27018}:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${CENTRAL_AUTH_TEST_MONGO_ROOT_USERNAME:-root} + MONGO_INITDB_ROOT_PASSWORD: ${CENTRAL_AUTH_TEST_MONGO_ROOT_PASSWORD:-password} + MONGO_INITDB_DATABASE: ${CENTRAL_AUTH_TEST_MONGODB_DB_NAME:-central_auth_test} + volumes: + - central-auth-test-mongo-data:/data/db + healthcheck: + test: + [ + "CMD-SHELL", + "mongosh --quiet --username \"$${MONGO_INITDB_ROOT_USERNAME}\" --password \"$${MONGO_INITDB_ROOT_PASSWORD}\" --authenticationDatabase admin --eval \"db.adminCommand('ping').ok\"", + ] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + +volumes: + central-auth-test-mongo-data: + name: unicron-central-auth-test-mongo-data diff --git a/ops/testing/docker-compose.central-auth-test-postgres.yml b/ops/testing/docker-compose.central-auth-test-postgres.yml deleted file mode 100644 index ce579ff..0000000 --- a/ops/testing/docker-compose.central-auth-test-postgres.yml +++ /dev/null @@ -1,27 +0,0 @@ -services: - central-auth-test-postgres: - image: postgres:15-alpine - container_name: unicron-central-auth-test-postgres - restart: unless-stopped - ports: - - "127.0.0.1:${CENTRAL_AUTH_TEST_POSTGRES_PORT:-5433}:5432" - environment: - POSTGRES_USER: ${CENTRAL_AUTH_TEST_POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${CENTRAL_AUTH_TEST_POSTGRES_PASSWORD:-password} - POSTGRES_DB: ${CENTRAL_AUTH_TEST_POSTGRES_DB:-central_auth_test} - volumes: - - central-auth-test-postgres-data:/var/lib/postgresql/data - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\"", - ] - interval: 10s - timeout: 5s - retries: 6 - start_period: 5s - -volumes: - central-auth-test-postgres-data: - name: unicron-central-auth-test-postgres-data diff --git a/ops/testing/test-central-auth-compose.py b/ops/testing/test-central-auth-compose.py deleted file mode 100644 index 4a300c6..0000000 --- a/ops/testing/test-central-auth-compose.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise the production auth Compose wiring using disposable volumes and networks. - -Build central/auth/Dockerfile first, then pass the local image tag as the argument. -No existing containers, volumes, or host ports are used. -""" - -import copy -import json -from pathlib import Path -import subprocess -import sys -import tempfile -import uuid - - -ROOT = Path(__file__).resolve().parents[2] -IMAGE = sys.argv[1] if len(sys.argv) == 2 else None -if not IMAGE: - raise SystemExit("Usage: test-central-auth-compose.py LOCAL_AUTH_IMAGE") - - -def run(*args, stdin=None, check=True): - result = subprocess.run(args, input=stdin, text=True, capture_output=True) - if check and result.returncode: - raise RuntimeError(f"Command failed: {args[0:3]}\n{result.stdout}\n{result.stderr}") - return result.stdout.strip() - - -production = json.loads(run( - "docker", "compose", "-f", str(ROOT / "ops/unicron/docker-compose.unicron.yaml"), - "--env-file", str(ROOT / "ops/unicron/.env.example"), "config", "--no-env-resolution", "--format", "json", -)) - - -def scenario(legacy, conflict=False, invalid=None): - project = f"auth-upgrade-test-{uuid.uuid4().hex[:10]}" - network = f"{project}-network" - seed_container = f"{project}-old-mongo" - selected = ("postgres", "central-auth-mongodb", "central-auth") - config = {"services": {name: copy.deepcopy(production["services"][name]) for name in selected}} - volume_keys = ("postgres-data", "central-auth-mongo-data", "central-auth-migration") - config["volumes"] = {name: {"name": f"{project}-{name}"} for name in volume_keys} - config["networks"] = {"unicron-network": {"name": network}} - for name, service in config["services"].items(): - for key in ("build", "env_file", "container_name", "ports", "pull_policy"): - service.pop(key, None) - service["restart"] = "no" - service["healthcheck"]["interval"] = "1s" - auth = config["services"]["central-auth"] - auth["image"] = IMAGE - auth["environment"].update({ - "CENTRAL_AUTH_POSTGRES_SCHEMA": "ComposeAuth_test", - "CENTRAL_ADMIN_PASSWORD": "Stale-Bootstrap-Password1!", - "CENTRAL_AUTH_BASE_URL": "http://central-auth:3020", - }) - if invalid == "database": - auth["environment"]["LEGACY_MONGODB_DB_NAME"] = "wrong_database" - mongo_volume = config["volumes"]["central-auth-mongo-data"]["name"] - - with tempfile.TemporaryDirectory(prefix=project) as directory: - path = Path(directory) / "compose.json" - path.write_text(json.dumps(config)) - compose = ("docker", "compose", "-p", project, "-f", str(path)) - - def node(script): - return run("docker", "run", "--rm", "--network", network, - "--entrypoint", "node", IMAGE, "--input-type=module", "-e", script) - - def login(password, expected): - node(f""" - const r = await fetch('http://central-auth:3020/api/auth/sign-in/username', {{ - method: 'POST', headers: {{'content-type': 'application/json'}}, - body: JSON.stringify({{username: 'admin', password: {json.dumps(password)}}}) - }}); - if (r.status !== {expected}) throw new Error('Unexpected login status: ' + r.status); - if (r.ok) {{ - const cookie = r.headers.get('set-cookie').split(';')[0]; - const profile = await fetch('http://central-auth:3020/api/v1/profile', {{headers: {{cookie}}}}); - if (!profile.ok) throw new Error('Authenticated profile failed: ' + profile.status); - }} - """) - - def assert_mongo_idle(): - output = run(*compose, "exec", "-T", "central-auth-mongodb", "sh", "-c", "cat /proc/1/comm") - assert output == "sleep", output - - try: - run(*compose, "up", "-d", "--wait", "--wait-timeout", "120", "postgres") - if conflict: - run(*compose, "up", "-d", "--wait", "--wait-timeout", "180", "central-auth") - login("Stale-Bootstrap-Password1!", 200) - run(*compose, "stop", "central-auth", "central-auth-mongodb") - if legacy: - run("docker", "volume", "create", mongo_volume) - run("docker", "run", "-d", "--name", seed_container, "--network", network, - "-v", f"{mongo_volume}:/data/db", "-e", "MONGO_INITDB_ROOT_USERNAME=root", - "-e", "MONGO_INITDB_ROOT_PASSWORD=password", "mongo:7") - node(f""" - import {{ MongoClient, ObjectId }} from 'mongodb'; - import {{ hashPassword }} from 'better-auth/crypto'; - const client = new MongoClient('mongodb://root:password@{seed_container}:27017/?authSource=admin', {{serverSelectionTimeoutMS: 60000}}); - await client.connect(); - const db = client.db('unicron_central_auth'); - const id = new ObjectId(); const now = new Date(); - await db.collection('user').insertOne({{_id: id, username: 'admin', displayUsername: 'admin', name: 'admin', email: 'admin@local.unicron.invalid', emailVerified: true, requiresPasswordChange: false, createdAt: now, updatedAt: now}}); - if ({json.dumps(invalid != 'credential')}) await db.collection('account').insertOne({{_id: new ObjectId(), userId: id, accountId: String(id), providerId: 'credential', password: await hashPassword('Changed-Legacy-Password1!'), createdAt: now, updatedAt: now}}); - await db.collection('session').insertOne({{userId: id, token: 'old-session-must-not-migrate'}}); - await client.close(); - """) - run("docker", "rm", "-f", seed_container) - - if conflict or invalid: - result = subprocess.run((*compose, "up", "-d", "--wait", "--wait-timeout", "45", "central-auth"), capture_output=True, text=True) - assert result.returncode != 0, "Unsafe migration must block startup" - expected_error = "different administrators" if conflict else "exactly one administrator" if invalid == "database" else "no password credential" - assert expected_error in run(*compose, "logs", "central-auth") - run(*compose, "exec", "-T", "central-auth-mongodb", "test", "!", "-e", "/migration/completed") - print(f"PASS: {expected_error} blocks startup without marking migration complete", flush=True) - return - - run(*compose, "up", "-d", "--wait", "--wait-timeout", "180", "central-auth") - password = "Changed-Legacy-Password1!" if legacy else "Stale-Bootstrap-Password1!" - login(password, 200) - login("Stale-Bootstrap-Password1!" if legacy else "Wrong-Password1!", 401) - if not legacy: - assert_mongo_idle() - else: - # Simulate a crash after the database commit but before persisting the marker. - run(*compose, "exec", "-T", "central-auth", "rm", "/migration/completed") - run(*compose, "up", "-d", "--force-recreate", "--wait", "--wait-timeout", "180", "central-auth-mongodb", "central-auth") - login(password, 200) - # Recreate both containers to prove persisted migration state, not in-memory state. - run(*compose, "up", "-d", "--force-recreate", "--wait", "--wait-timeout", "180", "central-auth-mongodb", "central-auth") - assert_mongo_idle() - login(password, 200) - login("Stale-Bootstrap-Password1!" if legacy else "Wrong-Password1!", 401) - schema = run(*compose, "exec", "-T", "postgres", "psql", "-U", auth["environment"]["POSTGRES_USER"], - "-d", auth["environment"]["POSTGRES_DB"], "-Atc", - "SELECT count(*) FROM pg_tables WHERE schemaname = 'ComposeAuth_test' AND tablename IN ('user','account','session','verification')") - assert schema == "4", schema - print(f"PASS: {'legacy migration' if legacy else 'fresh install'}, login, rejected password, profile, restart, mixed-case schema, idle MongoDB", flush=True) - finally: - run("docker", "rm", "-f", seed_container, check=False) - run(*compose, "down", "-v", "--remove-orphans") - - -scenario(False) -scenario(True) -scenario(True, conflict=True) -scenario(True, invalid="database") -scenario(True, invalid="credential") diff --git a/ops/unicron/.env.example b/ops/unicron/.env.example index 5ea9c49..b71e789 100644 --- a/ops/unicron/.env.example +++ b/ops/unicron/.env.example @@ -36,11 +36,8 @@ CENTRAL_ADMIN_USERNAME=admin # Set only for an explicit bootstrap or recovery credential. # CENTRAL_ADMIN_PASSWORD=ChangeMe-Admin-Password1! CENTRAL_ADMIN_RECOVERY_OVERRIDE=false -CENTRAL_AUTH_POSTGRES_SCHEMA=central_auth -# Upgrades only: retain the credentials and database name used by your old MongoDB service. -# CENTRAL_AUTH_MONGO_ROOT_USERNAME=root -# CENTRAL_AUTH_MONGO_ROOT_PASSWORD=password -# CENTRAL_AUTH_MONGODB_DB_NAME=unicron_central_auth +CENTRAL_AUTH_MONGO_ROOT_USERNAME=root +CENTRAL_AUTH_MONGO_ROOT_PASSWORD=password # Dev only: skip TLS verification for frontend auth calls when using a dev TLS target. SKIP_AUTH_TLS_VERIFY=true diff --git a/ops/unicron/.env.production.example b/ops/unicron/.env.production.example index 0e68811..c0a5a46 100644 --- a/ops/unicron/.env.production.example +++ b/ops/unicron/.env.production.example @@ -24,7 +24,8 @@ CENTRAL_ADMIN_USERNAME=admin # Set only for an explicit bootstrap or recovery credential. # CENTRAL_ADMIN_PASSWORD=changeme-strong-admin-password CENTRAL_ADMIN_RECOVERY_OVERRIDE=false -CENTRAL_AUTH_POSTGRES_SCHEMA=central_auth +CENTRAL_AUTH_MONGO_ROOT_USERNAME=root +CENTRAL_AUTH_MONGO_ROOT_PASSWORD=changeme-strong-auth-mongo-password # Set false when the auth target uses valid public CA certs. SKIP_AUTH_TLS_VERIFY=false diff --git a/ops/unicron/docker-compose.unicron.yaml b/ops/unicron/docker-compose.unicron.yaml index 7d58900..f3d0e20 100644 --- a/ops/unicron/docker-compose.unicron.yaml +++ b/ops/unicron/docker-compose.unicron.yaml @@ -374,23 +374,22 @@ services: start_period: 5s central-auth-mongodb: + <<: *push_defaults image: mongo:7 container_name: unicron-central-auth-mongodb restart: unless-stopped networks: - unicron-network - entrypoint: ['/bin/sh', '/legacy-auth-mongodb.sh'] + environment: + MONGO_INITDB_ROOT_USERNAME: ${CENTRAL_AUTH_MONGO_ROOT_USERNAME:-root} + MONGO_INITDB_ROOT_PASSWORD: ${CENTRAL_AUTH_MONGO_ROOT_PASSWORD:-password} volumes: - central-auth-mongo-data:/data/db - - central-auth-migration:/migration - - ./scripts/legacy-auth-mongodb.sh:/legacy-auth-mongodb.sh:ro healthcheck: - test: - - CMD-SHELL - - 'case "$$(cat /migration/source-state 2>/dev/null)" in empty|completed) exit 0;; required) mongosh --quiet --eval "quit(db.adminCommand({ping: 1}).ok ? 0 : 1)";; *) exit 1;; esac' - interval: 5s + test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping").ok'] + interval: 10s timeout: 5s - retries: 24 + retries: 6 start_period: 10s central-auth: @@ -400,31 +399,18 @@ services: dockerfile: central/auth/Dockerfile image: unicron-central-auth container_name: central-auth - user: '1000:1000' restart: unless-stopped networks: - unicron-network depends_on: - postgres: - condition: service_healthy central-auth-mongodb: condition: service_healthy - volumes: - - central-auth-migration:/migration environment: NODE_ENV: production PORT: 3020 - POSTGRES_HOST: postgres - POSTGRES_PORT: 5432 - POSTGRES_USER: '${POSTGRES_USER}' - POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' - POSTGRES_DB: '${POSTGRES_DB}' - CENTRAL_AUTH_POSTGRES_SCHEMA: '${CENTRAL_AUTH_POSTGRES_SCHEMA:-central_auth}' - POSTGRES_SSL: 'false' - LEGACY_MONGODB_URI: 'mongodb://${CENTRAL_AUTH_MONGO_ROOT_USERNAME:-root}:${CENTRAL_AUTH_MONGO_ROOT_PASSWORD:-password}@central-auth-mongodb:27017/?authSource=admin' - LEGACY_MONGODB_DB_NAME: '${CENTRAL_AUTH_MONGODB_DB_NAME:-unicron_central_auth}' - LEGACY_MONGODB_SOURCE_STATE_FILE: /migration/source-state - LEGACY_MONGODB_MIGRATION_MARKER: /migration/completed + MONGODB_URI: 'mongodb://${CENTRAL_AUTH_MONGO_ROOT_USERNAME:-root}:${CENTRAL_AUTH_MONGO_ROOT_PASSWORD:-password}@central-auth-mongodb:27017/?authSource=admin' + MONGODB_DB_NAME: '${CENTRAL_AUTH_MONGODB_DB_NAME:-unicron_central_auth}' + MONGODB_TLS: 'false' CENTRAL_AUTH_SECRET: '${CENTRAL_AUTH_SECRET:?CENTRAL_AUTH_SECRET is required}' CENTRAL_AUTH_BASE_URL: 'https://${UNICRON_CENTRAL_FQDN}:${UNICRON_CENTRAL_PORT}/unicron/auth' CENTRAL_AUTH_COOKIE_NAME: '${CENTRAL_AUTH_COOKIE_NAME:-unicron.central_auth.session}' @@ -710,10 +696,6 @@ services: start_period: 30s volumes: - central-auth-mongo-data: - name: unicron-central-auth-mongo-data - central-auth-migration: - name: unicron-central-auth-migration stepca-data: name: unicron-stepca-data stepca-trust: @@ -740,6 +722,8 @@ volumes: name: unicron-otel-data notifier-data: name: unicron-notifier-data + central-auth-mongo-data: + name: unicron-central-auth-mongo-data networks: unicron-network: diff --git a/ops/unicron/scripts/legacy-auth-mongodb.sh b/ops/unicron/scripts/legacy-auth-mongodb.sh deleted file mode 100644 index 98a65e8..0000000 --- a/ops/unicron/scripts/legacy-auth-mongodb.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/sh -set -eu - -state_dir=/migration -mkdir -p "$state_dir" -chown 1000:1000 "$state_dir" -chmod 700 "$state_dir" -rm -f "$state_dir/source-state" - -state=empty -if [ -s "$state_dir/completed" ]; then - state=completed -else - # Ignore an empty Docker volume, but never initialize over unrecognized old data. - for entry in /data/db/* /data/db/.[!.]* /data/db/..?*; do - [ -e "$entry" ] || continue - [ "$(basename "$entry")" = lost+found ] && continue - state=required - break - done -fi -printf '%s\n' "$state" > "$state_dir/source-state.tmp" -chmod 644 "$state_dir/source-state.tmp" -mv "$state_dir/source-state.tmp" "$state_dir/source-state" - -if [ "$state" = required ]; then - # This is an existing database. Do not let the upstream entrypoint initialize a new one. - if [ ! -f /data/db/WiredTiger ] && [ ! -f /data/db/storage.bson ]; then - echo 'Unrecognized legacy auth data; refusing to start a new MongoDB database.' >&2 - exit 1 - fi - exec /usr/local/bin/docker-entrypoint.sh mongod --auth --bind_ip_all -fi - -# Keep the dependency healthy without invoking MongoDB on fresh or migrated installs. -exec sleep infinity