From 190f8ed4cb5c9bdda984b8fab0d407c81b987578 Mon Sep 17 00:00:00 2001 From: Dmitriy Myakotin <75628188+MDI74@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:45:36 +0500 Subject: [PATCH] cd: #493: add CORS settings to allow call API requests from the local UI but for prod use strict settings limited to only prod domain and only used headers and method types --- .devcontainer/devcontainer.json | 1 + .../.reusable-e2e-tests-against-prod.yml | 1 + .../workflows/deploy-to-prod-from-default.yml | 1 + .../workflows/e2e-tests-on-pull-request.yml | 1 + Api/CorsOptions.cs | 4 ++ Api/Program.cs | 27 +++++------- Api/appsettings.MockForDevelopment.json | 3 ++ Api/appsettings.MockForPullRequest.json | 3 ++ Api/ci/helmfile.yaml | 1 + docker-compose.yml | 1 + e2e/cors-settings.feature | 43 +++++++++++++++++++ 11 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 Api/CorsOptions.cs create mode 100644 e2e/cors-settings.feature diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 01cac26..8cea545 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,6 +24,7 @@ "AUTH_FIRST_TENANT_PASSWORD_WITH_ALL_PERMISSIONS": "first-tenant-password-with-all-permissions", "AUTH_API_ROOT_URL": "http://localhost:8504/api/auth", "API_ROOT_URL": "http://localhost:6504/api/documents", + "CORS_ALLOWED_ORIGINS": "*", "SHOULD_USE_FAKE_EXTERNAL_DEPENDENCIES": "true" } } \ No newline at end of file diff --git a/.github/workflows/.reusable-e2e-tests-against-prod.yml b/.github/workflows/.reusable-e2e-tests-against-prod.yml index 12d3804..72f7bd7 100644 --- a/.github/workflows/.reusable-e2e-tests-against-prod.yml +++ b/.github/workflows/.reusable-e2e-tests-against-prod.yml @@ -33,4 +33,5 @@ jobs: "AUTH_FIRST_TENANT_PASSWORD_WITH_ALL_PERMISSIONS": ${{ secrets.INNER_CIRCLE_PROD_AUTH_FIRST_TENANT_PASSWORD_WITH_ALL_PERMISSIONS }} "AUTH_API_ROOT_URL": ${{ secrets.INNER_CIRCLE_PROD_AUTH_API_ROOT_URL }} "API_ROOT_URL": ${{ secrets.INNER_CIRCLE_PROD_DOCUMENTS_API_ROOT_URL }} + "CORS_ALLOWED_ORIGINS": ${{ secrets.INNER_CIRCLE_PROD_BASE_URL }} "SHOULD_USE_FAKE_EXTERNAL_DEPENDENCIES": "false" \ No newline at end of file diff --git a/.github/workflows/deploy-to-prod-from-default.yml b/.github/workflows/deploy-to-prod-from-default.yml index a5b04dc..efccb72 100644 --- a/.github/workflows/deploy-to-prod-from-default.yml +++ b/.github/workflows/deploy-to-prod-from-default.yml @@ -27,6 +27,7 @@ jobs: --state-values-set extraSecretEnvVars.ConnectionStrings__DefaultConnection="$INNER_CIRCLE_PROD_DOCUMENTS_DB_CONNECTION_STRING" \ --state-values-set extraSecretEnvVars.AuthenticationOptions__PublicSigningKey="${{ secrets.INNER_CIRCLE_PROD_PUBLIC_SIGNING_KEY }}" \ --state-values-set extraSecretEnvVars.InnerCircleServiceUrls__EmployeesServiceUrl="${{ secrets.INNER_CIRCLE_PROD_EMPLOYEES_API_ROOT_URL }}" \ + --state-values-set extraSecretEnvVars.CorsOptions__AllowedOrigins="${{ secrets.INNER_CIRCLE_PROD_BASE_URL }}" \ --state-values-set extraSecretEnvVars.InnerCircleServiceUrls__EmailSenderServiceUrl="${{ secrets.INNER_CIRCLE_PROD_EMAIL_SENDER_API_ROOT_URL }}" > "/var/log/inner-circle/e2e/$(date +%F)-${{ github.event.repository.name }}-run-${{ github.run_id }}-job-${{ job.check_run_id }}.log" 2>&1 # on the last line where we redirect output we don't add a line break for convenience because it is easy to make it wrong having an extra space after \ symbol which leads to exposure of logs to the public pipeline logs instead of the needed private file on a runner diff --git a/.github/workflows/e2e-tests-on-pull-request.yml b/.github/workflows/e2e-tests-on-pull-request.yml index 3fae268..57ed06f 100644 --- a/.github/workflows/e2e-tests-on-pull-request.yml +++ b/.github/workflows/e2e-tests-on-pull-request.yml @@ -64,6 +64,7 @@ jobs: "AUTH_FIRST_TENANT_PASSWORD_WITH_ALL_PERMISSIONS": "Serpens1!" "AUTH_API_ROOT_URL": "http://localhost:30090/api/auth" "API_ROOT_URL": "http://localhost:30090/api/documents" + "CORS_ALLOWED_ORIGINS": "*" "SHOULD_USE_FAKE_EXTERNAL_DEPENDENCIES": "false" e2e-karate-tests-in-docker-compose: diff --git a/Api/CorsOptions.cs b/Api/CorsOptions.cs new file mode 100644 index 0000000..7ccf9a9 --- /dev/null +++ b/Api/CorsOptions.cs @@ -0,0 +1,4 @@ +public class CorsOptions +{ + public required string AllowedOrigins { get; set; } +} \ No newline at end of file diff --git a/Api/Program.cs b/Api/Program.cs index ff7188b..2d572b2 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -6,22 +6,8 @@ using Microsoft.EntityFrameworkCore; using TourmalineCore.AspNetCore.JwtAuthentication.Core; -const string CorsPolicyName = "DocumentsSpecificOrigins"; - var builder = WebApplication.CreateBuilder(args); -builder.Services.AddCors(options => -{ - options.AddPolicy(CorsPolicyName, - policy => - { - policy - .WithOrigins("*") - .AllowAnyHeader() - .AllowAnyMethod(); - }); -}); - builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); @@ -52,10 +38,17 @@ app.UseRouting(); -app.UseCors(CorsPolicyName); - app.UseJwtAuthentication(); -app.UseEndpoints(endpoints => { endpoints.MapControllers().RequireCors(CorsPolicyName); }); +var corsOptions = configuration.GetSection(nameof(CorsOptions)).Get(); + +app.UseCors( + corsPolicyBuilder => corsPolicyBuilder + .WithOrigins(corsOptions!.AllowedOrigins) + .WithMethods("GET", "POST", "DELETE") + .WithHeaders("Authorization", "Content-Type") +); + +app.MapControllers(); app.Run(); diff --git a/Api/appsettings.MockForDevelopment.json b/Api/appsettings.MockForDevelopment.json index 766727f..fc74a53 100644 --- a/Api/appsettings.MockForDevelopment.json +++ b/Api/appsettings.MockForDevelopment.json @@ -9,5 +9,8 @@ "AuthenticationOptions": { "PublicSigningKey": "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAz+luHwhsNo4aQxYNCkaxcqL/HUcqWG1zz3pCpbyW5nbgxoo+Xw0jwAqVeRlrWHZf0WszbPObyCpmGVifyj6n0PSp5Np7431zjUhRUkxwyNSyVE5BWw5aJLyLB5EF9HH39CqtxdtWmYuLzhBS5fZT4tyR4xlQffNTxylg7xZgLfunUDRuLxdqR0JO3sjebgogrDVvHj3sif46uitipeTfUyCtqBG/JngPkMbDMNRkTH6QXnXfLgpX5Lr21O4PZPIBwCXzPCTCDMgbXHLvAzdlqgVYJcLf9xXPtVkPGOv8y+hbSTAyCNLViOLKKm2a2W4bPiElDIDwbtbHesj8zHPlpP5Q7QMtT168UxetgUeKsr5kfpxtLpE/QO4GkkqTA6rV7PQKrCTY0B5V8ZD8Ir/hlOKk8jxGe9NLui+8rLnnwJUZErT7Swp9yQL1eed2YtdrcR3q5eOE8+2pkzwjbEoFuIQidDKOghrZOwf6j217fme/xE+aEP0OPv5z07kJr2torh7tUefrVerT4Krj5LVl4DgdlkHAuILWOaYdSoLnRrsrfFa9Y1alM2D/juH9+YtaR/YjNWOhdZNMNyoDT08SbgE81ZbKmVgLGaWFLcMn/LBD6DBeRb5dRx12QZnv0jGJLVVgjTe9EqrjVF92ahGRljDIGjNzEI2f2syc0/qKS4sCAwEAAQ==", "IsDebugTokenEnabled": true + }, + "CorsOptions": { + "AllowedOrigins": "*" } } diff --git a/Api/appsettings.MockForPullRequest.json b/Api/appsettings.MockForPullRequest.json index afe19a8..970f475 100644 --- a/Api/appsettings.MockForPullRequest.json +++ b/Api/appsettings.MockForPullRequest.json @@ -9,5 +9,8 @@ "AuthenticationOptions": { "PublicSigningKey": "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAz+luHwhsNo4aQxYNCkaxcqL/HUcqWG1zz3pCpbyW5nbgxoo+Xw0jwAqVeRlrWHZf0WszbPObyCpmGVifyj6n0PSp5Np7431zjUhRUkxwyNSyVE5BWw5aJLyLB5EF9HH39CqtxdtWmYuLzhBS5fZT4tyR4xlQffNTxylg7xZgLfunUDRuLxdqR0JO3sjebgogrDVvHj3sif46uitipeTfUyCtqBG/JngPkMbDMNRkTH6QXnXfLgpX5Lr21O4PZPIBwCXzPCTCDMgbXHLvAzdlqgVYJcLf9xXPtVkPGOv8y+hbSTAyCNLViOLKKm2a2W4bPiElDIDwbtbHesj8zHPlpP5Q7QMtT168UxetgUeKsr5kfpxtLpE/QO4GkkqTA6rV7PQKrCTY0B5V8ZD8Ir/hlOKk8jxGe9NLui+8rLnnwJUZErT7Swp9yQL1eed2YtdrcR3q5eOE8+2pkzwjbEoFuIQidDKOghrZOwf6j217fme/xE+aEP0OPv5z07kJr2torh7tUefrVerT4Krj5LVl4DgdlkHAuILWOaYdSoLnRrsrfFa9Y1alM2D/juH9+YtaR/YjNWOhdZNMNyoDT08SbgE81ZbKmVgLGaWFLcMn/LBD6DBeRb5dRx12QZnv0jGJLVVgjTe9EqrjVF92ahGRljDIGjNzEI2f2syc0/qKS4sCAwEAAQ==", "IsDebugTokenEnabled": true + }, + "CorsOptions": { + "AllowedOrigins": "*" } } diff --git a/Api/ci/helmfile.yaml b/Api/ci/helmfile.yaml index 73d6f3d..eee5ca5 100644 --- a/Api/ci/helmfile.yaml +++ b/Api/ci/helmfile.yaml @@ -19,5 +19,6 @@ releases: ConnectionStrings__DefaultConnection: "{{ .StateValues.extraSecretEnvVars.ConnectionStrings__DefaultConnection }}" AuthenticationOptions__PublicSigningKey: "{{ .StateValues.extraSecretEnvVars.AuthenticationOptions__PublicSigningKey }}" InnerCircleServiceUrls__EmployeesServiceUrl: "{{ .StateValues.extraSecretEnvVars.InnerCircleServiceUrls__EmployeesServiceUrl }}" + CorsOptions__AllowedOrigins: "{{ .StateValues.extraSecretEnvVars.CorsOptions__AllowedOrigins }}" InnerCircleServiceUrls__EmailSenderServiceUrl: "{{ .StateValues.extraSecretEnvVars.InnerCircleServiceUrls__EmailSenderServiceUrl }}" diff --git a/docker-compose.yml b/docker-compose.yml index 6f79598..cd9cc9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,6 +89,7 @@ services: # here the port is 1080 because it needs to be an internal port, not an external which is 8504 in this case AUTH_API_ROOT_URL: "http://inner-circle-documents-api-mock-server:1080/api/auth" API_ROOT_URL: "http://inner-circle-documents-api/api/documents" + CORS_ALLOWED_ORIGINS: "*" SHOULD_USE_FAKE_EXTERNAL_DEPENDENCIES: "true" networks: - inner-circle-documents-api-network diff --git a/e2e/cors-settings.feature b/e2e/cors-settings.feature new file mode 100644 index 0000000..6e93be2 --- /dev/null +++ b/e2e/cors-settings.feature @@ -0,0 +1,43 @@ +Feature: CORS Settings + # https://github.com/karatelabs/karate/issues/1191 + # https://github.com/karatelabs/karate?tab=readme-ov-file#karate-fork + + Background: + * header Content-Type = 'application/json' + + Scenario: Verify API CORS settings + + * def jsUtils = read('./js-utils.js') + * def authApiRootUrl = jsUtils().getEnvVariable('AUTH_API_ROOT_URL') + * def apiRootUrl = jsUtils().getEnvVariable('API_ROOT_URL') + * def authLogin = jsUtils().getEnvVariable('AUTH_FIRST_TENANT_LOGIN_WITH_ALL_PERMISSIONS') + * def authPassword = jsUtils().getEnvVariable('AUTH_FIRST_TENANT_PASSWORD_WITH_ALL_PERMISSIONS') + * def corsAllowedOrigins = jsUtils().getEnvVariable('CORS_ALLOWED_ORIGINS') + + # Authentication + Given url authApiRootUrl + And path '/login' + And request + """ + { + "login": "#(authLogin)", + "password": "#(authPassword)" + } + """ + And method POST + Then status 200 + + * def accessToken = karate.toMap(response.accessToken.value) + + * configure headers = jsUtils().getAuthHeaders(accessToken) + + # Send CORS preflight OPTIONS request like UI does + Given url apiRootUrl + Given path '/getEmployees' + And header Origin = corsAllowedOrigins + And header Access-Control-Request-Method = 'GET' + When method OPTIONS + Then status 204 + And match responseHeaders["Access-Control-Allow-Origin"] == ["#(corsAllowedOrigins)"] + And match responseHeaders["Access-Control-Allow-Methods"] == ["GET,POST,DELETE"] + And match responseHeaders["Access-Control-Allow-Headers"] == ["Authorization,Content-Type"] \ No newline at end of file