documentation, cleanup

This commit is contained in:
Anatol Ulrich 2023-10-04 22:25:21 +02:00
parent fa52233404
commit d33b57cff8
7 changed files with 171 additions and 61 deletions

View File

@ -1,3 +1,55 @@
# py-microservice # py-microservice
Sample microservice for use with basebox, written in Python 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.)

1
app.py
View File

@ -14,7 +14,6 @@ import logging
from fastapi import FastAPI from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter from strawberry.fastapi import GraphQLRouter
# app imports # app imports
import schema import schema
import consts import consts

95
auth.py
View File

@ -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 from functools import cached_property
import logging import logging
import typing
# dependencies imports
import httpx import httpx
from jose import jws, jwt from jose import jws, jwt
import strawberry
from strawberry.permission import BasePermission
from strawberry.fastapi import BaseContext from strawberry.fastapi import BaseContext
from strawberry.types import Info as _Info from strawberry.types import Info as _Info
from strawberry.types.info import RootValueType from strawberry.types.info import RootValueType
# app imports
import consts import consts
import config 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'] VALID_ALGS = ['HS256', 'RS256']
logger = logging.getLogger(consts.LOG_ROOT)
PERMISSIONS_KEY = config.str_val('AUTH_PERMISSIONS_KEY') PERMISSIONS_KEY = config.str_val('AUTH_PERMISSIONS_KEY')
OLS_PREFIX = config.str_val('AUTH_OLS_PREFIX') OLS_PREFIX = config.str_val('AUTH_OLS_PREFIX')
logger = logging.getLogger(consts.LOG_ROOT)
logger.debug('loading JWKS data') logger.debug('loading JWKS data')
# fetch IdP configuration, extract JWKS url from there, and load its contents # 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): class Context(BaseContext):
"""
Context class that adds JWT data (in bearer token form)
"""
@cached_property @cached_property
def token(self) -> str | None: 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: if not self.request:
return None return None
# extract bearer token data from header, if available
token = self.request.headers.get("Authorization", None) token = self.request.headers.get("Authorization", None)
if token: if token:
token = token.split("Bearer ")[1] token = token.split("Bearer ")[1]
return token 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] Info = _Info[Context, RootValueType]
async def get_context() -> Context: async def get_context() -> Context:
"""
helper method to asynchronously access context data
"""
return Context() return Context()
class SecurityException(Exception): class BBPermission(BasePermission):
pass """
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: def has_permission(self, source: typing.Any, info: Info, **kwargs) -> bool:
if token is None: operation = info.field_name
logger.info("no token") token = info.context.token
return False
headers = jwt.get_unverified_headers(token) # no token present -> permission denied
logger.debug('headers: %s', headers) if token is None:
logger.info("no token")
return False
claims = jwt.get_unverified_claims(token) headers = jwt.get_unverified_headers(token)
logger.debug('claims: %s', claims) logger.debug('headers: %s', headers)
permissions = claims.get("basebox/permissions", []) claims = jwt.get_unverified_claims(token)
algorithm = headers.get('alg') logger.debug('claims: %s', claims)
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) permissions = claims.get("basebox/permissions", [])
if not token_valid: algorithm = headers.get('alg')
logger.info("invalid token") # invalid algorithm -> permission denied
return False if not algorithm in VALID_ALGS:
logger.info('Invalid signing algorithm: %s', algorithm)
return False
namespaced_method = f'{OLS_PREFIX}{method_name}' token_valid = jws.verify(token=token, key=jwks, algorithms=algorithm)
logger.debug("verify %s <> %s", namespaced_method, permissions) # token verification failed -> permission denied
has_permission = namespaced_method in permissions if not token_valid:
return has_permission 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

View File

@ -8,17 +8,23 @@ or in a toml file using
foo = ... foo = ...
bar_baz = ... 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 then inserted into the OS environment (in this case, resulting in
SECTION_FOO=... and SECTION_BAR_BAZ=... ). SECTION_FOO=... and SECTION_BAR_BAZ=... ).
The OS environment takes precedence - if a key is found in both env and config, The OS environment takes precedence - if a key is found in both env and config,
the config setting will be ignored. the config setting will be ignored.
""" """
import tomllib
import logging
# stdlib imports
from os import environ from os import environ
from functools import wraps from functools import wraps
# dependencies imports
import tomllib
import logging
# app imports
import consts import consts
logger = logging.getLogger(consts.LOG_ROOT) logger = logging.getLogger(consts.LOG_ROOT)
@ -26,25 +32,27 @@ logger = logging.getLogger(consts.LOG_ROOT)
def load(file): 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: if file is None:
logger.debug('no config file provided.') logger.debug('no config file provided.')
else: else:
logger.debug('loading config') logger.debug('loading config')
with open(file, 'rb') as fh: with open(file, 'rb') as handle:
config = tomllib.load(fh) config = tomllib.load(handle)
for k, v in config.items(): for section, vals in config.items():
for k_inner, v_inner in v.items(): for key, val in vals.items():
effective_key = f'{k.upper()}_{k_inner.upper()}' effective_key = f'{section.upper()}_{key.upper()}'
if effective_key in environ: if effective_key in environ:
logger.warning( logger.warning(
'%s already exists in os environment, skipping', effective_key) '%s already exists in os environment, skipping', effective_key)
else: else:
environ[effective_key] = str(v_inner) environ[effective_key] = str(val)
logger.debug('raw: %s = %s', effective_key, logger.debug('config/raw: %s = %s', effective_key,
environ[effective_key]) environ[effective_key])
def env(key, default=None): def env(key, default=None):
@ -59,12 +67,12 @@ def env(key, default=None):
def logged(f): def logged(f):
""" """
logging wrapper for resolved configuration settings logging wrapper for resolved configuration values
""" """
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
res = f(*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 res
return decorated return decorated
@ -79,7 +87,7 @@ def int_val(key, default=None):
def strtobool(val): 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 True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
'val' is anything else. 'val' is anything else.

View File

@ -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] [server]
port = 8891 port = 8891
# automatically reload on source file changes. Default: false # automatically reload on source file changes. Default: false
# development only - disable in production
autoreload = true autoreload = true
[auth] [auth]

View File

@ -9,6 +9,8 @@ or directly via `uvicorn app:app`.
in the latter case configuration can only be passed in the latter case configuration can only be passed
via OS environment or an env file (`--env-file`), not TOML. via OS environment or an env file (`--env-file`), not TOML.
For further information, check [uvicorn: deployment](https://www.uvicorn.org/deployment/)
""" """
# stdlib imports # stdlib imports

View File

@ -44,16 +44,13 @@ class Mutation:
""" """
Operation to order a pizza Operation to order a pizza
""" """
@strawberry.mutation @strawberry.mutation(permission_classes=[auth.BBPermission])
def order_pizza(self, name: str, toppings: typing.List[str], info: auth.Info) -> OrderInfo: def order_pizza(self, name: str, toppings: typing.List[str]) -> OrderInfo:
""" """
Order a pizza with a name and list of toppings Order a pizza with a name and list of toppings
""" """
logger.debug("orderPizza name=%s toppings=%s", name, 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( return OrderInfo(
id=str(randint(1, 100000)), id=str(randint(1, 100000)),
deliveryTime=str(datetime.now()), deliveryTime=str(datetime.now()),
@ -62,20 +59,13 @@ class Mutation:
) )
def empty():
"""
empty resolver for stub query (see below)
"""
@strawberry.type @strawberry.type
class Query: class Query:
""" """
`strawberry.Schema` requires at least one `Query`, so configure a stub `strawberry.Schema` requires at least one `Query`, so configure a stub
see https://strawberry.rocks/docs/general/mutations 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) schema = strawberry.Schema(query=Query, mutation=Mutation)