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

Feature/authz from spec and response #37

Merged
merged 14 commits into from
Jan 18, 2024
Merged
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
Loading