diff --git a/.gitignore b/.gitignore index e8bf92ead3e777406d4d7eeb484af7b7bfb557ad..33b68146aa9a024e14107b02d40c194a884ca0f2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ coverage.xml htmlcov dist venv +.venv .vscode docs/build diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 3502d074966387065a92af957caaae1a22c2aacd..28b30adfb9cf32669953e9562676164001524a1f 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -54,7 +54,7 @@ This module has been tested in the following execution environments: .. code-block:: bash $ export FLASK_APP=app.py - $ export SETTINGS_FILENAME=settings.cfg + $ export FLASK_SETTINGS_FILENAME=settings.cfg $ flask run * As an Apache/`mod_wsgi` service. diff --git a/inventory_provider/__init__.py b/inventory_provider/__init__.py index 09574fb6f842e27db80a60b3fd47de92d4b2e91b..3155d6cce97b98ca09e7942da6806f23292a2181 100644 --- a/inventory_provider/__init__.py +++ b/inventory_provider/__init__.py @@ -7,6 +7,7 @@ from flask import Flask from flask_cors import CORS from inventory_provider import environment +from inventory_provider.auth import auth def create_app(setup_logging=True): @@ -48,6 +49,13 @@ def create_app(setup_logging=True): app.config['INVENTORY_PROVIDER_CONFIG'] = inventory_provider_config + # Apply authentication globally to all routes + @app.before_request + @auth.login_required + def secure_before_request(): + # This method is a boilerplate required by the library to enable authentication + pass + # IMS based routes from inventory_provider.routes import lg diff --git a/inventory_provider/auth.py b/inventory_provider/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..ad7fe03396493fbd78996a2609fa58ceeeea1255 --- /dev/null +++ b/inventory_provider/auth.py @@ -0,0 +1,42 @@ +from functools import wraps +from flask import current_app, g, jsonify +from flask_httpauth import HTTPTokenAuth + +from inventory_provider.config import ServiceName +from typing import Callable, List, Optional + +auth = HTTPTokenAuth(scheme="ApiKey") + + +@auth.verify_token +def verify_api_key(api_key: Optional[str]) -> Optional[ServiceName]: + config = current_app.config["INVENTORY_PROVIDER_CONFIG"] + # This is to enable anonymous access for testing. + if not api_key: + g.auth_client = ServiceName.ANONYMOUS + return ServiceName.ANONYMOUS + + for client, details in config['api-keys'].items(): + if details.get('api-key') == api_key: + g.auth_client = client + return client + return None + + +def authorize(*, allowed_clients: List[ServiceName]) -> Callable: + """Decorator to restrict route access to specific clients.""" + if not isinstance(allowed_clients, list): + raise TypeError("allowed_clients must be a list of allowed service names") + + def decorator(f: Callable) -> Callable: + @wraps(f) + def wrapped(*args, **kwargs): + client = g.get("auth_client") + if client not in allowed_clients: + # Anonymous clients are allowed to access any resource without providing an API key + # TODO: Only for testing, should be removed in Production + if client != ServiceName.ANONYMOUS: + return jsonify({"error": "Forbidden"}), 403 + return f(*args, **kwargs) + return wrapped + return decorator diff --git a/inventory_provider/config.py b/inventory_provider/config.py index 3fa03b9de584b4783a4305ad9f4e9ee7ec014ba3..6e2dfb7fa365c1d0436aec8815de045dce6a8d6a 100644 --- a/inventory_provider/config.py +++ b/inventory_provider/config.py @@ -1,5 +1,14 @@ import json import jsonschema +from enum import StrEnum + + +class ServiceName(StrEnum): + DASHBOARD = 'dashboard' + BRIAN = 'brian' + REPORTING = 'reporting' + ANONYMOUS = 'anonymous' + CONFIG_SCHEMA = { '$schema': 'https://json-schema.org/draft-07/schema#', @@ -10,6 +19,23 @@ CONFIG_SCHEMA = { 'maximum': 60, # sanity 'exclusiveMinimum': 0 }, + 'api-key': { + "type": "object", + "properties": { + "api-key": {"type": "string"} + }, + "required": ["api-key"], + "additionalProperties": False + }, + "api-keys-credentials": { + "type": "object", + 'properties': { + ServiceName.DASHBOARD: {'$ref': '#/definitions/api-key'}, + ServiceName.BRIAN: {'$ref': '#/definitions/api-key'}, + ServiceName.REPORTING: {'$ref': '#/definitions/api-key'} + }, + "additionalProperties": False + }, 'ssh-credentials': { 'type': 'object', 'properties': { @@ -235,6 +261,7 @@ CONFIG_SCHEMA = { 'type': 'object', 'properties': { + 'api-keys': {'$ref': '#/definitions/api-keys-credentials'}, 'ssh': {'$ref': '#/definitions/ssh-credentials'}, 'nokia-ssh': {'$ref': '#/definitions/nokia-ssh-credentials'}, 'redis': {'$ref': '#/definitions/redis-credentials'}, diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py index a48d229b226f051f8d70b87d6ac70e5e98fb4a93..dcf264b70acfb9494838dc117d4b1f49772670c9 100644 --- a/inventory_provider/routes/classifier.py +++ b/inventory_provider/routes/classifier.py @@ -67,6 +67,9 @@ from redis import Redis from inventory_provider.routes import common from inventory_provider.routes.common import _ignore_cache_or_retrieve, cache_result +from inventory_provider.auth import authorize +from inventory_provider.config import ServiceName + routes = Blueprint("inventory-data-classifier-support-routes", __name__) logger = logging.getLogger(__name__) @@ -333,6 +336,7 @@ def get_link_info_response_body( @routes.route("/juniper-link-info/<source_equipment>/<path:interface>", methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def handle_link_info_request(source_equipment: str, interface: str) -> Response: """ Handler for /classifier/juniper-link-info that @@ -374,6 +378,7 @@ def handle_link_info_request(source_equipment: str, interface: str) -> Response: @routes.route("/epipe-sap-info/<source_equipment>/<service_id>/<vpn_id>", methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def handle_epipe_sap_info_request(source_equipment: str, service_id: str, vpn_id: str) -> Response: r = common.get_current_redis() @@ -602,6 +607,7 @@ def find_interfaces(address): @routes.route("/peer-info/<address_str>", methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def peer_info(address_str: str) -> Response: """ Handler for /classifier/peer-info that returns bgp peering metadata. @@ -697,6 +703,7 @@ def peer_info(address_str: str) -> Response: @routes.route( "/mtc-interface-info/<node>/<interface>", methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_mtc_interface_info(node, interface): """ Handler for /classifier/mtc-interface-info that @@ -738,6 +745,7 @@ def get_mtc_interface_info(node, interface): "<source_equipment>/<interface>/<circuit_id>", methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_trap_metadata(source_equipment: str, interface: str, circuit_id: str) \ -> Response: """ @@ -831,6 +839,7 @@ def get_trap_metadata(source_equipment: str, interface: str, circuit_id: str) \ @routes.route("/infinera-fiberlink-info/<ne_name_str>/<object_name_str>", methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_fiberlink_trap_metadata(ne_name_str: str, object_name_str: str) \ -> Response: """ @@ -967,6 +976,7 @@ def get_fiberlink_trap_metadata(ne_name_str: str, object_name_str: str) \ @routes.route("/tnms-fibre-info/<path:enms_pc_name>", methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_tnms_fibre_trap_metadata(enms_pc_name: str) -> Response: """ Handler for /classifier/infinera-fiberlink-info that @@ -1069,6 +1079,7 @@ def get_tnms_fibre_trap_metadata(enms_pc_name: str) -> Response: @routes.route('/coriant-port-info/<equipment_name>/<path:entity_string>', methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_coriant_port_info(equipment_name: str, entity_string: str) -> Response: """ Handler for /classifier/coriant-info that @@ -1090,6 +1101,7 @@ def get_coriant_port_info(equipment_name: str, entity_string: str) -> Response: @routes.route('/coriant-tp-info/<equipment_name>/<path:entity_string>', methods=['GET']) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_coriant_tp_info(equipment_name: str, entity_string: str) -> Response: """ Handler for /classifier/coriant-info that @@ -1200,6 +1212,7 @@ def _get_coriant_info( @routes.route("/router-info", methods=["GET"]) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_all_routers() -> Response: redis = common.get_current_redis() result = cache_result( @@ -1222,6 +1235,7 @@ def _get_router_list(redis): @routes.route("/router-info/<equipment_name>", methods=["GET"]) @common.require_accepts_json +@authorize(allowed_clients=[ServiceName.DASHBOARD]) def get_router_info(equipment_name: str) -> Response: redis = common.get_current_redis() ims_equipment_name = get_ims_equipment_name_or_none(equipment_name, redis) diff --git a/requirements.txt b/requirements.txt index 0d226651b91236f8b7bd716d444d6d71eb3c1d0a..9095eb74b7d4b98f3f86a727cbb003ab23ba72f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ lxml==4.9.4 requests netifaces tree-format +Flask-HTTPAuth pytest pytest-mock diff --git a/test/conftest.py b/test/conftest.py index 1c276b4c89fe5deec9bf787715799dd3b076d160..354126826574b8ce6c4227c458b0a22322429e29 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -70,6 +70,17 @@ def data_config_filename(): with tempfile.NamedTemporaryFile() as f: config = { + "api-keys": { + "brian": { + "api-key": "brian_key" + }, + "dashboard": { + "api-key": "dashboard_key" + }, + "reporting": { + "api-key": "reporting_key" + }, + }, "ssh": { "username": "uSeR-NaMe", "private-key": "private-key-filename", diff --git a/test/test_auth.py b/test/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..f12e2d6d3f63d5c846ce50ad42a3400cab5b79ff --- /dev/null +++ b/test/test_auth.py @@ -0,0 +1,75 @@ +import jsonschema + +from inventory_provider.routes.classifier_schema import ( + ROUTER_INFO_ALL_ROUTERS_RESPONSE_SCHEMA, +) + + +def test_classifier_router_no_key(client): + DEFAULT_REQUEST_HEADERS_NO_KEY = { + "Content-type": "application/json", + "Accept": ["application/json"], + } + rv = client.get("/classifier/router-info", headers=DEFAULT_REQUEST_HEADERS_NO_KEY) + assert rv.status_code == 200 + assert rv.is_json + result = rv.json + + jsonschema.validate(result, ROUTER_INFO_ALL_ROUTERS_RESPONSE_SCHEMA) + assert len(result) > 0 + + +def test_classifier_router_dashboard_key(client): + DEFAULT_REQUEST_HEADERS_DASHBOARD_KEY = { + "Content-type": "application/json", + "Accept": ["application/json"], + "Authorization": "ApiKey dashboard_key", + } + rv = client.get("/classifier/router-info", headers=DEFAULT_REQUEST_HEADERS_DASHBOARD_KEY) + assert rv.status_code == 200 + assert rv.is_json + result = rv.json + + jsonschema.validate(result, ROUTER_INFO_ALL_ROUTERS_RESPONSE_SCHEMA) + assert len(result) > 0 + + +def test_classifier_router_brian_key(client): + DEFAULT_REQUEST_HEADERS_BRIAN_KEY = { + "Content-type": "application/json", + "Accept": ["application/json"], + "Authorization": "ApiKey brian_key", + } + rv = client.get("/classifier/router-info", headers=DEFAULT_REQUEST_HEADERS_BRIAN_KEY) + assert rv.status_code == 403 + assert rv.is_json + result = rv.json + + assert result["error"] == "Forbidden" + + +def test_classifier_router_reporting_key(client): + DEFAULT_REQUEST_HEADERS_REPORTING_KEY = { + "Content-type": "application/json", + "Accept": ["application/json"], + "Authorization": "ApiKey reporting_key", + } + rv = client.get("/classifier/router-info", headers=DEFAULT_REQUEST_HEADERS_REPORTING_KEY) + assert rv.status_code == 403 + assert rv.is_json + result = rv.json + + assert result["error"] == "Forbidden" + + +def test_classifier_router_bad_key(client): + DEFAULT_REQUEST_HEADERS_BAD_KEY = { + "Content-type": "application/json", + "Accept": ["application/json"], + "Authorization": "ApiKey badapikey", + } + rv = client.get("/classifier/router-info", headers=DEFAULT_REQUEST_HEADERS_BAD_KEY) + assert rv.status_code == 401 + result = rv.text + + assert result == "Unauthorized Access" diff --git a/test/test_flask_config.py b/test/test_flask_config.py index 7ba83ec54c3e2e8389924e14b3cff0bad6aea29b..48f7e29c2ab998319c33894d7f11a27efe737449 100644 --- a/test/test_flask_config.py +++ b/test/test_flask_config.py @@ -7,6 +7,17 @@ from inventory_provider.config import CONFIG_SCHEMA @pytest.fixture def config(): return { + "api-keys": { + "brian": { + "api-key": "brian_key" + }, + "dashboard": { + "api-key": "dashboard_key" + }, + "reporting": { + "api-key": "reporting_key" + }, + }, 'redis': { 'hostname': 'localhost', 'port': 6379,