diff --git a/README.md b/README.md index 4d5f21f2..8b446e43 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ Generate a new key for [Authentication](https://docs.kernelci.org/api_pipeline/a After that, please refer to [create and add a user](https://docs.kernelci.org/api_pipeline/api/local-instance/#create-an-admin-user-account) in Mongo DB. The user can also generate an [API token](https://docs.kernelci.org/api_pipeline/api/local-instance/#create-an-admin-pipeline-token) to use API endpoints. +For simple user management (invite, accept, login, whoami), use the helper +script `scripts/usermanager.py`. It accepts an optional +`usermanager.toml` config file (including multiple instances) or +`KCI_API_URL` / `KCI_API_TOKEN` / `KCI_API_INSTANCE` environment variables. + Ultimately, there will be a web frontend to provide a login form. We don't have that yet as this new KernelCI API implementation is still in its early stages. diff --git a/api/config.py b/api/config.py index 1c25504f..815b859c 100644 --- a/api/config.py +++ b/api/config.py @@ -16,6 +16,8 @@ class AuthSettings(BaseSettings): algorithm: str = "HS256" # Set to None so tokens don't expire access_token_expire_seconds: float = 315360000 + invite_token_expire_seconds: int = 60 * 60 * 24 * 7 # 7 days + public_base_url: str | None = None # pylint: disable=too-few-public-methods diff --git a/api/main.py b/api/main.py index 543ed652..34eb5c0c 100644 --- a/api/main.py +++ b/api/main.py @@ -13,8 +13,10 @@ import re import asyncio import traceback +import secrets +import ipaddress from typing import List, Union, Optional -from datetime import datetime +from datetime import datetime, timedelta, timezone from contextlib import asynccontextmanager from fastapi import ( Depends, @@ -41,6 +43,8 @@ from fastapi_users import FastAPIUsers from beanie import PydanticObjectId from pydantic import BaseModel +from jose import jwt +from jose.exceptions import JWTError from kernelci.api.models import ( Node, Hierarchy, @@ -61,12 +65,17 @@ UserRead, UserCreate, UserCreateRequest, + UserInviteRequest, + UserInviteResponse, UserUpdate, UserUpdateRequest, UserGroup, + InviteAcceptRequest, + InviteUrlResponse, ) from .metrics import Metrics from .maintenance import purge_old_nodes +from .config import AuthSettings SUBSCRIPTION_CLEANUP_INTERVAL_MINUTES = 15 # How often to run cleanup task SUBSCRIPTION_MAX_AGE_MINUTES = 15 # Max age before stale @@ -182,6 +191,168 @@ def get_current_superuser(user: User = Depends( return user +async def _resolve_user_groups(group_names: List[str]) -> List[UserGroup]: + groups: List[UserGroup] = [] + for group_name in group_names: + group = await db.find_one(UserGroup, name=group_name) + if not group: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"User group does not exist with name: {group_name}", + ) + groups.append(group) + return groups + + +def _create_invite_token(user: User) -> str: + settings = AuthSettings() + now = datetime.now(timezone.utc) + exp = now + timedelta(seconds=settings.invite_token_expire_seconds) + payload = { + "sub": str(user.id), + "email": user.email, + "purpose": "invite", + "iat": int(now.timestamp()), + "exp": int(exp.timestamp()), + } + return jwt.encode( + payload, + settings.secret_key, + algorithm=settings.algorithm, + ) + + +def _decode_invite_token(token: str) -> dict: + settings = AuthSettings() + try: + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[settings.algorithm], + ) + except JWTError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired invite token", + ) from exc + if payload.get("purpose") != "invite": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid invite token", + ) + return payload + + +def _resolve_public_base_url(request: Request) -> str: + settings = AuthSettings() + if settings.public_base_url: + return settings.public_base_url.rstrip("/") + + def _is_proxy_addr() -> bool: + client = request.client + if not client or not client.host: + return False + try: + ip_addr = ipaddress.ip_address(client.host) + except ValueError: + return False + return not ip_addr.is_global + + forwarded_host = None + forwarded_proto = None + forwarded_header = request.headers.get("forwarded") + if forwarded_header and _is_proxy_addr(): + first_hop = forwarded_header.split(",", 1)[0] + for pair in first_hop.split(";"): + if "=" not in pair: + continue + key, value = pair.split("=", 1) + key = key.strip().lower() + value = value.strip().strip('"') + if key == "host": + forwarded_host = value + elif key == "proto": + forwarded_proto = value + + if _is_proxy_addr(): + forwarded_host = forwarded_host or request.headers.get( + "x-forwarded-host" + ) + forwarded_proto = forwarded_proto or request.headers.get( + "x-forwarded-proto" + ) + + if forwarded_host: + scheme = forwarded_proto or request.url.scheme + return f"{scheme}://{forwarded_host}".rstrip("/") + + return str(request.base_url).rstrip("/") + + +def _accept_invite_url(public_base_url: str) -> str: + return f"{public_base_url}/user/accept-invite" + + +async def _find_existing_user_for_invite( + invite: UserInviteRequest, +) -> User | None: + existing_by_username = await db.find_one(User, username=invite.username) + if existing_by_username: + return existing_by_username + return await db.find_one(User, email=invite.email) + + +def _validate_invite_resend(existing_user: User, invite: UserInviteRequest): + if not invite.resend_if_exists: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already exists", + ) + if existing_user.is_verified: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already verified", + ) + if existing_user.username != invite.username: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Existing user has a different username", + ) + if existing_user.email != invite.email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Existing user has a different email", + ) + + +async def _create_user_for_invite( + request: Request, + invite: UserInviteRequest, +) -> User: + groups: List[UserGroup] = [] + if invite.groups: + groups = await _resolve_user_groups(invite.groups) + + random_password = secrets.token_urlsafe(32) + user_create = UserCreate( + username=invite.username, + email=invite.email, + password=random_password, + is_superuser=invite.is_superuser, + ) + user_create.groups = groups + + created_user = await register_router.routes[0].endpoint( + request, user_create, user_manager) + + if invite.is_superuser: + user_from_id = await db.find_by_id(User, created_user.id) + user_from_id.is_superuser = True + created_user = await db.update(user_from_id) + + return created_user + + app.include_router( fastapi_users_instance.get_auth_router(auth_backend, requires_verification=True), @@ -204,35 +375,135 @@ async def register(request: Request, user: UserCreateRequest, This handler will convert them to `UserGroup` objects and insert user object to database. """ + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User registration is disabled; use /user/invite instead.", + ) + + +@app.get("/user/invite", response_class=HTMLResponse, include_in_schema=False) +async def invite_user_page(): + """Web UI for inviting a user (admin token required)""" + metrics.add('http_requests_total', 1) + root_dir = os.path.dirname(os.path.abspath(__file__)) + page_path = os.path.join(root_dir, 'templates', 'invite.html') + with open(page_path, 'r', encoding='utf-8') as file: + return HTMLResponse(file.read()) + + +@app.post("/user/invite", response_model=UserInviteResponse, tags=["user"], + response_model_by_alias=False) +async def invite_user(request: Request, invite: UserInviteRequest, + current_user: User = Depends(get_current_superuser)): + """Invite a user (admin-only) + + Creates the user with a random password and sends a single invite link + to set a password and verify the account. + """ metrics.add('http_requests_total', 1) - existing_user = await db.find_one(User, username=user.username) + existing_user = await _find_existing_user_for_invite(invite) if existing_user: + _validate_invite_resend(existing_user, invite) + created_user = existing_user + else: + created_user = await _create_user_for_invite(request, invite) + + public_base_url = _resolve_public_base_url(request) + accept_invite_url = _accept_invite_url(public_base_url) + token = _create_invite_token(created_user) + invite_url = f"{accept_invite_url}?token={token}" + + email_sent = False + if invite.send_email: + try: + await user_manager.send_invite_email( + created_user, + token, + invite_url, + ) + email_sent = True + except Exception as exc: # pylint: disable=broad-exception-caught + print(f"Failed to send invite email: {exc}") + + return UserInviteResponse( + user=created_user, + email_sent=email_sent, + public_base_url=public_base_url, + accept_invite_url=accept_invite_url, + invite_url=invite_url if invite.return_token else None, + token=token if invite.return_token else None, + ) + + +@app.get( + "/user/accept-invite", + response_class=HTMLResponse, + include_in_schema=False, +) +async def accept_invite_page(): + """Web UI for accepting an invite (sets password + verifies)""" + metrics.add('http_requests_total', 1) + root_dir = os.path.dirname(os.path.abspath(__file__)) + page_path = os.path.join(root_dir, 'templates', 'accept-invite.html') + with open(page_path, 'r', encoding='utf-8') as file: + return HTMLResponse(file.read()) + + +@app.get("/user/invite/url", response_model=InviteUrlResponse, tags=["user"]) +async def invite_url_preview(request: Request, + current_user: User = Depends( + get_current_superuser)): + """Preview the resolved public URL used in invite links (admin-only)""" + metrics.add('http_requests_total', 1) + public_base_url = _resolve_public_base_url(request) + return InviteUrlResponse( + public_base_url=public_base_url, + accept_invite_url=_accept_invite_url(public_base_url), + ) + + +@app.post("/user/accept-invite", response_model=UserRead, tags=["user"], + response_model_by_alias=False) +async def accept_invite(accept: InviteAcceptRequest): + """Accept an invite token, set password, and verify the user""" + metrics.add('http_requests_total', 1) + payload = _decode_invite_token(accept.token) + user_id = payload.get("sub") + email = payload.get("email") + + user_from_id = await db.find_by_id(User, user_id) + if not user_from_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + if not user_from_id.is_active: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Username already exists", + detail="User is inactive", + ) + if user_from_id.email != email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid invite token", + ) + if user_from_id.is_verified: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invite already accepted", ) - groups = [] - if user.groups: - for group_name in user.groups: - group = await db.find_one(UserGroup, name=group_name) - if not group: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"User group does not exist with name: \ - {group_name}") - groups.append(group) - user_create = UserCreate(**(user.model_dump( - exclude={'groups'}, exclude_none=True))) - user_create.groups = groups - created_user = await register_router.routes[0].endpoint( - request, user_create, user_manager) - # Update user to be an admin user explicitly if requested as - # `fastapi-users` register route does not allow it - if user.is_superuser: - user_from_id = await db.find_by_id(User, created_user.id) - user_from_id.is_superuser = True - created_user = await db.update(user_from_id) - return created_user + + user_from_id.hashed_password = user_manager.password_helper.hash( + accept.password + ) + user_from_id.is_verified = True + updated_user = await db.update(user_from_id) + + try: + await user_manager.send_invite_accepted_email(updated_user) + except Exception as exc: # pylint: disable=broad-exception-caught + print(f"Failed to send invite accepted email: {exc}") + return updated_user app.include_router( @@ -240,11 +511,6 @@ async def register(request: Request, user: UserCreateRequest, prefix="/user", tags=["user"], ) -app.include_router( - fastapi_users_instance.get_verify_router(UserRead), - prefix="/user", - tags=["user"], -) users_router = fastapi_users_instance.get_users_router( UserRead, UserUpdate, requires_verification=True) diff --git a/api/models.py b/api/models.py index 1cca2fa0..a9e02757 100644 --- a/api/models.py +++ b/api/models.py @@ -15,6 +15,7 @@ from typing import Optional, TypeVar, List from pydantic import ( BaseModel, + EmailStr, Field, field_validator, ) @@ -216,6 +217,53 @@ def validate_groups(cls, groups): # pylint: disable=no-self-argument return groups +# Invite-only user onboarding models + +class UserInviteRequest(BaseModel): + """Admin invite request schema for API router""" + + username: Annotated[str, Indexed(unique=True)] + email: EmailStr + groups: List[str] = Field(default=[]) + is_superuser: bool = False + send_email: bool = True + return_token: bool = False + resend_if_exists: bool = False + + @field_validator('groups') + def validate_groups(cls, groups): # pylint: disable=no-self-argument + """Unique group constraint""" + unique_names = set(groups) + if len(unique_names) != len(groups): + raise ValueError("Groups must have unique names.") + return groups + + +class InviteAcceptRequest(BaseModel): + """Accept invite request schema for API router""" + + token: str + password: str + + +class UserInviteResponse(BaseModel): + """Invite response schema""" + + user: UserRead + email_sent: bool + public_base_url: str + accept_invite_url: str + invite_url: Optional[str] = None + token: Optional[str] = None + + +class InviteUrlResponse(BaseModel): + """Resolved public URL info for invite/accept endpoints""" + + public_base_url: str + accept_invite_url: str + + # Pagination models class CustomLimitOffsetParams(LimitOffsetParams): diff --git a/api/pubsub_mongo.py b/api/pubsub_mongo.py index af6ddcd3..811022e4 100644 --- a/api/pubsub_mongo.py +++ b/api/pubsub_mongo.py @@ -384,7 +384,11 @@ async def subscribe(self, channel: str, user: str, # If subscriber_id provided, set up durable subscription if subscriber_id: await self._setup_durable_subscription( - sub_id, subscriber_id, channel, user, promiscuous=promiscuous + sub_id, + subscriber_id, + channel=channel, + user=user, + promiscuous=promiscuous, ) return sub @@ -392,7 +396,7 @@ async def subscribe(self, channel: str, user: str, # pylint: disable=too-many-arguments async def _setup_durable_subscription( self, sub_id: int, subscriber_id: str, - channel: str, user: str, *, promiscuous: bool): + *, channel: str, user: str, promiscuous: bool): """Set up or restore durable subscription state""" col = self._mongo_db[self.SUBSCRIBER_STATE_COLLECTION] existing = await col.find_one({'subscriber_id': subscriber_id}) diff --git a/api/templates/accept-invite.html b/api/templates/accept-invite.html new file mode 100644 index 00000000..a9338fe2 --- /dev/null +++ b/api/templates/accept-invite.html @@ -0,0 +1,63 @@ + + + + + + KernelCI API - Accept Invite + + + +

Accept invite

+

Set your password to activate your account.

+ + + + + + +

Result

+
{}
+ + + + + diff --git a/api/templates/email-verification-successful.jinja2 b/api/templates/email-verification-successful.jinja2 deleted file mode 100644 index 01a04e3f..00000000 --- a/api/templates/email-verification-successful.jinja2 +++ /dev/null @@ -1,8 +0,0 @@ -{# SPDX-License-Identifier: LGPL-2.1-or-later -#} - -Hello {{ username }}, - -Your email address has been verified successfully. - -Thanks, -KernelCI SysAdmin diff --git a/api/templates/email-verification.jinja2 b/api/templates/email-verification.jinja2 deleted file mode 100644 index 053a1f5c..00000000 --- a/api/templates/email-verification.jinja2 +++ /dev/null @@ -1,10 +0,0 @@ -{# SPDX-License-Identifier: LGPL-2.1-or-later -#} - -Hello {{ username }}, - -The token for verifying your email address for KernelCI API account is: - - {{ token }} - -Thanks, -KernelCI SysAdmin diff --git a/api/templates/invite-accepted.jinja2 b/api/templates/invite-accepted.jinja2 new file mode 100644 index 00000000..2ab74904 --- /dev/null +++ b/api/templates/invite-accepted.jinja2 @@ -0,0 +1,6 @@ +Hello {{ username }}, + +Your KernelCI API account has been activated and your password has been set. + +You can now log in and create an API token using your username and password. + diff --git a/api/templates/invite-email.jinja2 b/api/templates/invite-email.jinja2 new file mode 100644 index 00000000..ff05b09a --- /dev/null +++ b/api/templates/invite-email.jinja2 @@ -0,0 +1,14 @@ +Hello {{ username }}, + +You have been invited to access the KernelCI API. + +Accept your invite and set your password using this link: + + {{ invite_url }} + +If you can't open the link, you can also accept via API by using this token: + + {{ token }} + +If you did not expect this invite, you can ignore this email. + diff --git a/api/templates/invite.html b/api/templates/invite.html new file mode 100644 index 00000000..6f87f7cc --- /dev/null +++ b/api/templates/invite.html @@ -0,0 +1,129 @@ + + + + + + KernelCI API - Invite User + + + +

Invite user

+

Requires an admin bearer token. This page stores it in localStorage.

+ + + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +

Result

+
{}
+ + + + diff --git a/api/user_manager.py b/api/user_manager.py index 946f1ca8..67ded7bc 100644 --- a/api/user_manager.py +++ b/api/user_manager.py @@ -45,25 +45,14 @@ async def on_after_register(self, user: User, """Handler to execute after successful user registration""" print(f"User {user.id} {user.username} has registered.") - async def on_after_request_verify(self, user: User, token: str, - request: Optional[Request] = None): - """Handler to execute after successful verification request""" - template = self._template_env.get_template("email-verification.jinja2") - subject = "Email verification Token for KernelCI API account" - content = template.render( - username=user.username, token=token - ) - self.email_sender.create_and_send_email(subject, content, user.email) - - async def on_after_verify(self, user: User, - request: Optional[Request] = None): - """Handler to execute after successful user verification""" - print(f"Verification successful for user {user.id} {user.username}") - template = self._template_env.get_template( - "email-verification-successful.jinja2") - subject = "Email verification successful for KernelCI API account" + async def send_invite_email(self, user: User, token: str, invite_url: str): + """Send an invite email containing a link to accept the invite""" + template = self._template_env.get_template("invite-email.jinja2") + subject = "You have been invited to KernelCI API" content = template.render( username=user.username, + invite_url=invite_url, + token=token, ) self.email_sender.create_and_send_email(subject, content, user.email) @@ -95,6 +84,13 @@ async def on_after_reset_password(self, user: User, ) self.email_sender.create_and_send_email(subject, content, user.email) + async def send_invite_accepted_email(self, user: User): + """Send a confirmation email after invite acceptance""" + template = self._template_env.get_template("invite-accepted.jinja2") + subject = "Welcome to KernelCI API" + content = template.render(username=user.username) + self.email_sender.create_and_send_email(subject, content, user.email) + async def on_after_update(self, user: User, update_dict: Dict[str, Any], request: Optional[Request] = None): """Handler to execute after successful user update""" diff --git a/doc/api-details.md b/doc/api-details.md index 4bde2171..a3a17b41 100644 --- a/doc/api-details.md +++ b/doc/api-details.md @@ -51,75 +51,202 @@ tool provided in the `kernelci-api` repository. setup an admin user. We can use this admin user to create other user accounts. -### Create user using endpoint (Admin only) +### Invite user (Admin only, required) -Now, we can use above created admin user to create regular users and other -admin users using `/user/register` API endpoint. We need to provide token to the endpoint for the authorization. +The recommended onboarding flow is invite-only: -To create a regular user, provide username, email address, and password to request data dictionary. +1. Admin creates (or re-sends) an invite using `POST /user/invite` +2. User opens the invite link and sets a password (this also verifies the account) + +Invite a new user (and return the token/link in the response for CLI usage): ``` -$ curl -X 'POST' - 'http://localhost:8001/latest/user/register' \ +$ curl -X 'POST' \ + 'http://localhost:8001/latest/user/invite' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0Iiwic2NvcGVzIjpbImFkbWluIiwidXNlciJdfQ.KhcIWfMRr3xTFSCLcr5L4KTUVSsfSsLeyRDEjgkQRBg' \ - -d '{"username":"test", "email": "test@kernelci.org", "password": "test"}' -{'id': '615f30020eb7c3c6616e5ac3', 'email': 'test@kernelci.org', 'is_active':true, 'is_superuser':false, 'is_verified':false, 'username': 'test', 'groups': []} + -H 'Authorization: Bearer ' \ + -d '{ + "username": "test", + "email": "test@kernelci.org", + "groups": [], + "is_superuser": false, + "send_email": false, + "return_token": true, + "resend_if_exists": false +}' ``` -To create an admin user, provide username, email, password, and `"is_superuser": 1` to request data dictionary. -A user account can be added to multiple user groups by providing a list of user group names to request dictionary. +When `send_email` is false, no SMTP configuration is required and the response +includes `invite_url` and `token` (when `return_token` is set) so you can +deliver the link manually. -For example, the below command will create an admin user and add it to `kernelci` user group. +If a user already exists, set `resend_if_exists` to true and ensure the +username and email match the existing user. Invites can only be resent to +unverified users. + +To preview which public URL will be used in invite links (admin-only): ``` -$ curl -X 'POST' 'http://localhost:8001/latest/user/register' -H 'accept: application/json' -H 'Content-Type: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0Iiwic2NvcGVzIjpbImFkbWluIiwidXNlciJdfQ.KhcIWfMRr3xTFSCLcr5L4KTUVSsfSsLeyRDEjgkQRBg' -d '{"username": "test_admin", "email": "test-admin@kernelci.org", "password": "admin", "is_superuser": 1, "groups": ["kernelci"]}' -{'_id': '615f30020eb7c3c6616e5ac6', 'username': 'test_admin', 'email': 'test-admin@kernelci.org', 'is_active':true, 'is_superuser':true, 'is_verified':false, 'groups': [{"id":"648ff894bd39930355ed16ad","name":"kernelci"}]} +$ curl -X 'GET' \ + 'http://localhost:8001/latest/user/invite/url' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ' +``` + +The public URL can be overridden via `PUBLIC_BASE_URL` in the environment. + +To accept an invite and set the password (no authentication required): + +``` +$ curl -X 'POST' \ + 'http://localhost:8001/latest/user/accept-invite' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "token": "", + "password": "" +}' ``` -Another way of creating users is to use `kci user add` tool from kernelci-core. +There is also a minimal web UI: + +- Admin invite page: `GET /user/invite` +- Invite acceptance page: `GET /user/accept-invite?token=` + +### CLI-oriented user management +This section summarizes the minimum admin and user flows needed to implement a +CLI. Use these endpoints verbatim and expect JSON responses unless noted. -### Verify user account +Helper script: -A user account needs to be verified before a user token can be retrieved -for the account. -Send API request to `POST /user/request-verify-token` endpoint to receive -a verification token for provided email address: +- `scripts/usermanager.py` provides a small CLI for these endpoints. +- Optional config file (checked in order): `./usermanager.toml`, + `~/.config/kernelci/usermanager.toml` +- Environment overrides: `KCI_API_URL`, `KCI_API_TOKEN` +- Optional instance selection: `--instance` or `KCI_API_INSTANCE` + +Example config: + +``` +default_instance = "local" + +[instances.local] +url = "http://localhost:8001/latest" +token = "" + +[instances.staging] +url = "https://staging.kernelci.org/latest" +token = "" +``` + +Use `--instance staging` (or `KCI_API_INSTANCE=staging`) to select an instance. + +Admin flow (requires admin bearer token): + +- Create invite: `POST /user/invite` + - Request fields: `username`, `email`, `groups`, `is_superuser`, + `send_email`, `return_token`, `resend_if_exists` + - Response fields: `user`, `email_sent`, `public_base_url`, + `accept_invite_url`, `invite_url` (optional), `token` (optional) + - Error cases: `400` if user exists and `resend_if_exists` is false, or if + existing user is already verified, or if username/email mismatch +- Preview public link base: `GET /user/invite/url` +- List users: `GET /users` +- Get user by ID: `GET /user/{id}` +- Update user: `PATCH /user/{id}` +- Delete user: `DELETE /user/{id}` + +User flow (no auth until invite accepted): + +- Accept invite: `POST /user/accept-invite` + - Request fields: `token`, `password` + - Error cases: `400` invalid/expired token, inactive user, or already accepted + invite; `404` user not found +- Get auth token: `POST /user/login` (form-encoded `username`, `password`) +- Who am I: `GET /whoami` +- Update own profile: `PATCH /user/me` +- Update password: `POST /user/update-password` +- Forgot/reset password: `POST /user/forgot-password`, then + `POST /user/reset-password` + +Invite link composition: + +- Default base uses request host; `PUBLIC_BASE_URL` overrides. +- The acceptance URL is `PUBLIC_BASE_URL` + `/user/accept-invite` with a + `token` query parameter. + +Examples for a CLI: + +Invite a user and return the token/link: ``` $ curl -X 'POST' \ - 'http://localhost:8001/latest/user/request-verify-token' \ + 'http://localhost:8001/latest/user/invite' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ -d '{ - "email": "test@kernelci.org" + "username": "alice", + "email": "alice@example.org", + "groups": ["kernelci"], + "is_superuser": false, + "send_email": false, + "return_token": true, + "resend_if_exists": false +}' ``` -The user will receive a verification token via email. -Now, request `POST /user/verify` endpoint and provide the verification -token in the request dictionary. +Sample response: + +``` +{ + "user": { + "id": "6526448e7d140ee220971a0e", + "email": "alice@example.org", + "is_active": true, + "is_superuser": false, + "is_verified": false, + "username": "alice", + "groups": [{"id":"648ff894bd39930355ed16ad","name":"kernelci"}] + }, + "email_sent": false, + "public_base_url": "http://localhost:8001", + "accept_invite_url": "http://localhost:8001/user/accept-invite", + "invite_url": "http://localhost:8001/user/accept-invite?token=", + "token": "" +} +``` + +Accept an invite: ``` $ curl -X 'POST' \ - 'http://localhost:8001/latest/user/verify' \ + 'http://localhost:8001/latest/user/accept-invite' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ -d '{ - "token": "" + "token": "", + "password": "" }' -{"id":"615f30020eb7c3c6616e5ac3","email":"test@kernelci.org","is_active":true,"is_superuser":false,"is_verified":true,"username":"test","groups":[]} ``` -`is_verified:true` in the response above denotes that a user account -has been verified successfully. The user will also receive an email -confirming the verification. + +Get an auth token: + +``` +$ curl -X 'POST' \ + 'http://localhost:8001/latest/user/login' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'username=alice&password=' +``` ### Get authorization token -After successful user verification, the user can retrieve authorization -token to use certain API endpoints requiring user authorization. +After successful user activation via an invite, the user can retrieve an +authorization token to use certain API endpoints requiring user authorization. ``` $ curl -X 'POST' \ diff --git a/doc/local-instance.md b/doc/local-instance.md index 0d193a33..29170c43 100644 --- a/doc/local-instance.md +++ b/doc/local-instance.md @@ -125,6 +125,61 @@ $ curl -X 'GET' \ "groups": [], "username":"admin"} ``` +### Invite a user + +User registration is invite-only. Use the admin token to create an invite: + +``` +``` + +Create a small config file for the helper script (save as +`usermanager.toml` in the repo root or +`~/.config/kernelci/usermanager.toml`). This supports multiple instances: + +``` +default_instance = "local" + +[instances.local] +url = "http://localhost:8001/latest" +token = "" +``` + +Then run: + +``` +$ ./scripts/usermanager.py invite \ + --username alice \ + --email alice@example.org \ + --return-token +``` + +Send the returned `invite_url` to the user. The user accepts the invite and +sets a password: + +``` +$ ./scripts/usermanager.py accept-invite \ + --token "" +``` + +Curl equivalent for the invite call: + +``` +$ curl -X 'POST' \ + 'http://localhost:8001/latest/user/invite' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "username": "alice", + "email": "alice@example.org", + "groups": [], + "is_superuser": false, + "send_email": false, + "return_token": true, + "resend_if_exists": false +}' +``` + ### Setup SSH keys SSH container in the API can be used to upload files remotely to the storage diff --git a/doc/staging.md b/doc/staging.md index 99d371e5..5f7a95b5 100644 --- a/doc/staging.md +++ b/doc/staging.md @@ -40,8 +40,8 @@ It can be done in two simple steps: * Create an "API Staging Access" [issue on GitHub](https://github.com/kernelci/kernelci-project/issues/new/choose) -* Wait for an email confirmation of your user account which should contain a - randomly-generated password as well as an AzureFiles token +* Wait for an email confirmation of your user account which should contain an + invite link (and an AzureFiles token if applicable) > **Tip**: If you don't have a GitHub account, please send an email to [kernelci-sysadmin@groups.io](mailto:kernelci-sysadmin@groups.io) instead. @@ -82,33 +82,37 @@ From now on, all the shell commands are run **from within the same container** so the prompt `kernelci@3215c7c7b590:~$` is being replaced with `$` to make it easier to read. -* Once you've received your confirmation email with your randomly-generated - password, you should change it to use your own arbitrary one instead: +* Once you've received your confirmation email, open the invite link and set + your password. You can use the helper script from this repository (set + `--api-url` or configure a `staging` instance in `usermanager.toml` and use + `--instance staging`): ```sh -$ kci user password update -Current password: -New password: -Retype new password: +$ ./scripts/usermanager.py accept-invite \ + --api-url "https://staging.kernelci.org/latest" \ + --token "" ``` -* Then verify your email address by providing verification token -sent to your email: +Or via curl: ```sh -$ kci user verify -Sending verification token to -Verification token: -Email verification successful! +$ curl -X 'POST' \ + 'https://staging.kernelci.org/latest/user/accept-invite' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "token": "", + "password": "" +}' ``` * Then create an API token by providing your username and new password: ```sh -$ kci user token -Password: -"" +$ ./scripts/usermanager.py login \ + --api-url "https://staging.kernelci.org/latest" \ + --username ``` * Store your API token in a `kernelci.toml` file, for example: diff --git a/doc/usermanager.toml b/doc/usermanager.toml new file mode 100644 index 00000000..774f81e5 --- /dev/null +++ b/doc/usermanager.toml @@ -0,0 +1,10 @@ +# Example configuration for scripts/usermanager.py +default_instance = "local" + +[instances.local] +url = "http://localhost:8001/latest" +token = "" + +[instances.staging] +url = "https://staging.kernelci.org/latest" +token = "" diff --git a/env.sample b/env.sample index 6c345768..107bf4ba 100644 --- a/env.sample +++ b/env.sample @@ -1,6 +1,7 @@ SECRET_KEY= #algorithm= #access_token_expire_minutes= +PUBLIC_BASE_URL= SMTP_HOST= SMTP_PORT= EMAIL_SENDER= diff --git a/scripts/usermanager.py b/scripts/usermanager.py new file mode 100755 index 00000000..c2471f73 --- /dev/null +++ b/scripts/usermanager.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +import argparse +import getpass +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +try: + import tomllib +except ImportError as exc: # pragma: no cover - Python < 3.11 + raise SystemExit("Python 3.11+ is required for tomllib.") from exc + + +DEFAULT_CONFIG_PATHS = [ + os.path.join(os.getcwd(), "usermanager.toml"), + os.path.join(os.path.expanduser("~"), ".config", "kernelci", + "usermanager.toml"), +] + + +def _load_config(path: str | None) -> dict: + if not path: + return {} + if not os.path.exists(path): + return {} + with open(path, "rb") as handle: + return tomllib.load(handle) + + +def _resolve_config_path(path: str | None) -> str | None: + if path: + return path + for candidate in DEFAULT_CONFIG_PATHS: + if os.path.exists(candidate): + return candidate + return None + + +def _get_setting(args_value, env_key, config, config_key): + if args_value: + return args_value + env_value = os.getenv(env_key) + if env_value: + return env_value + current = config + for key in config_key.split("."): + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +def _get_instance_config(config, instance_name): + if not isinstance(config, dict): + return {} + instances = config.get("instances", {}) + if not isinstance(instances, dict): + return {} + return instances.get(instance_name, {}) or {} + + +def _prompt_if_missing(value, prompt_text, secret=False, default=None): + if value: + return value + if secret: + return getpass.getpass(prompt_text) + prompt = prompt_text + if default: + prompt = f"{prompt_text} [{default}] " + response = input(prompt) + if not response and default is not None: + return default + return response + + +def _request_json(method, url, data=None, token=None, form=False): + headers = {"accept": "application/json"} + body = None + if data is not None: + if form: + body = urllib.parse.urlencode(data).encode("utf-8") + headers["Content-Type"] = "application/x-www-form-urlencoded" + else: + body = json.dumps(data).encode("utf-8") + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, data=body, headers=headers, + method=method) + try: + with urllib.request.urlopen(req) as response: + payload = response.read().decode("utf-8") + return response.status, payload + except urllib.error.HTTPError as exc: + payload = exc.read().decode("utf-8") + return exc.code, payload + + +def _print_response(status, payload): + try: + parsed = json.loads(payload) if payload else None + except json.JSONDecodeError: + parsed = None + if parsed is not None: + print(json.dumps(parsed, indent=2)) + elif payload: + print(payload) + else: + print(f"Status: {status}") + + +def _require_token(token, args): + return _prompt_if_missing( + token, + f"{args.token_label} token: ", + secret=True, + ) + + +def main(): + parser = argparse.ArgumentParser( + description="KernelCI API user management helper", + epilog=( + "Examples:\n" + " ./scripts/usermanager.py invite --username alice --email " + "alice@example.org --return-token\n" + " ./scripts/usermanager.py accept-invite --token \n" + " ./scripts/usermanager.py login --username alice\n" + " ./scripts/usermanager.py whoami\n" + " ./scripts/usermanager.py list-users --instance staging\n" + " ./scripts/usermanager.py print-config\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--config", help="Path to usermanager.toml") + parser.add_argument("--api-url", help="API base URL, e.g. " + "http://localhost:8001/latest") + parser.add_argument("--token", help="Bearer token for admin/user actions") + parser.add_argument("--instance", help="Instance name from config") + parser.add_argument("--token-label", default="Auth", + help="Label used when prompting for a token") + + subparsers = parser.add_subparsers(dest="command", required=True) + + invite = subparsers.add_parser("invite", help="Invite a new user") + invite.add_argument("--username", required=True) + invite.add_argument("--email", required=True) + invite.add_argument("--groups", default="") + invite.add_argument("--superuser", action="store_true") + invite.add_argument("--send-email", action="store_true", default=True) + invite.add_argument("--no-send-email", action="store_true") + invite.add_argument("--return-token", action="store_true") + invite.add_argument("--resend-if-exists", action="store_true") + + invite_url = subparsers.add_parser("invite-url", + help="Preview invite URL base") + + accept = subparsers.add_parser("accept-invite", help="Accept an invite") + accept.add_argument("--token") + accept.add_argument("--password") + + login = subparsers.add_parser("login", help="Get an auth token") + login.add_argument("--username", required=True) + login.add_argument("--password") + + whoami = subparsers.add_parser("whoami", help="Show current user") + + list_users = subparsers.add_parser("list-users", help="List users") + + get_user = subparsers.add_parser("get-user", help="Get user by id") + get_user.add_argument("user_id") + + update_user = subparsers.add_parser("update-user", + help="Patch user by id") + update_user.add_argument("user_id") + update_user.add_argument("--data", required=True, + help="JSON object with fields to update") + + delete_user = subparsers.add_parser("delete-user", + help="Delete user by id") + delete_user.add_argument("user_id") + + subparsers.add_parser("print-config", + help="Print a sample usermanager.toml") + + args = parser.parse_args() + + if args.command == "print-config": + print( + "default_instance = \"local\"\n\n" + "[instances.local]\n" + "url = \"http://localhost:8001/latest\"\n" + "token = \"\"\n\n" + "[instances.staging]\n" + "url = \"https://staging.kernelci.org/latest\"\n" + "token = \"\"\n" + ) + return + + config_path = _resolve_config_path(args.config) + config = _load_config(config_path) + instance_name = ( + args.instance + or os.getenv("KCI_API_INSTANCE") + or config.get("default_instance") + or "default" + ) + instance_config = _get_instance_config(config, instance_name) + + api_url = args.api_url or os.getenv("KCI_API_URL") + if not api_url: + api_url = instance_config.get("url") + if not api_url: + api_url = _get_setting(None, "KCI_API_URL", config, "api.url") + api_url = _prompt_if_missing( + api_url, + "API URL", + default="http://localhost:8001/latest", + ).rstrip("/") + + token = args.token or os.getenv("KCI_API_TOKEN") + if not token: + token = instance_config.get("token") + if not token: + token = _get_setting(None, "KCI_API_TOKEN", config, "api.token") + + if args.command in {"invite", "invite-url", "whoami", "list-users", + "get-user", "update-user", "delete-user"}: + token = _require_token(token, args) + + if args.command == "invite": + groups = [g for g in args.groups.split(",") if g] + payload = { + "username": args.username, + "email": args.email, + "groups": groups, + "is_superuser": args.superuser, + "send_email": False if args.no_send_email else args.send_email, + "return_token": args.return_token, + "resend_if_exists": args.resend_if_exists, + } + status, body = _request_json( + "POST", f"{api_url}/user/invite", payload, token=token + ) + elif args.command == "invite-url": + status, body = _request_json( + "GET", f"{api_url}/user/invite/url", token=token + ) + elif args.command == "accept-invite": + invite_token = _prompt_if_missing( + args.token, + "Invite token: ", + secret=True, + ) + password = _prompt_if_missing( + args.password, + "New password: ", + secret=True, + ) + payload = {"token": invite_token, "password": password} + status, body = _request_json( + "POST", f"{api_url}/user/accept-invite", payload + ) + elif args.command == "login": + password = _prompt_if_missing( + args.password, + "Password: ", + secret=True, + ) + payload = {"username": args.username, "password": password} + status, body = _request_json( + "POST", + f"{api_url}/user/login", + payload, + form=True, + ) + elif args.command == "whoami": + status, body = _request_json("GET", f"{api_url}/whoami", token=token) + elif args.command == "list-users": + status, body = _request_json("GET", f"{api_url}/users", token=token) + elif args.command == "get-user": + status, body = _request_json( + "GET", f"{api_url}/user/{args.user_id}", token=token + ) + elif args.command == "update-user": + try: + data = json.loads(args.data) + except json.JSONDecodeError as exc: + raise SystemExit("Invalid JSON for --data") from exc + status, body = _request_json( + "PATCH", f"{api_url}/user/{args.user_id}", data, token=token + ) + elif args.command == "delete-user": + status, body = _request_json( + "DELETE", f"{api_url}/user/{args.user_id}", token=token + ) + else: + raise SystemExit("Unknown command") + + _print_response(status, body) + if status >= 400: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e_tests/test_user_invite.py b/tests/e2e_tests/test_user_invite.py new file mode 100644 index 00000000..0bbee960 --- /dev/null +++ b/tests/e2e_tests/test_user_invite.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# Copyright (C) 2025 Collabora Limited +# +# pylint: disable=unused-argument + +"""End-to-end test functions for KernelCI API invite flow""" + +import json +import pytest + + +@pytest.mark.dependency( + depends=["tests/e2e_tests/test_user_creation.py::test_create_admin_user"], + scope="session", +) +@pytest.mark.order(3) +@pytest.mark.asyncio +async def test_invite_and_accept_user(test_async_client): + """Test user invite flow: invite, accept, and login.""" + username = "invited_user" + password = "test" + email = "invited@kernelci.org" + + response = await test_async_client.post( + "user/invite", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {pytest.ADMIN_BEARER_TOKEN}", + }, + data=json.dumps( + { + "username": username, + "email": email, + "send_email": False, + "return_token": True, + } + ), + ) + assert response.status_code == 200 + body = response.json() + assert body["email_sent"] is False + assert "token" in body and body["token"] + + response = await test_async_client.post( + "user/accept-invite", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + data=json.dumps( + { + "token": body["token"], + "password": password, + } + ), + ) + assert response.status_code == 200 + assert response.json()["is_verified"] is True + assert response.json()["username"] == username + + response = await test_async_client.post( + "user/login", + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + data=f"username={username}&password={password}", + ) + assert response.status_code == 200 + assert response.json().keys() == {"access_token", "token_type"} diff --git a/tests/unit_tests/test_events_handler.py b/tests/unit_tests/test_events_handler.py new file mode 100644 index 00000000..07aef4c4 --- /dev/null +++ b/tests/unit_tests/test_events_handler.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# Copyright (C) 2025 Collabora Limited + +"""Unit tests for KernelCI API events handler""" + +from bson import ObjectId +from kernelci.api.models import EventHistory + + +def test_get_events_filter_by_id(mock_db_find_by_attributes, test_client): + """GET /events?id= forwards _id filter and returns items.""" + oid = ObjectId() + mock_db_find_by_attributes.return_value = [ + { + "_id": oid, + "timestamp": "2025-12-11T10:00:00+00:00", + "data": {"kind": "job", "id": "node1"}, + } + ] + + resp = test_client.get(f"events?id={str(oid)}") + + assert resp.status_code == 200 + assert resp.json()[0]["id"] == str(oid) + mock_db_find_by_attributes.assert_awaited_once() + called_model, called_query = mock_db_find_by_attributes.call_args.args + assert called_model is EventHistory + assert called_query["_id"] == oid + + +def test_get_events_filter_by_ids(mock_db_find_by_attributes, test_client): + """GET /events?ids=a,b forwards $in filter.""" + oid1, oid2 = ObjectId(), ObjectId() + mock_db_find_by_attributes.return_value = [] + + resp = test_client.get(f"events?ids={oid1},{oid2}") + + assert resp.status_code == 200 + called_model, called_query = mock_db_find_by_attributes.call_args.args + assert called_model is EventHistory + assert called_query["_id"]["$in"] == [oid1, oid2] + + +def test_get_events_rejects_both_id_and_ids(test_client): + """GET /events rejects requests with both id and ids parameters.""" + resp = test_client.get("events?id=deadbeefdeadbeefdeadbeef&ids=deadbeefdeadbeefdeadbeef") + assert resp.status_code == 400 + + +def test_get_events_rejects_invalid_id(test_client): + """GET /events rejects invalid ObjectId format.""" + resp = test_client.get("events?id=not-an-objectid") + assert resp.status_code == 400 + + +def test_get_events_filter_by_node_id_alias(mock_db_find_by_attributes, test_client): + """GET /events?node_id= aliases to data.id filter.""" + node_id = "693af4f5fee8383e92b6b0eb" + mock_db_find_by_attributes.return_value = [] + + resp = test_client.get(f"events?node_id={node_id}") + + assert resp.status_code == 200 + called_model, called_query = mock_db_find_by_attributes.call_args.args + assert called_model is EventHistory + assert called_query["data.id"] == node_id + + +def test_get_events_rejects_node_id_and_data_id(test_client): + """GET /events rejects requests with both node_id and data.id parameters.""" + resp = test_client.get( + "events?node_id=693af4f5fee8383e92b6b0eb&data.id=693af4f5fee8383e92b6b0eb" + ) + assert resp.status_code == 400