Skip to content

Commit

Permalink
Merge pull request #37 from FireTail-io/feature/authz-from-spec-and-r…
Browse files Browse the repository at this point in the history
…esponse

Feature/authz from spec and response
  • Loading branch information
timoruppell authored Jan 18, 2024
2 parents d8facc9 + 75af527 commit 9581a77
Show file tree
Hide file tree
Showing 7 changed files with 336 additions and 4 deletions.
62 changes: 59 additions & 3 deletions firetail/decorators/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
import functools
import logging

from flask import request
from jsonschema import ValidationError

from ..exceptions import NonConformingResponseBody, NonConformingResponseHeaders
from ..exceptions import (
AuthzFailed,
AuthzNotPopulated,
NonConformingResponseBody,
NonConformingResponseHeaders,
)
from ..utils import all_json, has_coroutine
from .decorator import BaseDecorator
from .validation import ResponseBodyValidator
Expand Down Expand Up @@ -44,7 +50,6 @@ def validate_response(self, data, status_code, headers, url):

response_definition = self.operation.response_definition(str(status_code), content_type)
response_schema = self.operation.response_schema(str(status_code), content_type)

if self.is_json_schema_compatible(response_schema):
v = ResponseBodyValidator(response_schema, validator=self.validator)
try:
Expand All @@ -61,10 +66,61 @@ def validate_response(self, data, status_code, headers, url):
missing_keys = required_header_keys - header_keys
if missing_keys:
pretty_list = ", ".join(missing_keys)
msg = ("Keys in header don't match response specification. " "Difference: {}").format(pretty_list)
msg = "Keys in header don't match response specification. Difference: {}".format(pretty_list)
raise NonConformingResponseHeaders(message=msg)
# Now we know the response is in the correct format, we can check authz
self.validate_response_authz(response_definition, data)
return True

def validate_response_authz(self, response_definition, data):
try:
authz_items = response_definition["x-ft-security"]
request_data_lookup = authz_items["authenticated-principal-path"]
response_data_lookup = authz_items["resource-authorized-principal-path"]
lookup_type = authz_items.get("resource-content-format", "object")
custom_resolver = authz_items.get("access-resolver")
except KeyError:
# no authz on this resp def.
return True
try:
request_authz_data = request.firetail_authz[request_data_lookup]
except AttributeError:
# we have authz in our specification, but the authz params are not being auth set in the app layer.
raise AuthzNotPopulated(
"No Authz data returned from our app layer - flask must populate IDs to compare in Authz"
)
except KeyError:
# we have incorrect authz being set in the app
raise AuthzNotPopulated("Authz data does not contain expected key for authz to be evaluated")

# use spec data to get from the request data.from and compare to the data returned.
if lookup_type == "object":
# we just check the single structure returned.
if request_authz_data != self.extract_item(data, response_data_lookup):
raise AuthzFailed()
elif lookup_type == "list":
# we must check many items.
for item in data:
if request_authz_data != self.extract_item(item, response_data_lookup):
raise AuthzFailed()

if custom_resolver:
# we must get custom_resolver from the request object.
try:
res_func = getattr(request, custom_resolver)
res_func(data, request_data_lookup, response_data_lookup, lookup_type)
except Exception:
# just fail on any users exception here.
raise AuthzFailed()
return True

def extract_item(self, data, response_data_lookup):
items = response_data_lookup.split(".")
dc = data.copy()
for i in items:
dc = dc[i]
return dc

def is_json_schema_compatible(self, response_schema: dict) -> bool:
"""
Verify if the specified operation responses are JSON schema
Expand Down
8 changes: 8 additions & 0 deletions firetail/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ class FiretailException(Exception):
pass


class AuthzNotPopulated(Unauthorized):
pass


class AuthzFailed(Unauthorized):
pass


class ProblemException(FiretailException):
def __init__(self, status=400, title=None, detail=None, type=None, instance=None, headers=None, ext=None):
"""
Expand Down
39 changes: 39 additions & 0 deletions tests/fakeapi/hello/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,45 @@ def get_user():
return {"user_id": 7, "name": "max"}


def get_user_list():
request.firetail_authz = {"user_id": 7}
return [{"user_id": 7, "name": "max"}, {"user_id": 7, "name": "min"}]


def get_user_authz():
request.firetail_authz = {"user_id": 7}
return {"user_id": 7, "name": "max"}


def name_check(*args, **kwargs):
return True


def fail_this():
raise Exception("Custom auth fail!")


def get_user_authz_extra_func():
request.firetail_authz = {"user_id": 7}
request.name_check = name_check
return {"user_id": 7, "name": "max"}


def get_user_authz_extra_func_fails():
request.firetail_authz = {"user_id": 7}
request.name_check = fail_this
return {"user_id": 7, "name": "max"}


def get_user_authz_fails():
request.firetail_authz = {"user_id": 8}
return {"user_id": 7, "name": "max"}


def get_user_authz_not_set():
return {"user_id": 7, "name": "max"}


def get_user_with_password():
return {"user_id": 7, "name": "max", "password": "5678"}

