Skip to content

Commit 9581a77

Browse files
authored
Merge pull request #37 from FireTail-io/feature/authz-from-spec-and-response
Feature/authz from spec and response
2 parents d8facc9 + 75af527 commit 9581a77

File tree

7 files changed

+336
-4
lines changed

7 files changed

+336
-4
lines changed

firetail/decorators/response.py

+59-3
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@
66
import functools
77
import logging
88

9+
from flask import request
910
from jsonschema import ValidationError
1011

11-
from ..exceptions import NonConformingResponseBody, NonConformingResponseHeaders
12+
from ..exceptions import (
13+
AuthzFailed,
14+
AuthzNotPopulated,
15+
NonConformingResponseBody,
16+
NonConformingResponseHeaders,
17+
)
1218
from ..utils import all_json, has_coroutine
1319
from .decorator import BaseDecorator
1420
from .validation import ResponseBodyValidator
@@ -44,7 +50,6 @@ def validate_response(self, data, status_code, headers, url):
4450

4551
response_definition = self.operation.response_definition(str(status_code), content_type)
4652
response_schema = self.operation.response_schema(str(status_code), content_type)
47-
4853
if self.is_json_schema_compatible(response_schema):
4954
v = ResponseBodyValidator(response_schema, validator=self.validator)
5055
try:
@@ -61,10 +66,61 @@ def validate_response(self, data, status_code, headers, url):
6166
missing_keys = required_header_keys - header_keys
6267
if missing_keys:
6368
pretty_list = ", ".join(missing_keys)
64-
msg = ("Keys in header don't match response specification. " "Difference: {}").format(pretty_list)
69+
msg = "Keys in header don't match response specification. Difference: {}".format(pretty_list)
6570
raise NonConformingResponseHeaders(message=msg)
71+
# Now we know the response is in the correct format, we can check authz
72+
self.validate_response_authz(response_definition, data)
6673
return True
6774

75+
def validate_response_authz(self, response_definition, data):
76+
try:
77+
authz_items = response_definition["x-ft-security"]
78+
request_data_lookup = authz_items["authenticated-principal-path"]
79+
response_data_lookup = authz_items["resource-authorized-principal-path"]
80+
lookup_type = authz_items.get("resource-content-format", "object")
81+
custom_resolver = authz_items.get("access-resolver")
82+
except KeyError:
83+
# no authz on this resp def.
84+
return True
85+
try:
86+
request_authz_data = request.firetail_authz[request_data_lookup]
87+
except AttributeError:
88+
# we have authz in our specification, but the authz params are not being auth set in the app layer.
89+
raise AuthzNotPopulated(
90+
"No Authz data returned from our app layer - flask must populate IDs to compare in Authz"
91+
)
92+
except KeyError:
93+
# we have incorrect authz being set in the app
94+
raise AuthzNotPopulated("Authz data does not contain expected key for authz to be evaluated")
95+
96+
# use spec data to get from the request data.from and compare to the data returned.
97+
if lookup_type == "object":
98+
# we just check the single structure returned.
99+
if request_authz_data != self.extract_item(data, response_data_lookup):
100+
raise AuthzFailed()
101+
elif lookup_type == "list":
102+
# we must check many items.
103+
for item in data:
104+
if request_authz_data != self.extract_item(item, response_data_lookup):
105+
raise AuthzFailed()
106+
107+
if custom_resolver:
108+
# we must get custom_resolver from the request object.
109+
try:
110+
res_func = getattr(request, custom_resolver)
111+
res_func(data, request_data_lookup, response_data_lookup, lookup_type)
112+
except Exception:
113+
# just fail on any users exception here.
114+
raise AuthzFailed()
115+
return True
116+
117+
def extract_item(self, data, response_data_lookup):
118+
items = response_data_lookup.split(".")
119+
dc = data.copy()
120+
for i in items:
121+
dc = dc[i]
122+
return dc
123+
68124
def is_json_schema_compatible(self, response_schema: dict) -> bool:
69125
"""
70126
Verify if the specified operation responses are JSON schema

firetail/exceptions.py

+8
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ class FiretailException(Exception):
1414
pass
1515

1616

17+
class AuthzNotPopulated(Unauthorized):
18+
pass
19+
20+
21+
class AuthzFailed(Unauthorized):
22+
pass
23+
24+
1725
class ProblemException(FiretailException):
1826
def __init__(self, status=400, title=None, detail=None, type=None, instance=None, headers=None, ext=None):
1927
"""

tests/fakeapi/hello/__init__.py

+39
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,45 @@ def get_user():
553553
return {"user_id": 7, "name": "max"}
554554

555555

