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
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 strawberry.fastapi import GraphQLRouter
# app imports
import schema
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
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

View File

@ -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.

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

View File

@ -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

View File

@ -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)