diff --git a/deploy/platformmonitoringapi/templates/platformmonitoring.gke.yml b/deploy/platformmonitoringapi/templates/platformmonitoring.gke.yml index 4e6f84b4..d04d4360 100644 --- a/deploy/platformmonitoringapi/templates/platformmonitoring.gke.yml +++ b/deploy/platformmonitoringapi/templates/platformmonitoring.gke.yml @@ -61,6 +61,8 @@ spec: value: {{ .Values.NP_CLUSTER_NAME }} - name: NP_MONITORING_K8S_KUBELET_PORT value: {{ .Values.NP_MONITORING_K8S_KUBELET_PORT | quote }} + - name: NP_CORS_ORIGINS + value: {{ .Values.NP_CORS_ORIGINS }} {{- if .Values.DOCKER_LOGIN_ARTIFACTORY_SECRET_NAME }} imagePullSecrets: - name: {{ .Values.DOCKER_LOGIN_ARTIFACTORY_SECRET_NAME }} diff --git a/deploy/platformmonitoringapi/values-dev.yaml b/deploy/platformmonitoringapi/values-dev.yaml index 6a55b176..c413b96a 100644 --- a/deploy/platformmonitoringapi/values-dev.yaml +++ b/deploy/platformmonitoringapi/values-dev.yaml @@ -11,3 +11,4 @@ NP_MONITORING_K8S_NS: default NP_MONITORING_REGISTRY_URL: https://registry-dev.neu.ro NP_CLUSTER_NAME: "default" NP_MONITORING_K8S_KUBELET_PORT: "" +NP_CORS_ORIGINS: http://localhost:8000,https://master--neuro-web.netlify.com \ No newline at end of file diff --git a/deploy/platformmonitoringapi/values-staging.yaml b/deploy/platformmonitoringapi/values-staging.yaml index ff8ac222..7bdb4e7e 100644 --- a/deploy/platformmonitoringapi/values-staging.yaml +++ b/deploy/platformmonitoringapi/values-staging.yaml @@ -11,3 +11,4 @@ NP_MONITORING_K8S_NS: default NP_MONITORING_REGISTRY_URL: https://registry-staging.neu.ro NP_CLUSTER_NAME: "" NP_MONITORING_K8S_KUBELET_PORT: "" +NP_CORS_ORIGINS: https://release--neuro-web.netlify.com,https://app.neu.ro diff --git a/platform_monitoring/api.py b/platform_monitoring/api.py index 017f8dd9..7c9c0b38 100644 --- a/platform_monitoring/api.py +++ b/platform_monitoring/api.py @@ -8,6 +8,7 @@ import aiohttp import aiohttp.web +import aiohttp_cors from aioelasticsearch import Elasticsearch from aiohttp.web import ( HTTPBadRequest, @@ -32,6 +33,7 @@ from .base import JobStats, Telemetry from .config import ( Config, + CORSConfig, ElasticsearchConfig, KubeConfig, PlatformApiConfig, @@ -355,6 +357,22 @@ async def create_elasticsearch_client( yield client +def _setup_cors(app: aiohttp.web.Application, config: CORSConfig) -> None: + if not config.allowed_origins: + return + + logger.info(f"Setting up CORS with allowed origins: {config.allowed_origins}") + default_options = aiohttp_cors.ResourceOptions( + allow_credentials=True, expose_headers="*", allow_headers="*", + ) + cors = aiohttp_cors.setup( + app, defaults={origin: default_options for origin in config.allowed_origins} + ) + for route in app.router.routes(): + logger.debug(f"Setting up CORS for {route}") + cors.add(route) + + async def create_app(config: Config) -> aiohttp.web.Application: app = aiohttp.web.Application(middlewares=[handle_exceptions]) app["config"] = config @@ -407,6 +425,8 @@ async def _init_app(app: aiohttp.web.Application) -> AsyncIterator[None]: api_v1_app.add_subapp("/jobs", monitoring_app) app.add_subapp("/api/v1", api_v1_app) + + _setup_cors(app, config.cors) return app diff --git a/platform_monitoring/config.py b/platform_monitoring/config.py index 85f61e67..d4eca47b 100644 --- a/platform_monitoring/config.py +++ b/platform_monitoring/config.py @@ -34,6 +34,11 @@ class KubeClientAuthType(str, enum.Enum): CERTIFICATE = "certificate" +@dataclass(frozen=True) +class CORSConfig: + allowed_origins: Sequence[str] = () + + @dataclass(frozen=True) class KubeConfig: endpoint_url: str @@ -76,4 +81,5 @@ class Config: kube: KubeConfig docker: DockerConfig registry: RegistryConfig + cors: CORSConfig cluster_name: str diff --git a/platform_monitoring/config_factory.py b/platform_monitoring/config_factory.py index a58a5cd6..8acce4fb 100644 --- a/platform_monitoring/config_factory.py +++ b/platform_monitoring/config_factory.py @@ -1,12 +1,13 @@ import logging import os from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Optional, Sequence from yarl import URL from .config import ( Config, + CORSConfig, DockerConfig, ElasticsearchConfig, KubeClientAuthType, @@ -36,6 +37,7 @@ def create(self) -> Config: registry=self._create_registry(), docker=self._create_docker(), cluster_name=cluster_name, + cors=self.create_cors(), ) def _create_server(self) -> ServerConfig: @@ -101,3 +103,10 @@ def _create_registry(self) -> RegistryConfig: def _create_docker(self) -> DockerConfig: return DockerConfig() + + def create_cors(self) -> CORSConfig: + origins: Sequence[str] = CORSConfig.allowed_origins + origins_str = self._environ.get("NP_CORS_ORIGINS", "").strip() + if origins_str: + origins = origins_str.split(",") + return CORSConfig(allowed_origins=origins) diff --git a/setup.cfg b/setup.cfg index c7224769..e2668391 100644 --- a/setup.cfg +++ b/setup.cfg @@ -74,3 +74,6 @@ ignore_missing_imports = true [mypy-trafaret] ignore_missing_imports = true + +[mypy-aiohttp_cors] +ignore_missing_imports = true diff --git a/setup.py b/setup.py index 2e57d689..69a628eb 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ "docker-image-py==0.1.10", "trafaret==2.0.2", "platform-logging==0.3", + "aiohttp-cors==0.7.0", ) setup( diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8743c7df..bc90ef7b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -19,6 +19,7 @@ ) from platform_monitoring.config import ( Config, + CORSConfig, DockerConfig, ElasticsearchConfig, KubeConfig, @@ -164,6 +165,7 @@ def _f(**kwargs: Any) -> Config: registry=registry_config, docker=docker_config, cluster_name=cluster_name, + cors=CORSConfig(allowed_origins=["https://neu.ro"]), ) kwargs = {**defaults, **kwargs} return Config(**kwargs) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index d5b7c7e7..c4326799 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -295,6 +295,72 @@ async def test_secured_ping_non_existing_token_unauthorized( async with client.get(url, headers=headers) as resp: assert resp.status == HTTPUnauthorized.status_code + @pytest.mark.asyncio + async def test_ping_unknown_origin( + self, monitoring_api: MonitoringApiEndpoints, client: aiohttp.ClientSession + ) -> None: + async with client.get( + monitoring_api.ping_url, headers={"Origin": "http://unknown"} + ) as response: + assert response.status == HTTPOk.status_code, await response.text() + assert "Access-Control-Allow-Origin" not in response.headers + + @pytest.mark.asyncio + async def test_ping_allowed_origin( + self, monitoring_api: MonitoringApiEndpoints, client: aiohttp.ClientSession + ) -> None: + async with client.get( + monitoring_api.ping_url, headers={"Origin": "https://neu.ro"} + ) as resp: + assert resp.status == HTTPOk.status_code, await resp.text() + assert resp.headers["Access-Control-Allow-Origin"] == "https://neu.ro" + assert resp.headers["Access-Control-Allow-Credentials"] == "true" + assert resp.headers["Access-Control-Expose-Headers"] == "" + + @pytest.mark.asyncio + async def test_ping_options_no_headers( + self, monitoring_api: MonitoringApiEndpoints, client: aiohttp.ClientSession + ) -> None: + async with client.options(monitoring_api.ping_url) as resp: + assert resp.status == HTTPForbidden.status_code, await resp.text() + assert await resp.text() == ( + "CORS preflight request failed: " + "origin header is not specified in the request" + ) + + @pytest.mark.asyncio + async def test_ping_options_unknown_origin( + self, monitoring_api: MonitoringApiEndpoints, client: aiohttp.ClientSession + ) -> None: + async with client.options( + monitoring_api.ping_url, + headers={ + "Origin": "http://unknown", + "Access-Control-Request-Method": "GET", + }, + ) as resp: + assert resp.status == HTTPForbidden.status_code, await resp.text() + assert await resp.text() == ( + "CORS preflight request failed: " + "origin 'http://unknown' is not allowed" + ) + + @pytest.mark.asyncio + async def test_ping_options( + self, monitoring_api: MonitoringApiEndpoints, client: aiohttp.ClientSession + ) -> None: + async with client.options( + monitoring_api.ping_url, + headers={ + "Origin": "https://neu.ro", + "Access-Control-Request-Method": "GET", + }, + ) as resp: + assert resp.status == HTTPOk.status_code, await resp.text() + assert resp.headers["Access-Control-Allow-Origin"] == "https://neu.ro" + assert resp.headers["Access-Control-Allow-Credentials"] == "true" + assert resp.headers["Access-Control-Allow-Methods"] == "GET" + class TestTopApi: @pytest.mark.asyncio diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index f65d3880..79bd3149 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -4,6 +4,7 @@ import pytest from platform_monitoring.config import ( Config, + CORSConfig, DockerConfig, ElasticsearchConfig, KubeClientAuthType, @@ -59,6 +60,7 @@ def test_create(cert_authority_path: str, token_path: str) -> None: "NP_MONITORING_REGISTRY_URL": "http://testhost:5000", "NP_CLUSTER_NAME": "default", "NP_MONITORING_K8S_KUBELET_PORT": "12321", + "NP_CORS_ORIGINS": "https://domain1.com,http://do.main", } config = EnvironConfigFactory(environ).create() assert config == Config( @@ -86,6 +88,7 @@ def test_create(cert_authority_path: str, token_path: str) -> None: registry=RegistryConfig(url=URL("http://testhost:5000")), docker=DockerConfig(), cluster_name="default", + cors=CORSConfig(["https://domain1.com", "http://do.main"]), )