From d33b57cff8d9c6e2a0547df9a1d3fba77414f68f Mon Sep 17 00:00:00 2001 From: Anatol Ulrich Date: Wed, 4 Oct 2023 22:25:21 +0200 Subject: [PATCH] documentation, cleanup --- README.md | 54 +++++++++++++++++++++++++++++- app.py | 1 - auth.py | 95 ++++++++++++++++++++++++++++++++++++++--------------- config.py | 46 +++++++++++++++----------- config.toml | 16 +++++++++ main.py | 2 ++ schema.py | 18 +++------- 7 files changed, 171 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index e1a0d80..4d58a8c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,55 @@ # py-microservice -Sample microservice for use with basebox, written in Python \ No newline at end of file +Sample microservice for use with basebox broker, written in Python. + +## Running + +Configure the broker's schema accordingly (see notes in `config.toml`) and launch the microservice: + +```shell +# optional, but recommended: +# setup/activate virtual env +$ python3 -mvenv venv # once +$ source venv/bin/activate # always + +# actual invocation +$ pip install -r requirements.txt +$ ./main.py -c config.toml +``` + +See top-level notes in `main.py` for further deployment information. + +## Extending the GraphQL schema + +see `schema.py` and [Strawberry GraphQL documentation](https://strawberry.rocks/docs) + +## Authorization + +### Attribute based permissions + +Assuming a zero trust deployment, the requesting user must have the correct claims to execute an operation - +to disable this during development (or if you operate the service in a trusted enviroment), remove the `permission_classes` parameter in all operations defined in `schema.py`. + +These permissions must be a list stored in the access token under the key `config.AUTH_PERMISSIONS_KEY`, +its values prefixed with `config.AUTH_OLS_PREFIX`. For example: +```javascript +claims = { + //... + "basebox/permissions": ["allow::bb::operation::orderPizza", "allow::bb::operation::cancelPizzaOrder"] +} +``` + +### JWT signature algorithm + +For security reasons the JWT signature algorithm must be a member of +the `VALID_ALGS` list configured in `auth.py`. +Since this is less of a user configurable setting and more a question +of underlying security libraries' capabilities (i.e. `openssl`), it is intentionally +not part of the config system. A more permissive but less secure alternative would be +to instead just check that the supplied algorithm (the `alg` field in JWT) +is [not equal to `none`](https://blog.pentesteracademy.com/hacking-jwt-tokens-the-none-algorithm-67c14bb15771). In the author's opinion however this leaves an attacker with too much surface freedom: + +As a trivial example it's unclear whether `NONE` would be +wrongly accepted by the verification library, and while normalization to lowercase isn't hard, +other loopholes might exist (future algorithms with unknown security profiles, +unicode normalization, implementation bugs, etc.) \ No newline at end of file diff --git a/app.py b/app.py index 7fa44de..705ad90 100644 --- a/app.py +++ b/app.py @@ -14,7 +14,6 @@ import logging from fastapi import FastAPI from strawberry.fastapi import GraphQLRouter - # app imports import schema import consts diff --git a/auth.py b/auth.py index ae66ab6..ca6406a 100644 --- a/auth.py +++ b/auth.py @@ -1,23 +1,38 @@ +""" +Authentication module. +Its main export is the `BBPermission` class, used in `permission_classes=[..., BBPermission]` +Further information: [strawberry permssions](https://strawberry.rocks/docs/guides/permissions) +""" + +# stdlib imports from functools import cached_property import logging +import typing +# dependencies imports import httpx + from jose import jws, jwt -import strawberry + +from strawberry.permission import BasePermission from strawberry.fastapi import BaseContext from strawberry.types import Info as _Info from strawberry.types.info import RootValueType +# app imports import consts import config -# valid signing algorithms. This mostly serves to avoid the 'none' exploit, add more algorithms as necessary + +# valid signing algorithms. +# This mostly serves to avoid the 'none' exploit, add more algorithms as necessary +# see README.md for further discussion 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 = logging.getLogger(consts.LOG_ROOT) logger.debug('loading JWKS data') # fetch IdP configuration, extract JWKS url from there, and load its contents @@ -33,51 +48,79 @@ jwks = httpx.get(jwks_url).json() class Context(BaseContext): + """ + Context class that adds JWT data (in bearer token form) + """ @cached_property def token(self) -> str | None: + """ + JWT bearer token property + """ + # no request means we cannot access the Authorization header -> token is None if not self.request: return None + # extract bearer token data from header, if available token = self.request.headers.get("Authorization", None) if token: token = token.split("Bearer ")[1] return token +# define Info class that contains our additional Context data +# this class is automatically populated when part of a corresponding method signature, +# e.g. `has_permission` or `@strawberry.___` Info = _Info[Context, RootValueType] async def get_context() -> Context: + """ + helper method to asynchronously access context data + """ return Context() -class SecurityException(Exception): - pass +class BBPermission(BasePermission): + """ + Generic permission check that checks the operation/field name against a list of + permissions supplied in the JWT's claims under `config.AUTH_PERMISSIONS_KEY`. The + permissions are prefixed with `config.AUTH_OLS_PREFIX`. + See `README.md` for further information. + """ + message = "Permission denied" -def validate_permissions(token: str | None, method_name: str) -> bool: - if token is None: - logger.info("no token") - return False + def has_permission(self, source: typing.Any, info: Info, **kwargs) -> bool: + operation = info.field_name + token = info.context.token - headers = jwt.get_unverified_headers(token) - logger.debug('headers: %s', headers) + # no token present -> permission denied + if token is None: + logger.info("no token") + return False - claims = jwt.get_unverified_claims(token) - logger.debug('claims: %s', claims) + headers = jwt.get_unverified_headers(token) + logger.debug('headers: %s', headers) - permissions = claims.get("basebox/permissions", []) - algorithm = headers.get('alg') - if not algorithm in VALID_ALGS: - logger.info('Invalid signing algorithm: %s', algorithm) - return False + claims = jwt.get_unverified_claims(token) + logger.debug('claims: %s', claims) - token_valid = jws.verify(token=token, key=jwks, algorithms=algorithm) - if not token_valid: - logger.info("invalid token") - return False + permissions = claims.get("basebox/permissions", []) + algorithm = headers.get('alg') + # invalid algorithm -> permission denied + if not algorithm in VALID_ALGS: + logger.info('Invalid signing algorithm: %s', algorithm) + 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 + token_valid = jws.verify(token=token, key=jwks, algorithms=algorithm) + # token verification failed -> permission denied + if not token_valid: + logger.info("invalid token") + return False + + # check whether the requested operation is in the list of valid operations + namespaced_method = f'{OLS_PREFIX}{operation}' + logger.debug("verify required permission: %s against claims: %s", + namespaced_method, permissions) + has_permission = namespaced_method in permissions + return has_permission diff --git a/config.py b/config.py index 6d3c249..ca7381d 100644 --- a/config.py +++ b/config.py @@ -8,17 +8,23 @@ or in a toml file using foo = ... bar_baz = ... -in the latter case, the section and name are uppercased and joined with `_`, +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 + + +# stdlib imports from os import environ from functools import wraps +# dependencies imports +import tomllib +import logging + +# app imports import consts logger = logging.getLogger(consts.LOG_ROOT) @@ -26,25 +32,27 @@ logger = logging.getLogger(consts.LOG_ROOT) def load(file): """ - Load configuration from toml and add to environment + Load configuration from toml and add to environment. + Skip anything already present there. + Configuration key names are converted according to the module documentation. """ 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]) + with open(file, 'rb') as handle: + config = tomllib.load(handle) + for section, vals in config.items(): + for key, val in vals.items(): + effective_key = f'{section.upper()}_{key.upper()}' + if effective_key in environ: + logger.warning( + '%s already exists in os environment, skipping', effective_key) + else: + environ[effective_key] = str(val) + logger.debug('config/raw: %s = %s', effective_key, + environ[effective_key]) def env(key, default=None): @@ -59,12 +67,12 @@ def env(key, default=None): def logged(f): """ - logging wrapper for resolved configuration settings + logging wrapper for resolved configuration values """ @wraps(f) def decorated(*args, **kwargs): res = f(*args, **kwargs) - logger.debug('resolved: %s = %s', args[0], res) + logger.debug('config/resolved: %s = %s', args[0], res) return res return decorated @@ -79,7 +87,7 @@ def int_val(key, default=None): def strtobool(val): - """Convert a string representation of truth to true (1) or false (0). + """Convert a string representation of truth to `True` or `False`. 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. diff --git a/config.toml b/config.toml index e68fe7c..c8322c3 100644 --- a/config.toml +++ b/config.toml @@ -1,6 +1,22 @@ +# Example configuration. The server address and port must be +# match the basebox Broker's GraphQL schema @bb_resolver metadata for +# the corresponding queries/mutations, e.g.: +# +# type Mutation { +# orderPizza( +# name: String! +# toppings: [String!]! +# ): OrderInfo +# @bb_resolver ( +# _type: HTTP_SERVICE +# _url: "http://127.0.0.1:8891/graphql" +# ) +# } + [server] port = 8891 # automatically reload on source file changes. Default: false +# development only - disable in production autoreload = true [auth] diff --git a/main.py b/main.py index 8867fe9..a71a344 100755 --- a/main.py +++ b/main.py @@ -9,6 +9,8 @@ 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. +For further information, check [uvicorn: deployment](https://www.uvicorn.org/deployment/) + """ # stdlib imports diff --git a/schema.py b/schema.py index 6e7c19f..db6fc1c 100644 --- a/schema.py +++ b/schema.py @@ -44,16 +44,13 @@ class Mutation: """ Operation to order a pizza """ - @strawberry.mutation - def order_pizza(self, name: str, toppings: typing.List[str], info: auth.Info) -> OrderInfo: + @strawberry.mutation(permission_classes=[auth.BBPermission]) + def order_pizza(self, name: str, toppings: typing.List[str]) -> 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()), @@ -62,20 +59,13 @@ class Mutation: ) -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) + stub: str = strawberry.field(resolver=lambda: ...) -# query= is required: https://strawberry.rocks/docs/general/mutations schema = strawberry.Schema(query=Query, mutation=Mutation)