template for service rather than hub
Previous template structure had direct handling of auth. This structure is designed to get auth from the hub instead.
This commit is contained in:
parent
ed4a3fe0b8
commit
ea9803536a
27 changed files with 223 additions and 117 deletions
|
|
@ -7,11 +7,8 @@ requests
|
|||
itsdangerous
|
||||
starlette
|
||||
pydantic-settings
|
||||
authlib
|
||||
joserfc
|
||||
httpx
|
||||
types-authlib
|
||||
python-jose
|
||||
pytest
|
||||
uvloop; sys_platform != 'win32'
|
||||
sqlalchemy
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Configurations for the _____ module
|
||||
Configurations for the <this> module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Constants and error codes for the _____ module
|
||||
Constants for the <this> module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""
|
||||
Router dependencies for the _____ module
|
||||
Dependencies related to the <this> module
|
||||
|
||||
Exports:
|
||||
- <dep_name>: <return_type>: <description>
|
||||
"""
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""
|
||||
Module specific exceptions for the _____ module
|
||||
Exceptions related to the <this> modules
|
||||
|
||||
Exports:
|
||||
Exceptions:
|
||||
- <ExceptionName>: Details e.g. optional params
|
||||
"""
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
"""
|
||||
Database models for the _____ module
|
||||
Database models for the <this> module
|
||||
|
||||
Exports:
|
||||
Models:
|
||||
- <ModelName>:
|
||||
- <normal_columns[FK][PK]>
|
||||
- <orm_relationships>
|
||||
- <calculated_properties>
|
||||
"""
|
||||
|
|
@ -1,15 +1,31 @@
|
|||
"""
|
||||
Router endpoints for the _____ module
|
||||
Router endpoints for the <this> 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"""
|
||||
Pydantic models for the _____ module
|
||||
Pydantic models for the <this> module
|
||||
|
||||
Exports:
|
||||
Models follow the nomenclature of:
|
||||
- Sub-models: "<Resource><Opt:>Schema"
|
||||
- Mixins: "<Attribute>Mixin"
|
||||
- Models: "<Module><Method><Resource><Opt:Resource><Direction>" ie ""
|
||||
"""
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Module specific business logic for the _____ module
|
||||
Module specific business logic for the <this> module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
"""
|
||||
Non-business logic reusable functions and classes for the _____ module
|
||||
|
||||
Exports:
|
||||
Non-business logic reusable functions and classes for the <this> module
|
||||
"""
|
||||
|
|
@ -7,11 +7,14 @@ 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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"""
|
||||
Constants and error codes for auth module
|
||||
|
||||
Exports:
|
||||
Constants for the auth module
|
||||
"""
|
||||
|
|
@ -1,5 +1,26 @@
|
|||
"""
|
||||
Router dependencies for auth module
|
||||
Auth dependencies
|
||||
|
||||
Exports:
|
||||
"""
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
"""
|
||||
Module specific exceptions for auth module
|
||||
Module specific exceptions for the auth module
|
||||
|
||||
Exports:
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"""
|
||||
Database models for auth module
|
||||
|
||||
Exports:
|
||||
Database models for the auth module
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"""
|
||||
Pydantic models for auth module
|
||||
|
||||
Exports:
|
||||
Pydantic models for the auth module
|
||||
"""
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
15
src/dependencies.py
Normal file
15
src/dependencies.py
Normal file
|
|
@ -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)]
|
||||
|
|
@ -1,3 +1,28 @@
|
|||
"""
|
||||
Global exceptions
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
12
src/main.py
12
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"] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
src/utils.py
16
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue