Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial auth for endpoints #8

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
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
@@ -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})
Empty file added clients/__init__.py
Empty file.
21 changes: 21 additions & 0 deletions clients/teams.py
@@ -0,0 +1,21 @@
import os

import requests


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


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
Expand Up @@ -2,6 +2,7 @@


class MaskinportenClientIn(BaseModel):
team_id: str
name: str
description: str
scopes: list[str]
Expand Down
44 changes: 43 additions & 1 deletion resources/authorizer.py
@@ -1,11 +1,14 @@
import os

from fastapi import Depends
from fastapi import Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from keycloak import KeycloakOpenID
from okdata.resource_auth import ResourceAuthorizer
from requests.exceptions import HTTPError

from clients import teams
from maskinporten_api.ssm import get_secret
from models import MaskinportenClientIn
from resources.errors import ErrorResponse


Expand Down Expand Up @@ -55,3 +58,42 @@ def _verify_permission(
raise ErrorResponse(403, "Forbidden")

return _verify_permission


def is_team_member(
body: MaskinportenClientIn,
auth_info: AuthInfo = Depends(),
):
"""Pass through without exception if user is a team member."""
try:
if not teams.has_member(
auth_info.bearer_token, body.team_id, auth_info.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_info: AuthInfo = Depends(),
):
"""Pass through without exception if specified team is assigned `role`."""
try:
if not teams.has_role(auth_info.bearer_token, body.team_id, role):
raise ErrorResponse(
status.HTTP_403_FORBIDDEN,
f"Team is not assigned required 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
9 changes: 8 additions & 1 deletion resources/maskinporten_clients.py
Expand Up @@ -11,6 +11,7 @@
)
from maskinporten_api.maskinporten_client import MaskinportenClient
from resources.authorizer import AuthInfo, authorize
from resources.authorizer import has_team_role, is_team_member
from resources.errors import error_message_models

logger = logging.getLogger()
Expand All @@ -22,12 +23,18 @@

@router.post(
"",
dependencies=[Depends(authorize(scope="okdata:maskinporten-client:create"))],
dependencies=[
Depends(has_team_role("origo-team")),
Depends(is_team_member),
Depends(authorize(scope="okdata:maskinporten-client:create")),
],
status_code=status.HTTP_201_CREATED,
response_model=MaskinportenClientOut,
responses=error_message_models(
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
status.HTTP_500_INTERNAL_SERVER_ERROR,
),
)
def create_client(
Expand Down
2 changes: 2 additions & 0 deletions test/resources/conftest.py
Expand Up @@ -7,6 +7,8 @@
valid_token = "valid-token"
valid_token_no_access = "valid-token-no-access"
username = "janedoe"
team_id = "abc-123-def-456"
origo_team_role = "origo-team"


@pytest.fixture
Expand Down
61 changes: 60 additions & 1 deletion test/resources/test_maskinporten_clients.py
Expand Up @@ -3,20 +3,33 @@
import requests_mock

from test.mock_utils import mock_access_token_generation_requests
from test.resources.conftest import valid_token
from test.resources.conftest import (
origo_team_role,
team_id,
username,
valid_token,
)


def test_create_client(
mock_client, mock_aws, mock_authorizer, maskinporten_create_client_response
):
body = {
"team_id": team_id,
"name": "some-client",
"description": "Very cool client",
"scopes": ["folkeregister:deling/offentligmedhjemmel"],
"env": "test",
}
with requests_mock.Mocker(real_http=True) as rm:
mock_access_token_generation_requests(rm)
rm.get(
os.getenv("TEAMS_API_URL") + f"/teams/{team_id}/roles",
json=[origo_team_role, "some-other-role"],
)
rm.get(
os.getenv("TEAMS_API_URL") + f"/teams/{team_id}/members/{username}",
)
rm.post(
os.getenv("MASKINPORTEN_CLIENTS_ENDPOINT"),
json=maskinporten_create_client_response,
Expand All @@ -34,6 +47,52 @@ def test_create_client(
}


def test_create_client_missing_role(mock_client, mock_aws, mock_authorizer):
with requests_mock.Mocker(real_http=True) as rm:
body = {
"team_id": "random-team",
"name": "some-client",
"description": "Very cool client",
"scopes": ["folkeregister:deling/offentligmedhjemmel"],
"env": "test",
}
rm.get(
os.getenv("TEAMS_API_URL") + "/teams/random-team/roles",
json=["some-role"],
)
response = mock_client.post(
"/clients", json=body, headers={"Authorization": f"Bearer {valid_token}"}
)

assert response.status_code == 403
assert response.json()["message"] == "Team is not assigned required role origo-team"


def test_create_client_not_team_member(mock_client, mock_aws, mock_authorizer):
with requests_mock.Mocker(real_http=True) as rm:
body = {
"team_id": team_id,
"name": "some-client",
"description": "Very cool client",
"scopes": ["folkeregister:deling/offentligmedhjemmel"],
"env": "test",
}
rm.get(
os.getenv("TEAMS_API_URL") + f"/teams/{team_id}/roles",
json=[origo_team_role, "some-other-role"],
)
rm.get(
os.getenv("TEAMS_API_URL") + f"/teams/{team_id}/members/{username}",
status_code=404,
)
response = mock_client.post(
"/clients", json=body, headers={"Authorization": f"Bearer {valid_token}"}
)

assert response.status_code == 400
assert response.json()["message"] == "User is not a member of specified team"


def test_create_client_key(mock_client):
client_id = "some-client-id"

Expand Down
1 change: 1 addition & 0 deletions tox.ini
Expand Up @@ -23,6 +23,7 @@ setenv =
KEYCLOAK_SERVER=http://keycloak-test.no
KEYCLOAK_REALM=some-realm
RESOURCE_SERVER_CLIENT_ID=some-resource-server
TEAMS_API_URL=http://api.teams.mock

[testenv:flake8]
skip_install = true
Expand Down