Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,32 @@
import argparse
import sys
import getpass
import os
import pymongo

from .auth import Authentication
from .db import Database
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}': ")

Check warning on line 37 in api/admin.py

View workflow job for this annotation

GitHub Actions / Lint

line too long (81 > 79 characters)
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:
Expand Down Expand Up @@ -55,8 +67,10 @@
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__':
Expand All @@ -69,5 +83,10 @@
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)
77 changes: 76 additions & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
import traceback
import secrets
import ipaddress
import pymongo
from typing import List, Union, Optional

Check warning on line 19 in api/main.py

View workflow job for this annotation

GitHub Actions / Lint

standard import "typing.List" should be placed before third party import "pymongo"
from datetime import datetime, timedelta, timezone

Check warning on line 20 in api/main.py

View workflow job for this annotation

GitHub Actions / Lint

standard import "datetime.datetime" should be placed before third party import "pymongo"
from contextlib import asynccontextmanager

Check warning on line 21 in api/main.py

View workflow job for this annotation

GitHub Actions / Lint

standard import "contextlib.asynccontextmanager" should be placed before third party import "pymongo"
from fastapi import (
Depends,
FastAPI,
Expand Down Expand Up @@ -46,7 +47,7 @@
from pydantic import BaseModel
from jose import jwt
from jose.exceptions import JWTError
from kernelci.api.models import (

Check failure on line 50 in api/main.py

View workflow job for this annotation

GitHub Actions / Lint

Unable to import 'kernelci.api.models'
Node,
Hierarchy,
PublishEvent,
Expand Down Expand Up @@ -84,12 +85,48 @@
SUBSCRIPTION_CLEANUP_RETRY_MINUTES = 1 # Retry interval if cleanup fails


def _validate_startup_environment():
"""Validate required environment variables before app initialization."""
required_env_vars = (
"SECRET_KEY",
"MONGO_SERVICE",
)
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
async def lifespan(app: FastAPI): # pylint: disable=redefined-outer-name
"""Lifespan functions for startup and shutdown events"""
await pubsub_startup()
await create_indexes()
await initialize_beanie()
await ensure_initial_admin_user()
await ensure_legacy_node_editors()
yield

Expand All @@ -101,7 +138,7 @@
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.environ['MONGO_SERVICE'])
auth = Authentication(token_url="user/login")
pubsub = None # pylint: disable=invalid-name

Expand Down Expand Up @@ -166,6 +203,44 @@
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'"""
Expand Down
19 changes: 13 additions & 6 deletions doc/api-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: `<username>@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)
Expand Down
46 changes: 27 additions & 19 deletions doc/local-instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<strong-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

Expand Down Expand Up @@ -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.
Expand All @@ -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:
`<username>@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' \
Expand Down
4 changes: 4 additions & 0 deletions env.sample
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
SECRET_KEY=
MONGO_SERVICE=mongodb://db:27017
#algorithm=
#access_token_expire_minutes=
PUBLIC_BASE_URL=
SMTP_HOST=
SMTP_PORT=
EMAIL_SENDER=
EMAIL_PASSWORD=
KCI_INITIAL_PASSWORD=
KCI_INITIAL_ADMIN_USERNAME=
KCI_INITIAL_ADMIN_EMAIL=
15 changes: 0 additions & 15 deletions scripts/setup_admin_user

This file was deleted.

Loading