— Add application API (fix #86)

This commit is contained in:
Luc Didry 2025-03-19 17:28:14 +01:00
parent a601fccad3
commit 32d11c5598
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
6 changed files with 234 additions and 17 deletions

View file

@ -8,6 +8,7 @@
- 🔒️ — Logging out now invalidate tokens - 🔒️ — Logging out now invalidate tokens
- 📝 — Improve OpenAPI doc - 📝 — Improve OpenAPI doc
- 🤕 — Fix order of tasks sent to agent - 🤕 — Fix order of tasks sent to agent
- ✨ — Add application API (fix #86)
## 0.9.0 ## 0.9.0

View file

@ -1,5 +1,5 @@
from fastapi import Request from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
class NotAuthenticatedException(Exception): 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 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( response = RedirectResponse(
url=request.url_for("login_view").include_query_params(msg="not-authenticated") url=request.url_for("login_view").include_query_params(msg="not-authenticated")
) )

View file

@ -70,6 +70,7 @@ def get_application() -> FastAPI:
return await queries.get_user(appli.state.db, user) return await queries.get_user(appli.state.db, user)
appli.include_router(routes.api, prefix="/api") appli.include_router(routes.api, prefix="/api")
appli.include_router(routes.api_app, prefix="/api/app")
appli.include_router(routes.views) appli.include_router(routes.views)
static_dir = Path(__file__).resolve().parent / "static" static_dir = Path(__file__).resolve().parent / "static"

View file

@ -1,2 +1,3 @@
from .api import route as api from .api import route as api
from .api_app import route as api_app
from .views import route as views from .views import route as views

View file

@ -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]} 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( @route.get(
"/stats", "/stats",
responses={ responses={

View file

@ -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"}