Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import org.apache.texera.web.resource._
import org.apache.texera.web.resource.auth.{AuthResource, GoogleAuthResource}
import org.apache.texera.web.resource.dashboard.DashboardResource
import org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionResource
import org.apache.texera.web.resource.dashboard.admin.settings.AdminSettingsResource
import org.apache.texera.web.resource.dashboard.admin.user.AdminUserResource
import org.apache.texera.web.resource.dashboard.hub.HubResource
import org.apache.texera.web.resource.dashboard.user.UserResource
Expand Down Expand Up @@ -159,7 +158,6 @@ class TexeraWebApplication
environment.jersey.register(classOf[GmailResource])
environment.jersey.register(classOf[AdminExecutionResource])
environment.jersey.register(classOf[UserQuotaResource])
environment.jersey.register(classOf[AdminSettingsResource])
environment.jersey.register(classOf[AIAssistantResource])
environment.jersey.register(classOf[HuggingFaceModelResource])

Expand Down

This file was deleted.

3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
.dependsOn(DAO, Config)
.dependsOn(DAO % "test->test") // reuse MockTexeraDB embedded Postgres in tests
lazy val ConfigService = (project in file("config-service"))
.dependsOn(Auth, Config, Resource)
.dependsOn(Auth, Config, DAO, Resource)
.dependsOn(DAO % "test->test") // reuse MockTexeraDB embedded Postgres in tests
.settings(commonModuleSettings)
.settings(
dependencyOverrides ++= Seq(
Expand Down Expand Up @@ -137,8 +138,8 @@
Test / fork := true,
Test / forkOptions := (Test / forkOptions).value
.withWorkingDirectory((ThisBuild / baseDirectory).value),
Test / testGrouping := (Test / definedTests).value.map { suite =>

Check warning on line 141 in build.sbt

View workflow job for this annotation

GitHub Actions / Bench

The evaluation of `/` inside an anonymous function is prohibited.
Tests.Group(suite.name, Seq(suite), Tests.SubProcess((Test / forkOptions).value))

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / Bench

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber (ubuntu-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber-integration (macos-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber-integration (ubuntu-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (config-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (computing-unit-managing-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (notebook-migration-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (workflow-compiling-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (file-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 142 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (access-control-service)

The evaluation of `/` inside an anonymous function is prohibited.
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,39 @@

package org.apache.texera.service.resource

import com.fasterxml.jackson.annotation.JsonProperty
import io.dropwizard.auth.Auth
import jakarta.annotation.security.{PermitAll, RolesAllowed}
import jakarta.ws.rs.core.MediaType
import jakarta.ws.rs.{GET, Path, Produces}
import jakarta.ws.rs.core.{MediaType, Response}
import jakarta.ws.rs.{Consumes, GET, POST, PUT, Path, PathParam, Produces}
import org.apache.texera.auth.SessionUser
import org.apache.texera.common.config.{
ApplicationConfig,
AuthConfig,
ComputingUnitConfig,
DefaultsConfig,
GuiConfig,
UserSystemConfig
}
import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.jooq.generated.Tables.SITE_SETTINGS
import org.jooq.impl.DSL

import scala.jdk.CollectionConverters._

// Wire DTO for /config/settings: the JSON contract is exactly {key, value};
// the generated jOOQ pojo would also expose updated_by/updated_at.
case class ConfigSettingPojo(
@JsonProperty("key") settingKey: String,
@JsonProperty("value") settingValue: String
)

@Path("/config")
@Produces(Array(MediaType.APPLICATION_JSON))
class ConfigResource {

private def ctx = SqlServer.getInstance().createDSLContext()

// Anonymous endpoint loaded by the frontend's APP_INITIALIZER before any user has
// logged in. Only fields that the login page (or the logged-out branches of the
// dashboard shell) actually need belong here — anything else lives on /gui or
Expand Down Expand Up @@ -99,4 +117,110 @@ class ConfigResource {
// flags from the user-system.conf
"inviteOnly" -> UserSystemConfig.inviteOnly
)

// The site_settings keys that logged-in, non-admin pages consume: dashboard
// branding, sidebar tab toggles, and dataset upload limits. Everything else
// in the table (e.g. csv_parser_max_columns) is management-only. Adding a
// key here makes it readable by every user — the explicit list forces that
// decision into review, same as the /pre-login payload above.
private val publicSettingKeys: Set[String] = Set(
"logo",
"mini_logo",
"favicon",
"hub_enabled",
"home_enabled",
"workflow_enabled",
"dataset_enabled",
"your_work_enabled",
"projects_enabled",
"workflows_enabled",
"datasets_enabled",
"compute_enabled",
"quota_enabled",
"forum_enabled",
"about_enabled",
"single_file_upload_max_size_mib",
"multipart_upload_chunk_size_mib",
"max_number_of_concurrent_uploading_file",
"max_number_of_concurrent_uploading_file_chunks"
)

// Read side for regular users: the public keys in one payload, so the
// dashboard doesn't fire a request per key.
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/settings/public")
def getPublicSettings: Map[String, String] = {
ctx
.select(SITE_SETTINGS.KEY, SITE_SETTINGS.VALUE)
.from(SITE_SETTINGS)
.where(SITE_SETTINGS.KEY.in(publicSettingKeys.asJava))
.fetchMap(SITE_SETTINGS.KEY, SITE_SETTINGS.VALUE)
.asScala
.toMap
}

// Management read over the site_settings table this service seeds at
// startup: any key, including the ones not exposed through /settings/public.
@GET
@RolesAllowed(Array("ADMIN"))
@Path("/settings/{key}")
def getSetting(@PathParam("key") keyParam: String): ConfigSettingPojo = {
ctx
.select(SITE_SETTINGS.KEY, SITE_SETTINGS.VALUE)
.from(SITE_SETTINGS)
.where(SITE_SETTINGS.KEY.eq(keyParam))
.fetchOneInto(classOf[ConfigSettingPojo])
}

@PUT
@RolesAllowed(Array("ADMIN"))
@Path("/settings/{key}")
@Consumes(Array(MediaType.APPLICATION_JSON))
def updateSetting(
@Auth currentUser: SessionUser,
@PathParam("key") keyParam: String,
setting: ConfigSettingPojo
): Response = {
if (setting.settingValue != null && keyParam.nonEmpty) {
upsertSetting(keyParam, setting.settingValue, currentUser.getName)
}
Response.ok().build()
}

/**
* Resets the specified configuration key to its default value defined in default.conf.
*/
@POST
@RolesAllowed(Array("ADMIN"))
@Path("/settings/reset/{key}")
def resetSetting(
@Auth currentUser: SessionUser,
@PathParam("key") keyParam: String
): Response = {
DefaultsConfig.allDefaults.get(keyParam) match {
case Some(defaultValue) =>
upsertSetting(keyParam, defaultValue, currentUser.getName)
Response.ok().build()
case None =>
Response
.status(Response.Status.NOT_FOUND)
.entity(s"No default for key '$keyParam'")
.build()
}
}

private def upsertSetting(keyParam: String, valueParam: String, userName: String): Unit = {
ctx
.insertInto(SITE_SETTINGS)
.set(SITE_SETTINGS.KEY, keyParam)
.set(SITE_SETTINGS.VALUE, valueParam)
.set(SITE_SETTINGS.UPDATED_BY, userName)
.onConflict(SITE_SETTINGS.KEY)
.doUpdate()
.set(SITE_SETTINGS.VALUE, valueParam)
.set(SITE_SETTINGS.UPDATED_BY, userName)
.set(SITE_SETTINGS.UPDATED_AT, DSL.currentTimestamp())
.execute()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ package org.apache.texera.service.resource

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import io.dropwizard.auth.AuthValueFactoryProvider
import io.dropwizard.jackson.Jackson
import io.dropwizard.testing.junit5.ResourceExtension
import jakarta.annotation.security.RolesAllowed
import jakarta.ws.rs.client.Entity
import jakarta.ws.rs.core.MediaType
import jakarta.ws.rs.{GET, Path, Produces}
import org.apache.texera.auth.{JwtAuth, JwtAuthFilter, UnauthorizedExceptionMapper}
import org.apache.texera.auth.{JwtAuth, JwtAuthFilter, SessionUser, UnauthorizedExceptionMapper}
import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
import org.apache.texera.dao.jooq.generated.tables.pojos.User
import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature
Expand Down Expand Up @@ -56,6 +58,10 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAft
.addProvider(classOf[JwtAuthFilter])
.addProvider(classOf[UnauthorizedExceptionMapper])
.addProvider(classOf[RolesAllowedDynamicFeature])
// Production (AuthFeatures.register) binds this so @Auth SessionUser
// parameters resolve; without it the /config/settings write endpoints
// fail resource-model validation at startup.
.addProvider(new AuthValueFactoryProvider.Binder(classOf[SessionUser]))
.addResource(new ConfigResource)
.addResource(new ConfigResourceAuthSpec.ProtectedProbe)
.build()
Expand Down Expand Up @@ -188,6 +194,70 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAft
guiPayload.keySet should not contain "defaultDataTransferBatchSize"
}

// /config/settings is the site_settings API: /settings/public serves the
// whitelisted user-visible keys to any logged-in user; the single-key read
// and all mutation are ADMIN-only. Positive read/write paths need a
// database, so this spec only pins the auth gates plus the one ADMIN path
// that never reaches the DB (reset of a key absent from default.conf → 404).
"GET /config/settings/public" should "return 401 with a Bearer challenge without an Authorization header" in {
val response =
resources.target("/config/settings/public").request(MediaType.APPLICATION_JSON).get()
response.getStatus shouldBe 401
response.getHeaderString("WWW-Authenticate") shouldBe JwtAuthFilter.BearerChallenge
}

"GET /config/settings/{key}" should "return 401 with a Bearer challenge without an Authorization header" in {
val response =
resources.target("/config/settings/logo").request(MediaType.APPLICATION_JSON).get()
response.getStatus shouldBe 401
response.getHeaderString("WWW-Authenticate") shouldBe JwtAuthFilter.BearerChallenge
}

it should "return 403 for a REGULAR user (management read is ADMIN-only)" in {
val response = resources
.target("/config/settings/logo")
.request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer ${regularToken()}")
.get()
response.getStatus shouldBe 403
}

"PUT /config/settings/{key}" should "return 401 without an Authorization header" in {
val response = resources
.target("/config/settings/logo")
.request(MediaType.APPLICATION_JSON)
.put(Entity.json("""{"key":"logo","value":"x"}"""))
response.getStatus shouldBe 401
response.getHeaderString("WWW-Authenticate") shouldBe JwtAuthFilter.BearerChallenge
}

it should "return 403 for a REGULAR user" in {
val response = resources
.target("/config/settings/logo")
.request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer ${regularToken()}")
.put(Entity.json("""{"key":"logo","value":"x"}"""))
response.getStatus shouldBe 403
}

"POST /config/settings/reset/{key}" should "return 403 for a REGULAR user" in {
val response = resources
.target("/config/settings/reset/logo")
.request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer ${regularToken()}")
.post(Entity.json("{}"))
response.getStatus shouldBe 403
}

it should "pass the role gate for an ADMIN (404 for a key with no default)" in {
val response = resources
.target("/config/settings/reset/no-such-key")
.request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer ${adminToken()}")
.post(Entity.json("{}"))
response.getStatus shouldBe 404
}

"GET an @RolesAllowed probe endpoint" should "return 401 without an Authorization header" in {
// Sanity: JwtAuthFilter is now eager — missing Authorization is rejected
// by the filter itself with a 401 + Bearer challenge, before
Expand Down
Loading
Loading