This is an automated email from the ASF dual-hosted git repository.
alopresto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/master by this push:
new e973cac NIFI-5973 Adds ShellUserGroupProvider. NIFI-5973 More
comments and better defaults for the shell provider. NIFI-5973 Fixed bug where
user was being retrieved by identifier when identity was provided. NIFI-5973
Fixed a formatting string in the OS X shell commands. Updated testing
conditions to run IT in OS X environment. Changed unit test to provide identity
rather than identifier.
e973cac is described below
commit e973cacb2fe349028dae03b85a00ed89f3049967
Author: Troy Melhase <[email protected]>
AuthorDate: Tue Jun 18 16:41:44 2019 -0800
NIFI-5973 Adds ShellUserGroupProvider.
NIFI-5973 More comments and better defaults for the shell provider.
NIFI-5973 Fixed bug where user was being retrieved by identifier when
identity was provided.
NIFI-5973 Fixed a formatting string in the OS X shell commands.
Updated testing conditions to run IT in OS X environment.
Changed unit test to provide identity rather than identifier.
This closes #3537.
Signed-off-by: Andy LoPresto <[email protected]>
---
.../src/main/asciidoc/administration-guide.adoc | 21 +
.../nifi-framework-nar/pom.xml | 4 +
.../nifi-framework/nifi-file-authorizer/pom.xml | 6 +
.../src/main/resources/conf/authorizers.xml | 18 +
.../nifi-framework/nifi-shell-authorizer/pom.xml | 46 ++
.../nifi/authorization/NssShellCommands.java | 90 ++++
.../nifi/authorization/OsxShellCommands.java | 82 +++
.../nifi/authorization/RemoteShellCommands.java | 74 +++
.../nifi/authorization/ShellCommandsProvider.java | 100 ++++
.../nifi/authorization/ShellUserGroupProvider.java | 585 +++++++++++++++++++++
.../nifi/authorization/util/ShellRunner.java | 77 +++
...org.apache.nifi.authorization.UserGroupProvider | 15 +
.../authorization/ShellUserGroupProviderBase.java | 176 +++++++
.../authorization/ShellUserGroupProviderIT.java | 283 ++++++++++
.../src/main/webapp/js/nf/users/nf-users-table.js | 2 +-
.../nifi-framework-bundle/nifi-framework/pom.xml | 1 +
nifi-nar-bundles/nifi-framework-bundle/pom.xml | 5 +
pom.xml | 7 +-
18 files changed, 1590 insertions(+), 2 deletions(-)
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc
b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index e0a20ff..25a9769 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -452,6 +452,27 @@ The LdapUserGroupProvider has the following properties:
NOTE: Any identity mapping rules specified in _nifi.properties_ will also be
applied to the user identities. Group names are not mapped.
+==== ShellUserGroupProvider
+
+The ShellUserGroupProvider fetches user and group details from Unix-like
systems using shell commands.
+
+This provider executes various shell pipelines with commands such as `getent`
on Linux and `dscl` on MacOS.
+
+Supported systems may be configured to retrieve users and groups from an
external source, such as LDAP or NIS. In these cases the shell commands
+will return those external users and groups. This provides administrators
another mechanism to integrate user and group directory services.
+
+The ShellUserGroupProvider has the following properties:
+
+[options="header,footer"]
+|==================================================================================================================================================
+| Property Name | Description
+|`Initial Refresh Delay` | Duration of initial delay before first user and
group refresh. (i.e. `10 secs`). Default is `5 mins`.
+|`Refresh Delay` | Duration of delay between each user and group refresh.
(i.e. `10 secs`). Default is `5 mins`.
+|==================================================================================================================================================
+
+Like LdapUserGroupProvider, the ShellUserGroupProvider is commented out in the
_authorizers.xml_ file. Refer to that comment for usage examples.
+
+
==== Composite Implementations
Another option for the UserGroupProvider are composite implementations. This
means that multiple sources/implementations can be configured and composed. For
instance, an admin can configure users/groups to be loaded from a file and a
directory server. There are two composite implementations, one that supports
multiple UserGroupProviders and one that supports multiple UserGroupProviders
and a single configurable UserGroupProvider.
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml
index d5495d6..d67b68a 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml
@@ -37,6 +37,10 @@
</dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
+ <artifactId>nifi-shell-authorizer</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.nifi</groupId>
<artifactId>nifi-authorizer</artifactId>
</dependency>
<dependency>
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
index 705ccfd..11134f4 100644
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
@@ -130,5 +130,11 @@
<artifactId>nifi-expression-language</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <version>1.11.3</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
index d6d3c45..2210c43 100644
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
@@ -167,6 +167,23 @@
To enable the ldap-user-group-provider remove 2 lines. This is 2 of 2. -->
<!--
+ The ShellUserGroupProvider provides support for retrieving users and
groups by way of shell commands
+ on systems that support `sh`. Implementations available for Linux and
Mac OS, and are selected by the
+ provider based on the system property `os.name`.
+
+ 'Initial Refresh Delay' - duration to wait before first refresh.
Default is '5 mins'.
+ 'Refresh Delay' - duration to wait between subsequent refreshes.
Default is '5 mins'.
+ -->
+ <!-- To enable the shell-user-group-provider remove 2 lines. This is 1 of
2.
+ <userGroupProvider>
+ <identifier>shell-user-group-provider</identifier>
+ <class>org.apache.nifi.authorization.ShellUserGroupProvider</class>
+ <property name="Initial Refresh Delay">5 mins</property>
+ <property name="Refresh Delay">5 mins</property>
+ </userGroupProvider>
+ To enable the shell-user-group-provider remove 2 lines. This is 2 of 2. -->
+
+ <!--
The CompositeUserGroupProvider will provide support for retrieving
users and groups from multiple sources.
- User Group Provider [unique key] - The identifier of user group
providers to load from. The name of
@@ -198,6 +215,7 @@
NOTE: Any identity mapping rules specified in nifi.properties are
not applied in this implementation. This behavior
would need to be applied by the base implementation.
-->
+
<!-- To enable the composite-configurable-user-group-provider remove 2
lines. This is 1 of 2.
<userGroupProvider>
<identifier>composite-configurable-user-group-provider</identifier>
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/pom.xml
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/pom.xml
new file mode 100644
index 0000000..175b831
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/pom.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <parent>
+ <artifactId>nifi-framework</artifactId>
+ <groupId>org.apache.nifi</groupId>
+ <version>1.10.0-SNAPSHOT</version>
+ </parent>
+ <modelVersion>4.0.0</modelVersion>
+ <artifactId>nifi-shell-authorizer</artifactId>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.nifi</groupId>
+ <artifactId>nifi-framework-api</artifactId>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <version>1.11.3</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.nifi</groupId>
+ <artifactId>nifi-mock</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.nifi</groupId>
+ <artifactId>nifi-utils</artifactId>
+ <version>1.10.0-SNAPSHOT</version>
+ </dependency>
+ </dependencies>
+</project>
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/NssShellCommands.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/NssShellCommands.java
new file mode 100644
index 0000000..fe49200
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/NssShellCommands.java
@@ -0,0 +1,90 @@
+/*
+ * 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.nifi.authorization;
+
+
+/**
+ * Provides shell commands to read users and groups on NSS-enabled systems.
+ *
+ * See `man 5 nsswitch.conf` for more info.
+ */
+class NssShellCommands implements ShellCommandsProvider {
+ /**
+ * @return Shell command string that will return a list of users.
+ */
+ public String getUsersList() {
+ return "getent passwd | cut -f 1,3,4 -d ':'";
+ }
+
+ /**
+ * @return Shell command string that will return a list of groups.
+ */
+ public String getGroupsList() {
+ return "getent group | cut -f 1,3 -d ':'";
+ }
+
+ /**
+ * @param groupName name of group.
+ * @return Shell command string that will return a list of users for a
group.
+ */
+ public String getGroupMembers(String groupName) {
+ return String.format("getent group %s | cut -f 4 -d ':'", groupName);
+ }
+
+ /**
+ * Gets the command for reading a single user by id.
+ *
+ * When executed, this command should output a single line, in the format
used by `getUsersList`.
+ *
+ * @param userId name of user.
+ * @return Shell command string that will read a single user.
+ */
+ @Override
+ public String getUserById(String userId) {
+ return String.format("getent passwd %s | cut -f 1,3,4 -d ':'", userId);
+ }
+
+ /**
+ * This method reuses `getUserById` because the getent command is the same
for
+ * both uid and username.
+ *
+ * @param userName name of user.
+ * @return Shell command string that will read a single user.
+ */
+ public String getUserByName(String userName) {
+ return getUserById(userName);
+ }
+
+ /**
+ * This method supports gid or group name because getent does.
+ *
+ * @param groupId name of group.
+ * @return Shell command string that will read a single group.
+ */
+ public String getGroupById(String groupId) {
+ return String.format("getent group %s | cut -f 1,3,4 -d ':'", groupId);
+ }
+
+ /**
+ * This gives exit code 0 on all tested distributions.
+ *
+ * @return Shell command string that will exit normally (0) on a suitable
system.
+ */
+ public String getSystemCheck() {
+ return "getent passwd";
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/OsxShellCommands.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/OsxShellCommands.java
new file mode 100644
index 0000000..85dca06
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/OsxShellCommands.java
@@ -0,0 +1,82 @@
+/*
+ * 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.nifi.authorization;
+
+
+/**
+ * Provides shell commands to read users and groups on Mac OSX systems.
+ *
+ * See `man dscl` for more info.
+ */
+class OsxShellCommands implements ShellCommandsProvider {
+ /**
+ * @return Shell command string that will return a list of users.
+ */
+ public String getUsersList() {
+ return "dscl . -readall /Users UniqueID PrimaryGroupID | awk 'BEGIN {
OFS = \":\"; ORS=\"\\n\"; i=0;} /RecordName: / {name = $2;i = 0;}" +
+ "/PrimaryGroupID: / {gid = $2;} /^ / {if (i == 0) { i++; name =
$1;}} /UniqueID: / {uid = $2;print name, uid, gid;}' | grep -v ^_";
+ }
+
+ /**
+ * @return Shell command string that will return a list of groups.
+ */
+ public String getGroupsList() {
+ return "dscl . -list /Groups PrimaryGroupID | grep -v '^_' | sed 's/
\\{1,\\}/:/g'";
+ }
+
+ /**
+ *
+ * @param groupName name of group.
+ * @return Shell command string that will return a list of users for a
group.
+ */
+ public String getGroupMembers(String groupName) {
+ return String.format("dscl . -read /Groups/%s GroupMembership | cut -f
2- -d ' ' | sed 's/\\ /,/g'", groupName);
+ }
+
+ /**
+ * @param userId name of user.
+ * @return Shell command string that will read a single user.
+ */
+ @Override
+ public String getUserById(String userId) {
+ return String.format("id -P %s | cut -f 1,3,4 -d ':'", userId);
+ }
+
+ /**
+ * @param userName name of user.
+ * @return Shell command string that will read a single user.
+ */
+ public String getUserByName(String userName) {
+ return getUserById(userName); // 'id' command works for both
uid/username
+ }
+
+ /**
+ * @param groupId name of group.
+ * @return Shell command string that will read a single group.
+ */
+ public String getGroupById(String groupId) {
+ return String.format(" dscl . -read /Groups/`dscl . -search /Groups
gid %s | head -n 1 | cut -f 1` RecordName PrimaryGroupID | awk 'BEGIN { OFS =
\":\"; ORS=\"\\n\"; i=0;} " +
+ "/RecordName: / {name = $2;i =
1;}/PrimaryGroupID: / {gid = $2;}; {if (i==1) {print name,gid,\"\"}}'",
groupId);
+ }
+
+ /**
+ * @return Shell command string that will exit normally (0) on a suitable
system.
+ */
+ public String getSystemCheck() {
+ return "which dscl";
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/RemoteShellCommands.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/RemoteShellCommands.java
new file mode 100644
index 0000000..3c26ba7
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/RemoteShellCommands.java
@@ -0,0 +1,74 @@
+/*
+ * 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.nifi.authorization;
+
+
+class RemoteShellCommands implements ShellCommandsProvider {
+ // Carefully crafted command replacement string:
+ private final static String remoteCommand = "ssh " +
+ "-o 'StrictHostKeyChecking no' " +
+ "-o 'PasswordAuthentication no' " +
+ "-o \"RemoteCommand %s\" " +
+ "-i %s -p %s -l root %s";
+
+ private ShellCommandsProvider innerProvider;
+ private String privateKeyPath;
+ private String remoteHost;
+ private Integer remotePort;
+
+ private RemoteShellCommands() {
+ }
+
+ public static ShellCommandsProvider
wrapOtherProvider(ShellCommandsProvider otherProvider, String keyPath, String
host, Integer port) {
+ RemoteShellCommands remote = new RemoteShellCommands();
+
+ remote.innerProvider = otherProvider;
+ remote.privateKeyPath = keyPath;
+ remote.remoteHost = host;
+ remote.remotePort = port;
+
+ return remote;
+ }
+
+ public String getUsersList() {
+ return String.format(remoteCommand, innerProvider.getUsersList(),
privateKeyPath, remotePort, remoteHost);
+ }
+
+ public String getGroupsList() {
+ return String.format(remoteCommand, innerProvider.getGroupsList(),
privateKeyPath, remotePort, remoteHost);
+ }
+
+ public String getGroupMembers(String groupName) {
+ return String.format(remoteCommand,
innerProvider.getGroupMembers(groupName), privateKeyPath, remotePort,
remoteHost);
+ }
+
+ public String getUserById(String userId) {
+ return String.format(remoteCommand, innerProvider.getUserById(userId),
privateKeyPath, remotePort, remoteHost);
+ }
+
+ public String getUserByName(String userName) {
+ return String.format(remoteCommand,
innerProvider.getUserByName(userName), privateKeyPath, remotePort, remoteHost);
+ }
+
+ public String getGroupById(String groupId) {
+ return String.format(remoteCommand,
innerProvider.getGroupById(groupId), privateKeyPath, remotePort, remoteHost);
+ }
+
+ public String getSystemCheck() {
+ return String.format(remoteCommand, innerProvider.getSystemCheck(),
privateKeyPath, remotePort, remoteHost);
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellCommandsProvider.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellCommandsProvider.java
new file mode 100644
index 0000000..14c7de4
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellCommandsProvider.java
@@ -0,0 +1,100 @@
+/*
+ * 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.nifi.authorization;
+
+/**
+ * Common interface for shell command strings to read users and groups.
+ *
+ */
+interface ShellCommandsProvider {
+ /**
+ * Gets the command for listing users.
+ *
+ * When executed, this command should output one record per line in this
format:
+ *
+ * `username:user-id:primary-group-id`
+ *
+ * @return Shell command string that will return a list of users.
+ */
+ String getUsersList();
+
+ /**
+ * Gets the command for listing groups.
+ *
+ * When executed, this command should output one record per line in this
format:
+ *
+ * `group-name:group-id`
+ *
+ * @return Shell command string that will return a list of groups.
+ */
+ String getGroupsList();
+
+ /**
+ * Gets the command for listing the members of a group.
+ *
+ * When executed, this command should output one line in this format:
+ *
+ * `user-name-1,user-name-2,user-name-n`
+ *
+ * @param groupName name of group.
+ * @return Shell command string that will return a list of users for a
group.
+ */
+ String getGroupMembers(String groupName);
+
+ /**
+ * Gets the command for reading a single user by id. Implementations may
return null if reading a single
+ * user by id is not supported.
+ *
+ * When executed, this command should output a single line, in the format
used by `getUsersList`.
+ *
+ * @param userId name of user.
+ * @return Shell command string that will read a single user.
+ */
+ String getUserById(String userId);
+
+ /**
+ * Gets the command for reading a single user. Implementations may return
null if reading a single user by
+ * username is not supported.
+ *
+ * When executed, this command should output a single line, in the format
used by `getUsersList`.
+ *
+ * @param userName name of user.
+ * @return Shell command string that will read a single user.
+ */
+ String getUserByName(String userName);
+
+ /**
+ * Gets the command for reading a single group. Implementations may
return null if reading a single group
+ * by name is not supported.
+ *
+ * When executed, this command should output a single line, in the format
used by `getGroupsList`.
+ *
+ * @param groupId name of group.
+ * @return Shell command string that will read a single group.
+ */
+ String getGroupById(String groupId);
+
+ /**
+ * Gets the command for checking the suitability of the host system.
+ *
+ * The command is expected to exit with status 0 (zero) to indicate
success, and any other status
+ * to indicate failure.
+ *
+ * @return Shell command string that will exit normally (0) on a suitable
system.
+ */
+ String getSystemCheck();
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
new file mode 100644
index 0000000..e499b6e
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
@@ -0,0 +1,585 @@
+/*
+ * 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.nifi.authorization;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.apache.nifi.authorization.exception.AuthorizationAccessException;
+import org.apache.nifi.authorization.exception.AuthorizerCreationException;
+import org.apache.nifi.authorization.exception.AuthorizerDestructionException;
+import org.apache.nifi.authorization.util.ShellRunner;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.util.FormatUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/*
+ * ShellUserGroupProvider implements UserGroupProvider by way of shell
commands.
+ */
+public class ShellUserGroupProvider implements UserGroupProvider {
+
+ private final static Logger logger =
LoggerFactory.getLogger(ShellUserGroupProvider.class);
+
+ private final static String OS_TYPE_ERROR = "Unsupported operating
system.";
+ private final static String SYS_CHECK_ERROR = "System check failed -
cannot provide users and groups.";
+ private final static Map<String, User> usersById = new HashMap<>(); //
id == identifier
+ private final static Map<String, User> usersByName = new HashMap<>(); //
name == identity
+ private final static Map<String, Group> groupsById = new HashMap<>();
+
+ public static final String INITIAL_REFRESH_DELAY_PROPERTY = "Initial
Refresh Delay";
+ public static final String REFRESH_DELAY_PROPERTY = "Refresh Delay";
+
+ private static final long MINIMUM_SYNC_INTERVAL_MILLISECONDS = 10_000;
+ private long initialDelay;
+ private long fixedDelay;
+
+ // Our scheduler has one thread for users, one for groups:
+ private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(2);
+
+ // Our shell timeout, in seconds:
+ @SuppressWarnings("FieldCanBeLocal")
+ private final Integer shellTimeout = 10;
+
+ // Commands selected during initialization:
+ private ShellCommandsProvider selectedShellCommands;
+
+ // Start of the UserGroupProvider implementation. Javadoc strings
+ // copied from the interface definition for reference.
+
+ /**
+ * Retrieves all users. Must be non null
+ *
+ * @return a list of users
+ * @throws AuthorizationAccessException if there was an unexpected error
performing the operation
+ */
+ @Override
+ public Set<User> getUsers() throws AuthorizationAccessException {
+ synchronized (usersById) {
+ logger.debug("getUsers has user set of size: " + usersById.size());
+ return new HashSet<>(usersById.values());
+ }
+ }
+
+ /**
+ * Retrieves the user with the given identifier.
+ *
+ * @param identifier the id of the user to retrieve
+ * @return the user with the given id, or null if no matching user was
found
+ * @throws AuthorizationAccessException if there was an unexpected error
performing the operation
+ */
+ @Override
+ public User getUser(String identifier) throws AuthorizationAccessException
{
+ User user;
+
+ synchronized (usersById) {
+ user = usersById.get(identifier);
+ }
+
+ if (user == null) {
+ refreshOneUser(selectedShellCommands.getUserById(identifier), "Get
Single User by Id");
+ user = usersById.get(identifier);
+ }
+
+ if (user == null) {
+ logger.debug("getUser (by id) user not found: " + identifier);
+ } else {
+ logger.debug("getUser (by id) found user: " + user + " for id: " +
identifier);
+ }
+ return user;
+ }
+
+ /**
+ * Retrieves the user with the given identity.
+ *
+ * @param identity the identity of the user to retrieve
+ * @return the user with the given identity, or null if no matching user
was found
+ * @throws AuthorizationAccessException if there was an unexpected error
performing the operation
+ */
+ @Override
+ public User getUserByIdentity(String identity) throws
AuthorizationAccessException {
+ User user;
+
+ synchronized (usersByName) {
+ user = usersByName.get(identity);
+ }
+
+ if (user == null) {
+ refreshOneUser(selectedShellCommands.getUserByName(identity), "Get
Single User by Name");
+ user = usersByName.get(identity);
+ }
+
+ if (user == null) {
+ logger.debug("getUser (by name) user not found: " + identity);
+ } else {
+ logger.debug("getUser (by name) found user: " + user + " for name:
" + identity);
+ }
+ return user;
+ }
+
+ /**
+ * Retrieves all groups. Must be non null
+ *
+ * @return a list of groups
+ * @throws AuthorizationAccessException if there was an unexpected error
performing the operation
+ */
+ @Override
+ public Set<Group> getGroups() throws AuthorizationAccessException {
+ synchronized (groupsById) {
+ logger.debug("getGroups has group set of size: " +
groupsById.size());
+ return new HashSet<>(groupsById.values());
+ }
+ }
+
+ /**
+ * Retrieves a Group by Id.
+ *
+ * @param identifier the identifier of the Group to retrieve
+ * @return the Group with the given identifier, or null if no matching
group was found
+ * @throws AuthorizationAccessException if there was an unexpected error
performing the operation
+ */
+ @Override
+ public Group getGroup(String identifier) throws
AuthorizationAccessException {
+ Group group;
+
+ synchronized (groupsById) {
+ group = groupsById.get(identifier);
+ }
+
+ if (group == null) {
+ refreshOneGroup(selectedShellCommands.getGroupById(identifier),
"Get Single Group by Id");
+ group = groupsById.get(identifier);
+ }
+
+ if (group == null) {
+ logger.debug("getGroup (by id) group not found: " + identifier);
+ } else {
+ logger.debug("getGroup (by id) found group: " + group + " for id:
" + identifier);
+ }
+ return group;
+
+ }
+
+ /**
+ * Gets a user and their groups.
+ *
+ * @return the UserAndGroups for the specified identity
+ * @throws AuthorizationAccessException if there was an unexpected error
performing the operation
+ */
+ @Override
+ public UserAndGroups getUserAndGroups(String identity) throws
AuthorizationAccessException {
+ User user = getUserByIdentity(identity);
+ logger.debug("Retrieved user {} for identity {}", new Object[]{user,
identity});
+
+ Set<Group> groups = new HashSet<>();
+
+ for (Group g : getGroups()) {
+ if (user != null && g.getUsers().contains(user.getIdentity())) {
+ groups.add(g);
+ }
+ }
+
+ if (groups.isEmpty()) {
+ logger.debug("User {} belongs to no groups", user);
+ }
+
+ return new UserAndGroups() {
+ @Override
+ public User getUser() {
+ return user;
+ }
+
+ @Override
+ public Set<Group> getGroups() {
+ return groups;
+ }
+ };
+ }
+
+ /**
+ * Called immediately after instance creation for implementers to perform
additional setup
+ *
+ * @param initializationContext in which to initialize
+ */
+ @Override
+ public void initialize(UserGroupProviderInitializationContext
initializationContext) throws AuthorizerCreationException {
+ }
+
+ /**
+ * Called to configure the Authorizer.
+ *
+ * @param configurationContext at the time of configuration
+ * @throws AuthorizerCreationException for any issues configuring the
provider
+ */
+ @Override
+ public void onConfigured(AuthorizerConfigurationContext
configurationContext) throws AuthorizerCreationException {
+ initialDelay = getDelayProperty(configurationContext,
INITIAL_REFRESH_DELAY_PROPERTY, "5 mins");
+ fixedDelay = getDelayProperty(configurationContext,
REFRESH_DELAY_PROPERTY, "5 mins");
+
+ // Our next init step is to select the command set based on the
operating system name:
+ ShellCommandsProvider commands = getCommandsProvider();
+
+ if (commands == null) {
+ commands = getCommandsProviderFromName(null);
+ setCommandsProvider(commands);
+ }
+
+ // Our next init step is to run the system check from that command set
to determine if the other commands
+ // will work on this host or not.
+ try {
+ ShellRunner.runShell(commands.getSystemCheck());
+ } catch (final IOException ioexc) {
+ logger.error("initialize exception: " + ioexc + " system check
command: " + commands.getSystemCheck());
+ throw new AuthorizerCreationException(SYS_CHECK_ERROR,
ioexc.getCause());
+ }
+
+ // With our command set selected, and our system check passed, we can
pull in the users and groups:
+ refreshUsersAndGroups();
+
+ // And finally, our last init step is to fire off the refresh thread:
+ scheduler.scheduleWithFixedDelay(this::refreshUsersAndGroups,
initialDelay, fixedDelay, TimeUnit.SECONDS);
+ }
+
+ private static ShellCommandsProvider getCommandsProviderFromName(String
osName) {
+ if (osName == null) {
+ osName = System.getProperty("os.name");
+ }
+
+ ShellCommandsProvider commands;
+ if (osName.startsWith("Linux")) {
+ logger.debug("Selected Linux command set.");
+ commands = new NssShellCommands();
+ } else if (osName.startsWith("Mac OS X")) {
+ logger.debug("Selected OSX command set.");
+ commands = new OsxShellCommands();
+ } else {
+ throw new AuthorizerCreationException(OS_TYPE_ERROR);
+ }
+ return commands;
+ }
+
+ private long getDelayProperty(AuthorizerConfigurationContext authContext,
String propertyName, String defaultValue) {
+ final PropertyValue intervalProperty =
authContext.getProperty(propertyName);
+ final String propertyValue;
+ final long syncInterval;
+
+ if (intervalProperty.isSet()) {
+ propertyValue = intervalProperty.getValue();
+ } else {
+ propertyValue = defaultValue;
+ }
+
+ try {
+ syncInterval =
Math.round(FormatUtils.getPreciseTimeDuration(propertyValue,
TimeUnit.MILLISECONDS));
+ } catch (final IllegalArgumentException ignored) {
+ throw new AuthorizerCreationException(String.format("The %s '%s'
is not a valid time interval.", propertyName, propertyValue));
+ }
+
+ if (syncInterval < MINIMUM_SYNC_INTERVAL_MILLISECONDS) {
+ throw new AuthorizerCreationException(String.format("The %s '%s'
is below the minimum value of '%d ms'", propertyName, propertyValue,
MINIMUM_SYNC_INTERVAL_MILLISECONDS));
+ }
+ return syncInterval;
+ }
+
+ /**
+ * Called immediately before instance destruction for implementers to
release resources.
+ *
+ * @throws AuthorizerDestructionException If pre-destruction fails.
+ */
+ @Override
+ public void preDestruction() throws AuthorizerDestructionException {
+ try {
+ scheduler.shutdownNow();
+ } catch (final Exception ignored) {
+ }
+ }
+
+ public ShellCommandsProvider getCommandsProvider() {
+ return selectedShellCommands;
+ }
+
+ public void setCommandsProvider(ShellCommandsProvider commandsProvider) {
+ selectedShellCommands = commandsProvider;
+ }
+
+ /**
+ * Refresh a single user.
+ *
+ * @param command Shell command to read a single user. Pre-formatted
by caller.
+ * @param description Shell command description.
+ */
+ private void refreshOneUser(String command, String description) {
+ if (command != null) {
+ Map<String, User> idToUser = new HashMap<>();
+ Map<String, User> usernameToUser = new HashMap<>();
+ Map<String, User> gidToUser = new HashMap<>();
+ List<String> userLines;
+
+ try {
+ userLines = ShellRunner.runShell(command, description);
+ rebuildUsers(userLines, idToUser, usernameToUser, gidToUser);
+ } catch (final IOException ioexc) {
+ logger.error("refreshOneUser shell exception: " + ioexc);
+ }
+
+ if (idToUser.size() > 0) {
+ synchronized (usersById) {
+ usersById.putAll(idToUser);
+ }
+ }
+
+ if (usernameToUser.size() > 0) {
+ synchronized (usersByName) {
+ usersByName.putAll(usernameToUser);
+ }
+ }
+ } else {
+ logger.info("Get Single User not supported on this system.");
+ }
+ }
+
+ /**
+ * Refresh a single group.
+ *
+ * @param command Shell command to read a single group. Pre-formatted
by caller.
+ * @param description Shell command description.
+ */
+ private void refreshOneGroup(String command, String description) {
+ if (command != null) {
+ Map<String, Group> gidToGroup = new HashMap<>();
+ List<String> groupLines;
+
+ try {
+ groupLines = ShellRunner.runShell(command, description);
+ rebuildGroups(groupLines, gidToGroup);
+ } catch (final IOException ioexc) {
+ logger.error("refreshOneGroup shell exception: " + ioexc);
+ }
+
+ if (gidToGroup.size() > 0) {
+ synchronized (groupsById) {
+ groupsById.putAll(gidToGroup);
+ }
+ }
+ } else {
+ logger.info("Get Single Group not supported on this system.");
+ }
+ }
+
+ /**
+ * This is our entry point for user and group refresh. This method runs
the top-level
+ * `getUserList()` and `getGroupsList()` shell commands, then passes those
results to the
+ * other methods for record parse, extract, and object construction.
+ */
+ private void refreshUsersAndGroups() {
+ Map<String, User> uidToUser = new HashMap<>();
+ Map<String, User> usernameToUser = new HashMap<>();
+ Map<String, User> gidToUser = new HashMap<>();
+ Map<String, Group> gidToGroup = new HashMap<>();
+
+ List<String> userLines;
+ List<String> groupLines;
+
+ try {
+ userLines =
ShellRunner.runShell(selectedShellCommands.getUsersList(), "Get Users List");
+ groupLines =
ShellRunner.runShell(selectedShellCommands.getGroupsList(), "Get Groups List");
+ } catch (final IOException ioexc) {
+ logger.error("refreshUsersAndGroups shell exception: " + ioexc);
+ return;
+ }
+
+ rebuildUsers(userLines, uidToUser, usernameToUser, gidToUser);
+ rebuildGroups(groupLines, gidToGroup);
+ reconcilePrimaryGroups(gidToUser, gidToGroup);
+
+ synchronized (usersById) {
+ usersById.clear();
+ usersById.putAll(uidToUser);
+ }
+
+ synchronized (usersByName) {
+ usersByName.clear();
+ usersByName.putAll(usernameToUser);
+ logger.debug("users now size: " + usersByName.size());
+ }
+
+ synchronized (groupsById) {
+ groupsById.clear();
+ groupsById.putAll(gidToGroup);
+ logger.debug("groups now size: " + groupsById.size());
+ }
+ }
+
+ /**
+ * This method parses the output of the `getUsersList()` shell command,
where we expect the output
+ * to look like `user-name:user-id:primary-group-id`.
+ * <p>
+ * This method splits each output line on the ":" and attempts to build a
User object
+ * from the resulting name, uid, and primary gid. Unusable records are
logged.
+ */
+ private void rebuildUsers(List<String> userLines, Map<String, User>
idToUser, Map<String, User> usernameToUser, Map<String, User> gidToUser) {
+ userLines.forEach(line -> {
+ String[] record = line.split(":");
+ if (record.length > 2) {
+ String name = record[0], id = record[1], gid = record[2];
+
+ if (name != null && id != null && !name.equals("") &&
!id.equals("")) {
+
+ User user = new
User.Builder().identity(name).identifier(id).build();
+ idToUser.put(id, user);
+ usernameToUser.put(name, user);
+
+ if (gid != null && !gid.equals("")) {
+ gidToUser.put(gid, user);
+ } else {
+ logger.warn("Null or empty primary group id for: " +
name);
+ }
+
+ } else {
+ logger.warn("Null or empty user name: " + name + " or id:
" + id);
+ }
+ } else {
+ logger.warn("Unexpected record format. Expected 3 or more
colon separated values per line.");
+ }
+ });
+ }
+
+ /**
+ * This method parses the output of the `getGroupsList()` shell command,
where we expect the output
+ * to look like `group-name:group-id`.
+ * <p>
+ * This method splits each output line on the ":" and attempts to build a
Group object
+ * from the resulting name and gid. Unusable records are logged.
+ * <p>
+ * This command also runs the `getGroupMembers(username)` command once per
group. The expected output
+ * of that command should look like `group-name-1,group-name-2`.
+ */
+ private void rebuildGroups(List<String> groupLines, Map<String, Group>
groupsById) {
+ groupLines.forEach(line -> {
+ String[] record = line.split(":");
+ if (record.length > 1) {
+ Set<String> users = new HashSet<>();
+ String name = record[0], id = record[1];
+
+ try {
+ List<String> memberLines =
ShellRunner.runShell(selectedShellCommands.getGroupMembers(name));
+ // Use the first line only, and log if the line count
isn't exactly one:
+ if (!memberLines.isEmpty()) {
+
users.addAll(Arrays.asList(memberLines.get(0).split(",")));
+ } else {
+ logger.debug("list membership returned zero lines.");
+ }
+ if (memberLines.size() > 1) {
+ logger.error("list membership returned too many lines,
only used the first.");
+ }
+
+ } catch (final IOException ioexc) {
+ logger.error("list membership shell exception: " + ioexc);
+ }
+
+ if (name != null && id != null && !name.equals("") &&
!id.equals("")) {
+ Group group = new
Group.Builder().name(name).identifier(id).addUsers(users).build();
+ groupsById.put(id, group);
+ logger.debug("Refreshed group: " + group);
+ } else {
+ logger.warn("Null or empty group name: " + name + " or id:
" + id);
+ }
+ } else {
+ logger.warn("Unexpected record format. Expected 1 or more
comma separated values.");
+ }
+ });
+ }
+
+ /**
+ * This method parses the output of the `getGroupsList()` shell command,
where we expect the output
+ * to look like `group-name:group-id`.
+ * <p>
+ * This method splits each output line on the ":" and attempts to build a
Group object
+ * from the resulting name and gid.
+ */
+ private void reconcilePrimaryGroups(Map<String, User> uidToUser,
Map<String, Group> gidToGroup) {
+ uidToUser.forEach((primaryGid, primaryUser) -> {
+ Group primaryGroup = gidToGroup.get(primaryGid);
+
+ if (primaryGroup == null) {
+ logger.warn("user: " + primaryUser + " primary group not
found");
+ } else {
+ Set<String> groupUsers = primaryGroup.getUsers();
+ if (!groupUsers.contains(primaryUser.getIdentity())) {
+ Set<String> secondSet = new HashSet<>(groupUsers);
+ secondSet.add(primaryUser.getIdentity());
+ Group group = new
Group.Builder().name(primaryGroup.getName()).identifier(primaryGid).addUsers(secondSet).build();
+ gidToGroup.put(primaryGid, group);
+ }
+ }
+ });
+ }
+
+ /**
+ * @return The initial refresh delay.
+ */
+ public long getInitialRefreshDelay() {
+ return initialDelay;
+ }
+
+
+ /**
+ * @return The fixed refresh delay.
+ */
+ public long getRefreshDelay() {
+ return fixedDelay;
+ }
+
+ /**
+ * Testing concession for clearing the internal caches.
+ */
+ void clearCaches() {
+ synchronized (usersById) {
+ usersById.clear();
+ }
+
+ synchronized (usersByName) {
+ usersByName.clear();
+ }
+
+ synchronized (groupsById) {
+ groupsById.clear();
+ }
+ }
+
+ /**
+ * @return The size of the internal user cache.
+ */
+ public int userCacheSize() {
+ return usersById.size();
+ }
+
+ /**
+ * @return The size of the internal group cache.
+ */
+ public int groupCacheSize() {
+ return groupsById.size();
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/util/ShellRunner.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/util/ShellRunner.java
new file mode 100644
index 0000000..46bc1cc
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/util/ShellRunner.java
@@ -0,0 +1,77 @@
+/*
+ * 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.nifi.authorization.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class ShellRunner {
+ private final static Logger logger =
LoggerFactory.getLogger(ShellRunner.class);
+
+ static String SHELL = "sh";
+ static String OPTS = "-c";
+ static Integer TIMEOUT = 30;
+
+ public static List<String> runShell(String command) throws IOException {
+ return runShell(command, "<unknown>");
+ }
+
+ public static List<String> runShell(String command, String description)
throws IOException {
+ final ProcessBuilder builder = new ProcessBuilder(SHELL, OPTS,
command);
+ final List<String> builderCommand = builder.command();
+
+ logger.debug("Run Command '" + description + "': " + builderCommand);
+ final Process proc = builder.start();
+
+ try {
+ proc.waitFor(TIMEOUT, TimeUnit.SECONDS);
+ } catch (InterruptedException irexc) {
+ throw new IOException(irexc.getMessage(), irexc.getCause());
+ }
+
+ if (proc.exitValue() != 0) {
+ try (final Reader stderr = new
InputStreamReader(proc.getErrorStream());
+ final BufferedReader reader = new BufferedReader(stderr)) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ logger.warn(line.trim());
+ }
+ }
+ throw new IOException("Command exit non-zero: " +
proc.exitValue());
+ }
+
+ final List<String> lines = new ArrayList<>();
+ try (final Reader stdin = new InputStreamReader(proc.getInputStream());
+ final BufferedReader reader = new BufferedReader(stdin)) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ lines.add(line.trim());
+ }
+ }
+
+ return lines;
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/resources/META-INF/services/org.apache.nifi.authorization.UserGroupProvider
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/resources/META-INF/services/org.apache.nifi.authorization.UserGroupProvider
new file mode 100755
index 0000000..fb3360d
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/resources/META-INF/services/org.apache.nifi.authorization.UserGroupProvider
@@ -0,0 +1,15 @@
+# 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.
+org.apache.nifi.authorization.ShellUserGroupProvider
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderBase.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderBase.java
new file mode 100644
index 0000000..fa760b2
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderBase.java
@@ -0,0 +1,176 @@
+/*
+ * 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.nifi.authorization;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
+
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+abstract class ShellUserGroupProviderBase {
+ private static final Logger logger =
LoggerFactory.getLogger(ShellUserGroupProviderBase.class);
+
+ private final String KNOWN_USER = "root";
+ private final String KNOWN_UID = "0";
+
+ @SuppressWarnings("FieldCanBeLocal")
+ private final String KNOWN_GROUP = "root";
+
+ @SuppressWarnings("FieldCanBeLocal")
+ private final String OTHER_GROUP = "wheel"; // e.g., macos
+ private final String KNOWN_GID = "0";
+
+ // We're using this knob to control the test runs on Travis. The issue
there is that tests
+ // running on Travis do not have `getent`, thus not behaving like a
typical Linux installation.
+ protected static boolean systemCheckFailed = false;
+
+ /**
+ * Ensures that the test can run because Docker is available and the
remote instance can be reached via ssh.
+ *
+ * @return true if Docker is available on this OS
+ */
+ protected boolean isSSHAvailable() {
+ return !systemCheckFailed;
+ }
+
+
+ /**
+ * Tests the provider behavior by getting its users and checking minimum
size.
+ *
+ * @param provider {@link UserGroupProvider}
+ */
+ void testGetUsersAndUsersMinimumCount(UserGroupProvider provider) {
+ assumeTrue(isSSHAvailable());
+
+ Set<User> users = provider.getUsers();
+ assertNotNull(users);
+ assertTrue(users.size() > 0);
+ }
+
+
+ /**
+ * Tests the provider behavior by getting a known user by uid.
+ *
+ * @param provider {@link UserGroupProvider}
+ */
+ void testGetKnownUserByUsername(UserGroupProvider provider) {
+ // assumeTrue(isSSHAvailable());
+
+ User root = provider.getUser(KNOWN_UID);
+ assertNotNull(root);
+ assertEquals(KNOWN_USER, root.getIdentity());
+ assertEquals(KNOWN_UID, root.getIdentifier());
+ }
+
+ /**
+ * Tests the provider behavior by getting a known user by id.
+ *
+ * @param provider {@link UserGroupProvider}
+ */
+ void testGetKnownUserByUid(UserGroupProvider provider) {
+ assumeTrue(isSSHAvailable());
+
+ User root = provider.getUserByIdentity(KNOWN_USER);
+ assertNotNull(root);
+ assertEquals(KNOWN_USER, root.getIdentity());
+ assertEquals(KNOWN_UID, root.getIdentifier());
+ }
+
+ /**
+ * Tests the provider behavior by getting its groups and checking minimum
size.
+ *
+ * @param provider {@link UserGroupProvider}
+ */
+ void testGetGroupsAndMinimumGroupCount(UserGroupProvider provider) {
+ assumeTrue(isSSHAvailable());
+
+ Set<Group> groups = provider.getGroups();
+ assertNotNull(groups);
+ assertTrue(groups.size() > 0);
+ }
+
+ /**
+ * Tests the provider behavior by getting a known group by GID.
+ *
+ * @param provider {@link UserGroupProvider}
+ */
+ void testGetKnownGroupByGid(UserGroupProvider provider) {
+ assumeTrue(isSSHAvailable());
+
+ Group group = provider.getGroup(KNOWN_GID);
+ assertNotNull(group);
+ assertTrue(group.getName().equals(KNOWN_GROUP) ||
group.getName().equals(OTHER_GROUP));
+ assertEquals(KNOWN_GID, group.getIdentifier());
+ }
+
+ /**
+ * Tests the provider behavior by getting a known group and checking for a
known member of it.
+ *
+ * @param provider {@link UserGroupProvider}
+ */
+ void testGetGroupByGidAndGetGroupMembership(UserGroupProvider provider) {
+ assumeTrue(isSSHAvailable());
+
+ Group group = provider.getGroup(KNOWN_GID);
+ assertNotNull(group);
+
+ // These next few try/catch blocks are here for debugging. The
user-to-group relationship
+ // is delicate with this implementation, and this approach allows us a
measure of control.
+ // Check your logs if you're having problems!
+
+ try {
+ assertTrue(group.getUsers().size() > 0);
+ logger.info("root group count: " + group.getUsers().size());
+ } catch (final AssertionError ignored) {
+ logger.info("root group count zero on this system");
+ }
+
+ try {
+ assertTrue(group.getUsers().contains(KNOWN_USER));
+ logger.info("root group membership: " + group.getUsers());
+ } catch (final AssertionError ignored) {
+ logger.info("root group membership unexpected on this system");
+ }
+ }
+
+ /**
+ * Tests the provider behavior by getting a known user and checking its
group membership.
+ *
+ * @param provider {@link UserGroupProvider}
+ */
+ void testGetUserByIdentityAndGetGroupMembership(UserGroupProvider
provider) {
+ assumeTrue(isSSHAvailable());
+
+ UserAndGroups user = provider.getUserAndGroups(KNOWN_USER);
+ assertNotNull(user);
+
+ try {
+ assertTrue(user.getGroups().size() > 0);
+ logger.info("root user group count: " + user.getGroups().size());
+ } catch (final AssertionError ignored) {
+ logger.info("root user and groups group count zero on this
system");
+ }
+
+ Set<Group> groups = provider.getGroups();
+ assertTrue(groups.size() > user.getGroups().size());
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderIT.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderIT.java
new file mode 100644
index 0000000..1525ae5
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderIT.java
@@ -0,0 +1,283 @@
+/*
+ * 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.nifi.authorization;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.nifi.authorization.exception.AuthorizerCreationException;
+import org.apache.nifi.authorization.util.ShellRunner;
+import org.apache.nifi.util.MockPropertyValue;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.MountableFile;
+
+
+public class ShellUserGroupProviderIT extends ShellUserGroupProviderBase {
+ private static final Logger logger =
LoggerFactory.getLogger(ShellUserGroupProviderIT.class);
+
+ // These images are publicly available on the hub.docker.com, and the
source to each
+ // is available on github. In lieu of using named images, the Dockerfiles
could be
+ // migrated into module and referenced in the testcontainer setup.
+ private final static String ALPINE_IMAGE = "natural/alpine-sshd:latest";
+ private final static String CENTOS_IMAGE = "natural/centos-sshd:latest";
+ private final static String DEBIAN_IMAGE = "natural/debian-sshd:latest";
+ private final static String UBUNTU_IMAGE = "natural/ubuntu-sshd:latest";
+ private final static List<String> TEST_CONTAINER_IMAGES =
+ Arrays.asList(
+ ALPINE_IMAGE,
+ CENTOS_IMAGE,
+ DEBIAN_IMAGE,
+ UBUNTU_IMAGE
+ );
+
+ private final static String CONTAINER_SSH_AUTH_KEYS =
"/root/.ssh/authorized_keys";
+ private final static Integer CONTAINER_SSH_PORT = 22;
+
+ private static String sshPrivKeyFile;
+ private static String sshPubKeyFile;
+
+ private AuthorizerConfigurationContext authContext;
+ private ShellUserGroupProvider localProvider;
+ private UserGroupProviderInitializationContext initContext;
+
+ @ClassRule
+ static public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ @Rule
+ public ExpectedException expectedException = ExpectedException.none();
+
+ @BeforeClass
+ public static void setupOnce() throws IOException {
+ sshPrivKeyFile = tempFolder.getRoot().getAbsolutePath() + "/id_rsa";
+ sshPubKeyFile = sshPrivKeyFile + ".pub";
+
+ try {
+ // NB: this command is a bit perplexing: it works without prompt
from the shell, but hangs
+ // here without the pipe from `yes`:
+ ShellRunner.runShell("yes | ssh-keygen -C '' -N '' -t rsa -f " +
sshPrivKeyFile);
+ } catch (final IOException ioexc) {
+ systemCheckFailed = true;
+ logger.error("setupOnce() exception: " + ioexc + "; tests cannot
run on this system.");
+ return;
+ }
+
+ // Fix the file permissions to abide by the ssh client
+ // requirements:
+ Arrays.asList(sshPrivKeyFile, sshPubKeyFile).forEach(name -> {
+ final File f = new File(name);
+ Assert.assertTrue(f.setReadable(false, false));
+ Assert.assertTrue(f.setReadable(true));
+ });
+ }
+
+ @Before
+ public void setup() throws IOException {
+ authContext = Mockito.mock(AuthorizerConfigurationContext.class);
+ initContext =
Mockito.mock(UserGroupProviderInitializationContext.class);
+
+
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.INITIAL_REFRESH_DELAY_PROPERTY))).thenReturn(new
MockPropertyValue("10 sec"));
+
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.REFRESH_DELAY_PROPERTY))).thenReturn(new
MockPropertyValue("15 sec"));
+
+ localProvider = new ShellUserGroupProvider();
+ try {
+ localProvider.initialize(initContext);
+ localProvider.onConfigured(authContext);
+ } catch (final Exception exc) {
+ systemCheckFailed = true;
+ logger.error("setup() exception: " + exc + "; tests cannot run on
this system.");
+ return;
+ }
+ Assert.assertEquals(10000, localProvider.getInitialRefreshDelay());
+ Assert.assertEquals(15000, localProvider.getRefreshDelay());
+ }
+
+ @After
+ public void tearDown() {
+ localProvider.preDestruction();
+ }
+
+ // Our primary test methods all accept a provider; here we define
overloads to those methods to
+ // use the local provider. This allows the reuse of those test methods
with the remote provider.
+
+ @Test
+ public void testGetUsersAndUsersMinimumCount() {
+ testGetUsersAndUsersMinimumCount(localProvider);
+ }
+
+ @Test
+ public void testGetKnownUserByUsername() {
+ testGetKnownUserByUsername(localProvider);
+ }
+
+ @Test
+ public void testGetKnownUserByUid() {
+ testGetKnownUserByUid(localProvider);
+ }
+
+ @Test
+ public void testGetGroupsAndMinimumGroupCount() {
+ testGetGroupsAndMinimumGroupCount(localProvider);
+ }
+
+ @Test
+ public void testGetKnownGroupByGid() {
+ testGetKnownGroupByGid(localProvider);
+ }
+
+ @Test
+ public void testGetGroupByGidAndGetGroupMembership() {
+ testGetGroupByGidAndGetGroupMembership(localProvider);
+ }
+
+ @Test
+ public void testGetUserByIdentityAndGetGroupMembership() {
+ testGetUserByIdentityAndGetGroupMembership(localProvider);
+ }
+
+ @SuppressWarnings("RedundantThrows")
+ private GenericContainer createContainer(String image) throws IOException,
InterruptedException {
+ GenericContainer container = new GenericContainer(image)
+ .withEnv("SSH_ENABLE_ROOT",
"true").withExposedPorts(CONTAINER_SSH_PORT);
+ container.start();
+
+ // This can go into the docker images:
+ container.execInContainer("mkdir", "-p", "/root/.ssh");
+
container.copyFileToContainer(MountableFile.forHostPath(sshPubKeyFile),
CONTAINER_SSH_AUTH_KEYS);
+ return container;
+ }
+
+ private UserGroupProvider createRemoteProvider(GenericContainer container)
{
+ final ShellCommandsProvider remoteCommands =
+ RemoteShellCommands.wrapOtherProvider(new NssShellCommands(),
+ sshPrivKeyFile,
+
container.getContainerIpAddress(),
+
container.getMappedPort(CONTAINER_SSH_PORT));
+
+ ShellUserGroupProvider remoteProvider = new ShellUserGroupProvider();
+ remoteProvider.setCommandsProvider(remoteCommands);
+ remoteProvider.initialize(initContext);
+ remoteProvider.onConfigured(authContext);
+ return remoteProvider;
+ }
+
+ @Test
+ public void testTooShortDelayIntervalThrowsException() throws
AuthorizerCreationException {
+ final AuthorizerConfigurationContext authContext =
Mockito.mock(AuthorizerConfigurationContext.class);
+ final ShellUserGroupProvider localProvider = new
ShellUserGroupProvider();
+
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.INITIAL_REFRESH_DELAY_PROPERTY))).thenReturn(new
MockPropertyValue("1 milliseconds"));
+
+ expectedException.expect(AuthorizerCreationException.class);
+ expectedException.expectMessage("The Initial Refresh Delay '1
milliseconds' is below the minimum value of '10000 ms'");
+
+ localProvider.onConfigured(authContext);
+ }
+
+ @Test
+ public void testInvalidDelayIntervalThrowsException() throws
AuthorizerCreationException {
+ final AuthorizerConfigurationContext authContext =
Mockito.mock(AuthorizerConfigurationContext.class);
+ final ShellUserGroupProvider localProvider = new
ShellUserGroupProvider();
+
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.INITIAL_REFRESH_DELAY_PROPERTY))).thenReturn(new
MockPropertyValue("Not an interval"));
+
+ expectedException.expect(AuthorizerCreationException.class);
+ expectedException.expectMessage("The Initial Refresh Delay 'Not an
interval' is not a valid time interval.");
+
+ localProvider.onConfigured(authContext);
+ }
+
+ @Test
+ public void testCacheSizesAfterClearingCaches() {
+ localProvider.clearCaches();
+ assert localProvider.userCacheSize() == 0;
+ assert localProvider.groupCacheSize() == 0;
+ }
+
+ @Test
+ public void testGetOneUserAfterClearingCaches() {
+ // assert known state: empty, testable, not empty
+ localProvider.clearCaches();
+ testGetKnownUserByUid(localProvider);
+ assert localProvider.userCacheSize() > 0;
+ }
+
+ @Test
+ public void testGetOneGroupAfterClearingCaches() {
+ Assume.assumeTrue(isSSHAvailable());
+
+ // assert known state: empty, testable, not empty
+ localProvider.clearCaches();
+ testGetKnownGroupByGid(localProvider);
+ assert localProvider.groupCacheSize() > 0;
+ }
+
+ @Test
+ public void testVariousSystemImages() {
+ // Here we explicitly clear the system check flag to allow the remote
checks that follow:
+ systemCheckFailed = false;
+ Assume.assumeTrue(isSSHAvailable());
+
+ TEST_CONTAINER_IMAGES.forEach(image -> {
+ GenericContainer container;
+ UserGroupProvider remoteProvider;
+ logger.debug("creating container from image: " + image);
+
+ try {
+ container = createContainer(image);
+ } catch (final Exception exc) {
+ logger.error("create container exception: " + exc);
+ return;
+ }
+ try {
+ remoteProvider = createRemoteProvider(container);
+ } catch (final Exception exc) {
+ logger.error("create user provider exception: " + exc);
+ return;
+ }
+
+ try {
+ testGetUsersAndUsersMinimumCount(remoteProvider);
+ testGetKnownUserByUsername(remoteProvider);
+ testGetGroupsAndMinimumGroupCount(remoteProvider);
+ testGetKnownGroupByGid(remoteProvider);
+ testGetGroupByGidAndGetGroupMembership(remoteProvider);
+ testGetUserByIdentityAndGetGroupMembership(remoteProvider);
+ } catch (final Exception e) {
+ // Some environments don't allow our tests to work.
+ logger.error("Exception running remote provider on image:
" + image + ", exception: " + e);
+ }
+
+ container.stop();
+ remoteProvider.preDestruction();
+ logger.debug("finished with container image: " + image);
+ });
+ }
+
+ // TODO: Make test which retrieves list of users and then
getUserByIdentity to ensure the user is populated in the response
+}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
index 1529779..496dba9 100644
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
@@ -1342,7 +1342,7 @@
});
// set the rows
- usersData.setItems(users);
+ usersData.setItems(users, 'uri');
// end the update
usersData.endUpdate();
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml
index 33b53ff..d3136e4 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml
@@ -47,6 +47,7 @@
<module>nifi-properties-loader</module>
<module>nifi-standard-prioritizers</module>
<module>nifi-mock-authorizer</module>
+ <module>nifi-shell-authorizer</module>
</modules>
<dependencies>
<dependency>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/pom.xml
b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
index 2e33739..e2366ac 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
@@ -186,6 +186,11 @@
</dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
+ <artifactId>nifi-shell-authorizer</artifactId>
+ <version>1.10.0-SNAPSHOT</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.nifi</groupId>
<artifactId>nifi-authorizer</artifactId>
<version>1.10.0-SNAPSHOT</version>
</dependency>
diff --git a/pom.xml b/pom.xml
index 6f083ef..c8955b2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -239,7 +239,12 @@
<version>1.3</version>
<scope>test</scope>
</dependency>
-
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <version>1.11.3</version>
+ <scope>test</scope>
+ </dependency>
<!-- These Jetty dependencies are required for the Jetty Web
Server all nars extend from it so we dont want this getting overriden -->
<dependency>
<groupId>org.eclipse.jetty</groupId>