documentation, cleanup
This commit is contained in:
parent
fa52233404
commit
d33b57cff8
54
README.md
54
README.md
@ -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
1
app.py
@ -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
95
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
|
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
|
||||||
|
46
config.py
46
config.py
@ -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.
|
||||||
|
16
config.toml
16
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]
|
[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]
|
||||||
|
2
main.py
2
main.py
@ -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
|
||||||
|
18
schema.py
18
schema.py
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user