From d275aa75f0d8e8a2efef84b4bdbf1e553c608e8b Mon Sep 17 00:00:00 2001 From: "Petter H. Juliussen" Date: Fri, 3 Sep 2021 10:03:14 +0200 Subject: [PATCH] Initial auth for endpoints --- Makefile | 1 + app.py | 11 +++- clients/__init__.py | 5 ++ clients/teams.py | 24 +++++++++ models/models.py | 1 + requirements.txt | 62 +++++++++++---------- resources/authorizer.py | 90 +++++++++++++++++++++++++++++++ resources/errors.py | 17 ++++++ resources/maskinporten_clients.py | 24 +++++++-- setup.py | 4 +- 10 files changed, 201 insertions(+), 38 deletions(-) create mode 100644 clients/__init__.py create mode 100644 clients/teams.py create mode 100644 resources/authorizer.py create mode 100644 resources/errors.py diff --git a/Makefile b/Makefile index 35460f5..7845c78 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ node_modules: package.json package-lock.json $(BUILD_VENV): $(GLOBAL_PY) -m venv $(BUILD_VENV) $(BUILD_PY) -m pip install -U pip + $(BUILD_PY) -m pip install -r requirements.txt .PHONY: format format: $(BUILD_VENV)/bin/black diff --git a/app.py b/app.py index c29932d..6a378a4 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,14 @@ import os -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from resources import maskinporten_clients +from resources.errors import ErrorResponse + root_path = os.environ.get("ROOT_PATH", "") + app = FastAPI( title="TODO", description="TODO", @@ -13,3 +17,8 @@ ) app.include_router(maskinporten_clients.router, prefix="/clients") + + +@app.exception_handler(ErrorResponse) +def abort_exception_handler(request: Request, exc: ErrorResponse): + return JSONResponse(status_code=exc.status_code, content={"message": exc.message}) diff --git a/clients/__init__.py b/clients/__init__.py new file mode 100644 index 0000000..4ec642c --- /dev/null +++ b/clients/__init__.py @@ -0,0 +1,5 @@ +from clients.teams import TeamsClient + +__all__ = [ + "TeamsClient", +] diff --git a/clients/teams.py b/clients/teams.py new file mode 100644 index 0000000..ba22f97 --- /dev/null +++ b/clients/teams.py @@ -0,0 +1,24 @@ +import os +from keycloak.authorization import Authorization + +import requests + + +class TeamsClient: + @staticmethod + def has_member(access_token: str, team_id: str, user_id: str): + r = requests.get( + url=f"{os.environ['TEAMS_API_URL']}/teams/{team_id}/members/{user_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + r.raise_for_status() + return r.status_code == 200 + + @staticmethod + def has_role(access_token: str, team_id: str, role: str): + r = requests.get( + url=f"{os.environ['TEAMS_API_URL']}/teams/{team_id}/roles", + headers={"Authorization": f"Bearer {access_token}"}, + ) + r.raise_for_status() + return role in r.json() diff --git a/models/models.py b/models/models.py index 56480ed..70e89cb 100644 --- a/models/models.py +++ b/models/models.py @@ -2,6 +2,7 @@ class MaskinportenClientIn(BaseModel): + team_id: str name: str description: str scopes: list[str] diff --git a/requirements.txt b/requirements.txt index 223c1ef..a595b77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,85 +1,83 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.9 # To update, run: # # pip-compile # -attrs==20.3.0 +attrs==21.2.0 # via jsonschema -aws-xray-sdk==2.6.0 +aws-xray-sdk==2.8.0 # via okdata-maskinporten-api (setup.py) -botocore==1.19.60 +botocore==1.21.35 # via aws-xray-sdk -certifi==2020.12.5 +certifi==2021.5.30 # via requests -chardet==4.0.0 +charset-normalizer==2.0.4 # via requests -ecdsa==0.14.1 +ecdsa==0.17.0 # via python-jose -fastapi==0.68.0 +fastapi==0.68.1 # via okdata-maskinporten-api (setup.py) future==0.18.2 # via aws-xray-sdk -idna==2.10 +idna==3.2 # via requests jmespath==0.10.0 # via botocore -jsonpickle==1.5.0 - # via aws-xray-sdk jsonschema==3.2.0 # via okdata-sdk mangum==0.12.2 # via okdata-maskinporten-api (setup.py) -okdata-aws==0.3.0 +okdata-aws==0.4.0 # via okdata-maskinporten-api (setup.py) -okdata-sdk==0.6.0 +okdata-sdk==0.9.0 # via okdata-aws -prettytable==2.0.0 - # via okdata-sdk pyasn1==0.4.8 # via # python-jose # rsa -pydantic==1.7.4 +pydantic==1.8.2 # via # fastapi # okdata-aws # okdata-maskinporten-api (setup.py) -pyjwt==2.0.1 +pyjwt==2.1.0 # via okdata-sdk -pyrsistent==0.17.3 +pyrsistent==0.18.0 # via jsonschema -python-dateutil==2.8.1 +python-dateutil==2.8.2 # via botocore -python-jose==3.2.0 +python-jose==3.3.0 # via python-keycloak -python-keycloak==0.24.0 - # via okdata-sdk -requests==2.25.1 +python-keycloak==0.26.1 # via + # okdata-maskinporten-api (setup.py) + # okdata-sdk +requests==2.26.0 + # via + # okdata-maskinporten-api (setup.py) # okdata-sdk # python-keycloak -rsa==4.7 +rsa==4.7.2 # via python-jose -six==1.15.0 +six==1.16.0 # via # ecdsa # jsonschema # python-dateutil - # python-jose starlette==0.14.2 # via fastapi -structlog==20.2.0 +structlog==21.1.0 # via okdata-aws -typing-extensions==3.10.0.0 - # via mangum -urllib3==1.26.5 +typing-extensions==3.10.0.2 + # via + # mangum + # pydantic +urllib3==1.26.6 # via # botocore # okdata-sdk # requests -wcwidth==0.2.5 - # via prettytable wrapt==1.12.1 # via aws-xray-sdk diff --git a/resources/authorizer.py b/resources/authorizer.py new file mode 100644 index 0000000..116f41c --- /dev/null +++ b/resources/authorizer.py @@ -0,0 +1,90 @@ +import os + +from fastapi import Depends, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from keycloak.keycloak_openid import KeycloakOpenID +from requests.exceptions import HTTPError + +from clients import TeamsClient +from models import MaskinportenClientIn +from resources.errors import ErrorResponse + + +http_bearer = HTTPBearer(scheme_name="Keycloak token") + + +class ServiceClient: + keycloak: KeycloakOpenID + + def __init__(self): + self.keycloak = KeycloakOpenID( + server_url=f"{os.environ['KEYCLOAK_SERVER']}/auth/", + realm_name=os.environ["KEYCLOAK_REALM"], + client_id=os.environ["CLIENT_ID"], + client_secret_key=os.environ["CLIENT_SECRET"], + ) + + @property + def authorization_header(self): + response = self.keycloak.token(grant_type=["client_credentials"]) + access_token = f"{response['token_type']} {response['access_token']}" + return {"Authorization": access_token} + + +class Auth: + principal_id: str + bearer_token: str + service_client: ServiceClient + + def __init__( + self, + authorization: HTTPAuthorizationCredentials = Depends(http_bearer), + service_client: ServiceClient = Depends(), + ): + introspected = service_client.keycloak.introspect(authorization.credentials) + + if not introspected["active"]: + raise ErrorResponse(status.HTTP_401_UNAUTHORIZED, "Invalid access token") + + self.principal_id = introspected["username"] + self.bearer_token = authorization.credentials + self.service_client = service_client + + +def is_team_member( + body: MaskinportenClientIn, + auth: Auth = Depends(), +): + """Pass through without exception if user is a team member.""" + try: + if not TeamsClient.has_member( + auth.bearer_token, body.team_id, auth.principal_id + ): + raise ErrorResponse(status.HTTP_403_FORBIDDEN, "Forbidden") + except HTTPError as e: + if e.response.status_code == 404: + raise ErrorResponse( + status.HTTP_400_BAD_REQUEST, + "User is not a member of specified team", + ) + raise ErrorResponse(status.HTTP_500_INTERNAL_SERVER_ERROR, "Server error") + + +def has_team_role(role: str): + def _verify_team_role( + body: MaskinportenClientIn, + auth: Auth = Depends(), + ): + """Pass through without exception if specified team is assigned `role`.""" + try: + if not TeamsClient.has_role(auth.bearer_token, body.team_id, role): + raise ErrorResponse( + status.HTTP_403_FORBIDDEN, + f"Team is not assigned role {role}", + ) + except HTTPError as e: + if e.response.status_code == 404: + raise ErrorResponse(status.HTTP_400_BAD_REQUEST, "Team does not exist") + raise ErrorResponse(status.HTTP_500_INTERNAL_SERVER_ERROR, "Server error") + + return _verify_team_role diff --git a/resources/errors.py b/resources/errors.py new file mode 100644 index 0000000..951c0cd --- /dev/null +++ b/resources/errors.py @@ -0,0 +1,17 @@ +from typing import Optional, TypedDict + +from pydantic import BaseModel + + +class ErrorResponse(Exception): + def __init__(self, status_code: int, message: Optional[str] = None): + self.status_code = status_code + self.message = message + + +class Message(BaseModel): + message: Optional[str] + + +def error_message_models(*status_codes) -> dict: + return {code: {"model": Message} for code in status_codes} diff --git a/resources/maskinporten_clients.py b/resources/maskinporten_clients.py index 74f6dea..ec137f1 100644 --- a/resources/maskinporten_clients.py +++ b/resources/maskinporten_clients.py @@ -1,7 +1,7 @@ import logging import os -from fastapi import APIRouter, status +from fastapi import APIRouter, Depends, status from models import ( MaskinportenClientIn, @@ -9,6 +9,8 @@ ClientKey, ClientKeyMetadata, ) +from resources.authorizer import has_team_role, is_team_member +from resources.errors import ErrorResponse, error_message_models logger = logging.getLogger() logger.setLevel(os.environ.get("LOG_LEVEL", logging.INFO)) @@ -18,10 +20,24 @@ @router.post( - "", status_code=status.HTTP_201_CREATED, response_model=MaskinportenClientOut + "", + dependencies=[ + Depends(has_team_role("origo-team")), + Depends(is_team_member), + ], + status_code=status.HTTP_201_CREATED, + responses=error_message_models( + status.HTTP_400_BAD_REQUEST, + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + status.HTTP_500_INTERNAL_SERVER_ERROR, + ), + response_model=MaskinportenClientOut, ) -def create_client(body: MaskinportenClientIn): - # TODO: Implement real functionality +def create_client( + body: MaskinportenClientIn, +): + # TODO: Create pubreg-client resource using `okdata-permission-api` return MaskinportenClientOut( client_id="some-client-id", name=body.name, diff --git a/setup.py b/setup.py index 9e1521e..4859118 100644 --- a/setup.py +++ b/setup.py @@ -19,9 +19,11 @@ packages=find_packages(), install_requires=[ "aws-xray-sdk", - "okdata-aws", "fastapi", "mangum", + "okdata-aws", "pydantic", + "python-keycloak", + "requests", ], )