diff --git a/api/admin.py b/api/admin.py index 8ca5fd3d..c0123d12 100644 --- a/api/admin.py +++ b/api/admin.py @@ -13,6 +13,7 @@ import argparse import sys import getpass +import os import pymongo from .auth import Authentication @@ -20,13 +21,24 @@ from .models import User -async def setup_admin_user(db, username, email): +async def setup_admin_user(db, username, email, password=None): """Create an admin user""" - password = getpass.getpass(f"Password for user '{username}': ") - retyped = getpass.getpass(f"Retype password for user '{username}': ") - if password != retyped: - print("Sorry, passwords do not match, aborting.") - return None + if not password: + password = getpass.getpass(f"Password for user '{username}': ") + if not password: + password = os.getenv("KCI_INITIAL_PASSWORD") + if not password: + print( + "Password is empty and KCI_INITIAL_PASSWORD is not set, " + "aborting." + ) + return None + else: + retyped = getpass.getpass(f"Retype password for user '{username}': ") + if password != retyped: + print("Sorry, passwords do not match, aborting.") + return None + hashed_password = Authentication.get_password_hash(password) print(f"Creating {username} user...") try: @@ -55,8 +67,10 @@ async def main(args): db = Database(args.mongo, args.database) await db.initialize_beanie() await db.create_indexes() - await setup_admin_user(db, args.username, args.email) - return True + created = await setup_admin_user( + db, args.username, args.email, password=args.password + ) + return created is not None if __name__ == '__main__': @@ -69,5 +83,10 @@ async def main(args): help="KernelCI database name") parser.add_argument('--email', required=True, help="Admin user email address") + parser.add_argument( + '--password', + default='', + help="Admin password (if empty, falls back to KCI_INITIAL_PASSWORD)", + ) arguments = parser.parse_args() sys.exit(0 if asyncio.run(main(arguments)) else 1) diff --git a/api/main.py b/api/main.py index 6d23f6b7..281efd1d 100644 --- a/api/main.py +++ b/api/main.py @@ -15,6 +15,7 @@ import traceback import secrets import ipaddress +import pymongo from typing import List, Union, Optional from datetime import datetime, timedelta, timezone from contextlib import asynccontextmanager @@ -82,6 +83,41 @@ SUBSCRIPTION_CLEANUP_INTERVAL_MINUTES = 15 # How often to run cleanup task SUBSCRIPTION_MAX_AGE_MINUTES = 15 # Max age before stale SUBSCRIPTION_CLEANUP_RETRY_MINUTES = 1 # Retry interval if cleanup fails +DEFAULT_MONGO_SERVICE = "mongodb://db:27017" + + +def _validate_startup_environment(): + """Validate required environment variables before app initialization.""" + required_env_vars = ( + "SECRET_KEY", + ) + missing = [] + empty = [] + for name in required_env_vars: + value = os.getenv(name) + if value is None: + missing.append(name) + elif value.strip() == "": + empty.append(name) + + if missing or empty: + details = [] + if missing: + details.append( + "missing: " + ", ".join(sorted(missing)) + ) + if empty: + details.append( + "empty: " + ", ".join(sorted(empty)) + ) + raise RuntimeError( + "Startup environment validation failed. " + "Set required environment variables before starting the API. " + + "; ".join(details) + ) + + +_validate_startup_environment() @asynccontextmanager @@ -90,6 +126,7 @@ async def lifespan(app: FastAPI): # pylint: disable=redefined-outer-name await pubsub_startup() await create_indexes() await initialize_beanie() + await ensure_initial_admin_user() await ensure_legacy_node_editors() yield @@ -101,7 +138,7 @@ async def lifespan(app: FastAPI): # pylint: disable=redefined-outer-name metrics = Metrics() app = FastAPI(lifespan=lifespan, debug=True, docs_url=None, redoc_url=None) -db = Database(service=(os.getenv('MONGO_SERVICE') or 'mongodb://db:27017')) +db = Database(service=os.getenv("MONGO_SERVICE", DEFAULT_MONGO_SERVICE)) auth = Authentication(token_url="user/login") pubsub = None # pylint: disable=invalid-name @@ -166,6 +203,44 @@ async def ensure_legacy_node_editors(): await db.update(user) +async def ensure_initial_admin_user(): + """Create initial admin user on startup when none exists.""" + admin_count = await db.count(User, {"is_superuser": True}) + if admin_count > 0: + return + + initial_password = os.getenv("KCI_INITIAL_PASSWORD") + if not initial_password: + raise RuntimeError( + "No admin user exists. Set KCI_INITIAL_PASSWORD to bootstrap " + "the initial admin user." + ) + + username = os.getenv("KCI_INITIAL_ADMIN_USERNAME") or "admin" + email = os.getenv("KCI_INITIAL_ADMIN_EMAIL") or f"{username}@kernelci.org" + + try: + await db.create(User( + username=username, + hashed_password=Authentication.get_password_hash(initial_password), + email=email, + is_superuser=1, + is_verified=1, + )) + print(f"Created initial admin user '{username}' ({email}).") + except pymongo.errors.DuplicateKeyError as exc: + # Handle startup races across multiple API instances. + admin_count = await db.count(User, {"is_superuser": True}) + if admin_count > 0: + return + raise RuntimeError( + "Failed to bootstrap initial admin user due to duplicate " + f"username/email conflict ({exc}). " + "Set KCI_INITIAL_ADMIN_USERNAME/KCI_INITIAL_ADMIN_EMAIL to unique " + "values or create an admin manually." + ) from exc + + @app.exception_handler(ValueError) async def value_error_exception_handler(request: Request, exc: ValueError): """Global exception handler for 'ValueError'""" diff --git a/api/maintenance.py b/api/maintenance.py index 8103a5fb..2d8a436f 100644 --- a/api/maintenance.py +++ b/api/maintenance.py @@ -13,6 +13,8 @@ import os from pymongo import MongoClient +DEFAULT_MONGO_SERVICE = "mongodb://db:27017" + def purge_ids(db, collection, ids): """ @@ -33,16 +35,12 @@ def purge_ids(db, collection, ids): def connect_to_db(): """ Connect to the MongoDB database using the MONGO_SERVICE environment - variable. + variable, with a default fallback. Returns: db: The 'kernelci' MongoDB database instance. - Raises: - ValueError: If the MONGO_SERVICE environment variable is not set. """ - mongo_service = os.environ["MONGO_SERVICE"] - if not mongo_service: - raise ValueError("MONGO_SERVICE environment variable is not set") + mongo_service = os.getenv("MONGO_SERVICE", DEFAULT_MONGO_SERVICE) client = MongoClient(mongo_service) db = client["kernelci"] return db diff --git a/doc/api-details.md b/doc/api-details.md index 5d79cd20..802f8ccc 100644 --- a/doc/api-details.md +++ b/doc/api-details.md @@ -34,7 +34,9 @@ should be added to the .env file. By default, API uses Redis and Database services specified in [`docker-compose.yaml`](https://github.com/kernelci/kernelci-api/blob/main/docker-compose.yaml). API is configured to use redis hostname `redis` and database service URL `mongodb://db:27017` at the moment. -In case of using different services or configurations, `REDIS_HOST` and `MONGO_SERVICE` variables should be added to .env file. +`MONGO_SERVICE` must be set in `.env` (for example +`MONGO_SERVICE=mongodb://db:27017`). `REDIS_HOST` is optional and only needed +when not using the default redis hostname `redis`. ## Users @@ -44,11 +46,16 @@ user management. ### Create an admin user -The very first admin user needs to be created with -[`api.admin`](https://github.com/kernelci/kernelci-api/blob/main/api/admin.py) -tool provided in the `kernelci-api` repository. -[Here](../local-instance/#create-an-admin-user-account) is a guide to -setup an admin user. We can use this admin user to create other user accounts. +On startup, if no admin exists yet, the API automatically creates the first +admin user from environment variables: + +- `KCI_INITIAL_PASSWORD` (required for first bootstrap) +- `KCI_INITIAL_ADMIN_USERNAME` (optional, default: `admin`) +- `KCI_INITIAL_ADMIN_EMAIL` (optional, default: `@kernelci.org`) + +[Here](../local-instance/#bootstrap-the-initial-admin-user) is a guide to +bootstrap an admin user. You can use this admin account to create other user +accounts. ### Invite user (Admin only, required) diff --git a/doc/local-instance.md b/doc/local-instance.md index 29170c43..f08bbf7d 100644 --- a/doc/local-instance.md +++ b/doc/local-instance.md @@ -38,7 +38,20 @@ encryption algorithms. To generate one for your local instance: $ echo SECRET_KEY=$(openssl rand -hex 32) >> .env ``` -This `SECRET_KEY` environment variable is currently the only required one. +For a fresh database, define an initial admin password too: + +``` +$ echo KCI_INITIAL_PASSWORD= >> .env +``` + +Set the MongoDB connection string too: + +``` +$ echo MONGO_SERVICE=mongodb://db:27017 >> .env +``` + +`SECRET_KEY` and `MONGO_SERVICE` are always required. +`KCI_INITIAL_PASSWORD` is required only when no admin user exists yet. ### Start docker-compose @@ -69,7 +82,7 @@ show: kernelci-api | INFO: 172.20.0.1:49228 - "GET / HTTP/1.1" 200 OK ``` -### Create an admin user account +### Bootstrap the initial admin user Some parts of the API don't require any authentication, like in the example above with the root `/` endpoint and most `GET` requests to retrieve data. @@ -78,31 +91,26 @@ by authenticated users. This will be required to run a full pipeline or to subscribe to the pub/sub interface. Then some users have administrator rights, which enables them to create new user accounts. -So let's start by creating the initial admin user account. This can be done -with the -[`api.admin`](https://github.com/kernelci/kernelci-api/blob/main/api/admin.py) -tool provided in the `kernelci-api` repository which has a wrapper script -`setup_admin_user`. It can be called with the name of the admin user you want to create such as `admin`, then enter the admin password when prompted. Also, provide email address for the user account in the command line argument. +On startup, the API bootstraps the first admin account automatically if no +admin exists yet: -``` -$ ./scripts/setup_admin_user --email EMAIL -Creating kernelci-api_api_run ... done -Password for user 'admin': -Creating admin user... -``` +* `KCI_INITIAL_PASSWORD` must be set, otherwise startup fails with an error + and the API exits. +* `KCI_INITIAL_ADMIN_USERNAME` is optional (default: `admin`) +* `KCI_INITIAL_ADMIN_EMAIL` is optional (default: + `@kernelci.org`) -> **Note** Strictly speaking, only the `db` service needs to be running in -> order to use this tool. In fact it can also be used with any other MongoDB -> instance such as an Atlas account using the `--mongo` command line argument. +After the first admin exists, `KCI_INITIAL_PASSWORD` is no longer required for +startup. > **Note** For more details about how to create users via the raw API, see the > [API documentation](../api-details/#users) ### Create an admin API token -Then to get an API token, the `/user/login` API endpoint can be used. For example, -to create an admin token with the same user name and password as used -previously: +Then to get an API token, the `/user/login` API endpoint can be used. For +example, to create an admin token with the same username and password set in +`KCI_INITIAL_PASSWORD`: ``` $ curl -X 'POST' \ diff --git a/env.sample b/env.sample index 107bf4ba..85c7ec8e 100644 --- a/env.sample +++ b/env.sample @@ -1,4 +1,5 @@ SECRET_KEY= +MONGO_SERVICE=mongodb://db:27017 #algorithm= #access_token_expire_minutes= PUBLIC_BASE_URL= @@ -6,3 +7,6 @@ SMTP_HOST= SMTP_PORT= EMAIL_SENDER= EMAIL_PASSWORD= +KCI_INITIAL_PASSWORD= +KCI_INITIAL_ADMIN_USERNAME= +KCI_INITIAL_ADMIN_EMAIL= diff --git a/scripts/setup_admin_user b/scripts/setup_admin_user deleted file mode 100755 index 2869484b..00000000 --- a/scripts/setup_admin_user +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -# is docker-compose exists? if not use docker compose -if [ -z "$(which docker-compose)" ]; then - echo "docker-compose is not installed, using docker compose" - DOCKER_COMPOSE="docker compose" -else - DOCKER_COMPOSE="docker-compose" -fi - -set -e - -${DOCKER_COMPOSE} run --rm api python3 -m api.admin $* - -exit 0