Skip to content

Commit

Permalink
[LIVY-348] Improve the ACLs in Livy
Browse files Browse the repository at this point in the history
This PR propose to improve current Livy's access control mechanism with fine-grained control:

1. If `livy.server.access-control.enabled` is disabled, which means ACLs is disabled, any user could send any request to Livy.
2. If `livy.server.access-control.enabled` is enabled, then this ACL mechanism divides users into 3 groups:
    1. view accessible users: users could get session, statement data, but cannot POST any queries.
    2. modify accessible users: users could submit new statements, kill sessions.
    3. super users: this is the same as previous, super user could impersonate any user.

    In the meanwhile, modify accessible users automatically have the view accessibility, and super user has all the permissions.

Also add new configuration `livy.server.access-control.allowed-users`, this is the same as previous `livy.server.access-control.users`, when ACLs is enabled only users in the allowed list could issue REST queries to Livy server, other users will get 403.

Please review and comment.

Author: jerryshao <[email protected]>

Closes apache#15 from jerryshao/LIVY-348.
  • Loading branch information
jerryshao committed Jul 24, 2017
1 parent feeb867 commit 75902eb
Show file tree
Hide file tree
Showing 16 changed files with 434 additions and 98 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import org.scalatra.servlet.ScalatraListener
import org.apache.livy._
import org.apache.livy.client.common.{BufferUtils, Serializer}
import org.apache.livy.client.common.HttpMessages._
import org.apache.livy.server.WebServer
import org.apache.livy.server.{AccessManager, WebServer}
import org.apache.livy.server.interactive.{InteractiveSession, InteractiveSessionServlet}
import org.apache.livy.server.recovery.SessionStore
import org.apache.livy.sessions.{InteractiveSessionManager, SessionState, Spark}
Expand Down Expand Up @@ -267,7 +267,8 @@ private class HttpClientTestBootstrap extends LifeCycle {
val conf = new LivyConf()
val stateStore = mock(classOf[SessionStore])
val sessionManager = new InteractiveSessionManager(conf, stateStore, Some(Seq.empty))
val servlet = new InteractiveSessionServlet(sessionManager, stateStore, conf) {
val accessManager = new AccessManager(conf)
val servlet = new InteractiveSessionServlet(sessionManager, stateStore, conf, accessManager) {
override protected def createSession(req: HttpServletRequest): InteractiveSession = {
val session = mock(classOf[InteractiveSession])
val id = sessionManager.nextId()
Expand Down
16 changes: 16 additions & 0 deletions conf/livy.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,19 @@

# If the Livy Web UI should be included in the Livy Server. Enabled by default.
# livy.ui.enabled = true

# Whether to enable Livy server access control, if it is true then all the income requests will
# be checked if the requested user has permission.
# livy.server.access-control.enabled = false

# Allowed users to access Livy, by default any user is allowed to access Livy. If user want to
# limit who could access Livy, user should list all the permitted users with comma separated.
# livy.server.access-control.allowed-users = *

# A list of users with comma separated has the permission to change other user's submitted
# session, like submitting statements, deleting session.
# livy.server.access-control.modify-users =

# A list of users with comma separated has the permission to view other user's infomation, like
# submitted session state, statement results.
# livy.server.access-control.view-users =
21 changes: 8 additions & 13 deletions server/src/main/scala/org/apache/livy/LivyConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ object LivyConf {
val SUPERUSERS = Entry("livy.superusers", null)

val ACCESS_CONTROL_ENABLED = Entry("livy.server.access-control.enabled", false)
val ACCESS_CONTROL_USERS = Entry("livy.server.access-control.users", null)
// Allowed users to access Livy, by default any user is allowed to access Livy. If user want to
// limit who could access Livy, user should list all the permitted users with comma
// separated.
val ACCESS_CONTROL_ALLOWED_USERS = Entry("livy.server.access-control.allowed-users", "*")
val ACCESS_CONTROL_MODIFY_USERS = Entry("livy.server.access-control.modify-users", null)
val ACCESS_CONTROL_VIEW_USERS = Entry("livy.server.access-control.view-users", null)

val SSL_KEYSTORE = Entry("livy.keystore", null)
val SSL_KEYSTORE_PASSWORD = Entry("livy.keystore.password", null)
Expand Down Expand Up @@ -186,7 +191,6 @@ object LivyConf {
ENABLE_HIVE_CONTEXT.key -> DepConf("livy.repl.enableHiveContext", "0.4"),
CSRF_PROTECTION.key -> DepConf("livy.server.csrf_protection.enabled", "0.4"),
ACCESS_CONTROL_ENABLED.key -> DepConf("livy.server.access_control.enabled", "0.4"),
ACCESS_CONTROL_USERS.key -> DepConf("livy.server.access_control.users", "0.4"),
AUTH_KERBEROS_NAME_RULES.key -> DepConf("livy.server.auth.kerberos.name_rules", "0.4"),
LAUNCH_KERBEROS_REFRESH_INTERVAL.key ->
DepConf("livy.server.launch.kerberos.refresh_interval", "0.4"),
Expand All @@ -199,7 +203,7 @@ object LivyConf {

private val deprecatedConfigs: Map[String, DeprecatedConf] = {
val configs: Seq[DepConf] = Seq(
// There are no deprecated configs without alternatives currently.
DepConf("livy.server.access_control.users", "0.4")
)

Map(configs.map { cfg => (cfg.key -> cfg) }: _*)
Expand All @@ -215,9 +219,6 @@ class LivyConf(loadDefaults: Boolean) extends ClientConf[LivyConf](null) {

import LivyConf._

private lazy val _superusers = configToSeq(SUPERUSERS)
private lazy val _allowedUsers = configToSeq(ACCESS_CONTROL_USERS).toSet

lazy val hadoopConf = new Configuration()
lazy val localFsWhitelist = configToSeq(LOCAL_FS_WHITELIST).map { path =>
// Make sure the path ends with a single separator.
Expand Down Expand Up @@ -260,12 +261,6 @@ class LivyConf(loadDefaults: Boolean) extends ClientConf[LivyConf](null) {
sparkHome().map { _ + File.separator + "bin" + File.separator + "spark-submit" }.get
}

/** Return the list of superusers. */
def superusers(): Seq[String] = _superusers

/** Return the set of users allowed to use Livy via SPNEGO. */
def allowedUsers(): Set[String] = _allowedUsers

private val configDir: Option[File] = {
sys.env.get("LIVY_CONF_DIR")
.orElse(sys.env.get("LIVY_HOME").map(path => s"$path${File.separator}conf"))
Expand All @@ -285,7 +280,7 @@ class LivyConf(loadDefaults: Boolean) extends ClientConf[LivyConf](null) {
}
}

private def configToSeq(entry: LivyConf.Entry): Seq[String] = {
def configToSeq(entry: LivyConf.Entry): Seq[String] = {
Option(get(entry)).map(_.split("[, ]+").toSeq).getOrElse(Nil)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ package org.apache.livy.server
import javax.servlet._
import javax.servlet.http.{HttpServletRequest, HttpServletResponse}

import org.apache.livy.LivyConf

class AccessFilter(livyConf: LivyConf) extends Filter {
private[livy] class AccessFilter(accessManager: AccessManager) extends Filter {

override def init(filterConfig: FilterConfig): Unit = {}

Expand All @@ -31,11 +29,11 @@ class AccessFilter(livyConf: LivyConf) extends Filter {
chain: FilterChain): Unit = {
val httpRequest = request.asInstanceOf[HttpServletRequest]
val remoteUser = httpRequest.getRemoteUser
if (livyConf.allowedUsers.contains(remoteUser)) {
if (accessManager.isUserAllowed(remoteUser)) {
chain.doFilter(request, response)
} else {
val httpServletResponse = response.asInstanceOf[HttpServletResponse]
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED,
httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN,
"User not authorised to use Livy.")
}
}
Expand Down
97 changes: 97 additions & 0 deletions server/src/main/scala/org/apache/livy/server/AccessManager.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.livy.server

import org.apache.livy.{LivyConf, Logging}

private[livy] class AccessManager(conf: LivyConf) extends Logging {
private val aclsOn = conf.getBoolean(LivyConf.ACCESS_CONTROL_ENABLED)

private val WILDCARD_ACL = "*"

private val superUsers = conf.configToSeq(LivyConf.SUPERUSERS)
private val modifyUsers = conf.configToSeq(LivyConf.ACCESS_CONTROL_MODIFY_USERS)
private val viewUsers = conf.configToSeq(LivyConf.ACCESS_CONTROL_VIEW_USERS)
private val allowedUsers = conf.configToSeq(LivyConf.ACCESS_CONTROL_ALLOWED_USERS).toSet

private val viewAcls = (superUsers ++ modifyUsers ++ viewUsers).toSet
private val modifyAcls = (superUsers ++ modifyUsers).toSet
private val superAcls = superUsers.toSet
private val allowedAcls = (superUsers ++ modifyUsers ++ viewUsers ++ allowedUsers).toSet

info(s"AccessControlManager acls ${if (aclsOn) "enabled" else "disabled"};" +
s"users with view permission: ${viewUsers.mkString(", ")};" +
s"users with modify permission: ${modifyUsers.mkString(", ")};" +
s"users with super permission: ${superUsers.mkString(", ")};" +
s"other allowed users: ${allowedUsers.mkString(", ")}")

/**
* Check whether the given user has view access to the REST APIs.
*/
def checkViewPermissions(user: String): Boolean = {
debug(s"user=$user aclsOn=$aclsOn viewAcls=${viewAcls.mkString(", ")}")
if (!aclsOn || user == null || viewAcls.contains(WILDCARD_ACL) || viewAcls.contains(user)) {
true
} else {
false
}
}

/**
* Check whether the give user has modification access to the REST APIs.
*/
def checkModifyPermissions(user: String): Boolean = {
debug(s"user=$user aclsOn=$aclsOn modifyAcls=${modifyAcls.mkString(", ")}")
if (!aclsOn || user == null || modifyAcls.contains(WILDCARD_ACL) || modifyAcls.contains(user)) {
true
} else {
false
}
}

/**
* Check whether the give user has super access to the REST APIs. This will always be checked
* no matter acls is on or off.
*/
def checkSuperUser(user: String): Boolean = {
debug(s"user=$user aclsOn=$aclsOn superAcls=${superAcls.mkString(", ")}")
if (user == null || superUsers.contains(WILDCARD_ACL) || superUsers.contains(user)) {
true
} else {
false
}
}

/**
* Check whether the given user has the permission to access REST APIs.
*/
def isUserAllowed(user: String): Boolean = {
debug(s"user=$user aclsOn=$aclsOn, allowedAcls=${allowedAcls.mkString(", ")}")
if (!aclsOn || user == null || allowedAcls.contains(WILDCARD_ACL) ||
allowedAcls.contains(user)) {
true
} else {
false
}
}

/**
* Check whether access control is enabled or not.
*/
def isAccessControlOn: Boolean = aclsOn
}
22 changes: 10 additions & 12 deletions server/src/main/scala/org/apache/livy/server/LivyServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ class LivyServer extends Logging {

private var kinitFailCount: Int = 0
private var executor: ScheduledExecutorService = _
private var accessManager: AccessManager = _

def start(): Unit = {
livyConf = new LivyConf().loadFromFile("livy.conf")
accessManager = new AccessManager(livyConf)

val host = livyConf.get(SERVER_HOST)
val port = livyConf.getInt(SERVER_PORT)
Expand Down Expand Up @@ -187,11 +189,12 @@ class LivyServer extends Logging {
val context = sce.getServletContext()
context.initParameters(org.scalatra.EnvironmentKey) = livyConf.get(ENVIRONMENT)

val interactiveServlet =
new InteractiveSessionServlet(interactiveSessionManager, sessionStore, livyConf)
val interactiveServlet = new InteractiveSessionServlet(
interactiveSessionManager, sessionStore, livyConf, accessManager)
mount(context, interactiveServlet, "/sessions/*")

val batchServlet = new BatchSessionServlet(batchSessionManager, sessionStore, livyConf)
val batchServlet =
new BatchSessionServlet(batchSessionManager, sessionStore, livyConf, accessManager)
mount(context, batchServlet, "/batches/*")

if (livyConf.getBoolean(UI_ENABLED)) {
Expand Down Expand Up @@ -247,15 +250,10 @@ class LivyServer extends Logging {
server.context.addFilter(csrfHolder, "/*", EnumSet.allOf(classOf[DispatcherType]))
}

if (livyConf.getBoolean(ACCESS_CONTROL_ENABLED)) {
if (livyConf.get(AUTH_TYPE) != null) {
info("Access control is enabled.")
val accessHolder = new FilterHolder(new AccessFilter(livyConf))
server.context.addFilter(accessHolder, "/*", EnumSet.allOf(classOf[DispatcherType]))
} else {
throw new IllegalArgumentException("Access control was requested but could " +
"not be enabled, since authentication is disabled.")
}
if (accessManager.isAccessControlOn) {
info("Access control is enabled")
val accessHolder = new FilterHolder(new AccessFilter(accessManager))
server.context.addFilter(accessHolder, "/*", EnumSet.allOf(classOf[DispatcherType]))
}

server.start()
Expand Down
51 changes: 39 additions & 12 deletions server/src/main/scala/org/apache/livy/server/SessionServlet.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ object SessionServlet extends Logging
*/
abstract class SessionServlet[S <: Session, R <: RecoveryMetadata](
private[livy] val sessionManager: SessionManager[S, R],
livyConf: LivyConf)
livyConf: LivyConf,
accessManager: AccessManager)
extends JsonServlet
with ApiVersioningSupport
with MethodOverride
Expand Down Expand Up @@ -90,7 +91,7 @@ abstract class SessionServlet[S <: Session, R <: RecoveryMetadata](
}

get("/:id/log") {
withSession { session =>
withViewAccessSession { session =>
val from = params.get("from").map(_.toInt)
val size = params.get("size").map(_.toInt)
val (from_, total, logLines) = serializeLogs(session, from, size)
Expand All @@ -104,7 +105,7 @@ abstract class SessionServlet[S <: Session, R <: RecoveryMetadata](
}

delete("/:id") {
withSession { session =>
withModifyAccessSession { session =>
sessionManager.delete(session.id) match {
case Some(future) =>
Await.ready(future, Duration.Inf)
Expand Down Expand Up @@ -156,7 +157,7 @@ abstract class SessionServlet[S <: Session, R <: RecoveryMetadata](
target: Option[String],
req: HttpServletRequest): Option[String] = {
if (livyConf.getBoolean(LivyConf.IMPERSONATION_ENABLED)) {
if (!target.map(hasAccess(_, req)).getOrElse(true)) {
if (!target.map(hasSuperAccess(_, req)).getOrElse(true)) {
halt(Forbidden(s"User '${remoteUser(req)}' not allowed to impersonate '$target'."))
}
target.orElse(Option(remoteUser(req)))
Expand All @@ -166,31 +167,57 @@ abstract class SessionServlet[S <: Session, R <: RecoveryMetadata](
}

/**
* Check that the request's user has access to resources owned by the given target user.
* Check that the request's user has view access to resources owned by the given target user.
*/
protected def hasAccess(target: String, req: HttpServletRequest): Boolean = {
protected def hasViewAccess(target: String, req: HttpServletRequest): Boolean = {
val user = remoteUser(req)
user == null || user == target || livyConf.superusers().contains(user)
user == target || accessManager.checkViewPermissions(user)
}

/**
* Check that the request's user has modify access to resources owned by the given target user.
*/
protected def hasModifyAccess(target: String, req: HttpServletRequest): Boolean = {
val user = remoteUser(req)
user == target || accessManager.checkModifyPermissions(user)
}

/**
* Check that the request's user has admin access to resources owned by the given target user.
*/
protected def hasSuperAccess(target: String, req: HttpServletRequest): Boolean = {
val user = remoteUser(req)
user == target || accessManager.checkSuperUser(user)
}

/**
* Performs an operation on the session, without checking for ownership. Operations executed
* via this method must not modify the session in any way, or return potentially sensitive
* information.
*/
protected def withUnprotectedSession(fn: (S => Any)): Any = doWithSession(fn, true)
protected def withUnprotectedSession(fn: (S => Any)): Any = doWithSession(fn, true, None)

/**
* Performs an operation on the session, verifying whether the caller has view access of the
* session.
*/
protected def withViewAccessSession(fn: (S => Any)): Any =
doWithSession(fn, false, Some(hasViewAccess))

/**
* Performs an operation on the session, verifying whether the caller is the owner of the
* Performs an operation on the session, verifying whether the caller has view access of the
* session.
*/
protected def withSession(fn: (S => Any)): Any = doWithSession(fn, false)
protected def withModifyAccessSession(fn: (S => Any)): Any =
doWithSession(fn, false, Some(hasModifyAccess))

private def doWithSession(fn: (S => Any), allowAll: Boolean): Any = {
private def doWithSession(fn: (S => Any),
allowAll: Boolean,
checkFn: Option[(String, HttpServletRequest) => Boolean]): Any = {
val sessionId = params("id").toInt
sessionManager.get(sessionId) match {
case Some(session) =>
if (allowAll || hasAccess(session.owner, request)) {
if (allowAll || checkFn.map(_(session.owner, request)).getOrElse(false)) {
fn(session)
} else {
Forbidden()
Expand Down
Loading

0 comments on commit 75902eb

Please sign in to comment.