From 32d11c5598af1f41eed301a46106f4d1efd233c1 Mon Sep 17 00:00:00 2001 From: Luc Didry Date: Wed, 19 Mar 2025 17:28:14 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E2=80=94=20Add=20application=20API?= =?UTF-8?q?=20(fix=20#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + argos/server/exceptions.py | 12 +- argos/server/main.py | 1 + argos/server/routes/__init__.py | 1 + argos/server/routes/api.py | 16 --- argos/server/routes/api_app.py | 220 ++++++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 argos/server/routes/api_app.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cedeb98..aea0778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - 🔒️ — Logging out now invalidate tokens - 📝 — Improve OpenAPI doc - 🤕 — Fix order of tasks sent to agent +- ✨ — Add application API (fix #86) ## 0.9.0 diff --git a/argos/server/exceptions.py b/argos/server/exceptions.py index 042d80d..c543ee1 100644 --- a/argos/server/exceptions.py +++ b/argos/server/exceptions.py @@ -1,5 +1,5 @@ from fastapi import Request -from fastapi.responses import RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse class NotAuthenticatedException(Exception): @@ -10,6 +10,16 @@ def auth_exception_handler(request: Request, exc: NotAuthenticatedException): """ Redirect the user to the login page if not logged in """ + print(request.headers.get("accept", "")) + print("application/json" in request.headers.get("accept", "")) + if "application/json" in request.headers.get("accept", ""): + return JSONResponse( + content={ + "access": "denied", + "msg": "You are not authenticated or your token has expired", + } + ) + response = RedirectResponse( url=request.url_for("login_view").include_query_params(msg="not-authenticated") ) diff --git a/argos/server/main.py b/argos/server/main.py index b9b9f3f..b6378b2 100644 --- a/argos/server/main.py +++ b/argos/server/main.py @@ -70,6 +70,7 @@ def get_application() -> FastAPI: return await queries.get_user(appli.state.db, user) appli.include_router(routes.api, prefix="/api") + appli.include_router(routes.api_app, prefix="/api/app") appli.include_router(routes.views) static_dir = Path(__file__).resolve().parent / "static" diff --git a/argos/server/routes/__init__.py b/argos/server/routes/__init__.py index 9b27c64..90721d5 100644 --- a/argos/server/routes/__init__.py +++ b/argos/server/routes/__init__.py @@ -1,2 +1,3 @@ from .api import route as api +from .api_app import route as api_app from .views import route as views diff --git a/argos/server/routes/api.py b/argos/server/routes/api.py index 78dd83d..e783cc0 100644 --- a/argos/server/routes/api.py +++ b/argos/server/routes/api.py @@ -85,22 +85,6 @@ async def create_results( # pylint: disable-msg=too-many-positional-arguments return {"result_ids": [r.id for r in db_results]} -@route.post( - "/reschedule/all", - responses={ - 200: { - "content": { - "application/json": {"example": {"msg": "Non OK tasks reschuled"}} - } - } - }, -) -async def reschedule_all(request: Request, db: Session = Depends(get_db)): - """Reschedule checks of all non OK tasks ASAP""" - await queries.reschedule_all(db) - return {"msg": "Non OK tasks reschuled"} - - @route.get( "/stats", responses={ diff --git a/argos/server/routes/api_app.py b/argos/server/routes/api_app.py new file mode 100644 index 0000000..487caf3 --- /dev/null +++ b/argos/server/routes/api_app.py @@ -0,0 +1,220 @@ +"""Web interface for humans""" +from pathlib import Path +from typing import Annotated, Dict, Literal + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import JSONResponse +from fastapi.security import OAuth2PasswordRequestForm +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from argos import VERSION +from argos.schemas import Config +from argos.server import queries +from argos.server.models import Result, User +from argos.server.routes.dependencies import ( + create_user_token, + get_config, + get_db, + get_manager, + good_user_credentials, +) + +route = APIRouter() + +current_dir = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=current_dir / ".." / "templates") +SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4} + + +class Version(BaseModel): + argos_monitoring: str + logged_in: bool + model_config = { + "json_schema_extra": { + "examples": [ + {"argos_monitoring": VERSION, "logged_in": True}, + {"argos_monitoring": VERSION, "logged_in": False}, + ] + } + } + + +@route.get("/version", response_model=Version) +async def version( + request: Request, +): + """Return a JSON object containing argos version + and indicates if you are logged in""" + token = request.cookies.get("access-token") + if token is not None and token != "": + manager = request.app.state.manager + user = await manager.get_current_user(token) + return Version(argos_monitoring=VERSION, logged_in=user is not None) + + return Version(argos_monitoring=VERSION, logged_in=False) + + +class GrantedLogin(BaseModel): + access: Literal["granted"] + msg: str + cookie_name: Literal["access-token"] | None + cookie: str | None + model_config = { + "json_schema_extra": { + "examples": [ + { + "access": "granted", + "msg": "Login successful", + "cookie_name": "access-token", + "cookie": "foobarbaz", + }, + {"access": "granted", "msg": "No authentication needed"}, + ] + } + } + + +class DeniedLogin(BaseModel): + access: Literal["deniel"] + msg: str + model_config = { + "json_schema_extra": { + "examples": [ + {"access": "denied", "msg": "Sorry, invalid username or bad password."}, + { + "access": "denied", + "msg": "Sorry, invalid username or bad password. " + "Or the LDAP server is unreachable (see logs to verify).", + }, + ] + } + } + + +@route.post( + "/login", response_model=GrantedLogin, responses={401: {"model": DeniedLogin}} +) +async def login( + request: Request, + db: Session = Depends(get_db), + data: OAuth2PasswordRequestForm = Depends(), + rememberme: Annotated[str | None, Form()] = None, + config: Config = Depends(get_config), +): + """If the login is successful, sends a token to be included as cookie + in requests needing authentication. + """ + if config.general.unauthenticated_access == "all": + return {"access": "granted", "msg": "No authentication needed"} + + good_credentials = await good_user_credentials( + config, request, data.username, data.password + ) + if config.general.ldap is not None: + if not good_credentials: + return JSONResponse( + content={ + "access": "denied", + "msg": "Sorry, invalid username or bad password. " + "Or the LDAP server is unreachable (see logs to verify).", + }, + status_code=401, + ) + elif not good_credentials: + return JSONResponse( + content={ + "access": "denied", + "msg": "Sorry, invalid username or bad password.", + }, + status_code=401, + ) + + manager = request.app.state.manager + token = await create_user_token(manager, config.general, data.username, rememberme) + + return JSONResponse( + content={ + "access": "granted", + "msg": "Login successful", + "cookie_name": manager.cookie_name, + "cookie": token["token"], + } + ) + + +class SuccessLogout(BaseModel): + logout: Literal["success"] + msg: str + + +@route.get("/logout", response_model=SuccessLogout) +async def logout( + request: Request, + config: Config = Depends(get_config), + user: User | None = Depends(get_manager), + db: Session = Depends(get_db), +): + """Discard the token used for the request, so logging out the user""" + if config.general.unauthenticated_access == "all": + return {"logout": "error", "msg": "No authentication needed"} + + await queries.block_token(db, request) + + response = JSONResponse(content={"logout": "success", "msg": "logout successful"}) + + return response + + +class SeverityCounts(BaseModel): + severities: Dict[Literal["ok", "warning", "critical", "unknown"], int] + agents: int + + model_config = { + "json_schema_extra": { + "examples": [ + { + "severities": {"ok": 10, "warning": 0, "critical": 2, "unknown": 0}, + "agents": 1, + } + ] + } + } + + +@route.get("/", response_model=SeverityCounts) +async def get_severity_counts( + request: Request, + user: User | None = Depends(get_manager), + db: Session = Depends(get_db), +): + """Shows the number of results per severity and number of known agents""" + counts_dict = await queries.get_severity_counts(db) + + agents = db.query(Result.agent_id).distinct().all() + + return { + "severities": counts_dict, + "agents": len(agents), + } + + +@route.post( + "/reschedule/all", + responses={ + 200: { + "content": { + "application/json": {"example": {"msg": "Non OK tasks reschuled"}} + } + } + }, +) +async def reschedule_all( + request: Request, + user: User | None = Depends(get_manager), + db: Session = Depends(get_db), +): + """Reschedule checks of all non OK tasks ASAP""" + await queries.reschedule_all(db) + return {"msg": "Non OK tasks reschuled"}