diff --git a/requirements.txt b/requirements.txt index 49cb6ed..206eb32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,11 +7,8 @@ requests itsdangerous starlette pydantic-settings -authlib joserfc httpx -types-authlib -python-jose pytest uvloop; sys_platform != 'win32' sqlalchemy diff --git a/src/_module_template/config.py b/src/_module_template/config.py index bea51a8..45d3182 100644 --- a/src/_module_template/config.py +++ b/src/_module_template/config.py @@ -1,5 +1,5 @@ """ -Configurations for the _____ module +Configurations for the module Exports: """ \ No newline at end of file diff --git a/src/_module_template/constants.py b/src/_module_template/constants.py index d9e79f0..cc72009 100644 --- a/src/_module_template/constants.py +++ b/src/_module_template/constants.py @@ -1,5 +1,5 @@ """ -Constants and error codes for the _____ module +Constants for the module Exports: """ \ No newline at end of file diff --git a/src/_module_template/dependencies.py b/src/_module_template/dependencies.py index af9f765..71750bc 100644 --- a/src/_module_template/dependencies.py +++ b/src/_module_template/dependencies.py @@ -1,5 +1,6 @@ """ -Router dependencies for the _____ module +Dependencies related to the module Exports: + - : : """ \ No newline at end of file diff --git a/src/_module_template/exceptions.py b/src/_module_template/exceptions.py index 7cb95f3..402940a 100644 --- a/src/_module_template/exceptions.py +++ b/src/_module_template/exceptions.py @@ -1,5 +1,6 @@ """ -Module specific exceptions for the _____ module +Exceptions related to the modules -Exports: +Exceptions: + - : Details e.g. optional params """ \ No newline at end of file diff --git a/src/_module_template/models.py b/src/_module_template/models.py index d109cbd..d03c882 100644 --- a/src/_module_template/models.py +++ b/src/_module_template/models.py @@ -1,5 +1,9 @@ """ -Database models for the _____ module +Database models for the module -Exports: +Models: + - : + - + - + - """ \ No newline at end of file diff --git a/src/_module_template/router.py b/src/_module_template/router.py index 7bd9858..6593c45 100644 --- a/src/_module_template/router.py +++ b/src/_module_template/router.py @@ -1,15 +1,31 @@ """ -Router endpoints for the _____ module +Router endpoints for the module -Endpoints: -- /timer/start -- /timer/stop +Exports: + - router: fastapi.APIRouter + +### Router Guidelines ### +- Add responses to decorators +- Add status_codes to decorators +- All endpoints should either return a response object or 204 +- Ensure response_model is declared in the decorator +- All query and path params should have validation and descriptions +- All endpoints should have a docstring (this is used in place of a description) +- All endpoints should have a summary +- All modules should have metadata in main.py +- All exceptions should have a custom definition in exceptions.py +- Dependencies should be used for db model get and validation where possible +- Verify module level docstring is still accurate after updates """ import threading +from typing import Annotated from fastapi import APIRouter, Request, HTTPException +from fastapi.params import Query -from src.utils import create_timer +from src.utils import create_timer, generate_resource_name +from src.auth.dependencies import header_dependency +from src.dependencies import http_client_dependency router = APIRouter( tags=[""], @@ -52,3 +68,28 @@ async def stop_timer(request: Request, ident: str): timers.pop(idx) return {"timer_ident": ident, "status": "stopped"} + + +@router.get("/hub/access") +async def test_hub_access(headers: header_dependency, client: http_client_dependency, org_name: Annotated[str, Query()]): + resource_name = "example_resource" + action = "read" + rn = generate_resource_name(resource=resource_name, org=org_name) + + hub_response = await client.post( + f"http://localhost:8001/api/v1/iam/can_act_on_resource", + headers=headers, + params={"action": action}, + json=rn.model_dump() + ) + + response = { + "resource_name": rn, + "action": action, + "response": { + "status": hub_response.status_code, + "json": hub_response.json() + } + } + + return response diff --git a/src/_module_template/schemas.py b/src/_module_template/schemas.py index 65e8ef9..71cfc07 100644 --- a/src/_module_template/schemas.py +++ b/src/_module_template/schemas.py @@ -1,5 +1,8 @@ """ -Pydantic models for the _____ module +Pydantic models for the module -Exports: +Models follow the nomenclature of: +- Sub-models: "Schema" +- Mixins: "Mixin" +- Models: "" ie "" """ \ No newline at end of file diff --git a/src/_module_template/service.py b/src/_module_template/service.py index 868b9a1..139a237 100644 --- a/src/_module_template/service.py +++ b/src/_module_template/service.py @@ -1,5 +1,5 @@ """ -Module specific business logic for the _____ module +Module specific business logic for the module Exports: """ \ No newline at end of file diff --git a/src/_module_template/utils.py b/src/_module_template/utils.py index 7025443..4e99ff6 100644 --- a/src/_module_template/utils.py +++ b/src/_module_template/utils.py @@ -1,5 +1,3 @@ """ -Non-business logic reusable functions and classes for the _____ module - -Exports: +Non-business logic reusable functions and classes for the module """ \ No newline at end of file diff --git a/src/api.py b/src/api.py index 3e14c72..6b4abb9 100644 --- a/src/api.py +++ b/src/api.py @@ -7,12 +7,15 @@ from src.auth.router import router as auth_router from src._module_template.router import router as template_router -api_router = APIRouter() +api_router = APIRouter( + prefix="/api/v1" +) api_router.include_router(auth_router) api_router.include_router(template_router) + @api_router.get("/healthcheck", include_in_schema=False) def healthcheck(): - """Simple health check endpoint.""" - return {"status": "ok"} + """Simple health check endpoint.""" + return {"status": "ok"} diff --git a/src/auth/config.py b/src/auth/config.py index 224338d..eddca9e 100644 --- a/src/auth/config.py +++ b/src/auth/config.py @@ -1,16 +1,17 @@ """ -Configurations for auth module +Configurations for the auth module Exports: - - auth_settings: Configs for auth loaded from environment variables + - auth_settings: Contains OIDC & hub access information """ from src.config import CustomBaseSettings + class AuthConfig(CustomBaseSettings): OIDC_CONFIG: str = "" - OIDC_ISSUER: str = "" - OIDC_AUDIENCE: str = "" CLIENT_ID: str = "" + HUB_ACCESS_KEY: str = "" + auth_settings = AuthConfig() diff --git a/src/auth/constants.py b/src/auth/constants.py index 60103e8..faabd82 100644 --- a/src/auth/constants.py +++ b/src/auth/constants.py @@ -1,5 +1,3 @@ """ -Constants and error codes for auth module - -Exports: +Constants for the auth module """ \ No newline at end of file diff --git a/src/auth/dependencies.py b/src/auth/dependencies.py index da69593..10f2252 100644 --- a/src/auth/dependencies.py +++ b/src/auth/dependencies.py @@ -1,5 +1,26 @@ """ -Router dependencies for auth module +Auth dependencies Exports: -""" \ No newline at end of file +""" +from typing import Annotated, Any + +from fastapi import Depends +from fastapi.security import OpenIdConnect + +from src.auth.config import auth_settings + + +oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG) +oidc_dependency = Annotated[str, Depends(oidc)] + + +async def generate_access_headers(oidc_auth_string: oidc_dependency) -> dict[str, Any]: + headers = { + "Authorization": f"Bearer {oidc_auth_string}", + "X-API-Key": auth_settings.HUB_ACCESS_KEY, + } + return headers + + +header_dependency = Annotated[dict[str, Any], Depends(generate_access_headers)] diff --git a/src/auth/exceptions.py b/src/auth/exceptions.py index 0c885e3..613b166 100644 --- a/src/auth/exceptions.py +++ b/src/auth/exceptions.py @@ -1,5 +1,18 @@ """ -Module specific exceptions for auth module +Module specific exceptions for the auth module -Exports: -""" \ No newline at end of file +Exceptions: + - UnauthorizedException: Takes an optional message string +""" +from typing import Optional + +from fastapi import HTTPException, status + + +class UnauthorizedException(HTTPException): + def __init__(self, message: Optional[str] = None) -> None: + detail = "Not authorized" if not message else message + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + ) diff --git a/src/auth/models.py b/src/auth/models.py index 1a03980..4717477 100644 --- a/src/auth/models.py +++ b/src/auth/models.py @@ -1,5 +1,3 @@ """ -Database models for auth module - -Exports: +Database models for the auth module """ \ No newline at end of file diff --git a/src/auth/router.py b/src/auth/router.py index 056369c..9cd7fad 100644 --- a/src/auth/router.py +++ b/src/auth/router.py @@ -1,20 +1,11 @@ """ -Router endpoints for auth module -Contains oauth registration +Router endpoints for the auth module -Endpoints: - - /auth/me/claims: Test endpoint returning user claims +Exports: + - router: fastapi.APIRouter """ from fastapi import APIRouter -from src.auth.service import claims_dependency - router = APIRouter( tags=["auth"], - prefix="/auth", -) - - -@router.get("/me/claims") -async def current_user_claims(user: claims_dependency): - return user +) \ No newline at end of file diff --git a/src/auth/schemas.py b/src/auth/schemas.py index 584a709..279bb1b 100644 --- a/src/auth/schemas.py +++ b/src/auth/schemas.py @@ -1,5 +1,3 @@ """ -Pydantic models for auth module - -Exports: +Pydantic models for the auth module """ \ No newline at end of file diff --git a/src/auth/service.py b/src/auth/service.py index 2328df7..14bc08c 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -1,50 +1,5 @@ """ -Module specific business logic for auth module +Module specific business logic for the auth module Exports: - - claims_dependency """ -import json -import requests - -from typing import Annotated, Any -from joserfc import jwt -from urllib.request import urlopen - -from fastapi import Depends -from fastapi.security import OpenIdConnect -from joserfc.jwk import KeySet - -from src.auth.config import auth_settings - - -oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG) -oidc_dependency = Annotated[str, Depends(oidc)] - - -async def get_current_user(oidc_auth_string: oidc_dependency) -> dict[str, Any]: - config_url = urlopen(auth_settings.OIDC_CONFIG) - config = json.loads(config_url.read()) - jwks_uri = config["jwks_uri"] - key_response = requests.get(jwks_uri) - jwk_keys = KeySet.import_key_set(key_response.json()) - - claims_options = { - "exp": {"essential": True}, - "aud": {"essential": True, "value": "account"}, - "iss": {"essential": True, "value": auth_settings.OIDC_ISSUER}, - } - - token = jwt.decode( - oidc_auth_string.replace("Bearer ", ""), - jwk_keys - ) - - claims_requests = jwt.JWTClaimsRegistry(**claims_options) - - claims_requests.validate(token.claims) - - return token.claims - - -claims_dependency = Annotated[dict[str, Any], Depends(get_current_user)] diff --git a/src/auth/utils.py b/src/auth/utils.py index e946914..ed66e7c 100644 --- a/src/auth/utils.py +++ b/src/auth/utils.py @@ -1,5 +1,3 @@ """ -Non-business logic reusable functions and classes for auth module - -Exports: +Non-business logic reusable functions and classes for the auth module """ \ No newline at end of file diff --git a/src/config.py b/src/config.py index b211070..f07cf3d 100644 --- a/src/config.py +++ b/src/config.py @@ -2,8 +2,9 @@ Global configurations Exports: - - settings: Global configs loaded from environment variables - CustomBaseSettings - Base class to be used by all modules for loading configs + - settings: Global configurations object + - app_configs: Dict generated from configs, used in app initialisation """ from typing import Any @@ -24,6 +25,9 @@ class Config(CustomBaseSettings): APP_VERSION: str = "0.1" ENVIRONMENT: Environment = Environment.PRODUCTION SECRET_KEY: SecretStr = "" + DISABLE_AUTH: bool = False + SERVICE_NAME: str = "template_app" + HUB_ADDRESS: str = "http://localhost:8000" CORS_ORIGINS: list[str] = ["*"] CORS_ORIGINS_REGEX: str | None = None @@ -46,6 +50,9 @@ _QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD)) SQLALCHEMY_DATABASE_URI = SecretStr(f"postgresql+psycopg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}") +if settings.ENVIRONMENT == Environment.TESTING: + SQLALCHEMY_DATABASE_URI = SecretStr("sqlite:///:memory:") + app_configs: dict[str, Any] = {"title": "App API"} if settings.ENVIRONMENT.is_deployed: app_configs["root_path"] = f"/v{settings.APP_VERSION}" diff --git a/src/database.py b/src/database.py index 75edecc..a56f80d 100644 --- a/src/database.py +++ b/src/database.py @@ -6,17 +6,24 @@ Exports: - Base (sqlalchemy base model) """ from typing import Annotated -from sqlalchemy import create_engine -from sqlalchemy.orm import declarative_base, sessionmaker, Session +from sqlalchemy import create_engine, StaticPool +from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session from fastapi import Depends -from src.config import SQLALCHEMY_DATABASE_URI +from src.constants import Environment +from src.config import SQLALCHEMY_DATABASE_URI, settings as global_settings + +if global_settings.ENVIRONMENT == Environment.TESTING: + connect_args = {"check_same_thread": False} + engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value(), connect_args=connect_args, poolclass=StaticPool) +else: + engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value()) -engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value()) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + def get_db(): db = SessionLocal() try: @@ -29,4 +36,5 @@ def get_db(): db_dependency = Annotated[Session, Depends(get_db)] -Base = declarative_base() +class Base(DeclarativeBase): + pass diff --git a/src/dependencies.py b/src/dependencies.py new file mode 100644 index 0000000..72ca157 --- /dev/null +++ b/src/dependencies.py @@ -0,0 +1,15 @@ +""" +Global dependencies + +Exports: +""" +import httpx + +from typing import Annotated +from fastapi import Depends + +async def create_http_client(): + async with httpx.AsyncClient() as client: + yield client + +http_client_dependency = Annotated[httpx.AsyncClient, Depends(create_http_client)] diff --git a/src/exceptions.py b/src/exceptions.py index b18e221..66507a4 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,3 +1,28 @@ """ Global exceptions -""" \ No newline at end of file + +Exports: + - UnprocessableContentException + - ConflictException +""" +from typing import Optional + +from fastapi import HTTPException, status + + +class UnprocessableContentException(HTTPException): + def __init__(self, message: Optional[str] = None) -> None: + detail = "Unprocessable content" if not message else message + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=detail, + ) + + +class ConflictException(HTTPException): + def __init__(self, message: Optional[str] = None) -> None: + detail = "Conflict" if not message else message + super().__init__( + status_code=status.HTTP_409_CONFLICT, + detail=detail, + ) diff --git a/src/main.py b/src/main.py index f456897..c7a8491 100644 --- a/src/main.py +++ b/src/main.py @@ -12,6 +12,7 @@ from fastapi import FastAPI from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.cors import CORSMiddleware +from src.constants import Environment from src.config import settings, app_configs from src.api import api_router @@ -31,9 +32,10 @@ async def lifespan(_application: FastAPI) -> AsyncGenerator: if settings.ENVIRONMENT.is_deployed: - # Do this only on prod - pass + # Just a precaution, should be False anyway + settings.DISABLE_AUTH = False +tags_metadata = [] app = FastAPI( swagger_ui_init_oauth={ @@ -41,6 +43,7 @@ app = FastAPI( "usePkceWithAuthorizationCodeGrant": True, "scopes": "openid profile email", }, + openapi_tags=tags_metadata, **app_configs ) @@ -61,6 +64,11 @@ app.add_middleware( allow_headers=settings.CORS_HEADERS, ) +if settings.DISABLE_AUTH and (settings.ENVIRONMENT == Environment.LOCAL): + # app.dependency_overrides[] = get_dev_user + pass + + app.include_router(api_router) app.state["timers"] = [] diff --git a/src/schemas.py b/src/schemas.py index 8ae2e17..812b574 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -1,12 +1,20 @@ """ -Global Pydantic models +Global Pydantic schemas Exports: - - CustomBaseModel: Pydantic BaseModel with extra parameters + - CustomBaseModel: Schema used for all other Pydantic models + - ResourceName """ - from pydantic import BaseModel +from typing import Optional class CustomBaseModel(BaseModel): pass + + +class ResourceName(CustomBaseModel): + service: str + organisation: str + resource: str + instance: Optional[str] = None diff --git a/src/utils.py b/src/utils.py index 1325c47..32af80c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -4,7 +4,10 @@ Global non-business-logic reusable functions and classes Exports: """ import threading -from typing import Callable +from typing import Callable, Optional + +from src.schemas import ResourceName +from src.config import settings def create_timer(func: Callable, interval: int, stop_event: threading.Event): @@ -13,3 +16,14 @@ def create_timer(func: Callable, interval: int, stop_event: threading.Event): func() return threading.Thread(target=target, daemon=True) + + +def generate_resource_name(org: str, resource: str, instance: Optional[str] = None) -> ResourceName: + rn = ResourceName( + service=settings.SERVICE_NAME, + organisation=org, + resource=resource, + instance=instance, + ) + + return rn