Skip to content

Commit 4c33a42

Browse files
Yutong-1424yutong-27
authored andcommitted
[KYUUBI #7391] Improve session REST security
Co-authored-by: Han, Yutong <[email protected]> Signed-off-by: Cheng Pan <[email protected]>
1 parent db2c291 commit 4c33a42

7 files changed

Lines changed: 165 additions & 29 deletions

File tree

docs/configuration/settings.md

Lines changed: 22 additions & 20 deletions
Large diffs are not rendered by default.

docs/deployment/migration-guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
* Since Kyuubi 1.12, the support of variable `<KYUUBI_HOME>` substitution in config `kyuubi.metadata.store.jdbc.url` is deprecated, use `{{KYUUBI_HOME}}` instead.
2323
* Since Kyuubi 1.12, default value of `kyuubi.metrics.json.location` is changed to `{{KYUUBI_HOME}}/metrics`, to restore previous behavior, change it to `{{KYUUBI_WORK_DIR_ROOT}}/metrics`.
24+
* Since Kyuubi 1.12, session configurations in REST API responses are redacted by default using `kyuubi.server.redaction.regex`. Use `kyuubi.server.conf.retrieveMode` to control this behavior: `REDACTED` (default), `ORIGINAL` (no redaction), or `NONE` (omit configs entirely).
25+
* Since Kyuubi 1.12, `GET /api/v1/sessions` returns only sessions owned by the authenticated user instead of all sessions on the server. To restore the previous behavior, set `kyuubi.frontend.rest.legacy.v1.sessionsReturnAllUsers=true`.
2426
* Since Kyuubi 1.12, the configuration `spark.sql.kyuubi.hive.connector.dropTableAsPurgeTable` is introduced by Kyuubi Spark Hive connector(KSHC) to control whether DROP TABLE command completely remove its data by skipping HDFS trash. The default value is false. To restore the legacy behavior, set it to true.
2527

2628
## Upgrading from Kyuubi 1.10 to 1.11

kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3336,6 +3336,32 @@ object KyuubiConf {
33363336
.regexConf
33373337
.createOptional
33383338

3339+
val SERVER_CONF_RETRIEVE_MODE: ConfigEntry[String] =
3340+
buildConf("kyuubi.server.conf.retrieveMode")
3341+
.serverOnly
3342+
.doc("Controls how session configurations are returned in REST API responses. " +
3343+
"Supported values: " +
3344+
"<ul>" +
3345+
"<li>REDACTED: Mask values that match kyuubi.server.redaction.regex (default).</li>" +
3346+
"<li>ORIGINAL: Return the raw config values as-is.</li>" +
3347+
"<li>NONE: Omit the conf map from responses entirely.</li>" +
3348+
"</ul>")
3349+
.version("1.12.0")
3350+
.stringConf
3351+
.checkValues(Set("REDACTED", "ORIGINAL", "NONE"))
3352+
.createWithDefault("REDACTED")
3353+
3354+
val FRONTEND_REST_SESSION_LIST_LEGACY_MODE: ConfigEntry[Boolean] =
3355+
buildConf("kyuubi.frontend.rest.legacy.v1.sessionsReturnAllUsers")
3356+
.serverOnly
3357+
.doc("When true, GET /api/v1/sessions returns all sessions on the server regardless " +
3358+
"of the calling user (legacy behavior). When false (default), only sessions owned " +
3359+
"by the authenticated user are returned. " +
3360+
"This flag is provided for backward compatibility and will be removed in a future release.")
3361+
.version("1.12.0")
3362+
.booleanConf
3363+
.createWithDefault(false)
3364+
33393365
val SERVER_PERIODIC_GC_INTERVAL: ConfigEntry[Long] =
33403366
buildConf("kyuubi.server.periodicGC.interval")
33413367
.doc("How often to trigger the periodic garbage collection. 0 will disable it.")

kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/ApiUtils.scala

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,33 @@ import scala.collection.JavaConverters._
2222
import org.apache.kyuubi.{Logging, Utils}
2323
import org.apache.kyuubi.client.api.v1.dto
2424
import org.apache.kyuubi.client.api.v1.dto.{OperationData, OperationProgress, ServerData, SessionData}
25+
import org.apache.kyuubi.config.KyuubiConf.{SERVER_CONF_RETRIEVE_MODE, SERVER_SECRET_REDACTION_PATTERN}
2526
import org.apache.kyuubi.ha.client.ServiceNodeInfo
2627
import org.apache.kyuubi.operation.KyuubiOperation
2728
import org.apache.kyuubi.session.KyuubiSession
2829

30+
object ConfRetrieveMode extends Enumeration {
31+
val REDACTED, ORIGINAL, NONE = Value
32+
}
33+
2934
object ApiUtils extends Logging {
35+
36+
private def buildConf(
37+
rawConf: Map[String, String],
38+
session: KyuubiSession): java.util.Map[String, String] = {
39+
ConfRetrieveMode.withName(
40+
session.sessionManager.getConf.get(SERVER_CONF_RETRIEVE_MODE)) match {
41+
case ConfRetrieveMode.NONE => Map.empty[String, String].asJava
42+
case ConfRetrieveMode.ORIGINAL => rawConf.asJava
43+
case ConfRetrieveMode.REDACTED =>
44+
val pattern = session.sessionManager.getConf.get(SERVER_SECRET_REDACTION_PATTERN)
45+
Utils.redact(pattern, rawConf.toSeq).toMap.asJava
46+
}
47+
}
48+
3049
def sessionEvent(session: KyuubiSession): dto.KyuubiSessionEvent = {
31-
session.getSessionEvent.map(event =>
50+
session.getSessionEvent.map { event =>
51+
val conf = buildConf(event.conf, session)
3252
dto.KyuubiSessionEvent.builder()
3353
.sessionId(event.sessionId)
3454
.clientVersion(event.clientVersion)
@@ -37,7 +57,7 @@ object ApiUtils extends Logging {
3757
.user(event.user)
3858
.clientIp(event.clientIP)
3959
.serverIp(event.serverIP)
40-
.conf(event.conf.asJava)
60+
.conf(conf)
4161
.remoteSessionId(event.remoteSessionId)
4262
.engineId(event.engineId)
4363
.engineName(event.engineName)
@@ -48,17 +68,19 @@ object ApiUtils extends Logging {
4868
.endTime(event.endTime)
4969
.totalOperations(event.totalOperations)
5070
.exception(event.exception.orNull)
51-
.build()).orNull
71+
.build()
72+
}.orNull
5273
}
5374

5475
def sessionData(session: KyuubiSession): SessionData = {
5576
val sessionEvent = session.getSessionEvent
77+
val conf = buildConf(session.conf, session)
5678
new SessionData(
5779
session.handle.identifier.toString,
5880
sessionEvent.map(_.remoteSessionId).getOrElse(""),
5981
session.user,
6082
session.ipAddress,
61-
session.conf.asJava,
83+
conf,
6284
session.createTime,
6385
session.lastAccessTime - session.createTime,
6486
session.getNoOperationTime,

kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/SessionsResource.scala

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import org.apache.commons.lang3.StringUtils
3232
import org.apache.kyuubi.Logging
3333
import org.apache.kyuubi.client.api.v1.dto
3434
import org.apache.kyuubi.client.api.v1.dto._
35+
import org.apache.kyuubi.config.KyuubiConf.FRONTEND_REST_SESSION_LIST_LEGACY_MODE
3536
import org.apache.kyuubi.config.KyuubiReservedKeys._
3637
import org.apache.kyuubi.operation.{KyuubiOperation, OperationHandle}
3738
import org.apache.kyuubi.server.api.{ApiRequestContext, ApiUtils}
@@ -52,11 +53,18 @@ private[v1] class SessionsResource extends ApiRequestContext with Logging {
5253
content = Array(new Content(
5354
mediaType = MediaType.APPLICATION_JSON,
5455
array = new ArraySchema(schema = new Schema(implementation = classOf[SessionData])))),
55-
description = "get the list of all live sessions")
56+
description = "get the list of live sessions for the current user")
5657
@GET
5758
def sessions(): Seq[SessionData] = {
58-
sessionManager.allSessions()
59-
.map(session => ApiUtils.sessionData(session.asInstanceOf[KyuubiSession])).toSeq
59+
val legacyMode = sessionManager.getConf.get(FRONTEND_REST_SESSION_LIST_LEGACY_MODE)
60+
val allSessions = sessionManager.allSessions()
61+
val filtered =
62+
if (legacyMode) allSessions
63+
else {
64+
val userName = fe.getSessionUser(Map.empty[String, String])
65+
allSessions.filter(session => session.user == userName)
66+
}
67+
filtered.map(session => ApiUtils.sessionData(session.asInstanceOf[KyuubiSession])).toSeq
6068
}
6169

6270
@ApiResponse(

kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/SessionsResourceSuite.scala

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ import org.apache.kyuubi.session.SessionType
4040

4141
class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper {
4242

43+
override protected lazy val conf: KyuubiConf = {
44+
val c = KyuubiConf()
45+
c.set(KyuubiConf.SERVER_SECRET_REDACTION_PATTERN, "(?i)password".r)
46+
c
47+
}
48+
4349
override protected def beforeEach(): Unit = {
4450
super.beforeEach()
4551
eventually(timeout(10.seconds), interval(200.milliseconds)) {
@@ -389,4 +395,74 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper {
389395
assert(operations.size == 1)
390396
assert(sessionHandle.toString.equals(operations.head.getSessionId))
391397
}
398+
399+
test("get /sessions returns redacted spark confs when mode is REDACTED") {
400+
val sensitiveKey = "spark.password"
401+
val sensitiveValue = "superSecret123"
402+
val requestObj = new SessionOpenRequest(Map(sensitiveKey -> sensitiveValue).asJava)
403+
404+
val response = webTarget.path("api/v1/sessions")
405+
.request(MediaType.APPLICATION_JSON_TYPE)
406+
.post(Entity.entity(requestObj, MediaType.APPLICATION_JSON_TYPE))
407+
assert(200 == response.getStatus)
408+
val sessionHandle = response.readEntity(classOf[SessionHandle]).getIdentifier
409+
410+
val response2 = webTarget.path("api/v1/sessions").request().get()
411+
assert(200 == response2.getStatus)
412+
val sessions = response2.readEntity(new GenericType[Seq[SessionData]]() {})
413+
val sessionConf = sessions.find(_.getIdentifier == sessionHandle.toString).get.getConf
414+
415+
assert(sessionConf.get(sensitiveKey) != sensitiveValue)
416+
assert(sessionConf.get(sensitiveKey) == "*********(redacted)")
417+
418+
val delResp = webTarget.path(s"api/v1/sessions/$sessionHandle").request().delete()
419+
assert(200 == delResp.getStatus)
420+
}
421+
422+
test("get /sessions returns empty conf when mode is NONE") {
423+
withSessionConfDisplayMode("NONE") {
424+
val requestObj =
425+
new SessionOpenRequest(Map("spark.password" -> "secret", "key" -> "val").asJava)
426+
val r = webTarget.path("api/v1/sessions")
427+
.request(MediaType.APPLICATION_JSON_TYPE)
428+
.post(Entity.entity(requestObj, MediaType.APPLICATION_JSON_TYPE))
429+
assert(200 == r.getStatus)
430+
val sessionHandle = r.readEntity(classOf[SessionHandle]).getIdentifier
431+
432+
val r2 = webTarget.path("api/v1/sessions").request().get()
433+
assert(200 == r2.getStatus)
434+
val sessions = r2.readEntity(new GenericType[Seq[SessionData]]() {})
435+
val sessionConf = sessions.find(_.getIdentifier == sessionHandle.toString).get.getConf
436+
assert(sessionConf.isEmpty)
437+
438+
webTarget.path(s"api/v1/sessions/$sessionHandle").request().delete()
439+
}
440+
}
441+
442+
test("get /sessions returns raw conf when mode is ORIGINAL") {
443+
withSessionConfDisplayMode("ORIGINAL") {
444+
val sensitiveKey = "spark.password"
445+
val sensitiveValue = "plainVisible"
446+
val requestObj = new SessionOpenRequest(Map(sensitiveKey -> sensitiveValue).asJava)
447+
val r = webTarget.path("api/v1/sessions")
448+
.request(MediaType.APPLICATION_JSON_TYPE)
449+
.post(Entity.entity(requestObj, MediaType.APPLICATION_JSON_TYPE))
450+
assert(200 == r.getStatus)
451+
val sessionHandle = r.readEntity(classOf[SessionHandle]).getIdentifier
452+
453+
val r2 = webTarget.path("api/v1/sessions").request().get()
454+
assert(200 == r2.getStatus)
455+
val sessions = r2.readEntity(new GenericType[Seq[SessionData]]() {})
456+
val sessionConf = sessions.find(_.getIdentifier == sessionHandle.toString).get.getConf
457+
assert(sessionConf.get(sensitiveKey) == sensitiveValue)
458+
459+
webTarget.path(s"api/v1/sessions/$sessionHandle").request().delete()
460+
}
461+
}
462+
463+
private def withSessionConfDisplayMode(mode: String)(f: => Unit): Unit = {
464+
conf.set(KyuubiConf.SERVER_CONF_RETRIEVE_MODE, mode)
465+
try f
466+
finally conf.set(KyuubiConf.SERVER_CONF_RETRIEVE_MODE, "REDACTED")
467+
}
392468
}

kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/SessionCtlSuite.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ class SessionCtlSuite extends RestClientTestHelper with TestPrematureExit {
3838
test("list sessions") {
3939
fe.be.sessionManager.openSession(
4040
TProtocolVersion.findByValue(1),
41-
"admin",
41+
clientPrincipalUser,
4242
"123456",
4343
"localhost",
4444
Map("testConfig" -> "testValue"))
4545

4646
val args = Array("list", "session", "--authSchema", "spnego")
47-
testPrematureExitForControlCli(args, "Session List (total 1)")
47+
testPrematureExitForControlCli(args, "Live Session List (total 1)")
4848
}
4949

5050
}

0 commit comments

Comments
 (0)