Expand Down
86 changes: 86 additions & 0 deletions tests/fixtures/json_validation/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,92 @@ paths:
responses:
200:
description: Success
/authzEnd:
get:
operationId: fakeapi.hello.get_user_authz
responses:
200:
description: Success
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/authzEndList:
get:
operationId: fakeapi.hello.get_user_list
responses:
200:
description: Success
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
resource-content-format: "list"
content:
application/json:
schema:
type: array
additionalProperties: true
items:
$ref: '#/components/schemas/User'
/authzEndExtraFunc:
get:
operationId: fakeapi.hello.get_user_authz_extra_func
responses:
200:
description: Success
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
access-resolver: "name_check"
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/authzEndExtraFuncFail:
get:
operationId: fakeapi.hello.get_user_authz_extra_func_fails
responses:
200:
description: Success
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
access-resolver: "name_check"
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/authzEndFails:
get:
operationId: fakeapi.hello.get_user_authz_fails
responses:
200:
description: Success
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
content:
application/json:
schema:
$ref: '#/components/schemas/User'

/authzEndNotSet:
get:
operationId: fakeapi.hello.get_user_authz_not_set
responses:
200:
description: Success
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
content:
application/json:
schema:
$ref: '#/components/schemas/User'


/user:
get:
Expand Down
72 changes: 72 additions & 0 deletions tests/fixtures/json_validation/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,78 @@ paths:
description: User object
schema:
$ref: '#/definitions/User'
/authzEnd:
get:
operationId: fakeapi.hello.get_user_authz
responses:
200:
description: User object
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
schema:
$ref: '#/definitions/User'
/authzEndList:
get:
operationId: fakeapi.hello.get_user_list
responses:
200:
description: User object
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
resource-content-format: "list"
schema:
type: array
additionalProperties: true
items:
$ref: '#/definitions/User'
/authzEndExtraFunc:
get:
operationId: fakeapi.hello.get_user_authz_extra_func
responses:
200:
description: User object
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
access-resolver: "name_check"
schema:
$ref: '#/definitions/User'
/authzEndExtraFuncFail:
get:
operationId: fakeapi.hello.get_user_authz_extra_func_fails
responses:
200:
description: User object
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
access-resolver: "name_check"
schema:
$ref: '#/definitions/User'
/authzEndFails:
get:
operationId: fakeapi.hello.get_user_authz_fails
responses:
200:
description: User object
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
schema:
$ref: '#/definitions/User'
/authzEndNotSet:
get:
operationId: fakeapi.hello.get_user_authz_not_set
responses:
200:
description: User object
x-ft-security:
authenticated-principal-path: "user_id"
resource-authorized-principal-path: "user_id"
schema:
$ref: '#/definitions/User'
/user_with_password:
get:
operationId: fakeapi.hello.get_user_with_password
Expand Down
71 changes: 71 additions & 0 deletions tests/test_json_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,77 @@ def __init__(self, *args, **kwargs):
assert res.status_code == 400


@pytest.mark.parametrize("spec", SPECS)
def test_validator_map_ft_authz_success(json_validation_spec_dir, spec):
app = App(__name__, specification_dir=json_validation_spec_dir)
app.add_api(spec, validate_responses=True)
app_client = app.app.test_client()

res = app_client.get("/v1.0/authzEnd") # type: flask.Response
assert res.status_code == 200


@pytest.mark.parametrize("spec", SPECS)
def test_validator_map_ft_authz_list_success(json_validation_spec_dir, spec):
app = App(__name__, specification_dir=json_validation_spec_dir)
app.add_api(spec, validate_responses=True)
app_client = app.app.test_client()

res = app_client.get("/v1.0/authzEndList") # type: flask.Response

assert res.status_code == 200


@pytest.mark.parametrize("spec", SPECS)
def test_validator_map_ft_authz_success_extra_auth(json_validation_spec_dir, spec):
app = App(__name__, specification_dir=json_validation_spec_dir)
app.add_api(spec, validate_responses=True)
app_client = app.app.test_client()

res = app_client.get("/v1.0/authzEndExtraFunc") # type: flask.Response
assert res.status_code == 200


@pytest.mark.parametrize("spec", SPECS)
def test_validator_map_ft_authz_extra_auth_fails(json_validation_spec_dir, spec):
app = App(__name__, specification_dir=json_validation_spec_dir)
app.add_api(spec, validate_responses=True)
app_client = app.app.test_client()

res = app_client.get("/v1.0/authzEndExtraFuncFail") # type: flask.Response
assert res.status_code == 401


@pytest.mark.parametrize("spec", SPECS)
def x_test_validator_map_ft_authz_fails_extra_auth(json_validation_spec_dir, spec):
app = App(__name__, specification_dir=json_validation_spec_dir)
app.add_api(spec, validate_responses=True)
app_client = app.app.test_client()

res = app_client.get("/v1.0/authzEndExtraFuncFails") # type: flask.Response
assert res.status_code == 200


@pytest.mark.parametrize("spec", SPECS)
def test_validator_map_ft_authz_fail(json_validation_spec_dir, spec):
app = App(__name__, specification_dir=json_validation_spec_dir)
app.add_api(spec, validate_responses=True)
app_client = app.app.test_client()

res = app_client.get("/v1.0/authzEndFails") # type: flask.Response
assert res.status_code == 401 # unauthorized because of authz


@pytest.mark.parametrize("spec", SPECS)
def test_validator_map_ft_authz_not_set(json_validation_spec_dir, spec):
app = App(__name__, specification_dir=json_validation_spec_dir)
app.add_api(spec, validate_responses=True)
app_client = app.app.test_client()

res = app_client.get("/v1.0/authzEndFails") # type: flask.Response
assert res.status_code == 401 # unauthorized because of authz


@pytest.mark.parametrize("spec", SPECS)
def test_readonly(json_validation_spec_dir, spec):
app = build_app_from_fixture(json_validation_spec_dir, spec, validate_responses=True)
Expand Down
Loading

0 comments on commit 9581a77

Please sign in to comment.