diff --git a/app.py b/app.py new file mode 100644 index 0000000..7fa44de --- /dev/null +++ b/app.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +""" +Basebox microservices example app + +this defines an `app:app` module which is loaded from `main.py` or directly via `uvicorn app:app`. +in the latter case configuration can only be passed via OS environment or an env file (`--env-file`), not TOML. +""" + +# stdlib imports +import logging + +# dependencies imports +from fastapi import FastAPI +from strawberry.fastapi import GraphQLRouter + + +# app imports +import schema +import consts + +import auth + +logger = logging.getLogger(consts.LOG_ROOT) + +logger.debug('starting app') + +# configure GraphQL with `schema.schema` and additional auth context +graphql_app = GraphQLRouter(schema.schema, context_getter=auth.get_context) + +# create app with GraphQL routing +app = FastAPI() +app.include_router(graphql_app, prefix="/graphql") diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..ae66ab6 --- /dev/null +++ b/auth.py @@ -0,0 +1,83 @@ +from functools import cached_property +import logging + +import httpx +from jose import jws, jwt +import strawberry +from strawberry.fastapi import BaseContext +from strawberry.types import Info as _Info +from strawberry.types.info import RootValueType + +import consts +import config + +# valid signing algorithms. This mostly serves to avoid the 'none' exploit, add more algorithms as necessary +VALID_ALGS = ['HS256', 'RS256'] + +logger = logging.getLogger(consts.LOG_ROOT) +PERMISSIONS_KEY = config.str_val('AUTH_PERMISSIONS_KEY') +OLS_PREFIX = config.str_val('AUTH_OLS_PREFIX') + +logger.debug('loading JWKS data') + +# fetch IdP configuration, extract JWKS url from there, and load its contents +idp_url = config.str_val('AUTH_IDP_URL') +config_url = idp_url + '/.well-known/openid-configuration' + +logger.debug('loading openid configuration from %s', config_url) +config = httpx.get(config_url).json() + +jwks_url = config['jwks_uri'] +logger.debug('loading JWKS data from %s', jwks_url) +jwks = httpx.get(jwks_url).json() + + +class Context(BaseContext): + @cached_property + def token(self) -> str | None: + if not self.request: + return None + + token = self.request.headers.get("Authorization", None) + if token: + token = token.split("Bearer ")[1] + return token + + +Info = _Info[Context, RootValueType] + + +async def get_context() -> Context: + return Context() + + +class SecurityException(Exception): + pass + + +def validate_permissions(token: str | None, method_name: str) -> bool: + if token is None: + logger.info("no token") + return False + + headers = jwt.get_unverified_headers(token) + logger.debug('headers: %s', headers) + + claims = jwt.get_unverified_claims(token) + logger.debug('claims: %s', claims) + + permissions = claims.get("basebox/permissions", []) + algorithm = headers.get('alg') + if not algorithm in VALID_ALGS: + logger.info('Invalid signing algorithm: %s', algorithm) + return False + + token_valid = jws.verify(token=token, key=jwks, algorithms=algorithm) + if not token_valid: + logger.info("invalid token") + return False + + namespaced_method = f'{OLS_PREFIX}{method_name}' + logger.debug("verify %s <> %s", namespaced_method, permissions) + has_permission = namespaced_method in permissions + return has_permission diff --git a/config.py b/config.py new file mode 100644 index 0000000..6d3c249 --- /dev/null +++ b/config.py @@ -0,0 +1,109 @@ +""" +Application configuration + +values can be configured in the OS environment via KEY_NAME=... +or in a toml file using + +[section] +foo = ... +bar_baz = ... + +in the latter case, the section and name are uppercased and joined with `_`, +then inserted into the OS environment (in this case, resulting in +SECTION_FOO=... and SECTION_BAR_BAZ=... ). +The OS environment takes precedence - if a key is found in both env and config, +the config setting will be ignored. +""" +import tomllib +import logging +from os import environ +from functools import wraps + +import consts + +logger = logging.getLogger(consts.LOG_ROOT) + + +def load(file): + """ + Load configuration from toml and add to environment + """ + + if file is None: + logger.debug('no config file provided.') + else: + logger.debug('loading config') + with open(file, 'rb') as fh: + config = tomllib.load(fh) + for k, v in config.items(): + for k_inner, v_inner in v.items(): + effective_key = f'{k.upper()}_{k_inner.upper()}' + if effective_key in environ: + logger.warning( + '%s already exists in os environment, skipping', effective_key) + else: + environ[effective_key] = str(v_inner) + logger.debug('raw: %s = %s', effective_key, + environ[effective_key]) + + +def env(key, default=None): + """ + retrieve a key from the OS environment. + throws `KeyError` when the key is not found and no default fallback is provided. + """ + if default is None: + return environ[key] + return environ.get(key, default=default) + + +def logged(f): + """ + logging wrapper for resolved configuration settings + """ + @wraps(f) + def decorated(*args, **kwargs): + res = f(*args, **kwargs) + logger.debug('resolved: %s = %s', args[0], res) + return res + return decorated + + +@logged +def int_val(key, default=None): + """ + retrieve configuration value as int + """ + + return int(env(key, default=default)) + + +def strtobool(val): + """Convert a string representation of truth to true (1) or false (0). + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return True + elif val in ("n", "no", "f", "false", "off", "0"): + return False + else: + raise ValueError(f'Invalid bool string: {val}') + + +@logged +def bool_val(key, default=None): + """ + retrieve configuration value as bool + """ + return strtobool(env(key, default=default)) + + +@logged +def str_val(key, default=None): + """ + retrieve configuration value as bool + """ + return env(key, default=default) diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..e68fe7c --- /dev/null +++ b/config.toml @@ -0,0 +1,14 @@ +[server] +port = 8891 +# automatically reload on source file changes. Default: false +autoreload = true + +[auth] +# realm for identity provider, for loading key store data +idp_url = "https://kcdev.basebox.io:8443/realms/test-runner" + +# prefix for permission values, resulting in a check against e.g. "allow::bb::operation::orderPizza" +ols_prefix = "allow::bb::operation::" + +# permissions key name in token claims +permissions_key = "basebox/permissions" diff --git a/consts.py b/consts.py new file mode 100644 index 0000000..cfd9cf5 --- /dev/null +++ b/consts.py @@ -0,0 +1,11 @@ +"""global constants""" + +# application log hierarchy root +# +# an empty root will configure logging for all packages +# use this to debug issues with underlying modules +# LOG_ROOT = '' +LOG_ROOT = 'bb.mspy' + +# default log level +DEFAULT_LOG_LEVEL = 'debug' diff --git a/main.py b/main.py new file mode 100755 index 0000000..8867fe9 --- /dev/null +++ b/main.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +""" +Basebox microservices example app + +this defines an `app:app` module which is loaded from `main.py` +or directly via `uvicorn app:app`. + +in the latter case configuration can only be passed +via OS environment or an env file (`--env-file`), not TOML. + +""" + +# stdlib imports +from os import environ +import argparse +import logging + +# dependencies imports +import coloredlogs +import uvicorn + +# app imports +import config +import consts + + +def setup_logging(): + """ + Configure logging from LOG_LEVEL environment variable, defaulting to DEBUG. + This intentionally does not use the config system to enable log messages + from config initialization. + + Since logging is taken over by uvicorn, this will only be effective during initial startup. + + :returns: the effective log level + """ + level = environ.get('LOG_LEVEL', consts.DEFAULT_LOG_LEVEL) + logger = logging.getLogger(consts.LOG_ROOT) + coloredlogs.install(level=level, logger=logger) + return level + + +def parse_args(): + """ + parse command line arguments + """ + parser = argparse.ArgumentParser( + description='Basebox microservice example') + parser.add_argument('-c', '--config-file', type=str, + help='config file') + return parser.parse_args() + + +def main(): + """ + application entry point + """ + + # setup logging + log_level = setup_logging() + + # load config + args = parse_args() + config.load(args.config_file) + + # inject our application log root into uvicorn's own log config + # + # discussion: + # https://github.com/tiangolo/fastapi/discussions/7457 + # https://github.com/encode/uvicorn/issues/491 + log_config = uvicorn.config.LOGGING_CONFIG + log_config["loggers"][consts.LOG_ROOT] = { + "level": log_level.upper(), + "handlers": [ + "default" + ], + } + + # start server + uvicorn.run('app:app', + log_config=log_config, + port=config.int_val('SERVER_PORT'), + reload=config.bool_val( + 'SERVER_AUTORELOAD', 'False')) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..86b6c9e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +python-jose[cryptography] +toml +strawberry-graphql[fastapi] +uvicorn[standard] +coloredlogs +pydantic +httpx \ No newline at end of file diff --git a/schema.py b/schema.py new file mode 100644 index 0000000..6e7c19f --- /dev/null +++ b/schema.py @@ -0,0 +1,81 @@ +""" +GraphQL schema definitions +""" + +# stdlib imports +from random import randint +from datetime import datetime +import typing +import logging + +# dependencies imports +import strawberry + +# app imports +import consts +import auth + + +logger = logging.getLogger(consts.LOG_ROOT) + + +@strawberry.type +class Pizza: + """ + Pizza + """ + name: str + toppings: typing.List[str] + + +@strawberry.type +class OrderInfo: + """ + Information about a pizza order + """ + id: str + deliveryTime: str + bakerName: str + pizza: Pizza + + +@strawberry.type +class Mutation: + """ + Operation to order a pizza + """ + @strawberry.mutation + def order_pizza(self, name: str, toppings: typing.List[str], info: auth.Info) -> OrderInfo: + """ + Order a pizza with a name and list of toppings + """ + logger.debug("orderPizza name=%s toppings=%s", name, toppings) + token = info.context.token + has_permission = auth.validate_permissions(token, "orderPizza") + if not has_permission: + raise auth.SecurityException("Permission denied") + return OrderInfo( + id=str(randint(1, 100000)), + deliveryTime=str(datetime.now()), + bakerName="Domino's", + pizza=Pizza(name=name, toppings=toppings) + ) + + +def empty(): + """ + empty resolver for stub query (see below) + """ + + +@strawberry.type +class Query: + """ + `strawberry.Schema` requires at least one `Query`, so configure a stub + see https://strawberry.rocks/docs/general/mutations + """ + stub: str = strawberry.field(resolver=empty) + + +# query= is required: https://strawberry.rocks/docs/general/mutations +schema = strawberry.Schema(query=Query, mutation=Mutation)