Skip to content

Commit

Permalink
PXP-10039 Ability to make authenticated calls (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
paulineribeyre committed Jun 30, 2022
1 parent 0849571 commit 7655eba
Show file tree
Hide file tree
Showing 17 changed files with 281 additions and 153 deletions.
5 changes: 0 additions & 5 deletions .github/auto-label.json

This file was deleted.

14 changes: 0 additions & 14 deletions .github/workflows/auto-label.yml

This file was deleted.

4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"filename": "tests/test-requestor-config.yaml",
"hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3",
"is_verified": false,
"line_number": 10
"line_number": 18
}
],
"tests/test_migrations.py": [
Expand All @@ -137,5 +137,5 @@
}
]
},
"generated_at": "2022-06-15T21:04:38Z"
"generated_at": "2022-06-22T18:50:46Z"
}
139 changes: 61 additions & 78 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "requestor"
version = "1.5.1"
version = "1.6.0"
description = "Gen3 Access Request Service"
authors = ["CTDS UChicago <cdis@uchicago.edu>"]
license = "Apache-2.0"
Expand All @@ -13,8 +13,8 @@ include = [
[tool.poetry.dependencies]
python = "^3.9"
alembic = "^1.4.2"
authutils = "^5.0.4"
cdislogging = "^=1.0.0"
authutils = "^6.2.1"
cdislogging = "^1.0.0"
fastapi = "^0.65.0"
gen3authz = "^1.5.1"
gen3config = "^1.0.0"
Expand Down
9 changes: 6 additions & 3 deletions src/requestor/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def load_modules(app: FastAPI = None) -> None:

def app_init() -> FastAPI:
logger.info("Initializing app")
config.validate(logger)
config.validate()

debug = config["DEBUG"]
app = FastAPI(
Expand All @@ -63,10 +63,13 @@ def app_init() -> FastAPI:
app.arborist_client = ArboristClient(
arborist_base_url=custom_arborist_url,
authz_provider="requestor",
logger=logger,
logger=get_logger("requestor.gen3authz", log_level="debug"),
)
else:
app.arborist_client = ArboristClient(authz_provider="requestor", logger=logger)
app.arborist_client = ArboristClient(
authz_provider="requestor",
logger=get_logger("requestor.gen3authz", log_level="debug"),
)

db.init_app(app)
load_modules(app)
Expand Down
2 changes: 1 addition & 1 deletion src/requestor/arborist.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def get_auto_policy_id_for_resource_path(resource_path: str) -> str:
`read-storage` access to the provided `resource_path`.
"""
resources = resource_path.split("/")
policy_id = ".".join(resources[1:]) + "_reader"
policy_id = ".".join(resources[1:]) + "_accessor"
return policy_id


Expand Down
8 changes: 4 additions & 4 deletions src/requestor/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

# auto_error=False prevents FastAPI from raising a 403 when the request
# is missing an Authorization header. Instead, we want to return a 401
# to signify that we did not recieve valid credentials
# to signify that we did not receive valid credentials
bearer = HTTPBearer(auto_error=False)


Expand All @@ -36,9 +36,9 @@ async def get_token_claims(self) -> dict:
try:
# NOTE: token can be None if no Authorization header was provided, we
# expect this to cause a downstream exception since it is invalid
token_claims = await access_token("user", "openid", purpose="access")(
self.bearer_token
)
token_claims = await access_token(
"user", "openid", audience="openid", purpose="access"
)(self.bearer_token)
except Exception as e:
logger.error(f"Could not get token claims:\n{e}", exc_info=True)
raise HTTPException(
Expand Down
36 changes: 18 additions & 18 deletions src/requestor/config-default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,6 @@ ALLOWED_REQUEST_STATUSES:
- SIGNED
- REJECTED

# TODO maybe?
# display_name is optional
# ALLOWED_REQUEST_STATUSES:
# DRAFT:
# display_name: Draft
# SUBMITTED:
# display_name: Submitted
# APPROVED:
# display_name: Approved
# SIGNED:
# display_name: Signed
# REJECTED:
# display_name: Rejected

# status of new requests when no status is specified by
# the user
DEFAULT_INITIAL_STATUS: DRAFT
Expand All @@ -82,6 +68,9 @@ FINAL_STATUSES:
# ACTIONS ON STATUS UPDATE #
############################

# max number of times to retry external calls before giving up
DEFAULT_MAX_RETRIES: 5

REDIRECT_CONFIGS: {}
# my_redirect:
# redirect_url: http://localhost?something
Expand All @@ -101,15 +90,26 @@ EXTERNAL_CALL_CONFIGS: {}
# param: resource_id # internal parameter name (a field from the DB)
# - name: username
# param: username
# creds: "" # optional - a key from the CREDENTIALS section

# configure actions to trigger when the status of an access
# request for the specified resource path is updated
# /!\ If there are multiple resource_paths in a request's policy,
# only the first "redirect_configs" match is considered
# configure actions to trigger when the status of an access request for the
# specified resource path is updated. Multiple actions can be triggered by a
# single event, except redirect actions: if multiple "redirect_configs" match
# the event, only the first one is considered
ACTION_ON_UPDATE: {}
# /resource/with/redirect:
# DRAFT:
# redirect_configs:
# - my_redirect
# external_call_configs:
# - let_someone_know

# only the "client_credentials" type is supported at the moment
CREDENTIALS: {}
# unique_creds_id:
# type: client_credentials
# config:
# client_id: ""
# client_secret: ""
# url: "" # token endpoint
# scope: "space separated list of scopes"
77 changes: 62 additions & 15 deletions src/requestor/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from itertools import chain
from jsonschema import validate
import os
from sqlalchemy.engine.url import make_url, URL

from gen3config import Config

from . import logger

DEFAULT_CFG_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "config-default.yaml"
)
Expand All @@ -28,7 +31,7 @@ def post_process(self) -> None:
),
)

def validate(self, logger) -> None:
def validate(self) -> None:
"""
Perform a series of sanity checks on a loaded config.
"""
Expand All @@ -41,27 +44,24 @@ def validate(self, logger) -> None:
]

self.validate_statuses()
self.validate_credentials()
self.validate_actions()

def validate_statuses(self) -> None:
msg = "'{}' is not one of ALLOWED_REQUEST_STATUSES {}"
logger.info("Validating configuration: statuses")
allowed_statuses = self["ALLOWED_REQUEST_STATUSES"]
assert isinstance(
allowed_statuses, list
), "ALLOWED_REQUEST_STATUSES should be a list"

assert self["DEFAULT_INITIAL_STATUS"] in allowed_statuses, msg.format(
self["DEFAULT_INITIAL_STATUS"], allowed_statuses
)

for s in self["DRAFT_STATUSES"]:
assert s in allowed_statuses, msg.format(s, allowed_statuses)

for s in self["UPDATE_ACCESS_STATUSES"]:
assert s in allowed_statuses, msg.format(s, allowed_statuses)

for s in self["FINAL_STATUSES"]:
assert s in allowed_statuses, msg.format(s, allowed_statuses)
msg = "'{}' is not one of ALLOWED_REQUEST_STATUSES {}"
for status in chain(
[self["DEFAULT_INITIAL_STATUS"]],
self["DRAFT_STATUSES"],
self["UPDATE_ACCESS_STATUSES"],
self["FINAL_STATUSES"],
):
assert status in allowed_statuses, msg.format(status, allowed_statuses)

def validate_actions(self) -> None:
"""
Expand All @@ -74,6 +74,7 @@ def validate_actions(self) -> None:
external_call_configs:
- def
"""
logger.info("Validating configuration: actions")
self.validate_redirect_configs()
self.validate_external_call_configs()

Expand Down Expand Up @@ -146,13 +147,14 @@ def validate_redirect_configs(self):
def validate_external_call_configs(self):
"""
Example:
EXTERNAL_CALL_CONFIGS
EXTERNAL_CALL_CONFIGS:
let_someone_know:
method: POST
url: http://url.com
form:
- name: dataset
param: resource_id
creds: ""
"""
schema = {
"type": "object",
Expand All @@ -164,6 +166,7 @@ def validate_external_call_configs(self):
"properties": {
"method": NON_EMPTY_STRING_SCHEMA,
"url": NON_EMPTY_STRING_SCHEMA,
"creds": {"enum": list(self["CREDENTIALS"].keys())},
"form": {
"type": "array",
"items": {
Expand All @@ -188,5 +191,49 @@ def validate_external_call_configs(self):
config["method"].lower() in supported_methods
), f"EXTERNAL_CALL_CONFIGS method {config['method']} is not one of {supported_methods}"

def validate_credentials(self):
"""
Example:
CREDENTIALS:
unique_creds_id:
type: client_credentials
config:
client_id: ""
client_secret: ""
url: http://url.com/oauth2/token
scope: "space separated list of scopes"
"""
logger.info("Validating configuration: credentials")
schema = {
"type": "object",
"patternProperties": {
".*": { # unique ID
"type": "object",
"additionalProperties": False,
"required": ["type", "config"],
"properties": {
"type": {"enum": ["client_credentials"]},
"config": {},
},
}
},
}
validate(instance=self["CREDENTIALS"], schema=schema)

for credentials_config in self["CREDENTIALS"].values():
if credentials_config["type"] == "client_credentials":
schema = {
"type": "object",
"additionalProperties": False,
"required": ["client_id", "client_secret", "url", "scope"],
"properties": {
"client_id": NON_EMPTY_STRING_SCHEMA,
"client_secret": NON_EMPTY_STRING_SCHEMA,
"url": NON_EMPTY_STRING_SCHEMA,
"scope": NON_EMPTY_STRING_SCHEMA,
},
}
validate(instance=credentials_config["config"], schema=schema)


config = RequestorConfig(DEFAULT_CFG_PATH)
37 changes: 34 additions & 3 deletions src/requestor/request_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import requests
import time
from typing import Tuple
from urllib.parse import urlparse, urlencode, parse_qsl

from . import logger
Expand All @@ -9,7 +10,7 @@

def retry_wrapper(func):
def retry_logic(*args, **kwargs):
max_retries = kwargs.get("max_retries", 5)
max_retries = kwargs.get("max_retries", config["DEFAULT_MAX_RETRIES"])
retries = 0
sleep_sec = 0.1
while retries < max_retries:
Expand Down Expand Up @@ -72,6 +73,31 @@ def get_redirect_url(action_id: str, data: dict) -> str:
return final_redirect_url


def get_credentials(creds_id: str) -> Tuple[str, str]:
# the config validation ensures the credentials exists
creds = config["CREDENTIALS"][creds_id]
if creds["type"] == "client_credentials":
# TODO we get a fresh access token every time. A potential improvement
# would be to cache/store the access tokens
logger.debug(
f"Attempting to get an access token from '{creds['config']['url']}'"
)
response = requests.post(
creds["config"]["url"],
data={
"grant_type": "client_credentials",
"scope": creds["config"]["scope"],
},
auth=(creds["config"]["client_id"], creds["config"]["client_secret"]),
)
response.raise_for_status()
assert (
"access_token" in response.json()
), f"Did not receive an access token from {creds['config']['url']}"
return creds["type"], response.json()["access_token"]
return "", "" # this should never happen; the config validation checks `type`


@retry_wrapper
def make_external_call(external_call_id: str, data: dict) -> None:
conf = config["EXTERNAL_CALL_CONFIGS"][external_call_id]
Expand All @@ -81,13 +107,18 @@ def make_external_call(external_call_id: str, data: dict) -> None:
for e in conf.get("form", [])
if data.get(e["param"])
} or None
# headers = {} # TODO implement authorization here

headers = {}
if "creds" in conf:
creds_type, creds = get_credentials(conf["creds"])
if creds_type == "client_credentials":
headers["authorization"] = f"bearer {creds}"

logger.info(f"Making call to '{conf['url']}' with data: {form_data}")
response = requests_func(
conf["url"],
data=form_data,
# headers=headers,
headers=headers,
)

try:
Expand Down

0 comments on commit 7655eba

Please sign in to comment.