556+
def get_user_list():
557+
request.firetail_authz = {"user_id": 7}
558+
return [{"user_id": 7, "name": "max"}, {"user_id": 7, "name": "min"}]
559+
560+
561+
def get_user_authz():
562+
request.firetail_authz = {"user_id": 7}
563+
return {"user_id": 7, "name": "max"}
564+
565+
566+
def name_check(*args, **kwargs):
567+
return True
568+
569+
570+
def fail_this():
571+
raise Exception("Custom auth fail!")
572+
573+
574+
def get_user_authz_extra_func():
575+
request.firetail_authz = {"user_id": 7}
576+
request.name_check = name_check
577+
return {"user_id": 7, "name": "max"}
578+
579+
580+
def get_user_authz_extra_func_fails():
581+
request.firetail_authz = {"user_id": 7}
582+
request.name_check = fail_this
583+
return {"user_id": 7, "name": "max"}
584+
585+
586+
def get_user_authz_fails():
587+
request.firetail_authz = {"user_id": 8}
588+
return {"user_id": 7, "name": "max"}
589+
590+
591+
def get_user_authz_not_set():
592+
return {"user_id": 7, "name": "max"}
593+
594+
556595
def get_user_with_password():
557596
return {"user_id": 7, "name": "max", "password": "5678"}
558597

tests/fixtures/json_validation/openapi.yaml

+86
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,92 @@ paths:
4747
responses:
4848
200:
4949
description: Success
50+
/authzEnd:
51+
get:
52+
operationId: fakeapi.hello.get_user_authz
53+
responses:
54+
200:
55+
description: Success
56+
x-ft-security:
57+
authenticated-principal-path: "user_id"
58+
resource-authorized-principal-path: "user_id"
59+
content:
60+
application/json:
61+
schema:
62+
$ref: '#/components/schemas/User'
63+
/authzEndList:
64+
get:
65+
operationId: fakeapi.hello.get_user_list
66+
responses:
67+
200:
68+
description: Success
69+
x-ft-security:
70+
authenticated-principal-path: "user_id"
71+
resource-authorized-principal-path: "user_id"
72+
resource-content-format: "list"
73+
content:
74+
application/json:
75+
schema:
76+
type: array
77+
additionalProperties: true
78+
items:
79+
$ref: '#/components/schemas/User'
80+
/authzEndExtraFunc:
81+
get:
82+
operationId: fakeapi.hello.get_user_authz_extra_func
83+
responses:
84+
200:
85+
description: Success
86+
x-ft-security:
87+
authenticated-principal-path: "user_id"
88+
resource-authorized-principal-path: "user_id"
89+
access-resolver: "name_check"
90+
content:
91+
application/json:
92+
schema:
93+
$ref: '#/components/schemas/User'
94+
/authzEndExtraFuncFail:
95+
get:
96+
operationId: fakeapi.hello.get_user_authz_extra_func_fails
97+
responses:
98+
200:
99+
description: Success
100+
x-ft-security:
101+
authenticated-principal-path: "user_id"
102+
resource-authorized-principal-path: "user_id"
103+
access-resolver: "name_check"
104+
content:
105+
application/json:
106+
schema:
107+
$ref: '#/components/schemas/User'
108+
/authzEndFails:
109+
get:
110+
operationId: fakeapi.hello.get_user_authz_fails
111+
responses:
112+
200:
113+
description: Success
114+
x-ft-security:
115+
authenticated-principal-path: "user_id"
116+
resource-authorized-principal-path: "user_id"
117+
content:
118+
application/json:
119+
schema:
120+
$ref: '#/components/schemas/User'
121+
122+
/authzEndNotSet:
123+
get:
124+
operationId: fakeapi.hello.get_user_authz_not_set
125+
responses:
126+
200:
127+
description: Success
128+
x-ft-security:
129+
authenticated-principal-path: "user_id"
130+
resource-authorized-principal-path: "user_id"
131+
content:
132+
application/json:
133+
schema:
134+
$ref: '#/components/schemas/User'
135+
50136

51137
/user:
52138
get:

tests/fixtures/json_validation/swagger.yaml

+72
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,78 @@ paths:
5959
description: User object
6060
schema:
6161
$ref: '#/definitions/User'
62+
/authzEnd:
63+
get:
64+
operationId: fakeapi.hello.get_user_authz
65+
responses:
66+
200:
67+
description: User object
68+
x-ft-security:
69+
authenticated-principal-path: "user_id"
70+
resource-authorized-principal-path: "user_id"
71+
schema:
72+
$ref: '#/definitions/User'
73+
/authzEndList:
74+
get:
75+
operationId: fakeapi.hello.get_user_list
76+
responses:
77+
200:
78+
description: User object
79+
x-ft-security:
80+
authenticated-principal-path: "user_id"
81+
resource-authorized-principal-path: "user_id"
82+
resource-content-format: "list"
83+
schema:
84+
type: array
85+
additionalProperties: true
86+
items:
87+
$ref: '#/definitions/User'
88+
/authzEndExtraFunc:
89+
get:
90+
operationId: fakeapi.hello.get_user_authz_extra_func
91+
responses:
92+
200:
93+
description: User object
94+
x-ft-security:
95+
authenticated-principal-path: "user_id"
96+
resource-authorized-principal-path: "user_id"
97+
access-resolver: "name_check"
98+
schema:
99+
$ref: '#/definitions/User'
100+
/authzEndExtraFuncFail:
101+
get:
102+
operationId: fakeapi.hello.get_user_authz_extra_func_fails
103+
responses:
104+
200:
105+
description: User object
106+
x-ft-security:
107+
authenticated-principal-path: "user_id"
108+
resource-authorized-principal-path: "user_id"
109+
access-resolver: "name_check"
110+
schema:
111+
$ref: '#/definitions/User'
112+
/authzEndFails:
113+
get:
114+
operationId: fakeapi.hello.get_user_authz_fails
115+
responses:
116+
200:
117+
description: User object
118+
x-ft-security:
119+
authenticated-principal-path: "user_id"
120+
resource-authorized-principal-path: "user_id"
121+
schema:
122+
$ref: '#/definitions/User'
123+
/authzEndNotSet:
124+
get:
125+
operationId: fakeapi.hello.get_user_authz_not_set
126+
responses:
127+
200:
128+
description: User object
129+
x-ft-security:
130+
authenticated-principal-path: "user_id"
131+
resource-authorized-principal-path: "user_id"
132+
schema:
133+
$ref: '#/definitions/User'
62134
/user_with_password:
63135
get:
64136
operationId: fakeapi.hello.get_user_with_password

