From 12ac1fc8e7b8fcf9a1d8387bee1cdc92cbc6e17b Mon Sep 17 00:00:00 2001 From: Amit Bose Date: Thu, 4 Aug 2016 17:28:28 -0700 Subject: [PATCH 1/2] Added Auth and filtering for v2/apps|groups and scheduler/jobs endpoints --- aaad/aaad.py | 25 +++- aaad/authorizers.py | 30 +--- aaad/chronos.py | 31 +++++ aaad/framework.py | 14 ++ aaad/frameworkUtils.py | 23 ++++ aaad/marathon.py | 129 ++++++++++++++++++ aaad/utils.py | 39 ++++++ ansible/roles/nginx/files/auth_request.lua | 1 + .../auth_request_and_filter_response.lua | 47 +++++++ ansible/roles/nginx/tasks/main.yml | 8 ++ .../templates/rogeros_auth_proxy.conf.j2 | 50 ++++++- vagrant/single_node/hosts/single | 2 +- 12 files changed, 368 insertions(+), 31 deletions(-) create mode 100644 aaad/chronos.py create mode 100644 aaad/framework.py create mode 100644 aaad/frameworkUtils.py create mode 100644 aaad/marathon.py create mode 100644 ansible/roles/nginx/files/auth_request_and_filter_response.lua diff --git a/aaad/aaad.py b/aaad/aaad.py index ca38754..904ab2f 100644 --- a/aaad/aaad.py +++ b/aaad/aaad.py @@ -6,9 +6,11 @@ import threading import logging import argparse +import utils from SocketServer import ThreadingMixIn from authenticators import FileAuthenticator from authorizers import FileAuthorizer +from frameworkUtils import FrameworkUtils class AuthHTTPServer(ThreadingMixIn, HTTPServer, ): @@ -17,6 +19,7 @@ class AuthHTTPServer(ThreadingMixIn, HTTPServer, ): class AuthHandler(BaseHTTPRequestHandler): ctx = {} authenticator = None + frameworkUtils = FrameworkUtils() def do_GET(self): @@ -26,6 +29,7 @@ def do_GET(self): resource = self.headers.get('URI') action = self.headers.get('method') client_ip = self.headers.get('X-Forwarded-For') + auth_action = self.headers.get("auth_action", "") # Carry out Basic Authentication if auth_header is None or not auth_header.lower().strip().startswith('basic '): @@ -54,6 +58,14 @@ def do_GET(self): content_type = self.headers.getheader("content-type", "") + if auth_action == "filter_response": + filtered_response = self.filter_response(body, resource, act_as_user, permissions) + self.send_response(200) + self.end_headers() + self.wfile.write(filtered_response) + self.wfile.close() + return True + if not self.authenticate_request(user, password): self.send_response(401) self.end_headers() @@ -63,12 +75,22 @@ def do_GET(self): self.send_response(403) self.end_headers() return False - + self.log_message(ctx['action']) # Continue request processing self.send_response(200) self.end_headers() return True + def filter_response(self, body, resource, act_as_user, permissions): + framework = self.frameworkUtils.getFramework(resource) + allowed_namespaces = utils.getAllowedNamespacePatterns(act_as_user, permissions) + + if not allowed_namespaces: #Empty list + return "" + + response = framework.filterBody(body, allowed_namespaces, resource) + return response + def authenticate_request(self, user, password): ctx = self.ctx try: @@ -159,6 +181,7 @@ def parse_args(): FORMAT = "[%(asctime)-15s] %(levelname)s - %(name)s - IP:%(clientip)s User:%(user)s ActAs:%(act_as)s - %(message)s" logging.basicConfig(level=level, format=FORMAT) logger = logging.getLogger("AAAd") + permissions = utils.parse_permissions_file(permissions_file) server = AuthHTTPServer(Listen, AuthHandler) signal.signal(signal.SIGINT, exit_handler) server.serve_forever() diff --git a/aaad/authorizers.py b/aaad/authorizers.py index 0e3145d..e9a978d 100644 --- a/aaad/authorizers.py +++ b/aaad/authorizers.py @@ -11,30 +11,6 @@ def __init__(self, filename): self.filename = filename self.data = utils.parse_permissions_file(filename) - def get_merged_data(self, user, allowed_users, allowed_actions, data, action): - if user in allowed_users: - return - allowed_users.append(user) - if action != '' and 'action' in data[user]: - if data[user]['action'] != None and action in data[user]['action']: - for item in data[user]['action'][action]: - temp_item = {} - if type(item) == str: - temp_item = {} - temp_item[item] = {} - else: - if type(item) == dict: - temp_item = item - - if not temp_item in allowed_actions: - allowed_actions.append(temp_item) - - if 'can_act_as' not in data[user]: - return - - for u in data[user]['can_act_as']: - self.get_merged_data(u, allowed_users, allowed_actions, data, action) - def resource_check(self, request_uri, body, allowed_actions, content_type): for item in allowed_actions: uri = item.keys() @@ -105,7 +81,7 @@ def authorize(self, user, act_as, resource, logging, info, data, content_type, a return False allowed_users_list = [] - self.get_merged_data(user, allowed_users_list, [], self.data, '') + utils.get_merged_data(user, allowed_users_list, [], self.data, '') if act_as not in allowed_users_list: logger.warning("User act as failed", extra = info) @@ -126,8 +102,8 @@ def authorize(self, user, act_as, resource, logging, info, data, content_type, a if not temp_item in allowed_actions: allowed_actions.append(temp_item) - self.get_merged_data(act_as, allowed_users_list, allowed_actions, self.data, action) - + utils.get_merged_data(act_as, allowed_users_list, allowed_actions, self.data, action) + result = self.resource_check(resource, data, allowed_actions, content_type) if result == False: logger.warning("Unauthorized [{}]".format(resource), extra = info) diff --git a/aaad/chronos.py b/aaad/chronos.py new file mode 100644 index 0000000..5a2a967 --- /dev/null +++ b/aaad/chronos.py @@ -0,0 +1,31 @@ +#!/usr/bin/python + +from __future__ import print_function +import os +import sys +import json +import re +from framework import Framework + +class Chronos(Framework): + + def getName(self): + return "Chronos" + + def filterBody(self, body, allowed_namespaces, request_uri): + filtered_response = [] + try: + jobs = json.loads(body) + for job in jobs: + if 'name' in job: + job_name = job['name'] + for namespace in allowed_namespaces: + pattern = re.compile("^{}$".format(namespace)) + result = pattern.match(job_name) + if result: + if not job in filtered_response: + filtered_response.append(job) + + return json.dumps(filtered_response) + except (Exception) as e: + return "" diff --git a/aaad/framework.py b/aaad/framework.py new file mode 100644 index 0000000..1944bff --- /dev/null +++ b/aaad/framework.py @@ -0,0 +1,14 @@ +#!/usr/bin/python + +from __future__ import print_function +import os +import sys +from abc import ABCMeta, abstractmethod + +class Framework(object): + __metaclass__ = ABCMeta + + @abstractmethod + def filterBody(self, body, allowed_namespaces, request_uri): + pass + diff --git a/aaad/frameworkUtils.py b/aaad/frameworkUtils.py new file mode 100644 index 0000000..f465863 --- /dev/null +++ b/aaad/frameworkUtils.py @@ -0,0 +1,23 @@ +#!/usr/bin/python + +from __future__ import print_function +import os +import sys +import re +from marathon import Marathon +from chronos import Chronos + + +class FrameworkUtils: + + def getFramework(self, request_uri): + marathon_pattern = re.compile("^{}$".format("/marathon/.*")) + marathon_check = marathon_pattern.match(request_uri) + chronos_pattern = re.compile("^{}$".format("/chronos/.*")) + chronos_check = chronos_pattern.match(request_uri) + if marathon_check: + return Marathon() + elif chronos_check: + return Chronos() + else: + return None diff --git a/aaad/marathon.py b/aaad/marathon.py new file mode 100644 index 0000000..ab76597 --- /dev/null +++ b/aaad/marathon.py @@ -0,0 +1,129 @@ +#!/usr/bin/python + +from __future__ import print_function +import os +import sys +import json +import re +from framework import Framework + +class Marathon(Framework): + + def getName(self): + return "Marathon" + + def filterBody(self, body, allowed_namespaces, request_uri): + uri_pattern = re.compile("^{}$".format("/marathon/*v2/apps")) + uri_match = uri_pattern.match(request_uri) + if uri_match: + response = self.filterV2AppsResponse(body, allowed_namespaces) + return response + + uri_pattern = re.compile("^{}$".format("/marathon/*v2/groups")) + uri_match = uri_pattern.match(request_uri) + if uri_match: + response = self.filterV2GroupsResponse(body, allowed_namespaces) + return response + + return "" + + + def filterV2AppsResponse(self, body, allowed_namespaces): + filtered_apps = [] + filtered_response = {} + try: + data = json.loads(body) + if 'apps' in data: + apps = data['apps'] + if not type(apps) == list: + return "" + + for app in apps: + if 'id' in app: + app_id = app['id'] + for namespace in allowed_namespaces: + pattern = re.compile("^{}$".format(namespace)) + result = pattern.match(app_id) + if result: + if not app in filtered_apps: + filtered_apps.append(app) + + filtered_response['apps'] = filtered_apps + + return json.dumps(filtered_response) + except (Exception) as e: + return "" + + def filterV2GroupsResponse(self, body, allowed_namespaces): + response = {} + filtered_apps = [] + filtered_groups = [] + + try: + data = json.loads(body) + if 'apps' in data.keys(): + filtered_apps = self.filterApps(data['apps'], allowed_namespaces) + if 'groups' in data: + filtered_groups = self.filterGroups(data['groups'], allowed_namespaces) + + for item in data.keys(): + if item != 'apps' and item != 'groups': + response[item] = data[item] + + response['apps'] = filtered_apps + response['groups'] = filtered_groups + + return json.dumps(response) + + except (Exception) as e: + return "" + + def matchPattern(self, id_to_match, allowed_namespaces): + for namespace in allowed_namespaces: + pattern = re.compile("^{}$".format(namespace)) + result = pattern.match(id_to_match) + if result: + return True + + return False + + def filterApps(self, apps_list, allowed_namespaces): + allowed_apps = [] + for item in apps_list: + if 'id' in item: + app_id = item['id'] + match_app_id = self.matchPattern(app_id, allowed_namespaces) + if match_app_id and (not item in allowed_apps): + allowed_apps.append(item) + + return allowed_apps + + def filterGroups(self, groups_list, allowed_namespaces): + allowed_groups = [] + for item in groups_list: + filtered_item = {} + if 'id' in item: + group_id = item['id'] + match_group_id = self.matchPattern(group_id, allowed_namespaces) + if match_group_id and (not item in allowed_groups): + allowed_groups.append(item) + else: #The id at this level does not match any allowed_namespaces, but a nested id may match + filtered_apps = [] + filtered_groups = [] + if 'apps' in item and item['apps']: #Check to see if item['apps'] is not empty + filtered_apps = self.filterApps(item['apps'], allowed_namespaces) + + if 'groups' in item and item['groups']: #Check to see if item['groups'] is not empty + filtered_groups = self.filterGroups(item['groups'], allowed_namespaces) + + if filtered_apps or filtered_groups: #Either filtered apps or groups is not empty + for elem in item.keys(): + if elem != 'apps' and elem != 'groups': + filtered_item[elem] = item[elem] + + filtered_item['apps'] = filtered_apps + filtered_item['groups'] = filtered_groups + allowed_groups.append(filtered_item) + + return allowed_groups + diff --git a/aaad/utils.py b/aaad/utils.py index 2281d09..55569f2 100644 --- a/aaad/utils.py +++ b/aaad/utils.py @@ -5,3 +5,42 @@ def parse_permissions_file(filename): return yaml.load(data_file) return '' +def get_merged_data(user, allowed_users, allowed_actions, data, action): + if user in allowed_users: + return + allowed_users.append(user) + if action != '' and 'action' in data[user]: + if data[user]['action'] != None and action in data[user]['action']: + for item in data[user]['action'][action]: + temp_item = {} + if type(item) == str: + temp_item = {} + temp_item[item] = {} + else: + if type(item) == dict: + temp_item = item + + if not temp_item in allowed_actions: + allowed_actions.append(temp_item) + + if 'can_act_as' not in data[user]: + return + + for u in data[user]['can_act_as']: + get_merged_data(u, allowed_users, allowed_actions, data, action) + +def getAllowedNamespacePatterns(act_as, permissions): + + allowed_users_list = [] + get_merged_data(act_as, allowed_users_list, [], permissions, '') + + allowed_namespace_patterns = [] + + for user in allowed_users_list: + if 'allowed_names' in permissions[user]: + for pattern in permissions[user]['allowed_names']: + if not pattern in allowed_namespace_patterns: + allowed_namespace_patterns.append(pattern) + + return allowed_namespace_patterns + diff --git a/ansible/roles/nginx/files/auth_request.lua b/ansible/roles/nginx/files/auth_request.lua index e36543c..335114f 100644 --- a/ansible/roles/nginx/files/auth_request.lua +++ b/ansible/roles/nginx/files/auth_request.lua @@ -1,5 +1,6 @@ ngx.req.read_body() local data = ngx.req.get_body_data() +ngx.req.set_header("auth_action", "auth") local res = ngx.location.capture("/auth-proxy", {body=data}) if res.status == ngx.HTTP_OK then diff --git a/ansible/roles/nginx/files/auth_request_and_filter_response.lua b/ansible/roles/nginx/files/auth_request_and_filter_response.lua new file mode 100644 index 0000000..5118e33 --- /dev/null +++ b/ansible/roles/nginx/files/auth_request_and_filter_response.lua @@ -0,0 +1,47 @@ +ngx.req.read_body() +local req_data = ngx.req.get_body_data() +ngx.req.set_header("auth_action", "auth") +local req_res = ngx.location.capture("/auth-proxy", {body=req_data}) + +if req_res.status == ngx.HTTP_FORBIDDEN then + ngx.exit(req_res.status) +end + +if req_res.status == ngx.HTTP_UNAUTHORIZED then + ngx.exit(req_res.status) +end + +if req_res.status ~= ngx.HTTP_OK then + ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) +end + +local res = ngx.location.capture(ngx.var.location_endpoint, { copy_all_vars = true }) + +local data = res.body + + +ngx.req.set_header("auth_action", "filter_response") +local resp = ngx.location.capture("/auth-proxy", {body=data}) + +if resp.status == ngx.HTTP_OK then + ngx.header["Content-Type"] = "application/json; qs=2" + ngx.header["Pragma"] = "no-cache" + ngx.header["Expires"] = 0 + ngx.header["Connection"] = "close" + ngx.send_headers() + -- The print() should be only after all headers are sent + ngx.print(resp.body) + ngx.exit(ngx.OK) +end + +if resp.status == ngx.HTTP_FORBIDDEN then + ngx.exit(resp.status) +end + +if resp.status == ngx.HTTP_UNAUTHORIZED then + ngx.exit(resp.status) +end + +ngx.exit(resp.status) + + diff --git a/ansible/roles/nginx/tasks/main.yml b/ansible/roles/nginx/tasks/main.yml index 533e180..de5abfb 100644 --- a/ansible/roles/nginx/tasks/main.yml +++ b/ansible/roles/nginx/tasks/main.yml @@ -67,6 +67,14 @@ - install - nginx + - name: Copy Lua Auth and Filter Response Script file + copy: src=../files/auth_request_and_filter_response.lua dest={{ nginx_auth_script_dir }} mode=0644 + when: nginx_auth_enable is defined and nginx_auth_enable|bool + become: yes + tags: + - install + - nginx + - name: Copy Chronos overridden template file copy: src=chronos_overrides/templates/job_persistence_error.hbs dest={{ nginx_chronos_auth_templates_dir }} mode=0644 become: yes diff --git a/ansible/roles/nginx/templates/rogeros_auth_proxy.conf.j2 b/ansible/roles/nginx/templates/rogeros_auth_proxy.conf.j2 index 83cfda1..ba4f10c 100644 --- a/ansible/roles/nginx/templates/rogeros_auth_proxy.conf.j2 +++ b/ansible/roles/nginx/templates/rogeros_auth_proxy.conf.j2 @@ -9,7 +9,7 @@ upstream chronos { } server { - listen {{nginx_proxy_port}}; + listen {{ nginx_proxy_port }}; server_name server.alias; root html; index index.html index.htm; @@ -58,6 +58,52 @@ server { proxy_pass_header Authorization; } + location /marathon_internal { + proxy_set_header Host $host; + proxy_pass http://marathon/$marathon_endpoint; + internal; + } + + location /marathon/v2/apps { + satisfy all; + auth_basic "Restricted"; + auth_basic_user_file {{nginx_conf_dir}}/{{nginx_basic_auth_file_name}}; + set $auth_action ''; + set $marathon_endpoint 'v2/apps'; + set $location_endpoint '/marathon_internal'; + lua_use_default_type off; + access_by_lua_file '/usr/local/openresty/nginx/auth_request_and_filter_response.lua'; + } + + location /marathon/v2/groups { + satisfy all; + auth_basic "Restricted"; + auth_basic_user_file {{nginx_conf_dir}}/{{nginx_basic_auth_file_name}}; + set $auth_action ''; + set $marathon_endpoint 'v2/groups'; + set $location_endpoint '/marathon_internal'; + lua_use_default_type off; + access_by_lua_file '/usr/local/openresty/nginx/auth_request_and_filter_response.lua'; + + } + + location /chronos_internal { + proxy_set_header Host $host; + proxy_pass http://chronos/$chronos_endpoint; + internal; + } + + location /chronos/scheduler/jobs { + satisfy all; + auth_basic "Restricted"; + auth_basic_user_file {{nginx_conf_dir}}/{{nginx_basic_auth_file_name}}; + set $chronos_endpoint 'scheduler/jobs'; + set $location_endpoint '/chronos_internal'; + lua_use_default_type off; + access_by_lua_file '/usr/local/openresty/nginx/auth_request_and_filter_response.lua'; + + } + location /chronos/ping { auth_basic Off; proxy_pass http://chronos/ping; @@ -91,7 +137,7 @@ server { } location = /auth-proxy { - proxy_pass http://localhost:{{nginx_aaad_port}}; + proxy_pass http://localhost:{{ nginx_aaad_port }}; proxy_set_header URI $request_uri; proxy_set_header method $request_method; proxy_pass_request_headers on; diff --git a/vagrant/single_node/hosts/single b/vagrant/single_node/hosts/single index 711b8c6..d7c6e70 100644 --- a/vagrant/single_node/hosts/single +++ b/vagrant/single_node/hosts/single @@ -1,7 +1,7 @@ # inventory file for 'single' environment [machines:vars] -marathon_auth_enable=true +marathon_auth_enable=false nginx_auth_enable=true marathon_webui_host=localmesos01:4080/marathon From 3c3eddadf86caac7ff4c9b02adbdad7705d37e7e Mon Sep 17 00:00:00 2001 From: Amit Bose Date: Fri, 5 Aug 2016 10:51:55 -0700 Subject: [PATCH 2/2] Review changes --- aaad/aaad.py | 2 +- aaad/chronos.py | 2 +- aaad/framework.py | 2 +- aaad/marathon.py | 2 +- .../files/auth_request_and_filter_response.lua | 13 ++++++++++++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/aaad/aaad.py b/aaad/aaad.py index 904ab2f..b5101ea 100644 --- a/aaad/aaad.py +++ b/aaad/aaad.py @@ -88,7 +88,7 @@ def filter_response(self, body, resource, act_as_user, permissions): if not allowed_namespaces: #Empty list return "" - response = framework.filterBody(body, allowed_namespaces, resource) + response = framework.filterResponseBody(body, allowed_namespaces, resource) return response def authenticate_request(self, user, password): diff --git a/aaad/chronos.py b/aaad/chronos.py index 5a2a967..2392ca4 100644 --- a/aaad/chronos.py +++ b/aaad/chronos.py @@ -12,7 +12,7 @@ class Chronos(Framework): def getName(self): return "Chronos" - def filterBody(self, body, allowed_namespaces, request_uri): + def filterResponseBody(self, body, allowed_namespaces, request_uri): filtered_response = [] try: jobs = json.loads(body) diff --git a/aaad/framework.py b/aaad/framework.py index 1944bff..8fe7d25 100644 --- a/aaad/framework.py +++ b/aaad/framework.py @@ -9,6 +9,6 @@ class Framework(object): __metaclass__ = ABCMeta @abstractmethod - def filterBody(self, body, allowed_namespaces, request_uri): + def filterResponseBody(self, body, allowed_namespaces, request_uri): pass diff --git a/aaad/marathon.py b/aaad/marathon.py index ab76597..d217ecb 100644 --- a/aaad/marathon.py +++ b/aaad/marathon.py @@ -12,7 +12,7 @@ class Marathon(Framework): def getName(self): return "Marathon" - def filterBody(self, body, allowed_namespaces, request_uri): + def filterResponseBody(self, body, allowed_namespaces, request_uri): uri_pattern = re.compile("^{}$".format("/marathon/*v2/apps")) uri_match = uri_pattern.match(request_uri) if uri_match: diff --git a/ansible/roles/nginx/files/auth_request_and_filter_response.lua b/ansible/roles/nginx/files/auth_request_and_filter_response.lua index 5118e33..b46a999 100644 --- a/ansible/roles/nginx/files/auth_request_and_filter_response.lua +++ b/ansible/roles/nginx/files/auth_request_and_filter_response.lua @@ -17,8 +17,19 @@ end local res = ngx.location.capture(ngx.var.location_endpoint, { copy_all_vars = true }) -local data = res.body +if res.status == ngx.HTTP_FORBIDDEN then + ngx.exit(res.status) +end + +if res.status == ngx.HTTP_UNAUTHORIZED then + ngx.exit(res.status) +end +if res.status ~= ngx.HTTP_OK then + ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) +end + +local data = res.body ngx.req.set_header("auth_action", "filter_response") local resp = ngx.location.capture("/auth-proxy", {body=data})