diff --git a/api/main.py b/api/main.py index 53db5011b..7222addd9 100644 --- a/api/main.py +++ b/api/main.py @@ -16,14 +16,8 @@ HTTPException, status, Request, - Security, - Query, ) from fastapi.responses import JSONResponse -from fastapi.security import ( - OAuth2PasswordRequestForm, - SecurityScopes -) from fastapi_pagination import add_pagination from fastapi_versioning import VersionedFastAPI from bson import ObjectId, errors @@ -31,15 +25,13 @@ from fastapi_users import FastAPIUsers from fastapi_users.db import BeanieUserDatabase from beanie import PydanticObjectId -from .auth import Authentication, Token +from .auth import Authentication from .db import Database from .models import ( Node, Hierarchy, Regression, UserGroup, - UserProfile, - Password, get_model_from_kind ) from .paginator_models import PageModel @@ -100,49 +92,173 @@ async def invalid_id_exception_handler( content={"detail": str(exc)}, ) + +@app.get('/') +async def root(): + """Root endpoint handler""" + return {"message": "KernelCI API"} + +# ----------------------------------------------------------------------------- +# Users + + current_active_user = fastapi_users_instance.current_user(active=True) current_active_superuser = fastapi_users_instance.current_user(active=True, superuser=True) -async def get_current_user( - security_scopes: SecurityScopes, - token: str = Depends(auth.oauth2_scheme)): - """Return the user if authenticated successfully based on the provided - token""" +app.include_router( + fastapi_users_instance.get_auth_router(auth_backend, + requires_verification=True), + prefix="/user", + tags=["user"] +) + +register_router = fastapi_users_instance.get_register_router( + UserRead, UserCreate) + + +@app.post("/user/register", response_model=UserRead, tags=["user"], + response_model_by_alias=False) +async def register(request: Request, user: UserCreate, + current_user: str = Depends(current_active_superuser)): + """User registration route + + Custom user registration router to ensure unique username. + `user` from request has a list of user group name strings. + This handler will convert them to `UserGroup` objects and + insert user object to database. + """ + existing_user = await db.find_one(User, username=user.username) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already exists", + ) + 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.groups = groups + return await register_router.routes[0].endpoint( + request, user, UserManager(BeanieUserDatabase(User))) + + +app.include_router( + fastapi_users_instance.get_reset_password_router(), + 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) + +app.add_api_route( + path="/whoami", + tags=["user"], + methods=["GET"], + description="Get current user information", + endpoint=users_router.routes[0].endpoint) +app.add_api_route( + path="/user/{id}", + tags=["user"], + methods=["GET"], + description="Get user information by ID", + endpoint=users_router.routes[2].endpoint) +app.add_api_route( + path="/user/{id}", + tags=["user"], + methods=["DELETE"], + description="Delete user by ID", + dependencies=[Depends(current_active_superuser)], + endpoint=users_router.routes[4].endpoint) - if security_scopes.scopes: - authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' - else: - authenticate_value = "Bearer" - if token == 'None': +@app.patch("/user/me", response_model=UserRead, tags=["user"], + response_model_by_alias=False) +async def update_me(request: Request, user: UserUpdate, + current_user: str = Depends(current_active_user)): + """User update route + + Custom user update router handler will only allow users to update + its own profile. Adding itself to 'admin' group is not allowed. + """ + existing_user = await db.find_one(User, username=user.username) + if existing_user: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing credentials", - headers={"WWW-Authenticate": authenticate_value}, + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already exists", ) - user, message = await auth.get_current_user(token, security_scopes.scopes) - if user is None: + groups = [] + if user.groups: + for group_name in user.groups: + if group_name == "admin": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unauthorized to add user to 'admin' group") + 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) + if groups: + user.groups = groups + return await users_router.routes[1].endpoint( + request, user, current_user, UserManager(BeanieUserDatabase(User))) + + +@app.patch("/user/{user_id}", response_model=UserRead, tags=["user"], + response_model_by_alias=False) +async def update_user(user_id: str, request: Request, user: UserUpdate, + current_user: str = Depends(current_active_superuser)): + """Router to allow admin users to update other user account""" + + user_from_id = await db.find_by_id(User, user_id) + if not user_from_id: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=message, - headers={"WWW-Authenticate": authenticate_value}, + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User not found with id: {user_id}", ) - return user + existing_user = await db.find_one(User, username=user.username) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already exists", + ) -async def get_user(user: User = Depends(get_current_user)): - """Return the user if active and authenticated""" - if not user.active: - raise HTTPException(status_code=400, detail="Inactive user") - return user + 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) + if groups: + user.groups = groups + return await users_router.routes[3].endpoint( + user, request, user_from_id, UserManager(BeanieUserDatabase(User))) -async def authorize_user(node_id: str, user: User = Depends(get_current_user)): + +async def authorize_user(node_id: str, + user: User = Depends(current_active_user)): """Return the user if active, authenticated, and authorized""" - if not user.active: - raise HTTPException(status_code=400, detail="Inactive user") # Only the user that created the node or any other user from the permitted # user groups will be allowed to update the node @@ -152,9 +268,9 @@ async def authorize_user(node_id: str, user: User = Depends(get_current_user)): status_code=status.HTTP_404_NOT_FOUND, detail=f"Node not found with id: {node_id}" ) - if not user.profile.username == node_from_id.owner: + if not user.username == node_from_id.owner: if not any(group.name in node_from_id.user_groups - for group in user.profile.groups): + for group in user.groups): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized to complete the operation" @@ -162,7 +278,7 @@ async def authorize_user(node_id: str, user: User = Depends(get_current_user)): return user -@app.get('/users', response_model=PageModel, +@app.get('/users', response_model=PageModel, tags=["user"], response_model_exclude={"items": {"__all__": { "hashed_password"}}}) async def get_users(request: Request): @@ -178,89 +294,8 @@ async def get_users(request: Request): return paginated_resp -@app.put('/user/profile/{username}', response_model=User, - response_model_include={"profile"}, - response_model_by_alias=False) -async def put_user_profile( - username: str, - password: Password, - email: str = None, - groups: List[str] = Query([]), - current_user: User = Depends(get_user)): - """Update own user profile - The handler will only allow users to update its own profile. - Adding itself to 'admin' group is not allowed.""" - if str(current_user.profile.username) != username: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Unauthorized to update user with provided username") - - hashed_password = auth.get_password_hash( - password.password.get_secret_value()) - group_obj = [] - if groups: - for group_name in 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}") - if group_name == 'admin': - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Unauthorized to add user to 'admin' group") - group_obj.append(group) - obj = await db.update(User( - id=current_user.id, - profile=UserProfile( - username=username, - hashed_password=hashed_password, - email=email if email else current_user.profile.email, - groups=group_obj if group_obj else current_user.profile.groups - ))) - await pubsub.publish_cloudevent('user', {'op': 'updated', - 'id': str(obj.id)}) - return obj - - -@app.put('/user/{username}', response_model=User, - response_model_by_alias=False) -async def put_user( - username: str, - email: str = None, - groups: List[str] = Query([]), - current_user: User = Security(get_user, scopes=["admin"])): - """Update user model - Allow admin users to update all user fields except password""" - user = await db.find_one_by_attributes( - User, {'profile.username': username}) - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"User not found with username: {username}" - ) - group_obj = [] - if groups: - for group_name in 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}") - group_obj.append(group) - obj = await db.update(User( - id=user.id, - profile=UserProfile( - username=username, - hashed_password=user.profile.hashed_password, - email=email if email else user.profile.email, - groups=group_obj if group_obj else user.profile.groups - ))) - await pubsub.publish_cloudevent('user', {'op': 'updated', - 'id': str(obj.id)}) - return obj +# ----------------------------------------------------------------------------- +# User groups @app.post('/group', response_model=UserGroup, response_model_by_alias=False) @@ -304,80 +339,6 @@ async def get_group(group_id: str): return await db.find_by_id(UserGroup, group_id) -@app.get('/') -async def root(): - """Root endpoint handler""" - return {"message": "KernelCI API"} - - -@app.post('/token', response_model=Token) -async def login_for_access_token( - form_data: OAuth2PasswordRequestForm = Depends()): - """Get a bearer token for an authenticated user""" - user = await auth.authenticate_user(form_data.username, form_data.password) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - is_valid, scope = await auth.validate_scopes(form_data.scopes) - if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid scope: {scope}" - ) - - if 'admin' in form_data.scopes: - if not user.groups or not any( - group.name == 'admin' for group in user.groups): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Not allowed to use admin scope" - ) - - access_token = auth.create_access_token(data={ - "sub": user.username, - "scopes": form_data.scopes} - ) - return {"access_token": access_token, "token_type": "bearer"} - - -@app.get('/whoami', response_model=User, response_model_by_alias=False) -async def whoami(current_user: User = Depends(current_active_user)): - """Get current user information""" - return current_user - - -@app.post('/password') -async def reset_password( - username: str, current_password: Password, new_password: Password): - """Set a new password for an authenticated user""" - authenticated = await auth.authenticate_user( - username, current_password.password.get_secret_value()) - if not authenticated: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - ) - - user = await db.find_one_by_attributes( - User, {'profile.username': username}) - user.profile.hashed_password = auth.get_password_hash( - new_password.password.get_secret_value()) - obj = await db.update(user) - await pubsub.publish_cloudevent('user', {'op': 'updated', - 'id': str(obj.id)}) - return obj - - -@app.post('/hash') -def get_password_hash(password: Password): - """Get a password hash from the provided string password""" - return auth.get_password_hash(password.password.get_secret_value()) - - # ----------------------------------------------------------------------------- # Nodes @@ -630,69 +591,6 @@ async def put_regression(regression_id: str, regression: Regression, return obj -# ----------------------------------------------------------------------------- -# Users - -app.include_router( - fastapi_users_instance.get_auth_router(auth_backend, - requires_verification=True), - prefix="/user", - tags=["user"] -) - -register_router = fastapi_users_instance.get_register_router( - UserRead, UserCreate) - - -@app.post("/user/register", response_model=UserRead, tags=["user"], - response_model_by_alias=False) -async def register(request: Request, user: UserCreate, - current_user: str = Depends(current_active_superuser)): - """User registration route - - Custom user registration router to ensure unique username. - `user` from request has a list of user group name strings. - This handler will convert them to `UserGroup` objects and - insert user object to database. - """ - existing_user = await db.find_one(User, username=user.username) - if existing_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Username already exists", - ) - 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.groups = groups - return await register_router.routes[0].endpoint( - request, user, UserManager(BeanieUserDatabase(User))) - - -app.include_router( - fastapi_users_instance.get_reset_password_router(), - prefix="/user", - tags=["user"], -) -app.include_router( - fastapi_users_instance.get_verify_router(UserRead), - prefix="/user", - tags=["user"], -) -app.include_router( - fastapi_users_instance.get_users_router(UserRead, UserUpdate, - requires_verification=True), - prefix="/user", - tags=["user"], -) - - app = VersionedFastAPI( app, version_format='{major}',