From d2d8bc8d461da4e25db3480ddbc68d20744687e4 Mon Sep 17 00:00:00 2001 From: Paul Howells Date: Wed, 1 Apr 2026 09:45:40 -0700 Subject: [PATCH 1/7] APS-3732 Path-level validation for sdx related routes - implemented validation methods and unit tests - added validation check to endpoints --- microservices/gatewayApi/README.md | 6 + microservices/gatewayApi/config/test.json | 6 +- microservices/gatewayApi/tests/__init__.py | 0 microservices/gatewayApi/tests/conftest.py | 31 +- .../gatewayApi/tests/routes/__init__.py | 0 .../gatewayApi/tests/routes/v1/__init__.py | 0 .../gatewayApi/tests/routes/v1/test_sdx.py | 347 ++++++++++++++++++ .../gatewayApi/tests/routes/v2/__init__.py | 0 .../gatewayApi/tests/routes/v2/test_sdx.py | 346 ++++++++++++++++- .../tests/utils/test_validate_route_paths.py | 240 ++++++++++++ microservices/gatewayApi/utils/validators.py | 75 ++++ microservices/gatewayApi/v1/routes/gateway.py | 17 +- microservices/gatewayApi/v2/routes/gateway.py | 15 +- 13 files changed, 1054 insertions(+), 29 deletions(-) create mode 100644 microservices/gatewayApi/tests/__init__.py create mode 100644 microservices/gatewayApi/tests/routes/__init__.py create mode 100644 microservices/gatewayApi/tests/routes/v1/__init__.py create mode 100644 microservices/gatewayApi/tests/routes/v1/test_sdx.py create mode 100644 microservices/gatewayApi/tests/routes/v2/__init__.py create mode 100644 microservices/gatewayApi/tests/utils/test_validate_route_paths.py diff --git a/microservices/gatewayApi/README.md b/microservices/gatewayApi/README.md index 237eff7..983f59c 100644 --- a/microservices/gatewayApi/README.md +++ b/microservices/gatewayApi/README.md @@ -89,3 +89,9 @@ poetry run coverage run --branch -m pytest -s coverage xml ``` + +Or + +```sh +ENV=test GITHASH=11223344 poetry run pytest -s --cov=. --cov-branch --cov-report=xml +``` \ No newline at end of file diff --git a/microservices/gatewayApi/config/test.json b/microservices/gatewayApi/config/test.json index bb31eff..cd98d9d 100644 --- a/microservices/gatewayApi/config/test.json +++ b/microservices/gatewayApi/config/test.json @@ -25,7 +25,11 @@ "kube-ns": "abcd-1234", "validate-upstreams": true }, - "sdx-edge": {} + "sdx-edge": { + "kube-api": "http://kube-api", + "kube-ns": "abcd-1234", + "enforce-route-paths": true + } }, "kubeApiCreds": { "kubeApiPass": "password", diff --git a/microservices/gatewayApi/tests/__init__.py b/microservices/gatewayApi/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/microservices/gatewayApi/tests/conftest.py b/microservices/gatewayApi/tests/conftest.py index 27c0f8a..6bc5cb9 100644 --- a/microservices/gatewayApi/tests/conftest.py +++ b/microservices/gatewayApi/tests/conftest.py @@ -30,6 +30,10 @@ def app(mocker): """Create and configure a new app instance for each test.""" + sys.modules.pop("app", None) + sys.modules.pop("v1.routes.gateway", None) + sys.modules.pop("v2.routes.gateway", None) + mock_auth(mocker) mock_keycloak(mocker) mock_kong(mocker) @@ -54,8 +58,9 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function - mocker.patch('auth.auth.admin_jwt', return_value=mock_decorator) - + mocker.patch('auth.auth.admin_jwt', side_effect=lambda *a, **k: (lambda f: f)) + mocker.patch('v1.auth.auth.admin_jwt', return_value=mock_decorator) + mocker.patch('v1.auth.auth.enforce_authorization', return_value=None) mocker.patch("auth.uma.enforce", return_value=True) def mock_keycloak(mocker): @@ -106,11 +111,13 @@ def get_group(id): return { "attributes": { "perm-data-plane": ["sdx-edge"], - "perm-domains": [ "sdx01.servers.sdx" ] + "perm-domains": [ "sdx01.servers.sdx" ], + "perm-route-paths": ["/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1", "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v2"] } } - + mocker.patch("v2.services.namespaces.admin_api", return_value=mock_kc_admin) + mocker.patch("v1.services.namespaces.admin_api", return_value=mock_kc_admin) def mock_kong(mocker): @@ -134,7 +141,8 @@ def json(): elif (path == 'http://kong/certificates?tags=gwa.ns.mytest' or path == 'http://kong/certificates?tags=gwa.ns.sescookie' or path == 'http://kong/certificates?tags=gwa.ns.dclass' or - path == 'http://kong/certificates?tags=gwa.ns.customcert'): + path == 'http://kong/certificates?tags=gwa.ns.customcert' or + path == 'http://kong/certificates?tags=ns.sdx01'): class Response: def json(): return { @@ -182,20 +190,23 @@ class Response: def mock_deck(mocker): class decoded_response: - def __init__ (self, output): + def __init__(self, output): self.output = output + def decode(self, utf): return self.output class mock_popen_instance: - def __init__ (self, output): + def __init__(self, output): self.output = output + def communicate(self): return decoded_response(self.output), None + returncode = 0 mock_output = "Deck reported no changes" - mocker.patch("v2.routes.gateway.Popen", return_value=mock_popen_instance(mock_output)) + mocker.patch("subprocess.Popen", return_value=mock_popen_instance(mock_output)) def mock_kubeapi(mocker): @@ -281,6 +292,10 @@ class Response: # def json(): # return {} return Response + elif (url == 'http://kube-api/namespaces/sdx01/routes'): + class Response: + status_code = 201 + return Response else: raise Exception(url) diff --git a/microservices/gatewayApi/tests/routes/__init__.py b/microservices/gatewayApi/tests/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/microservices/gatewayApi/tests/routes/v1/__init__.py b/microservices/gatewayApi/tests/routes/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/microservices/gatewayApi/tests/routes/v1/test_sdx.py b/microservices/gatewayApi/tests/routes/v1/test_sdx.py new file mode 100644 index 0000000..99e3def --- /dev/null +++ b/microservices/gatewayApi/tests/routes/v1/test_sdx.py @@ -0,0 +1,347 @@ +import json + + +def put_gateway(client, config_file: str, dry_run: bool = False): + data = { + "configFile": config_file, + "dryRun": dry_run, + } + return client.put('/v1/namespaces/sdx01/gateway', json=data) + + +def assert_route_path_error( + response, + service_name: str, + route_name: str, + path: str, +): + body = response.get_data(as_text=True) + + assert response.status_code == 400 + assert "does not match any allowed paths (e7)" in body + assert f"service.{service_name}.route.{route_name}" in body + assert path in body + + +def test_success_sdx_call_empty(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_success_sdx_call(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_pass_v1_exact_match(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1 + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_pass_v1_child_path(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_pass_v2_exact_match(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v2 + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_pass_v2_child_path(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v2/orders + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_fail_invalid_prefix(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "my-route", invalid_path) + + +def test_sdx_route_path_validation_fail_similar_prefix_v10(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v10/users" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "my-route", invalid_path) + + +def test_sdx_route_path_validation_fail_one_of_multiple_paths_invalid(client): + valid_path = "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users" + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - {valid_path} + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "my-route", invalid_path) + + +def test_sdx_route_path_validation_fail_multiple_routes_one_invalid(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/health" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "route-2", invalid_path) + + +def test_sdx_route_path_validation_pass_multiple_routes_all_valid(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v2/orders + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_fail_multiple_services_one_invalid(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + + configFile = f''' + services: + - name: service-1 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + - name: service-2 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "service-2", "route-2", invalid_path) + + +def test_sdx_route_path_validation_fail_multiple_invalid_paths_reports_all(client): + invalid_path_1 = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + invalid_path_2 = "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v10/orders" + + configFile = f''' + services: + - name: service-1 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path_1} + tags: ["ns.sdx01.qualifier"] + - name: service-2 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path_2} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + body = response.get_data(as_text=True) + + assert response.status_code == 400 + assert "does not match any allowed paths (e7)" in body + assert "service.service-1.route.route-1" in body + assert invalid_path_1 in body + assert "service.service-2.route.route-2" in body + assert invalid_path_2 in body \ No newline at end of file diff --git a/microservices/gatewayApi/tests/routes/v2/__init__.py b/microservices/gatewayApi/tests/routes/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/microservices/gatewayApi/tests/routes/v2/test_sdx.py b/microservices/gatewayApi/tests/routes/v2/test_sdx.py index b6e88a6..387e813 100644 --- a/microservices/gatewayApi/tests/routes/v2/test_sdx.py +++ b/microservices/gatewayApi/tests/routes/v2/test_sdx.py @@ -1,20 +1,42 @@ -import yaml -import pytest import json -from v1.routes.gateway import validate_upstream -from tests.testutils import trimleft -from unittest import mock -def test_success_sdx_call_empty(client): - data={ - "configFile": '---', - "dryRun": False +def put_gateway(client, config_file: str, dry_run: bool = False): + data = { + "configFile": config_file, + "dryRun": dry_run, } - response = client.put('/v2/namespaces/sdx01/gateway', json=data) + return client.put('/v2/namespaces/sdx01/gateway', json=data) + + +def assert_route_path_error( + response, + service_name: str, + route_name: str, + path: str, +): + body = response.get_data(as_text=True) + + assert response.status_code == 400 + assert "does not match any allowed paths (e7)" in body + assert f"service.{service_name}.route.{route_name}" in body + assert path in body + + +def test_success_sdx_call_empty(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01"] + ''' + + response = put_gateway(client, configFile, False) + assert response.status_code == 200 assert json.dumps(response.json) == '{"message": "Sync successful.", "results": "Deck reported no changes"}' + def test_success_sdx_call(client): configFile = ''' services: @@ -23,17 +45,305 @@ def test_success_sdx_call(client): tags: ["ns.sdx01.qualifier"] routes: - name: route-1 - hosts: [ sdx01.servers.sdx] + hosts: [ sdx01.servers.sdx ] tags: ["ns.sdx01.qualifier"] plugins: - name: acl-auth tags: ["ns.sdx01.qualifier"] - ''' - - data={ - "configFile": configFile, - "dryRun": False - } - response = client.put('/v2/namespaces/sdx01/gateway', json=data) + ''' + + response = put_gateway(client, configFile, False) + assert response.status_code == 200 assert json.dumps(response.json) == '{"message": "Sync successful.", "results": "Deck reported no changes"}' + + +def test_sdx_route_path_validation_pass_v1_exact_match(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1 + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_pass_v1_child_path(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_pass_v2_exact_match(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v2 + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_pass_v2_child_path(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v2/orders + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_fail_invalid_prefix(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "my-route", invalid_path) + + +def test_sdx_route_path_validation_fail_similar_prefix_v10(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v10/users" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "my-route", invalid_path) + + +def test_sdx_route_path_validation_fail_one_of_multiple_paths_invalid(client): + valid_path = "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users" + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: my-route + hosts: [ sdx01.servers.sdx ] + paths: + - {valid_path} + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "my-route", invalid_path) + + +def test_sdx_route_path_validation_fail_multiple_routes_one_invalid(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/health" + + configFile = f''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "my-service", "route-2", invalid_path) + + +def test_sdx_route_path_validation_pass_multiple_routes_all_valid(client): + configFile = ''' + services: + - name: my-service + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v2/orders + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert response.status_code == 200 + + +def test_sdx_route_path_validation_fail_multiple_services_one_invalid(client): + invalid_path = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + + configFile = f''' + services: + - name: service-1 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users + tags: ["ns.sdx01.qualifier"] + - name: service-2 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + + assert_route_path_error(response, "service-2", "route-2", invalid_path) + + +def test_sdx_route_path_validation_fail_multiple_invalid_paths_reports_all(client): + invalid_path_1 = "/sdx/0/LAB.MIN.CITZ.INVALID.v1/users" + invalid_path_2 = "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v10/orders" + + configFile = f''' + services: + - name: service-1 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-1 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path_1} + tags: ["ns.sdx01.qualifier"] + - name: service-2 + host: myupstream.local + tags: ["ns.sdx01.qualifier"] + routes: + - name: route-2 + hosts: [ sdx01.servers.sdx ] + paths: + - {invalid_path_2} + tags: ["ns.sdx01.qualifier"] + plugins: + - name: acl-auth + tags: ["ns.sdx01.qualifier"] + ''' + + response = put_gateway(client, configFile, False) + body = response.get_data(as_text=True) + + assert response.status_code == 400 + assert "does not match any allowed paths (e7)" in body + assert "service.service-1.route.route-1" in body + assert invalid_path_1 in body + assert "service.service-2.route.route-2" in body + assert invalid_path_2 in body \ No newline at end of file diff --git a/microservices/gatewayApi/tests/utils/test_validate_route_paths.py b/microservices/gatewayApi/tests/utils/test_validate_route_paths.py new file mode 100644 index 0000000..632c5a8 --- /dev/null +++ b/microservices/gatewayApi/tests/utils/test_validate_route_paths.py @@ -0,0 +1,240 @@ +import yaml +import pytest +from utils.validators import validate_route_paths + + +ALLOWED_PREFIX = "/sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1" +SECOND_ALLOWED_PREFIX = "/sdx/0/LAB.MIN.CITZ.OTHER-USAGE.v1" + + +def test_route_paths_validation_disabled(app): + payload = ''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - /not-allowed +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + validate_route_paths(y, {}, False) + + +def test_route_paths_good_single_match(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - {ALLOWED_PREFIX}/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_good_exact_match(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - {ALLOWED_PREFIX} +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_good_multiple_allowed_prefixes(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - {SECOND_ALLOWED_PREFIX}/orders +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + validate_route_paths( + y, + {"perm-route-paths": [ALLOWED_PREFIX, SECOND_ALLOWED_PREFIX]}, + True, + ) + + +def test_route_paths_good_multiple_paths_all_valid(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - {ALLOWED_PREFIX}/users + - {ALLOWED_PREFIX}/orders + - {ALLOWED_PREFIX}/status +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_good_path_without_leading_slash(app): + payload = ''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - sdx/0/LAB.MIN.CITZ.DATA-USAGE.v1/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_fail_no_perm_route_paths(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - {ALLOWED_PREFIX}/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + + with pytest.raises(Exception, match=r"service\.my-service\.route\.my-route.*does not match any allowed paths \(e7\)"): + validate_route_paths(y, {}, True) + + +def test_route_paths_fail_empty_perm_route_paths(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - {ALLOWED_PREFIX}/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + + with pytest.raises(Exception, match=r"service\.my-service\.route\.my-route.*does not match any allowed paths \(e7\)"): + validate_route_paths(y, {"perm-route-paths": [""]}, True) + + +def test_route_paths_fail_not_matching_allowed_prefix(app): + payload = ''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - /sdx/0/LAB.MIN.CITZ.UNRELATED.v1/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + + with pytest.raises(Exception, match=r"service\.my-service\.route\.my-route.*does not match any allowed paths \(e7\)"): + validate_route_paths( + y, + {"perm-route-paths": [ALLOWED_PREFIX, SECOND_ALLOWED_PREFIX]}, + True, + ) + + +def test_route_paths_fail_one_of_multiple_paths_invalid(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - {ALLOWED_PREFIX}/users + - /sdx/0/LAB.MIN.CITZ.UNRELATED.v1/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + + with pytest.raises(Exception, match=r"service\.my-service\.route\.my-route.*does not match any allowed paths \(e7\)"): + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_fail_multiple_routes_one_invalid(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: route-1 + paths: + - {ALLOWED_PREFIX}/users + - name: route-2 + paths: + - /sdx/0/LAB.MIN.CITZ.UNRELATED.v1/health +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + + with pytest.raises(Exception, match=r"service\.my-service\.route\.route-2.*does not match any allowed paths \(e7\)"): + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_good_multiple_routes_all_valid(app): + payload = f''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: route-1 + paths: + - {ALLOWED_PREFIX}/users + - name: route-2 + paths: + - {ALLOWED_PREFIX}/orders +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_multiple_services_one_invalid(app): + payload = f''' +services: + - name: service-1 + tags: ["ns.mytest", "another"] + routes: + - name: route-1 + paths: + - {ALLOWED_PREFIX}/users + - name: service-2 + tags: ["ns.mytest", "another"] + routes: + - name: route-2 + paths: + - /sdx/0/LAB.MIN.CITZ.UNRELATED.v1/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + + with pytest.raises(Exception, match=r"service\.service-2\.route\.route-2.*does not match any allowed paths \(e7\)"): + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) + + +def test_route_paths_fail_similar_prefix_not_matching(app): + payload = ''' +services: + - name: my-service + tags: ["ns.mytest", "another"] + routes: + - name: my-route + paths: + - /sdx/0/LAB.MIN.CITZ.DATA-USAGE.v10/users +''' + y = yaml.load(payload, Loader=yaml.FullLoader) + + with pytest.raises(Exception, match=r"service\.my-service\.route\.my-route.*does not match any allowed paths \(e7\)"): + validate_route_paths(y, {"perm-route-paths": [ALLOWED_PREFIX]}, True) \ No newline at end of file diff --git a/microservices/gatewayApi/utils/validators.py b/microservices/gatewayApi/utils/validators.py index 89fd66c..00e4dca 100644 --- a/microservices/gatewayApi/utils/validators.py +++ b/microservices/gatewayApi/utils/validators.py @@ -72,3 +72,78 @@ def validate_upstream_host(_host, errors, allow_protected_ns, protected_kube_nam errors.append("service upstream is invalid (e6)") elif do_validate_upstreams and (host in perm_upstreams) is False: errors.append("service upstream is invalid (e6)") + +def validate_route_paths(yaml, ns_attributes, do_validate_route_paths: bool = False): + if not do_validate_route_paths: + return + + errors = [] + perm_route_paths = ns_attributes.get('perm-route-paths', []) + + for service in yaml.get('services', []): + for route in service.get('routes', []): + for path in route.get('paths', []): + validate_route_path(path, errors, perm_route_paths) + + if len(errors) != 0: + raise Exception('\n'.join(errors)) + + +def validate_route_paths(yaml, ns_attributes, do_validate_route_paths: bool = False): + if not do_validate_route_paths: + return + + errors = [] + perm_route_paths = ns_attributes.get('perm-route-paths', []) + + for service in yaml.get('services', []): + service_name = service.get('name', '') + + for route in service.get('routes', []): + route_name = route.get('name', '') + + for path in route.get('paths', []): + validate_route_path( + path, + service_name, + route_name, + errors, + perm_route_paths + ) + + if errors: + raise Exception('\n'.join(errors)) + + +def validate_route_path(_path, service_name, route_name, errors, perm_route_paths): + display_path = _path if _path else "" + + if not _path: + errors.append( + f"service.{service_name}.route.{route_name} " + f"path '{display_path}' does not match any allowed paths (e7)" + ) + return + + if not perm_route_paths: + errors.append( + f"service.{service_name}.route.{route_name} " + f"path '{display_path}' does not match any allowed paths (e7)" + ) + return + + path = _path if _path.startswith("/") else f"/{_path}" + + for allowed in perm_route_paths: + if not allowed: + continue + + allowed_path = allowed if allowed.startswith("/") else f"/{allowed}" + + if path == allowed_path or path.startswith(f"{allowed_path}/"): + return + + errors.append( + f"service.{service_name}.route.{route_name} " + f"path '{path}' does not match any allowed paths (e7)" + ) diff --git a/microservices/gatewayApi/v1/routes/gateway.py b/microservices/gatewayApi/v1/routes/gateway.py index d0c89e9..6af56d9 100644 --- a/microservices/gatewayApi/v1/routes/gateway.py +++ b/microservices/gatewayApi/v1/routes/gateway.py @@ -25,7 +25,7 @@ from clients.ocp_routes import get_host_list, get_route_overrides from clients.ocp_gateway_secret import prep_submitted_config, prep_and_apply_secret, write_submitted_config -from utils.validators import host_valid, validate_upstream +from utils.validators import host_valid, validate_upstream, validate_route_paths from utils.transforms import plugins_transformations, add_version_if_missing from utils.masking import mask from utils.deck import deck_cmd_sync_diff, deck_cmd_validate @@ -307,6 +307,21 @@ def write_config(namespace: str) -> object: log.error("%s - %s" % (namespace, " Upstream Validation Errors: %s" % ex)) abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ex)) + # Validate route paths are valid + try: + + dp = get_data_plane(ns_attributes) + + do_enforce_route_paths = app.config['data_planes'][dp].get("enforce-route-paths", False) + + log.debug("Validate route paths %s %s" % (dp, do_enforce_route_paths)) + + validate_route_paths(gw_config, ns_attributes, do_enforce_route_paths) + except Exception as ex: + traceback.print_exc() + log.error("%s - %s" % (namespace, " Route Path Validation Errors: %s" % ex)) + abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ex)) + # Validation #3 # Validate that certain plugins are configured (such as the gwa_gov_endpoint) at the right level diff --git a/microservices/gatewayApi/v2/routes/gateway.py b/microservices/gatewayApi/v2/routes/gateway.py index 9051d01..c305155 100644 --- a/microservices/gatewayApi/v2/routes/gateway.py +++ b/microservices/gatewayApi/v2/routes/gateway.py @@ -19,7 +19,7 @@ from clients.portal import record_gateway_event from clients.kong import get_routes, register_kong_certs, get_public_certs_by_ns from clients.ocp_gateway_secret import prep_submitted_config -from utils.validators import host_valid, validate_upstream +from utils.validators import host_valid, validate_upstream, validate_route_paths from utils.transforms import plugins_transformations, add_version_if_missing from utils.masking import mask from utils.deck import deck_cmd_sync_diff, deck_cmd_validate @@ -310,6 +310,19 @@ def write_config(namespace: str) -> object: log.error("%s - %s" % (namespace, " Upstream Validation Errors: %s" % ex)) abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ex)) + # Validate route paths are valid + try: + + do_validate_route_paths = app.config['data_planes'][dp].get("enforce-route-paths", False) + + log.debug("Validate route paths %s %s" % (dp, do_validate_route_paths)) + + validate_route_paths(gw_config, ns_attributes, do_validate_route_paths) + except Exception as ex: + traceback.print_exc() + log.error("%s - %s" % (namespace, " Route Path Validation Errors: %s" % ex)) + abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ex)) + # Validation #3 # Validate that certain plugins are configured (such as the gwa_gov_endpoint) at the right level From 42f3cab192e8b6f40ebfc0749ae6f62078b73448 Mon Sep 17 00:00:00 2001 From: Paul Howells Date: Mon, 13 Apr 2026 08:58:43 -0700 Subject: [PATCH 2/7] APS-3732 Code review fixes --- microservices/gatewayApi/utils/validators.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/microservices/gatewayApi/utils/validators.py b/microservices/gatewayApi/utils/validators.py index 00e4dca..0dd6e66 100644 --- a/microservices/gatewayApi/utils/validators.py +++ b/microservices/gatewayApi/utils/validators.py @@ -73,22 +73,6 @@ def validate_upstream_host(_host, errors, allow_protected_ns, protected_kube_nam elif do_validate_upstreams and (host in perm_upstreams) is False: errors.append("service upstream is invalid (e6)") -def validate_route_paths(yaml, ns_attributes, do_validate_route_paths: bool = False): - if not do_validate_route_paths: - return - - errors = [] - perm_route_paths = ns_attributes.get('perm-route-paths', []) - - for service in yaml.get('services', []): - for route in service.get('routes', []): - for path in route.get('paths', []): - validate_route_path(path, errors, perm_route_paths) - - if len(errors) != 0: - raise Exception('\n'.join(errors)) - - def validate_route_paths(yaml, ns_attributes, do_validate_route_paths: bool = False): if not do_validate_route_paths: return From cd39f97a7fb2add09cd68daa26c8b3e5fd72fff7 Mon Sep 17 00:00:00 2001 From: Russell Vinegar Date: Wed, 15 Apr 2026 10:54:26 -0700 Subject: [PATCH 3/7] document docs for token API --- microservices/sdxStepTokenApi/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/microservices/sdxStepTokenApi/README.md b/microservices/sdxStepTokenApi/README.md index 6b09ab8..b5c80db 100644 --- a/microservices/sdxStepTokenApi/README.md +++ b/microservices/sdxStepTokenApi/README.md @@ -10,6 +10,8 @@ See [sdx-ca-token-api-spec.md](sdx-ca-token-api-spec.md) for the full specificat |--------|-----------|--------------------------------------| | POST | `/token` | Generate a one-time CA token | | GET | `/health` | Health / readiness check | +| GET | `/docs` | Swagger UI for API documentation | +| GET | `/openapi.json` | OpenAPI specification | ## Environment Variables From 3a10bfe28c4477a631479f0d1a29ebcc00afb06c Mon Sep 17 00:00:00 2001 From: Russell Vinegar <38586679+rustyjux@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:37:54 -0700 Subject: [PATCH 4/7] Fix `sdxStepTokenApi` OpenAPI spec to pass CSIT governance linting (#264) * add .spectral.yaml * fixes to meet style ruleset --- .spectral.yaml | 2 + microservices/sdxStepTokenApi/README.md | 2 +- microservices/sdxStepTokenApi/app.py | 168 ++++++++++- microservices/sdxStepTokenApi/models.py | 62 ++++ microservices/sdxStepTokenApi/openapi.json | 277 ++++++++++++++++++ .../sdxStepTokenApi/routers/routes.py | 22 +- .../sdxStepTokenApi/sdx-ca-token-api-spec.md | 4 +- .../sdxStepTokenApi/tests/test_routes.py | 12 +- 8 files changed, 514 insertions(+), 35 deletions(-) create mode 100644 .spectral.yaml create mode 100644 microservices/sdxStepTokenApi/models.py create mode 100644 microservices/sdxStepTokenApi/openapi.json diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..2ac20f3 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,2 @@ +extends: + - https://raw.githubusercontent.com/bcgov/csit-api-governance-spectral-style-guide/main/dist/spectral/basic-ruleset.yaml diff --git a/microservices/sdxStepTokenApi/README.md b/microservices/sdxStepTokenApi/README.md index b5c80db..6064c9e 100644 --- a/microservices/sdxStepTokenApi/README.md +++ b/microservices/sdxStepTokenApi/README.md @@ -8,7 +8,7 @@ See [sdx-ca-token-api-spec.md](sdx-ca-token-api-spec.md) for the full specificat | Method | Path | Description | |--------|-----------|--------------------------------------| -| POST | `/token` | Generate a one-time CA token | +| POST | `/tokens` | Generate a one-time CA token | | GET | `/health` | Health / readiness check | | GET | `/docs` | Swagger UI for API documentation | | GET | `/openapi.json` | OpenAPI specification | diff --git a/microservices/sdxStepTokenApi/app.py b/microservices/sdxStepTokenApi/app.py index 78df386..bd64ae2 100644 --- a/microservices/sdxStepTokenApi/app.py +++ b/microservices/sdxStepTokenApi/app.py @@ -2,16 +2,151 @@ import logging from fastapi import FastAPI, Request, status -from fastapi.exceptions import RequestValidationError from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse from config import settings from clients.step import bootstrap from routers.routes import router +from models import HealthResponse logger = logging.getLogger(__name__) +_VALID_LOCATIONS = {"body", "query", "header", "path", "cookie"} + +_OPENAPI_PATH_SUMMARIES = { + "/tokens": "Step CA token management", + "/health": "Health check endpoint", +} + +# Injected in enrich_openapi_schema: FastAPI's schema pipeline sorts dict keys inside +# json_schema_extra, so examples are applied here to preserve field order. +_OPENAPI_COMPONENT_EXAMPLES = { + "TokenRequest": [ + { + "subject": "my-service.clients.sdx", + "san": ["alt-name-1.clients.sdx", "10.0.0.5"], + } + ], + "TokenResponse": [ + {"token": "eyJhbGciOiJFUzI1NiJ9.payload.sig"} + ], + "HealthResponse": [ + {"status": "ok"} + ], + "HTTPValidationError": [ + { + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.21", + "title": "Validation Error", + "status": 422, + "detail": "Request body failed validation.", + "errors": [ + { + "type": "ValidationError", + "location": "body", + "code": "missing", + "message": "Field required", + } + ], + } + ], + "ValidationError": [ + { + "type": "ValidationError", + "location": "body", + "code": "missing", + "message": "Field required", + } + ], +} + + +def _request_location(loc: tuple) -> str: + """Return the request section from a Pydantic error loc tuple.""" + if not loc: + return "body" + first = loc[0] + if not isinstance(first, str): + return "body" + if first not in _VALID_LOCATIONS: + return "body" + return first + + +def _pydantic_error_item(error: dict) -> dict: + """Build one Problem Details error object from a Pydantic validation error dict.""" + item = { + "type": "ValidationError", + "location": _request_location(error["loc"]), + "code": error["type"], + "message": error["msg"], + } + if "input" in error: + item["input"] = jsonable_encoder(error["input"]) + if "ctx" in error: + item["ctx"] = error["ctx"] + return item + + +def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content={ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.21", + "title": "Validation Error", + "status": 422, + "detail": "Request body failed validation.", + "errors": [_pydantic_error_item(e) for e in exc.errors()], + }, + ) + + +def get_health() -> HealthResponse: + return HealthResponse(status="ok") + + +def _apply_path_summaries(schema: dict) -> None: + paths = schema.get("paths") or {} + for path, summary in _OPENAPI_PATH_SUMMARIES.items(): + if path in paths: + paths[path]["summary"] = summary + + +def _apply_component_examples(schema: dict) -> None: + schemas = (schema.get("components") or {}).get("schemas") or {} + for name, examples in _OPENAPI_COMPONENT_EXAMPLES.items(): + if name in schemas: + schemas[name]["examples"] = examples + + +def _enrich_openapi_schema(schema: dict) -> None: + _apply_path_summaries(schema) + _apply_component_examples(schema) + + +def _make_openapi_fn(app: FastAPI): + def openapi(): + if app.openapi_schema: + return app.openapi_schema + + schema = get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + summary=app.summary, + description=app.description, + routes=app.routes, + ) + _enrich_openapi_schema(schema) + app.openapi_schema = schema + return app.openapi_schema + + return openapi + @asynccontextmanager async def lifespan(app: FastAPI): @@ -25,24 +160,27 @@ async def lifespan(app: FastAPI): def create_app(): app = FastAPI( title="SDX CA Token API", - description="API to generate one-time tokens for Step CA", + summary="One-time token generation for Step CA", + description=( + "Generates one-time-use tokens for the Step CA certificate authority, " + "used by SDX services to obtain X.509 certificates." + ), version="1.0.0", lifespan=lifespan, ) app.include_router(router) - - @app.exception_handler(RequestValidationError) - async def validation_exception_handler( - request: Request, exc: RequestValidationError - ): - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}), - ) - - @app.get("/health") - async def get_health(): - return {"status": "ok"} + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_api_route( + "/health", + get_health, + methods=["GET"], + operation_id="getHealth", + summary="Get Health", + description="Returns the health and readiness status of the service.", + response_model=HealthResponse, + responses={200: {"description": "Service is healthy and ready."}}, + ) + app.openapi = _make_openapi_fn(app) return app diff --git a/microservices/sdxStepTokenApi/models.py b/microservices/sdxStepTokenApi/models.py new file mode 100644 index 0000000..19d6373 --- /dev/null +++ b/microservices/sdxStepTokenApi/models.py @@ -0,0 +1,62 @@ +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +_LOCATION = Literal["body", "query", "header", "path", "cookie"] + + +class ValidationError(BaseModel): + model_config = ConfigDict(json_schema_extra={ + "description": "Details of a single request validation error.", + }) + + type: str = Field(..., title="Error Type") + location: _LOCATION = Field(..., title="Location") + code: str = Field(..., title="Error Code") + message: str = Field(..., title="Message") + input: Any = Field(default=None, title="Input") + ctx: dict[str, Any] | None = Field(default=None, title="Context") + + +class HTTPValidationError(BaseModel): + model_config = ConfigDict(json_schema_extra={ + "description": "RFC 7807 Problem Details response returned for request validation errors.", + }) + + type: str = Field(..., description="A URI reference identifying the problem type.") + title: str = Field(..., description="A short, human-readable summary of the problem type.") + status: int = Field(..., description="The HTTP status code for this occurrence of the problem.") + detail: str | None = Field( + default=None, + description="A human-readable explanation specific to this occurrence.", + ) + errors: list[ValidationError] = Field( + ..., + min_length=1, + description="List of individual validation errors.", + ) + + +class TokenRequest(BaseModel): + model_config = ConfigDict(json_schema_extra={ + "description": "Request body for generating a Step CA one-time token.", + }) + + subject: str + san: list[str] | None = None + + +class TokenResponse(BaseModel): + model_config = ConfigDict(json_schema_extra={ + "description": "Response containing the generated one-time Step CA token.", + }) + + token: str + + +class HealthResponse(BaseModel): + model_config = ConfigDict(json_schema_extra={ + "description": "Health and readiness status of the service.", + }) + + status: str diff --git a/microservices/sdxStepTokenApi/openapi.json b/microservices/sdxStepTokenApi/openapi.json new file mode 100644 index 0000000..d360fc7 --- /dev/null +++ b/microservices/sdxStepTokenApi/openapi.json @@ -0,0 +1,277 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "SDX CA Token API", + "summary": "One-time token generation for Step CA", + "description": "Generates one-time-use tokens for the Step CA certificate authority, used by SDX services to obtain X.509 certificates.", + "version": "1.0.0" + }, + "paths": { + "/tokens": { + "post": { + "tags": [ + "token" + ], + "summary": "Create Token", + "description": "Generate a one-time token for the Step CA.", + "operationId": "createToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Token generated successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "422": { + "description": "Request validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "summary": "Step CA token management" + }, + "/health": { + "get": { + "summary": "Get Health", + "description": "Returns the health and readiness status of the service.", + "operationId": "getHealth", + "responses": { + "200": { + "description": "Service is healthy and ready.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + }, + "summary": "Health check endpoint" + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "type": { + "type": "string", + "title": "Type", + "description": "A URI reference identifying the problem type." + }, + "title": { + "type": "string", + "title": "Title", + "description": "A short, human-readable summary of the problem type." + }, + "status": { + "type": "integer", + "title": "Status", + "description": "The HTTP status code for this occurrence of the problem." + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail", + "description": "A human-readable explanation specific to this occurrence." + }, + "errors": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "minItems": 1, + "title": "Errors", + "description": "List of individual validation errors." + } + }, + "type": "object", + "required": [ + "type", + "title", + "status", + "errors" + ], + "title": "HTTPValidationError", + "description": "RFC 7807 Problem Details response returned for request validation errors.", + "examples": [ + { + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.21", + "title": "Validation Error", + "status": 422, + "detail": "Request body failed validation.", + "errors": [ + { + "type": "ValidationError", + "location": "body", + "code": "missing", + "message": "Field required" + } + ] + } + ] + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "HealthResponse", + "description": "Health and readiness status of the service.", + "examples": [ + { + "status": "ok" + } + ] + }, + "TokenRequest": { + "properties": { + "subject": { + "type": "string", + "title": "Subject" + }, + "san": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "San" + } + }, + "type": "object", + "required": [ + "subject" + ], + "title": "TokenRequest", + "description": "Request body for generating a Step CA one-time token.", + "examples": [ + { + "subject": "my-service.clients.sdx", + "san": [ + "alt-name-1.clients.sdx", + "10.0.0.5" + ] + } + ] + }, + "TokenResponse": { + "properties": { + "token": { + "type": "string", + "title": "Token" + } + }, + "type": "object", + "required": [ + "token" + ], + "title": "TokenResponse", + "description": "Response containing the generated one-time Step CA token.", + "examples": [ + { + "token": "eyJhbGciOiJFUzI1NiJ9.payload.sig" + } + ] + }, + "ValidationError": { + "properties": { + "type": { + "type": "string", + "title": "Error Type" + }, + "location": { + "type": "string", + "enum": [ + "body", + "query", + "header", + "path", + "cookie" + ], + "title": "Location" + }, + "code": { + "type": "string", + "title": "Error Code" + }, + "message": { + "type": "string", + "title": "Message" + }, + "input": { + "title": "Input" + }, + "ctx": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Context" + } + }, + "type": "object", + "required": [ + "type", + "location", + "code", + "message" + ], + "title": "ValidationError", + "description": "Details of a single request validation error.", + "examples": [ + { + "type": "ValidationError", + "location": "body", + "code": "missing", + "message": "Field required" + } + ] + } + } + } + } \ No newline at end of file diff --git a/microservices/sdxStepTokenApi/routers/routes.py b/microservices/sdxStepTokenApi/routers/routes.py index bc25f6d..0f703a3 100644 --- a/microservices/sdxStepTokenApi/routers/routes.py +++ b/microservices/sdxStepTokenApi/routers/routes.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, HTTPException -from pydantic import BaseModel + from clients.step import generate_token from config import settings +from models import HTTPValidationError, TokenRequest, TokenResponse router = APIRouter( prefix="", @@ -9,16 +10,15 @@ ) -class TokenRequest(BaseModel): - subject: str - san: list[str] | None = None - - -class TokenResponse(BaseModel): - token: str - - -@router.post("/token", response_model=TokenResponse) +@router.post( + "/tokens", + operation_id="createToken", + response_model=TokenResponse, + responses={ + 200: {"description": "Token generated successfully."}, + 422: {"model": HTTPValidationError, "description": "Request validation failed."}, + }, +) async def create_token(request: TokenRequest) -> TokenResponse: """Generate a one-time token for the Step CA.""" try: diff --git a/microservices/sdxStepTokenApi/sdx-ca-token-api-spec.md b/microservices/sdxStepTokenApi/sdx-ca-token-api-spec.md index e9df899..fa29d97 100644 --- a/microservices/sdxStepTokenApi/sdx-ca-token-api-spec.md +++ b/microservices/sdxStepTokenApi/sdx-ca-token-api-spec.md @@ -10,7 +10,7 @@ This service runs in the same OpenShift namespace as the Step CA server and is a ## API Specification -### `POST /token` +### `POST /tokens` Generate a one-time token for the Step CA. @@ -126,7 +126,7 @@ Use structured JSON logging to match other services in the platform. ## Summary Checklist -- FastAPI app with `POST /token` and `GET /health` +- FastAPI app with `POST /tokens` and `GET /health` - Pydantic model: `subject` (required str), `san` (optional list of str) - `step ca bootstrap` at startup (lifespan event), fail if unsuccessful - Shell out to `step ca token` with constructed args diff --git a/microservices/sdxStepTokenApi/tests/test_routes.py b/microservices/sdxStepTokenApi/tests/test_routes.py index 5960f9d..c4568e3 100644 --- a/microservices/sdxStepTokenApi/tests/test_routes.py +++ b/microservices/sdxStepTokenApi/tests/test_routes.py @@ -14,7 +14,7 @@ def test_create_token_success(mock_generate): mock_generate.return_value = "eyJhbGciOiJFUzI1NiJ9.payload.sig" - response = client.post("/token", json={ + response = client.post("/tokens", json={ "subject": "my-service.clients.sdx", "san": ["alt.clients.sdx", "10.0.0.5"], }) @@ -35,7 +35,7 @@ def test_create_token_success(mock_generate): def test_create_token_no_san(mock_generate): mock_generate.return_value = "token-no-san" - response = client.post("/token", json={ + response = client.post("/tokens", json={ "subject": "my-service.clients.sdx", }) @@ -57,7 +57,7 @@ def test_create_token_failure(mock_generate): "Failed to generate token: error from step CLI" ) - response = client.post("/token", json={ + response = client.post("/tokens", json={ "subject": "my-service.clients.sdx", }) @@ -67,13 +67,13 @@ def test_create_token_failure(mock_generate): def test_create_token_missing_subject(): - response = client.post("/token", json={}) + response = client.post("/tokens", json={}) assert response.status_code == 422 def test_create_token_missing_subject_with_san(): - response = client.post("/token", json={ + response = client.post("/tokens", json={ "san": ["alt.clients.sdx"], }) @@ -81,7 +81,7 @@ def test_create_token_missing_subject_with_san(): def test_create_token_invalid_body(): - response = client.post("/token", content="not json", + response = client.post("/tokens", content="not json", headers={"Content-Type": "application/json"}) assert response.status_code == 422 From 0103f63f05250611f63cacc8fd59c109df62cb24 Mon Sep 17 00:00:00 2001 From: ike thecoder Date: Wed, 3 Jun 2026 13:21:03 -0700 Subject: [PATCH 5/7] cleanup the action type (#269) * cleanup the action type * fix sonarqube for pr --- .github/workflows/pr-build.yml | 22 +++++++++---------- microservices/gatewayApi/v1/routes/gateway.py | 2 +- microservices/gatewayApi/v2/routes/gateway.py | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index d52e410..431a6d9 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -1,8 +1,8 @@ on: # Trigger analysis when pushing in master or pull requests, and when creating - # a pull request. + # a pull request. pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened] push: branches: - master @@ -11,12 +11,12 @@ jobs: sonarcloud: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - with: - # Disabling shallow clone is recommended for improving relevancy of reporting - fetch-depth: 0 - - name: SonarCloud Scan - uses: sonarsource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file + - uses: actions/checkout@v2 + with: + # Disabling shallow clone is recommended for improving relevancy of reporting + fetch-depth: 0 + - name: SonarCloud Scan + uses: sonarsource/sonarqube-scan-action@v8.1.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/microservices/gatewayApi/v1/routes/gateway.py b/microservices/gatewayApi/v1/routes/gateway.py index 6af56d9..939f278 100644 --- a/microservices/gatewayApi/v1/routes/gateway.py +++ b/microservices/gatewayApi/v1/routes/gateway.py @@ -432,7 +432,7 @@ def write_config(namespace: str) -> object: message = "Dry-run. No changes applied." if cmd == 'sync': - record_gateway_event(event_id, 'published', 'completed', namespace, blob=orig_config) + record_gateway_event(event_id, 'publish', 'completed', namespace, blob=orig_config) results = mask(out.decode('utf-8')) diff --git a/microservices/gatewayApi/v2/routes/gateway.py b/microservices/gatewayApi/v2/routes/gateway.py index c305155..3d24f9e 100644 --- a/microservices/gatewayApi/v2/routes/gateway.py +++ b/microservices/gatewayApi/v2/routes/gateway.py @@ -452,7 +452,7 @@ def write_config(namespace: str) -> object: message = "Dry-run. No changes applied." if cmd == 'sync': - record_gateway_event(event_id, 'published', 'completed', namespace, blob=orig_config) + record_gateway_event(event_id, 'publish', 'completed', namespace, blob=orig_config) results = mask(out.decode('utf-8')) From 32b6169f9a1a5ca5fa5abe7371b3f2cf50938f2d Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Tue, 7 Jul 2026 15:40:31 -0700 Subject: [PATCH 6/7] incl build for amd and arm --- .github/workflows/dev.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 473caae..f6e0a30 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -141,6 +141,7 @@ jobs: with: context: microservices/gatewayApi file: microservices/gatewayApi/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/bcgov/gwa-api/gwa-gateway-api:${{ steps.tag.outputs.tag }} labels: | @@ -186,6 +187,7 @@ jobs: with: context: microservices/gatewayJobScheduler file: microservices/gatewayJobScheduler/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/bcgov/gwa-api/gwa-scheduler:${{ steps.tag.outputs.tag }} labels: | @@ -231,6 +233,7 @@ jobs: with: context: microservices/kubeApi file: microservices/kubeApi/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/bcgov/gwa-api/gwa-kube-api:${{ steps.tag.outputs.tag }} labels: | @@ -276,6 +279,7 @@ jobs: with: context: microservices/compatibilityApi file: microservices/compatibilityApi/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/bcgov/gwa-api/gwa-compatibility-api:${{ steps.tag.outputs.tag }} labels: | @@ -289,7 +293,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 2 - name: Check if build needed id: check run: | @@ -321,6 +325,7 @@ jobs: with: context: microservices/csitOasValidationApi file: microservices/csitOasValidationApi/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/bcgov/gwa-api/gwa-csit-oas-validation-api:${{ steps.tag.outputs.tag }} labels: | @@ -366,6 +371,7 @@ jobs: with: context: microservices/sdxStepTokenApi file: microservices/sdxStepTokenApi/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/bcgov/gwa-api/gwa-sdx-ca-token-api:${{ steps.tag.outputs.tag }} labels: | From 2caff67652dcd30608ac0e2fe6824810631de34e Mon Sep 17 00:00:00 2001 From: ike thecoder Date: Tue, 21 Jul 2026 21:47:31 -0700 Subject: [PATCH 7/7] APS-4665 resource list for gateway and optional consumers (#271) Co-authored-by: Russell Vinegar --- microservices/gatewayApi/Dockerfile | 19 ++++++--- microservices/gatewayApi/clients/kong.py | 14 +++++-- microservices/gatewayApi/utils/deck.py | 6 ++- microservices/gatewayApi/v2/routes/gateway.py | 8 ++-- .../gatewayApi/v2/routes/gw_resources.py | 39 +++++++++++++++++++ microservices/gatewayApi/v2/v2.py | 2 + 6 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 microservices/gatewayApi/v2/routes/gw_resources.py diff --git a/microservices/gatewayApi/Dockerfile b/microservices/gatewayApi/Dockerfile index bf48f3f..107080c 100644 --- a/microservices/gatewayApi/Dockerfile +++ b/microservices/gatewayApi/Dockerfile @@ -15,18 +15,25 @@ WORKDIR /app RUN apk add build-base libffi-dev openssl openssl-dev curl -RUN curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" && \ +# TARGETARCH is amd64/arm64 from BuildKit (docker buildx / compose build) +ARG TARGETARCH + +RUN curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/${TARGETARCH}/kubectl" && \ chmod +x kubectl; mv kubectl /usr/local/bin/. #COPY --from=build /deck/deck /usr/local/bin -# gwa api (kong 2) -RUN curl -sL https://github.com/kong/deck/releases/download/v1.5.0/deck_1.5.0_linux_amd64.tar.gz -o deck.tar.gz && \ - tar -xf deck.tar.gz -C /tmp && \ - cp /tmp/deck /usr/local/bin/deck_kong2_150 +# gwa api (kong 2) — v1.5.0 has no linux_arm64 release +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + curl -sL https://github.com/kong/deck/releases/download/v1.5.0/deck_1.5.0_linux_amd64.tar.gz -o deck.tar.gz && \ + tar -xf deck.tar.gz -C /tmp && \ + cp /tmp/deck /usr/local/bin/deck_kong2_150; \ + else \ + echo "Skipping deck_kong2_150 on ${TARGETARCH} (no upstream arm64 release for v1.5.0)"; \ + fi # gwa api (kong 3) -RUN curl -sL https://github.com/Kong/deck/releases/download/v1.53.1/deck_1.53.1_linux_amd64.tar.gz -o deck.tar.gz && \ +RUN curl -sL https://github.com/Kong/deck/releases/download/v1.53.1/deck_1.53.1_linux_${TARGETARCH}.tar.gz -o deck.tar.gz && \ tar -xf deck.tar.gz -C /tmp && \ cp /tmp/deck /usr/local/bin/deck && \ cp /tmp/deck /usr/local/bin/deck_kong3_1531 diff --git a/microservices/gatewayApi/clients/kong.py b/microservices/gatewayApi/clients/kong.py index 83d02da..038044b 100644 --- a/microservices/gatewayApi/clients/kong.py +++ b/microservices/gatewayApi/clients/kong.py @@ -1,6 +1,6 @@ from flask import current_app as app import requests -import urllib.parse +from urllib.parse import quote, quote_plus # Access the Kong Admin API for details about the Kong configuration # @@ -11,6 +11,9 @@ def get_routes (): def get_plugins (): return recurse_get_records ([], "/plugins") +def get_tagged_resources_by_tag (tag, base_url = None): + return recurse_get_records ([], "/tags/" + quote(tag), base_url=base_url) + def get_services_by_ns (ns): return recurse_get_records ([], "/services?tags=ns.%s" % ns) @@ -38,9 +41,12 @@ def get_acls (): def get_consumer (consumer_id): return get_record ([], "/consumers/%s" % consumer_id) -def recurse_get_records (result, url): +def recurse_get_records (result, url, base_url = None): log = app.logger - admin_url = app.config['kongAdminUrl'] + if base_url is None: + admin_url = app.config['kongAdminUrl'] + else: + admin_url = base_url log.debug("%s%s" % (admin_url, url)) r = requests.get("%s%s" % (admin_url, url)) @@ -49,7 +55,7 @@ def recurse_get_records (result, url): result.extend(data) if json['next'] is not None: - recurse_get_records (result, json['next']) + recurse_get_records (result, json['next'], base_url=admin_url) return result def get_record (result, url): diff --git a/microservices/gatewayApi/utils/deck.py b/microservices/gatewayApi/utils/deck.py index e76583f..bebe6eb 100644 --- a/microservices/gatewayApi/utils/deck.py +++ b/microservices/gatewayApi/utils/deck.py @@ -1,9 +1,11 @@ -def deck_cmd_sync_diff(deck_cli, cmd, select_tag, state, kong_addr = None): +def deck_cmd_sync_diff(deck_cli, cmd, select_tag, state, kong_addr = None, allow_consumers = False): if deck_cli == "deck" or deck_cli.startswith("deck_kong3_"): - args = [ deck_cli, "gateway", cmd, "--config", "/tmp/deck.yaml", "--skip-consumers", "--select-tag", select_tag] + args = [ deck_cli, "gateway", cmd, "--config", "/tmp/deck.yaml", "--select-tag", select_tag] if kong_addr: args.extend(["--kong-addr", kong_addr]) + if not allow_consumers: + args.extend(["--skip-consumers"]) args.append(state) return args else: diff --git a/microservices/gatewayApi/v2/routes/gateway.py b/microservices/gatewayApi/v2/routes/gateway.py index 3d24f9e..6113f18 100644 --- a/microservices/gatewayApi/v2/routes/gateway.py +++ b/microservices/gatewayApi/v2/routes/gateway.py @@ -50,6 +50,7 @@ def delete_config(namespace: str, qualifier="") -> object: dp = get_data_plane(ns_attributes) kube_api_url = app.config['data_planes'][dp].get("kube-api") kong_addr_override = app.config['data_planes'][dp].get("kong-addr") + allow_consumers = app.config['data_planes'][dp].get("allow-consumers", False) log = app.logger @@ -72,7 +73,7 @@ def delete_config(namespace: str, qualifier="") -> object: deck_cli = app.config['deckCLI'] log.info("[%s] (%s) %s action using %s" % (namespace, deck_cli, cmd, selectTag)) - args = deck_cmd_sync_diff(deck_cli, cmd, selectTag, tempFolder, kong_addr_override) + args = deck_cmd_sync_diff(deck_cli, cmd, selectTag, tempFolder, kong_addr_override, allow_consumers) log.debug("[%s] Running %s" % (namespace, args)) deck_run = Popen(args, stdout=PIPE, stderr=STDOUT) @@ -163,6 +164,7 @@ def write_config(namespace: str) -> object: dp = get_data_plane(ns_attributes) kube_api_url = app.config['data_planes'][dp].get("kube-api") kong_addr_override = app.config['data_planes'][dp].get("kong-addr") + allow_consumers = app.config['data_planes'][dp].get("allow-consumers", False) # Build a list of existing hosts that are outside this namespace # They become reserved and any conflict will return an error @@ -361,7 +363,7 @@ def write_config(namespace: str) -> object: abort_early(event_id, 'validate', namespace, jsonify( error="Validation Failed.", results=mask(out.decode('utf-8')))) - args = deck_cmd_sync_diff(deck_cli, cmd, selectTag, tempFolder, kong_addr_override) + args = deck_cmd_sync_diff(deck_cli, cmd, selectTag, tempFolder, kong_addr_override, allow_consumers) log.debug("[%s] Running %s" % (namespace, args)) deck_run = Popen(args, stdout=PIPE, stderr=STDOUT) @@ -477,7 +479,7 @@ def cleanup(dir_path): log.error("Error: %s : %s" % (dir_path, e.strerror)) def validate_base_entities(yaml, ns_attributes): - traversables = ['_format_version', '_plugin_configs', 'services', 'upstreams', 'certificates', 'key_sets', 'keys'] + traversables = ['_format_version', '_plugin_configs', 'services', 'upstreams', 'consumers', 'certificates', 'key_sets', 'keys'] allow_protected_ns = ns_attributes.get('perm-protected-ns', ['deny'])[0] == 'allow' if allow_protected_ns: diff --git a/microservices/gatewayApi/v2/routes/gw_resources.py b/microservices/gatewayApi/v2/routes/gw_resources.py new file mode 100644 index 0000000..fc8b909 --- /dev/null +++ b/microservices/gatewayApi/v2/routes/gw_resources.py @@ -0,0 +1,39 @@ +from flask import Blueprint, jsonify, request, Response, make_response, abort, g, current_app as app + +from v2.auth.auth import admin_jwt, uma_enforce +from clients.kong import get_tagged_resources_by_tag +from v2.services.namespaces import NamespaceService + +gw_resources = Blueprint('gw_resources', 'gw_resources') + +@gw_resources.route('', + methods=['GET'], strict_slashes=False) +@admin_jwt(None) +@uma_enforce('namespace', 'GatewayConfig.Publish') +def get_resources(namespace: str) -> object: + + log = app.logger + + log.info("Get resources for %s" % namespace) + + # Optional query parameter for tag + tag = "ns.%s" % namespace + if request.args.get('tag'): + tag = request.args.get('tag') + # check that tag starts with ns. + if not tag.startswith("ns.%s." % namespace): + abort(400, "Invalid tag parameter. Must start with ns.%s" % namespace) + + ns_svc = NamespaceService() + ns_attributes = ns_svc.get_namespace_attributes(namespace) + + dp = get_data_plane(ns_attributes) + kong_addr_override = app.config['data_planes'][dp].get("kong-addr") + + resources = get_tagged_resources_by_tag(tag, kong_addr_override) + + return make_response(jsonify(resources)) + +def get_data_plane(ns_attributes): + default_data_plane = app.config['defaultDataPlane'] + return ns_attributes.get('perm-data-plane', [default_data_plane])[0] diff --git a/microservices/gatewayApi/v2/v2.py b/microservices/gatewayApi/v2/v2.py index 360f8cf..8be73d6 100644 --- a/microservices/gatewayApi/v2/v2.py +++ b/microservices/gatewayApi/v2/v2.py @@ -8,6 +8,7 @@ from v2.routes.migrate_v1 import mg from v2.routes.whoami import whoami from v2.routes.consumers import consumers +from v2.routes.gw_resources import gw_resources v2 = Blueprint('v2', 'v2') @@ -25,6 +26,7 @@ def __init__(self, app): app.register_blueprint(authz, url_prefix="/v2/authz") app.register_blueprint(ns, url_prefix="/v2/namespaces") app.register_blueprint(gw, url_prefix="/v2/namespaces//gateway") + app.register_blueprint(gw_resources, url_prefix="/v2/namespaces//resources") app.register_blueprint(gw_status, url_prefix="/v2/namespaces//services") app.register_blueprint(whoami, url_prefix="/v2/whoami") app.register_blueprint(mg, url_prefix="/v2/migration")