tests/test_json_validation.py

+71
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,77 @@ def __init__(self, *args, **kwargs):
4646
assert res.status_code == 400
4747

4848

49+
@pytest.mark.parametrize("spec", SPECS)
50+
def test_validator_map_ft_authz_success(json_validation_spec_dir, spec):
51+
app = App(__name__, specification_dir=json_validation_spec_dir)
52+
app.add_api(spec, validate_responses=True)
53+
app_client = app.app.test_client()
54+
55+
res = app_client.get("/v1.0/authzEnd") # type: flask.Response
56+
assert res.status_code == 200
57+
58+
59+
@pytest.mark.parametrize("spec", SPECS)
60+
def test_validator_map_ft_authz_list_success(json_validation_spec_dir, spec):
61+
app = App(__name__, specification_dir=json_validation_spec_dir)
62+
app.add_api(spec, validate_responses=True)
63+
app_client = app.app.test_client()
64+
65+
res = app_client.get("/v1.0/authzEndList") # type: flask.Response
66+
67+
assert res.status_code == 200
68+
69+
70+
@pytest.mark.parametrize("spec", SPECS)
71+
def test_validator_map_ft_authz_success_extra_auth(json_validation_spec_dir, spec):
72+
app = App(__name__, specification_dir=json_validation_spec_dir)
73+
app.add_api(spec, validate_responses=True)
74+
app_client = app.app.test_client()
75+
76+
res = app_client.get("/v1.0/authzEndExtraFunc") # type: flask.Response
77+
assert res.status_code == 200
78+
79+
80+
@pytest.mark.parametrize("spec", SPECS)
81+
def test_validator_map_ft_authz_extra_auth_fails(json_validation_spec_dir, spec):
82+
app = App(__name__, specification_dir=json_validation_spec_dir)
83+
app.add_api(spec, validate_responses=True)
84+
app_client = app.app.test_client()
85+
86+
res = app_client.get("/v1.0/authzEndExtraFuncFail") # type: flask.Response
87+
assert res.status_code == 401
88+
89+
90+
@pytest.mark.parametrize("spec", SPECS)
91+
def x_test_validator_map_ft_authz_fails_extra_auth(json_validation_spec_dir, spec):
92+
app = App(__name__, specification_dir=json_validation_spec_dir)
93+
app.add_api(spec, validate_responses=True)
94+
app_client = app.app.test_client()
95+
96+
res = app_client.get("/v1.0/authzEndExtraFuncFails") # type: flask.Response
97+
assert res.status_code == 200
98+
99+
100+
@pytest.mark.parametrize("spec", SPECS)
101+
def test_validator_map_ft_authz_fail(json_validation_spec_dir, spec):
102+
app = App(__name__, specification_dir=json_validation_spec_dir)
103+
app.add_api(spec, validate_responses=True)
104+
app_client = app.app.test_client()
105+
106+
res = app_client.get("/v1.0/authzEndFails") # type: flask.Response
107+
assert res.status_code == 401 # unauthorized because of authz
108+
109+
110+
@pytest.mark.parametrize("spec", SPECS)
111+
def test_validator_map_ft_authz_not_set(json_validation_spec_dir, spec):
112+
app = App(__name__, specification_dir=json_validation_spec_dir)
113+
app.add_api(spec, validate_responses=True)
114+
app_client = app.app.test_client()
115+
116+
res = app_client.get("/v1.0/authzEndFails") # type: flask.Response
117+
assert res.status_code == 401 # unauthorized because of authz
118+
119+
49120
@pytest.mark.parametrize("spec", SPECS)
50121
def test_readonly(json_validation_spec_dir, spec):
51122
app = build_app_from_fixture(json_validation_spec_dir, spec, validate_responses=True)

0 commit comments

Comments
 (0)