Merge branch 'master' into extends-zrun-remote-transaction

# Conflicts:
#	zeppelin-interpreter/src/main/java/org/apache/zeppelin/interpreter/remote/RemoteInterpreter.java
#	zeppelin-interpreter/src/main/thrift/RemoteInterpreterService.thrift
#	zeppelin-interpreter/src/test/java/org/apache/zeppelin/interpreter/remote/RemoteAngularObjectTest.java
#	zeppelin-interpreter/src/test/java/org/apache/zeppelin/interpreter/remote/RemoteInterpreterOutputTestStream.java
#	zeppelin-interpreter/src/test/java/org/apache/zeppelin/interpreter/remote/RemoteInterpreterTest.java
#	zeppelin-interpreter/src/test/java/org/apache/zeppelin/resource/DistributedResourcePoolTest.java
#	zeppelin-interpreter/src/test/java/org/apache/zeppelin/scheduler/RemoteSchedulerTest.java
#	zeppelin-zengine/src/main/java/org/apache/zeppelin/interpreter/InterpreterFactory.java
This commit is contained in:
CloverHearts 2016-11-18 14:57:32 +09:00
commit 8d42c166a3
24 changed files with 217 additions and 66 deletions

View file

@ -23,7 +23,7 @@ function usage() {
echo "usage) $0 -p <port> -d <interpreter dir to load> -l <local interpreter repo dir to load>"
}
while getopts "hp:d:l:v" o; do
while getopts "hp:d:l:v:u:" o; do
case ${o} in
h)
usage
@ -42,6 +42,14 @@ while getopts "hp:d:l:v" o; do
. "${bin}/common.sh"
getZeppelinVersion
;;
u)
ZEPPELIN_IMPERSONATE_USER="${OPTARG}"
if [[ -z "$ZEPPELIN_IMPERSONATE_CMD" ]]; then
ZEPPELIN_IMPERSONATE_RUN_CMD=`echo "ssh ${ZEPPELIN_IMPERSONATE_USER}@localhost" `
else
ZEPPELIN_IMPERSONATE_RUN_CMD=$(eval "echo ${ZEPPELIN_IMPERSONATE_CMD} ")
fi
;;
esac
done
@ -178,9 +186,9 @@ addJarInDirForIntp "${LOCAL_INTERPRETER_REPO}"
CLASSPATH+=":${ZEPPELIN_INTP_CLASSPATH}"
if [[ -n "${SPARK_SUBMIT}" ]]; then
${SPARK_SUBMIT} --class ${ZEPPELIN_SERVER} --driver-class-path "${ZEPPELIN_INTP_CLASSPATH_OVERRIDES}:${CLASSPATH}" --driver-java-options "${JAVA_INTP_OPTS}" ${SPARK_SUBMIT_OPTIONS} ${SPARK_APP_JAR} ${PORT} &
${ZEPPELIN_IMPERSONATE_RUN_CMD} `${SPARK_SUBMIT} --class ${ZEPPELIN_SERVER} --driver-class-path "${ZEPPELIN_INTP_CLASSPATH_OVERRIDES}:${CLASSPATH}" --driver-java-options "${JAVA_INTP_OPTS}" ${SPARK_SUBMIT_OPTIONS} ${SPARK_APP_JAR} ${PORT} &`
else
${ZEPPELIN_RUNNER} ${JAVA_INTP_OPTS} ${ZEPPELIN_INTP_MEM} -cp ${ZEPPELIN_INTP_CLASSPATH_OVERRIDES}:${CLASSPATH} ${ZEPPELIN_SERVER} ${PORT} &
${ZEPPELIN_IMPERSONATE_RUN_CMD} ${ZEPPELIN_RUNNER} ${JAVA_INTP_OPTS} ${ZEPPELIN_INTP_MEM} -cp ${ZEPPELIN_INTP_CLASSPATH_OVERRIDES}:${CLASSPATH} ${ZEPPELIN_SERVER} ${PORT} &
fi
pid=$!

View file

@ -79,3 +79,4 @@
# export ZEPPELINHUB_API_ADDRESS # Refers to the address of the ZeppelinHub service in use
# export ZEPPELINHUB_API_TOKEN # Refers to the Zeppelin instance token of the user
# export ZEPPELINHUB_USER_KEY # Optional, when using Zeppelin with authentication.
# export ZEPPELIN_IMPERSONATE_CMD # Optional, when user want to run interpreter as end web user. eg) 'sudo -u ${ZEPPELIN_IMPERSONATE_USER}'

View file

@ -46,6 +46,7 @@
<li><a href="{{BASE_PATH}}/manual/interpreterinstallation.html">Interpreter Installation</a></li>
<!--<li><a href="{{BASE_PATH}}/manual/dynamicinterpreterload.html">Dynamic Interpreter Loading</a></li>-->
<li><a href="{{BASE_PATH}}/manual/dependencymanagement.html">Interpreter Dependency Management</a></li>
<li><a href="{{BASE_PATH}}/manual/userimpersonation.html">Interpreter User Impersonation</a></li>
<li role="separator" class="divider"></li>
<li class="title"><span><b>Available Interpreters</b><span></li>
<li><a href="{{BASE_PATH}}/interpreter/alluxio.html">Alluxio</a></li>

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

View file

@ -142,6 +142,7 @@ Join to our [Mailing list](https://zeppelin.apache.org/community.html) and repor
* Usage
* [Interpreter Installation](./manual/interpreterinstallation.html): Install not only community managed interpreters but also 3rd party interpreters
* [Interpreter Dependency Management](./manual/dependencymanagement.html) when you include external libraries to interpreter
* [Interpreter User Impersonation](./manual/userimpersonation.html) when you want to run interpreter as end user
* Available Interpreters: currently, about 20 interpreters are available in Apache Zeppelin.
####Display System

View file

@ -0,0 +1,66 @@
---
layout: page
title: "Run zeppelin interpreter process as web front end user"
description: "Set up zeppelin interpreter process as web front end user."
group: manual
---
<!--
Licensed 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.
-->
{% include JB/setup %}
## Run zeppelin interpreter process as web front end user
* Enable shiro auth in shiro.ini
```
[users]
user1 = password1, role1
user2 = password2, role2
```
* Enable password-less ssh for the user you want to impersonate (say user1).
```
adduser user1
#ssh-keygen (optional if you don't already have generated ssh-key.
ssh user1@localhost mkdir -p .ssh
cat ~/.ssh/id_rsa.pub | ssh user1@localhost 'cat >> .ssh/authorized_keys'
```
* Start zeppelin server.
<hr>
<div class="row">
<div class="col-md-12">
<b> Screenshot </b>
<br /><br />
</div>
<div class="col-md-12" >
<a data-lightbox="compiler" href="../assets/themes/zeppelin/img/screenshots/user-impersonation.gif">
<img class="img-responsive" src="../assets/themes/zeppelin/img/screenshots/user-impersonation.gif" />
</a>
</div>
</div>
<hr>
* Go to interpreter setting page, and enable "User Impersonate" in any of the interpreter (in my example its shell interpreter)
* Test with a simple paragraph
```
%sh
whoami
```

View file

@ -37,6 +37,7 @@ public class InterpreterOption {
boolean isExistingProcess;
boolean setPermission;
List<String> users;
boolean isUserImpersonate;
public boolean isExistingProcess() {
return isExistingProcess;
@ -66,6 +67,14 @@ public class InterpreterOption {
return users;
}
public boolean isUserImpersonate() {
return isUserImpersonate;
}
public void setUserImpersonate(boolean userImpersonate) {
isUserImpersonate = userImpersonate;
}
public InterpreterOption() {
this(false);
}

View file

@ -60,6 +60,8 @@ public class RemoteInterpreter extends Interpreter {
private int maxPoolSize;
private String host;
private int port;
private String userName;
private Boolean isUserImpersonate;
/**
* Remote interpreter and manage interpreter process
@ -74,7 +76,8 @@ public class RemoteInterpreter extends Interpreter {
int maxPoolSize,
RemoteInterpreterProcessListener remoteInterpreterProcessListener,
ApplicationEventListener appListener,
RemoteWorksController remoteWorksController) {
String userName,
Boolean isUserImpersonate) {
super(property);
this.noteId = noteId;
this.className = className;
@ -87,7 +90,8 @@ public class RemoteInterpreter extends Interpreter {
this.maxPoolSize = maxPoolSize;
this.remoteInterpreterProcessListener = remoteInterpreterProcessListener;
this.applicationEventListener = appListener;
this.remoteWorksController = remoteWorksController;
this.userName = userName;
this.isUserImpersonate = isUserImpersonate;
}
@ -104,7 +108,8 @@ public class RemoteInterpreter extends Interpreter {
int maxPoolSize,
RemoteInterpreterProcessListener remoteInterpreterProcessListener,
ApplicationEventListener appListener,
RemoteWorksController remoteWorksController) {
String userName,
Boolean isUserImpersonate) {
super(property);
this.noteId = noteId;
this.className = className;
@ -115,7 +120,8 @@ public class RemoteInterpreter extends Interpreter {
this.maxPoolSize = maxPoolSize;
this.remoteInterpreterProcessListener = remoteInterpreterProcessListener;
this.applicationEventListener = appListener;
this.remoteWorksController = remoteWorksController;
this.userName = userName;
this.isUserImpersonate = isUserImpersonate;
}
@ -131,7 +137,8 @@ public class RemoteInterpreter extends Interpreter {
int connectTimeout,
RemoteInterpreterProcessListener remoteInterpreterProcessListener,
ApplicationEventListener appListener,
RemoteWorksController remoteWorksController) {
String userName,
Boolean isUserImpersonate) {
super(property);
this.className = className;
this.noteId = noteId;
@ -144,7 +151,8 @@ public class RemoteInterpreter extends Interpreter {
this.maxPoolSize = 10;
this.remoteInterpreterProcessListener = remoteInterpreterProcessListener;
this.applicationEventListener = appListener;
this.remoteWorksController = remoteWorksController;
this.userName = userName;
this.isUserImpersonate = isUserImpersonate;
}
private Map<String, String> getEnvFromInterpreterProperty(Properties property) {
@ -214,7 +222,7 @@ public class RemoteInterpreter extends Interpreter {
RemoteInterpreterProcess interpreterProcess = getInterpreterProcess();
final InterpreterGroup interpreterGroup = getInterpreterGroup();
interpreterProcess.reference(interpreterGroup);
interpreterProcess.reference(interpreterGroup, userName, isUserImpersonate);
interpreterProcess.setMaxPoolSize(
Math.max(this.maxPoolSize, interpreterProcess.getMaxPoolSize()));
String groupId = interpreterGroup.getId();

View file

@ -93,7 +93,7 @@ public class RemoteInterpreterManagedProcess extends RemoteInterpreterProcess
}
@Override
public void start() {
public void start(String userName, Boolean isUserImpersonate) {
// start server process
try {
port = RemoteInterpreterUtils.findRandomAvailablePortOnAllLocalInterfaces();
@ -106,6 +106,10 @@ public class RemoteInterpreterManagedProcess extends RemoteInterpreterProcess
cmdLine.addArgument(interpreterDir, false);
cmdLine.addArgument("-p", false);
cmdLine.addArgument(Integer.toString(port), false);
if (isUserImpersonate && !userName.equals("anonymous")) {
cmdLine.addArgument("-u", false);
cmdLine.addArgument(userName, false);
}
cmdLine.addArgument("-l", false);
cmdLine.addArgument(localRepoDir, false);

View file

@ -65,7 +65,7 @@ public abstract class RemoteInterpreterProcess {
public abstract String getHost();
public abstract int getPort();
public abstract void start();
public abstract void start(String userName, Boolean isUserImpersonate);
public abstract void stop();
public abstract boolean isRunning();
@ -73,10 +73,11 @@ public abstract class RemoteInterpreterProcess {
return connectTimeout;
}
public int reference(InterpreterGroup interpreterGroup) {
public int reference(InterpreterGroup interpreterGroup, String userName,
Boolean isUserImpersonate) {
synchronized (referenceCount) {
if (!isRunning()) {
start();
start(userName, isUserImpersonate);
}
if (clientPool == null) {

View file

@ -53,7 +53,7 @@ public class RemoteInterpreterRunningProcess extends RemoteInterpreterProcess {
}
@Override
public void start() {
public void start(String userName, Boolean isUserImpersonate) {
// assume process is externally managed. nothing to do
}

View file

@ -75,7 +75,8 @@ public class RemoteAngularObjectTest implements AngularObjectRegistryListener {
10 * 1000,
null,
null,
null
"anonymous",
false
);
intpGroup.put("note", new LinkedList<Interpreter>());

View file

@ -73,7 +73,8 @@ public class RemoteInterpreterOutputTestStream implements RemoteInterpreterProce
10 * 1000,
this,
null,
null);
"anonymous",
false);
intpGroup.get("note").add(intp);
intp.setInterpreterGroup(intpGroup);

View file

@ -46,8 +46,8 @@ public class RemoteInterpreterProcessTest {
10 * 1000, null, null, null);
assertFalse(rip.isRunning());
assertEquals(0, rip.referenceCount());
assertEquals(1, rip.reference(intpGroup));
assertEquals(2, rip.reference(intpGroup));
assertEquals(1, rip.reference(intpGroup, "anonymous", false));
assertEquals(2, rip.reference(intpGroup, "anonymous", false));
assertEquals(true, rip.isRunning());
assertEquals(1, rip.dereference());
assertEquals(true, rip.isRunning());
@ -61,7 +61,7 @@ public class RemoteInterpreterProcessTest {
RemoteInterpreterManagedProcess rip = new RemoteInterpreterManagedProcess(
INTERPRETER_SCRIPT, "nonexists", "fakeRepo", new HashMap<String, String>(),
mock(RemoteInterpreterEventPoller.class), 10 * 1000);
rip.reference(intpGroup);
rip.reference(intpGroup, "anonymous", false);
assertEquals(0, rip.getNumActiveClient());
assertEquals(0, rip.getNumIdleClient());
@ -106,7 +106,7 @@ public class RemoteInterpreterProcessTest {
, 10 * 1000);
assertFalse(rip.isRunning());
assertEquals(0, rip.referenceCount());
assertEquals(1, rip.reference(intpGroup));
assertEquals(1, rip.reference(intpGroup, "anonymous", false));
assertEquals(true, rip.isRunning());
}
}

View file

@ -81,17 +81,18 @@ public class RemoteInterpreterTest {
private RemoteInterpreter createMockInterpreterA(Properties p, String noteId) {
return new RemoteInterpreter(
p,
noteId,
MockInterpreterA.class.getName(),
new File(INTERPRETER_SCRIPT).getAbsolutePath(),
"fake",
"fakeRepo",
env,
10 * 1000,
null,
null,
null);
p,
noteId,
MockInterpreterA.class.getName(),
new File(INTERPRETER_SCRIPT).getAbsolutePath(),
"fake",
"fakeRepo",
env,
10 * 1000,
null,
null,
"anonymous",
false);
}
private RemoteInterpreter createMockInterpreterB(Properties p) {
@ -100,17 +101,18 @@ public class RemoteInterpreterTest {
private RemoteInterpreter createMockInterpreterB(Properties p, String noteId) {
return new RemoteInterpreter(
p,
noteId,
MockInterpreterB.class.getName(),
new File(INTERPRETER_SCRIPT).getAbsolutePath(),
"fake",
"fakeRepo",
env,
10 * 1000,
null,
null,
null);
p,
noteId,
MockInterpreterB.class.getName(),
new File(INTERPRETER_SCRIPT).getAbsolutePath(),
"fake",
"fakeRepo",
env,
10 * 1000,
null,
null,
"anonymous",
false);
}
@Test
@ -210,8 +212,8 @@ public class RemoteInterpreterTest {
10 * 1000,
null,
null,
null);
"anonymous",
false);
intpGroup.get("note").add(intpA);
intpA.setInterpreterGroup(intpGroup);
@ -227,7 +229,8 @@ public class RemoteInterpreterTest {
10 * 1000,
null,
null,
null);
"anonymous",
false);
intpGroup.get("note").add(intpB);
intpB.setInterpreterGroup(intpGroup);
@ -691,8 +694,8 @@ public class RemoteInterpreterTest {
//Given
final Client client = Mockito.mock(Client.class);
final RemoteInterpreter intr = new RemoteInterpreter(new Properties(), "noteId",
MockInterpreterA.class.getName(),
"runner", "path","localRepo", env, 10 * 1000, null, null, null);
MockInterpreterA.class.getName(), "runner", "path", "localRepo", env, 10 * 1000, null,
null, "anonymous", false);
final AngularObjectRegistry registry = new AngularObjectRegistry("spark", null);
registry.add("name", "DuyHai DOAN", "nodeId", "paragraphId");
final InterpreterGroup interpreterGroup = new InterpreterGroup("groupId");
@ -739,7 +742,8 @@ public class RemoteInterpreterTest {
10 * 1000,
null,
null,
null);
"anonymous",
false);
intpGroup.put("note", new LinkedList<Interpreter>());
intpGroup.get("note").add(intp);

View file

@ -71,7 +71,8 @@ public class DistributedResourcePoolTest {
10 * 1000,
null,
null,
null
"anonymous",
false
);
intpGroup1 = new InterpreterGroup("intpGroup1");
@ -90,7 +91,8 @@ public class DistributedResourcePoolTest {
10 * 1000,
null,
null,
null
"anonymous",
false
);
intpGroup2 = new InterpreterGroup("intpGroup2");

View file

@ -82,7 +82,8 @@ public class RemoteSchedulerTest implements RemoteInterpreterProcessListener {
10 * 1000,
this,
null,
null);
"anonymous",
false);
intpGroup.put("note", new LinkedList<Interpreter>());
intpGroup.get("note").add(intpA);
@ -172,7 +173,8 @@ public class RemoteSchedulerTest implements RemoteInterpreterProcessListener {
10 * 1000,
this,
null,
null);
"anonymous",
false);
intpGroup.put("note", new LinkedList<Interpreter>());
intpGroup.get("note").add(intpA);

View file

@ -196,7 +196,20 @@ limitations under the License.
</div>
</div>
</div>
<div class="row interpreter" style="margin-top: 5px;">
<div class="row interpreter" style="margin-top: 5px;"
ng-show="getInterpreterRunningOption(setting.id)=='Per User' && getPerUserOption(setting.id)=='isolated'">
<div class="col-md-12">
<div class="checkbox remove-margin-top-bottom">
<span class="input-group" style="line-height:30px;">
<label>
<input type="checkbox" style="width:20px" ng-model="setting.option.isUserImpersonate" />
User Impersonate
</label>
</span>
</div>
</div>
</div>
<div class="row interpreter">
<div class="col-md-12">
<div class="checkbox remove-margin-top-bottom">
<span class="input-group" style="line-height:30px;">

View file

@ -327,6 +327,13 @@
if (setting.option.setPermission === undefined) {
setting.option.setPermission = false;
}
if (setting.option.isUserImpersonate === undefined) {
setting.option.isUserImpersonate = false;
}
if (!($scope.getInterpreterRunningOption(settingId) === 'Per User' &&
$scope.getPerUserOption(settingId) === 'isolated')) {
setting.option.isUserImpersonate = false;
}
if (setting.option.remote === undefined) {
// remote always true for now
setting.option.remote = true;

View file

@ -313,7 +313,23 @@ limitations under the License.
</div>
</div>
</div>
<div class="row interpreter" style="margin-top: 5px;">
<div class="row interpreter" style="margin-top: 5px;"
ng-show="getInterpreterRunningOption(setting.id)=='Per User' && getPerUserOption(setting.id)=='isolated'">
<div class="col-md-12">
<div class="checkbox remove-margin-top-bottom">
<span class="input-group" style="line-height:30px;">
<label>
<input type="checkbox" style="width:20px"
ng-model="setting.option.isUserImpersonate"
ng-disabled="!valueform.$visible" />
User Impersonate
</label>
</span>
</div>
</div>
</div>
<div class="row interpreter">
<div class="col-md-12">
<div class="checkbox remove-margin-top-bottom">
<span class="input-group" style="line-height:30px;">

View file

@ -17,11 +17,12 @@
angular.module('zeppelinWebApp').controller('LoginCtrl', LoginCtrl);
LoginCtrl.$inject = ['$scope', '$rootScope', '$http', '$httpParamSerializer', 'baseUrlSrv'];
function LoginCtrl($scope, $rootScope, $http, $httpParamSerializer, baseUrlSrv) {
$scope.SigningIn = false;
$scope.loginParams = {};
$scope.login = function() {
$scope.SigningIn = true;
$http({
method: 'POST',
url: baseUrlSrv.getRestApiBase() + '/login',
@ -39,6 +40,7 @@
$rootScope.userName = $scope.loginParams.userName;
}, function errorCallback(errorResponse) {
$scope.loginParams.errorText = 'The username and password that you entered don\'t match.';
$scope.SigningIn = false;
});
};

View file

@ -41,8 +41,11 @@ limitations under the License.
</div>
</div>
<div class="modal-footer">
<div>
<div class="modal-footer" ng-switch on="SigningIn">
<div ng-switch-when="true">
<button type="button" class="btn btn-default btn-primary" disabled><i class="fa fa-circle-o-notch fa-spin"></i> Signing In</button>
</div>
<div ng-switch-default>
<button type="button" class="btn btn-default btn-primary" ng-click="login()">Login</button>
</div>
</div>

View file

@ -785,10 +785,10 @@ public class InterpreterFactory implements InterpreterGroupFactory {
if (option.isExistingProcess()) {
interpreter =
connectToRemoteRepl(noteId, info.getClassName(), option.getHost(), option.getPort(),
properties);
properties, user, option.isUserImpersonate);
} else {
interpreter = createRemoteRepl(path, key, info.getClassName(), properties,
interpreterSetting.getId());
interpreterSetting.getId(), user, option.isUserImpersonate());
}
} else {
interpreter = createRepl(interpreterSetting.getPath(), info.getClassName(), properties);
@ -1109,17 +1109,18 @@ public class InterpreterFactory implements InterpreterGroupFactory {
}
private Interpreter connectToRemoteRepl(String noteId, String className, String host, int port,
Properties property) {
Properties property, String userName, Boolean isUserImpersonate) {
int connectTimeout = conf.getInt(ConfVars.ZEPPELIN_INTERPRETER_CONNECT_TIMEOUT);
int maxPoolSize = conf.getInt(ConfVars.ZEPPELIN_INTERPRETER_MAX_POOL_SIZE);
LazyOpenInterpreter intp = new LazyOpenInterpreter(
new RemoteInterpreter(property, noteId, className, host, port, connectTimeout, maxPoolSize,
remoteInterpreterProcessListener, appEventListener, remoteWorksController));
remoteInterpreterProcessListener, appEventListener, userName, isUserImpersonate));
return intp;
}
private Interpreter createRemoteRepl(String interpreterPath, String noteId, String className,
Properties property, String interpreterSettingId) {
Properties property, String interpreterSettingId, String userName,
Boolean isUserImpersonate) {
int connectTimeout = conf.getInt(ConfVars.ZEPPELIN_INTERPRETER_CONNECT_TIMEOUT);
String localRepoPath = conf.getInterpreterLocalRepoPath() + "/" + interpreterSettingId;
int maxPoolSize = conf.getInt(ConfVars.ZEPPELIN_INTERPRETER_MAX_POOL_SIZE);
@ -1127,7 +1128,7 @@ public class InterpreterFactory implements InterpreterGroupFactory {
RemoteInterpreter remoteInterpreter =
new RemoteInterpreter(property, noteId, className, conf.getInterpreterRemoteRunnerPath(),
interpreterPath, localRepoPath, connectTimeout, maxPoolSize,
remoteInterpreterProcessListener, appEventListener, remoteWorksController);
remoteInterpreterProcessListener, appEventListener, userName, isUserImpersonate);
remoteInterpreter.addEnv(env);
return new LazyOpenInterpreter(remoteInterpreter);
@ -1423,7 +1424,7 @@ public class InterpreterFactory implements InterpreterGroupFactory {
InterpreterGroup interpreterGroup = createInterpreterGroup("dev", option);
devInterpreter = connectToRemoteRepl("dev", DevInterpreter.class.getName(), "localhost",
ZeppelinDevServer.DEFAULT_TEST_INTERPRETER_PORT, new Properties());
ZeppelinDevServer.DEFAULT_TEST_INTERPRETER_PORT, new Properties(), "anonymous", false);
LinkedList<Interpreter> intpList = new LinkedList<>();
intpList.add(devInterpreter);

View file

@ -593,7 +593,7 @@ public class Paragraph extends Job implements Serializable, Cloneable {
}
private boolean isValidInterpreter(String replName) {
return factory.getInterpreter("",
return factory.getInterpreter(user,
note.getId(), replName) != null;
}
}