Skip to content

Commit

Permalink
Initial auth for endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
petterhj committed Sep 3, 2021
1 parent 9baa7ff commit d275aa7
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 38 deletions.
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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})
5 changes: 5 additions & 0 deletions clients/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from clients.teams import TeamsClient

__all__ = [
"TeamsClient",
]
24 changes: 24 additions & 0 deletions clients/teams.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@


class MaskinportenClientIn(BaseModel):
team_id: str
name: str
description: str
scopes: list[str]
Expand Down
62 changes: 30 additions & 32 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
90 changes: 90 additions & 0 deletions resources/authorizer.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions resources/errors.py
Original file line number Diff line number Diff line change
@@ -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}
24 changes: 20 additions & 4 deletions resources/maskinporten_clients.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import logging
import os

from fastapi import APIRouter, status
from fastapi import APIRouter, Depends, status

from models import (
MaskinportenClientIn,
MaskinportenClientOut,
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))
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
packages=find_packages(),
install_requires=[
"aws-xray-sdk",
"okdata-aws",
"fastapi",
"mangum",
"okdata-aws",
"pydantic",
"python-keycloak",
"requests",
],
)

0 comments on commit d275aa7

Please sign in to comment.