From 2cb383d7cd55a8b6741351543d4dfe9294ad3787 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Thu, 5 Feb 2026 20:59:41 +0200 Subject: [PATCH 1/3] Bootstrap initial admin from env on startup Add automatic initial-admin provisioning during API startup. Behavior: - On startup, check whether any superuser exists. - If none exists, require KCI_INITIAL_PASSWORD and create the first admin. - If KCI_INITIAL_PASSWORD is missing in that case, fail fast and exit. - Support optional KCI_INITIAL_ADMIN_USERNAME and KCI_INITIAL_ADMIN_EMAIL for identity defaults. Signed-off-by: Denys Fedoryshchenko --- api/admin.py | 35 +++++++++++++++++++++++++++-------- api/main.py | 40 ++++++++++++++++++++++++++++++++++++++++ doc/local-instance.md | 27 ++++++++++++++++++++++----- env.sample | 3 +++ 4 files changed, 92 insertions(+), 13 deletions(-) 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..8ffbcfea 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 @@ -90,6 +91,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 @@ -166,6 +168,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/doc/local-instance.md b/doc/local-instance.md index 29170c43..481befdc 100644 --- a/doc/local-instance.md +++ b/doc/local-instance.md @@ -38,7 +38,14 @@ 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 +``` + +`SECRET_KEY` is always required. `KCI_INITIAL_PASSWORD` is required only when +no admin user exists yet. ### Start docker-compose @@ -78,11 +85,21 @@ 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 +On startup, the API now bootstraps the first admin account automatically if no +admin exists yet: + +* `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`) + +After the first admin exists, `KCI_INITIAL_PASSWORD` is no longer required for +startup. + +You can still create an admin manually 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. +tool (wrapper: `setup_admin_user`), for example: ``` $ ./scripts/setup_admin_user --email EMAIL diff --git a/env.sample b/env.sample index 107bf4ba..fca974c2 100644 --- a/env.sample +++ b/env.sample @@ -6,3 +6,6 @@ SMTP_HOST= SMTP_PORT= EMAIL_SENDER= EMAIL_PASSWORD= +KCI_INITIAL_PASSWORD= +KCI_INITIAL_ADMIN_USERNAME= +KCI_INITIAL_ADMIN_EMAIL= From 3343ce1ad451ba7651dac11612b148b3e5974398 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Thu, 5 Feb 2026 21:05:28 +0200 Subject: [PATCH 2/3] docs: switch admin setup to startup bootstrap Update documentation to describe automatic initial admin creation via KCI_INITIAL_PASSWORD and optional identity env vars. Remove references to manual setup_admin_user flow and delete scripts/setup_admin_user. Signed-off-by: Denys Fedoryshchenko --- doc/api-details.md | 15 ++++++++++----- doc/local-instance.md | 25 +++++-------------------- scripts/setup_admin_user | 15 --------------- 3 files changed, 15 insertions(+), 40 deletions(-) delete mode 100755 scripts/setup_admin_user diff --git a/doc/api-details.md b/doc/api-details.md index 5d79cd20..9bea3a9f 100644 --- a/doc/api-details.md +++ b/doc/api-details.md @@ -44,11 +44,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 481befdc..b1f7fde3 100644 --- a/doc/local-instance.md +++ b/doc/local-instance.md @@ -76,7 +76,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. @@ -85,7 +85,7 @@ 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. -On startup, the API now bootstraps the first admin account automatically if no +On startup, the API bootstraps the first admin account automatically if no admin exists yet: * `KCI_INITIAL_PASSWORD` must be set, otherwise startup fails with an error @@ -97,29 +97,14 @@ admin exists yet: After the first admin exists, `KCI_INITIAL_PASSWORD` is no longer required for startup. -You can still create an admin manually with the -[`api.admin`](https://github.com/kernelci/kernelci-api/blob/main/api/admin.py) -tool (wrapper: `setup_admin_user`), for example: - -``` -$ ./scripts/setup_admin_user --email EMAIL -Creating kernelci-api_api_run ... done -Password for user 'admin': -Creating admin user... -``` - -> **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. - > **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/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 From 0ae60f72092ee34ed72fae199af1100e7d69b77e Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Thu, 5 Feb 2026 21:09:13 +0200 Subject: [PATCH 3/3] startup: add preflight env validation Fail fast during startup with a single clear validation error for required environment variables instead of piecemeal runtime failures. Validated at startup: - SECRET_KEY and update env/docs to reflect required first-boot configuration. Signed-off-by: Denys Fedoryshchenko --- api/main.py | 37 ++++++++++++++++++++++++++++++++++++- api/maintenance.py | 10 ++++------ doc/api-details.md | 4 +++- doc/local-instance.md | 10 ++++++++-- env.sample | 1 + 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/api/main.py b/api/main.py index 8ffbcfea..281efd1d 100644 --- a/api/main.py +++ b/api/main.py @@ -83,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 @@ -103,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 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 9bea3a9f..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 diff --git a/doc/local-instance.md b/doc/local-instance.md index b1f7fde3..f08bbf7d 100644 --- a/doc/local-instance.md +++ b/doc/local-instance.md @@ -44,8 +44,14 @@ For a fresh database, define an initial admin password too: $ echo KCI_INITIAL_PASSWORD= >> .env ``` -`SECRET_KEY` is always required. `KCI_INITIAL_PASSWORD` is required only when -no admin user exists yet. +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 diff --git a/env.sample b/env.sample index fca974c2..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=