Skip to content

Commit fbafc29

Browse files
committed
feat(cors): enhance CORS configuration and logging
- Introduced configurable CORS settings via environment variables, allowing for stricter security controls. - Updated PlanRoutes to log CORS configuration at startup for better visibility. - Refactored connection testing service to utilize a shared SparkSession, improving performance and resource management. - Fixed foreign key property name handling to align with backend expectations, ensuring proper loading of foreign key links in the UI.
1 parent ec73056 commit fbafc29

6 files changed

Lines changed: 91 additions & 29 deletions

File tree

app/src/main/resources/ui/connection/connection.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,28 +34,24 @@ async function testConnection(connection, button) {
3434
});
3535

3636
const result = await response.json();
37-
// Limit to 100 characters, show ellipsis if truncated
38-
const resultDetails = result.details ? ` - ${truncateString(result.details, 100)}` : "";
3937

4038
if (result.success) {
4139
createToast(
4240
`Test: ${connection.name}`,
43-
`${result.message}${resultDetails}`,
41+
`${result.message} ${result.details}`,
4442
"success"
4543
);
4644
} else {
4745
createToast(
4846
`Test: ${connection.name}`,
49-
`${result.message}${resultDetails}`,
47+
`${result.message} ${result.details}`,
5048
"fail"
5149
);
5250
}
5351
} catch (err) {
54-
// Limit to 100 characters, show ellipsis if truncated
55-
const resultDetails = err.message ? ` - ${truncateString(err.message, 100)}` : "";
5652
createToast(
5753
`Test: ${connection.name}`,
58-
`Connection test failed: ${resultDetails}`,
54+
`Connection test failed: ${err.message}`,
5955
"fail"
6056
);
6157
} finally {

app/src/main/resources/ui/helper-foreign-keys.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ async function createForeignKeyLinksFromPlan(newForeignKey, foreignKey, linkType
4949
foreignKeyLinkSources.removeChild(foreignKeyLinkSources.querySelectorAll(`.foreign-key-${linkType}-link-source`)[0]);
5050
}
5151
// Support both backend format (generate/delete) and legacy format (generationLinks/deleteLinks)
52-
let links = foreignKey[linkType] || foreignKey[`${linkType}Links`] || [];
52+
// Map linkType to the backend property name: "generation" -> "generate", "delete" -> "delete"
53+
let backendPropertyName = linkType === "generation" ? "generate" : linkType;
54+
let links = foreignKey[backendPropertyName] || foreignKey[linkType] || foreignKey[`${linkType}Links`] || [];
5355
for (const fkLink of Array.from(links)) {
5456
let newForeignKeyLink = await createForeignKeyInput(numForeignKeysLinks, `foreign-key-${linkType}-link`);
5557
foreignKeyLinkSources.insertBefore(newForeignKeyLink, foreignKeyLinkSources.lastChild);

app/src/main/scala/io/github/datacatering/datacaterer/core/ui/config/UiConfiguration.scala

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,44 @@ object UiConfiguration {
88

99
val INSTALL_DIRECTORY: String = getInstallDirectory
1010

11+
/**
12+
* CORS Configuration
13+
*
14+
* Environment variables:
15+
* - DATA_CATERER_CORS_ALLOWED_ORIGINS: Comma-separated list of allowed origins (default: "*" for all)
16+
* Examples: "*", "http://localhost:3000", "http://localhost:3000,https://example.com"
17+
* - DATA_CATERER_CORS_ALLOWED_METHODS: Comma-separated list of allowed HTTP methods (default: "GET,POST,PUT,DELETE,OPTIONS")
18+
* - DATA_CATERER_CORS_ALLOWED_HEADERS: Comma-separated list of allowed headers (default: "Content-Type,Authorization,X-Requested-With")
19+
* - DATA_CATERER_CORS_MAX_AGE: Max age for preflight cache in seconds (default: "86400")
20+
*/
21+
object Cors {
22+
val allowedOrigins: String = Option(System.getenv("DATA_CATERER_CORS_ALLOWED_ORIGINS"))
23+
.filter(_.nonEmpty)
24+
.getOrElse("*")
25+
26+
val allowedMethods: Seq[String] = Option(System.getenv("DATA_CATERER_CORS_ALLOWED_METHODS"))
27+
.filter(_.nonEmpty)
28+
.map(_.split(",").map(_.trim).toSeq)
29+
.getOrElse(Seq("GET", "POST", "PUT", "DELETE", "OPTIONS"))
30+
31+
val allowedHeaders: Seq[String] = Option(System.getenv("DATA_CATERER_CORS_ALLOWED_HEADERS"))
32+
.filter(_.nonEmpty)
33+
.map(_.split(",").map(_.trim).toSeq)
34+
.getOrElse(Seq("Content-Type", "Authorization", "X-Requested-With"))
35+
36+
val maxAge: Long = Option(System.getenv("DATA_CATERER_CORS_MAX_AGE"))
37+
.filter(_.nonEmpty)
38+
.flatMap(s => scala.util.Try(s.toLong).toOption)
39+
.getOrElse(86400L) // 24 hours
40+
41+
def logConfiguration(): Unit = {
42+
LOGGER.info(s"CORS configuration: allowed-origins=$allowedOrigins, " +
43+
s"allowed-methods=${allowedMethods.mkString(",")}, " +
44+
s"allowed-headers=${allowedHeaders.mkString(",")}, " +
45+
s"max-age=$maxAge")
46+
}
47+
}
48+
1149
def getInstallDirectory: String = {
1250
val osName = System.getProperty("os.name").toLowerCase
1351
val overrideDirectory = System.getProperty("data-caterer-install-dir")

app/src/main/scala/io/github/datacatering/datacaterer/core/ui/plan/PlanRoutes.scala

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.github.datacatering.datacaterer.core.ui.plan
33
import com.fasterxml.jackson.databind.ObjectMapper
44
import com.github.pjfanning.pekkohttpjackson.JacksonSupport
55
import io.github.datacatering.datacaterer.api.model.Step
6+
import io.github.datacatering.datacaterer.core.ui.config.UiConfiguration
67
import io.github.datacatering.datacaterer.core.ui.model.{Connection, ConnectionTestResult, EnhancedPlanRunRequest, MultiSchemaSampleResponse, PlanRunRequest, SampleError, SampleMetadata, SaveConnectionsRequest, SchemaSampleRequest, TestConnectionRequest}
78
import io.github.datacatering.datacaterer.core.ui.service.ConnectionTestService
89
import io.github.datacatering.datacaterer.core.ui.resource.SparkSessionManager
@@ -13,7 +14,7 @@ import org.apache.pekko.actor.typed.scaladsl.AskPattern.{Askable, schedulerFromA
1314
import org.apache.pekko.actor.typed.{ActorRef, ActorSystem}
1415
import org.apache.pekko.http.scaladsl.model.HttpMethods._
1516
import org.apache.pekko.http.scaladsl.model.headers._
16-
import org.apache.pekko.http.scaladsl.model.{ContentType, HttpEntity, HttpResponse, MediaTypes, StatusCodes}
17+
import org.apache.pekko.http.scaladsl.model.{ContentType, HttpEntity, HttpResponse, MediaTypes, StatusCodes, HttpHeader}
1718
import org.apache.pekko.http.scaladsl.server.{Directives, ExceptionHandler, Route}
1819
import org.apache.pekko.util.Timeout
1920

@@ -39,17 +40,46 @@ class PlanRoutes(
3940
implicit val ec: ExecutionContextExecutor = ExecutionContext.global
4041
implicit val objectMapper: ObjectMapper = ObjectMapperUtil.jsonObjectMapper
4142

43+
// Log CORS configuration at startup
44+
UiConfiguration.Cors.logConfiguration()
45+
4246
/**
43-
* CORS configuration to allow cross-origin requests from any origin.
44-
* This is necessary for the UI to communicate with the API when running
45-
* in different network contexts (Docker, remote access, etc.)
47+
* CORS configuration to allow cross-origin requests.
48+
* Configurable via environment variables:
49+
* - DATA_CATERER_CORS_ALLOWED_ORIGINS: Allowed origins (default: "*")
50+
* - DATA_CATERER_CORS_ALLOWED_METHODS: Allowed methods (default: "GET,POST,PUT,DELETE,OPTIONS")
51+
* - DATA_CATERER_CORS_ALLOWED_HEADERS: Allowed headers (default: "Content-Type,Authorization,X-Requested-With")
52+
* - DATA_CATERER_CORS_MAX_AGE: Preflight cache duration in seconds (default: 86400)
4653
*/
47-
private val corsHeaders = List(
48-
`Access-Control-Allow-Origin`.*,
49-
`Access-Control-Allow-Methods`(GET, POST, PUT, DELETE, OPTIONS),
50-
`Access-Control-Allow-Headers`("Content-Type", "Authorization", "X-Requested-With"),
51-
`Access-Control-Max-Age`(86400) // 24 hours
52-
)
54+
private val corsHeaders: List[HttpHeader] = {
55+
val corsConfig = UiConfiguration.Cors
56+
57+
val originHeader = if (corsConfig.allowedOrigins == "*") {
58+
`Access-Control-Allow-Origin`.*
59+
} else {
60+
// For specific origins, we'll use the first one as default
61+
// Note: For multiple origins, you'd need to check the request origin header
62+
`Access-Control-Allow-Origin`(HttpOrigin(corsConfig.allowedOrigins.split(",").head.trim))
63+
}
64+
65+
val methodsHeader = `Access-Control-Allow-Methods`(
66+
corsConfig.allowedMethods.flatMap {
67+
case "GET" => Some(GET)
68+
case "POST" => Some(POST)
69+
case "PUT" => Some(PUT)
70+
case "DELETE" => Some(DELETE)
71+
case "OPTIONS" => Some(OPTIONS)
72+
case "HEAD" => Some(HEAD)
73+
case "PATCH" => Some(PATCH)
74+
case _ => None
75+
}: _*
76+
)
77+
78+
val headersHeader = `Access-Control-Allow-Headers`(corsConfig.allowedHeaders: _*)
79+
val maxAgeHeader = `Access-Control-Max-Age`(corsConfig.maxAge)
80+
81+
List(originHeader, methodsHeader, headersHeader, maxAgeHeader)
82+
}
5383

5484
/**
5585
* Directive to add CORS headers to all responses

app/src/main/scala/io/github/datacatering/datacaterer/core/ui/service/ConnectionTestService.scala

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package io.github.datacatering.datacaterer.core.ui.service
22

33
import io.github.datacatering.datacaterer.api.model.Constants._
4-
import io.github.datacatering.datacaterer.core.config.ConfigParser
54
import io.github.datacatering.datacaterer.core.ui.model.{Connection, ConnectionTestResult}
6-
import io.github.datacatering.datacaterer.core.util.SparkProvider
5+
import io.github.datacatering.datacaterer.core.ui.resource.SparkSessionManager
76
import org.apache.log4j.Logger
87
import org.apache.spark.sql.SparkSession
98

@@ -339,17 +338,11 @@ object ConnectionTestService {
339338
}
340339

341340
/**
342-
* Helper to create a temporary SparkSession for connection testing
341+
* Helper to use the shared SparkSession for connection testing
343342
*/
344343
private def withSparkSession[T](f: SparkSession => T): T = {
345-
val config = ConfigParser.toDataCatererConfiguration
346-
val sparkProvider = new SparkProvider(config.master, config.runtimeConfig)
347-
val spark = sparkProvider.getSparkSession
348-
try {
349-
f(spark)
350-
} finally {
351-
// Don't stop the session as it might be shared
352-
}
344+
val spark = SparkSessionManager.getOrCreate()
345+
f(spark)
353346
}
354347

355348
private def getStackTraceAsString(ex: Throwable): String = {

docs/use-case/changelog/0.17.3.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Latest features and fixes for Data Caterer include fixing the connection details
1515
- Added connection testing functionality in the UI, allowing users to test connections before saving.
1616
- Connections now display their source (file or config) to indicate whether they can be deleted.
1717
- Added test buttons for existing saved connections in the UI.
18+
- CORS is now configurable via environment variables for stricter security settings.
1819

1920
## Bugfix
2021

@@ -28,3 +29,5 @@ Latest features and fixes for Data Caterer include fixing the connection details
2829
- Modified build.yml to set version dynamically based on the branch, supporting feature and bugfix branches.
2930
- Fixed JSON serialization issue with Connection model that caused POST /connection endpoint to fail.
3031
- Fixed route matching for POST /connection endpoint to properly handle path conflicts.
32+
- Fixed foreign key property name mismatch between backend (`generate`/`delete`) and UI (`generation`/`delete`) causing foreign key links to not load correctly when editing saved plans.
33+
- Connection test service now uses the shared SparkSession instead of creating a new one, improving performance and resource usage.

0 commit comments

Comments
 (0)