Skip to content

Commit

Permalink
gplazma: add gplazma2-jython module for auth using Python (#7627)
Browse files Browse the repository at this point in the history
Adds a new gplazma2 module to the project which uses Jython to parse and
execute Python files (only version 2.7 supported) within the dcache Java
runtime. This enables site admins to quickly script authentication plugins
without needing to understand or write the native dcache Java code.

This commit includes a sample "sample_plugin.py" auth plugin which is used
during testing and serves as a reference.

Implementation Details

Jython limits the Python version to version 2.7. The Python files are currently
stored directly within the source folder, i.e.
`modules/gplazma2-jython/gplazma2-jython/sample_plugin.py`. Multiple files can
be inserted which will be executed sequentially but in no particular order.

Each Python file shall be structured so as to contain one function
´py_auth(public_credentials, private_credentials, principals)´ which returns a
boolean whether the authentication was successful. Returning ´False´ will
trigger an ´AuthenticationException´ in Java. Each parameter passed to ´py_auth´ will be a Python set.

- ´private_credentials´ is a set of strings, integers or floats (implemented in
  Java as ´String || Number´
- ´public_credentials´ is the same as ´private_credentials´
- ´principals´ will be a set of strings in the form ´<principal_type>:<value>´,
  i.e. a pair of strings separated by a colon. Consider
  ´org.dcache.auth.Subjects#principalsFromArgs´to see how the principal type
  string maps to the principals classes.

The conversion from a set of principals to a set of strings is done entirely
within the ´authenticate´ method of the ´JythonPlugin´-class. The Python file
only ever sees a set of strings, while someone using the plugin will only ever
need to pass a set of principals from within Java. The conversion is done using
existing functions.

Tests

A test class ´JythonPluginTest´ has been added that makes use of the internal
logic of the sample ´sample_plugin.py´. The logic is simple and is repeated in
the Java class and the Python file as a comment.


Signed-off-by: Anton Schwarz <[email protected]>
  • Loading branch information
ThePhisch authored Aug 9, 2024
1 parent f779605 commit 7b2b3df
Show file tree
Hide file tree
Showing 6 changed files with 347 additions and 0 deletions.
39 changes: 39 additions & 0 deletions modules/gplazma2-pyscript/gplazma2-pyscript/sample_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
def py_auth(public_credentials, private_credentials, principals):
"""
Function py_auth, sample implementation for a gplazma2-pyscript plugin
:param public_credentials: set of public auth credentials
:param private_credentials: set of private auth credentials
:param principals: set of principals (string values)
:return: boolean whether authentication is permitted
Note that the set of principals may only be added to, existing principals
may never be removed!
Note that the principals are handled as strings in Python! Consider
org.dcache.auth.Subjects#principalsFromArgs how these are converted back
into principals in Java.
In case authentication is denied, we return None.
In this sample implementation, the following logic is used:
AUTHENTICATE:
Condition:
- Either "Tom" is in the public credentials
- Or "Rose" is in the set of private credentials
Result:
- the username principal "Joad" is added (as string "username:joad")
- we return True
REFUSE:
Condition:
- Either "Connie" is in either one of the credentials
- No passing condition from above is met
Result:
- we return False
"""
if ("Connie" in public_credentials) or ("Connie" in private_credentials):
return False
elif ("Tom" in public_credentials) or ("Rose" in private_credentials):
principals.add("username:Joad")
return True
else:
return False
40 changes: 40 additions & 0 deletions modules/gplazma2-pyscript/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<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">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.dcache</groupId>
<artifactId>dcache-parent</artifactId>
<version>10.2.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>

<artifactId>gplazma2-pyscript</artifactId>
<packaging>jar</packaging>

<name>gPlazma 2 Pyscript Generic plugin</name>

<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>org.python</groupId>
<artifactId>jython-standalone</artifactId>
</dependency>
<dependency>
<groupId>org.dcache</groupId>
<artifactId>dcache-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.dcache</groupId>
<artifactId>gplazma2</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package org.dcache.gplazma.pyscript;

import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Principal;
import java.util.Set;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.stream.Collectors;

import org.dcache.auth.Subjects;
import org.dcache.gplazma.AuthenticationException;
import org.dcache.gplazma.plugins.GPlazmaAuthenticationPlugin;
import org.python.core.PyFunction;
import org.python.core.PyObject;
import org.python.core.PySet;
import org.python.util.PythonInterpreter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


public class PyscriptPlugin implements GPlazmaAuthenticationPlugin {

private static final Logger LOGGER = LoggerFactory.getLogger(PyscriptPlugin.class);

private final PythonInterpreter PI = new PythonInterpreter();
private List<Path> pluginPathList = new ArrayList<>();

public PyscriptPlugin(Properties properties) {
// constructor
try {
pluginPathList = listDirectoryContents("gplazma2-pyscript"); // TODO fix this, currently just reads from local folder
} catch (IOException e) {
LOGGER.error("Error reading Pyscript directory");
}
}

private static List<Path> listDirectoryContents(String directoryPath) throws IOException {
return Files.list(Path.of(directoryPath)).toList();
}

public void authenticate(
Set<Object> publicCredentials,
Set<Object> privateCredentials,
Set<Principal> principals
) throws AuthenticationException {

for (Path pluginPath : pluginPathList) {
// for each path in pluginPathList
try {
// ===========
// IO
// ===========
// read file into string
LOGGER.debug("gplazma2-pyscript running {}", pluginPath);
String content = Files.lines(pluginPath).collect(
Collectors.joining("\n")
);
// execute file content, loading the function "py_auth" into the namespace
PI.exec(content);

// ======================
// Prepare Python Data
// ======================
// prepare datatypes for python
PySet pyPublicCredentials = new PySet();
PySet pyPrivateCredentials = new PySet();
PySet pyPrincipals = new PySet();

// Only handle select datatypes
for (Object pubCred : publicCredentials) {
if (
pubCred instanceof String
|| pubCred instanceof Number // all number datatypes
) {
pyPublicCredentials.add(pubCred);
} else {
LOGGER.warn(
"gplazma2-pyscript: Public Credential {} dropped. Use String or Number.",
pubCred
);
}
}
for (Object privCred : privateCredentials) {
if (
privCred instanceof String
|| privCred instanceof Number // all number datatypes
) {
pyPrivateCredentials.add(privCred);
} else {
LOGGER.warn(
"gplazma2-pyscript: Private Credential {} dropped. Use String or Number.",
privCred.toString()
);
}
}

// Convert principals into string representation
String principalsString = Subjects.toString(
Subjects.ofPrincipals(principals)
);
String[] principalStringList = principalsString.substring(
1, principalsString.length() - 1
).split(", ");
pyPrincipals.addAll(Arrays.asList(principalStringList));



// =============
// Run in Python
// =============
// Get function
PyFunction pluginAuthFunc = PI.get(
"py_auth",
PyFunction.class
);
// Invoke function
PyObject pyAuthOutput = pluginAuthFunc.__call__(
pyPublicCredentials,
pyPrivateCredentials,
pyPrincipals
);
// throw exception if needed
Boolean AuthenticationPassed = (Boolean) pyAuthOutput.__tojava__(Boolean.class);
if (!AuthenticationPassed) {
throw new AuthenticationException(
"Authentication failed in file " + pluginPath.getFileName().toString()
);
}
// ================
// update principals
// =================
// Convert principals back from string representation
List<String> convertedPyPrincipals = List.copyOf(pyPrincipals);

// Update original Principals
principals.addAll(Subjects.principalsFromArgs(convertedPyPrincipals));

} catch (IOException e) {
throw new AuthenticationException(
"Authentication failed due to I/O error: " + e.getMessage(), e
);
}
}



// finish authentication
LOGGER.debug("Finished gplazma2-pyscript step.");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<plugins>
<plugin>
<name>pyscript</name>
<class>org.dcache.gplazma.pyscript.PyscriptPlugin</class>
</plugin>
</plugins>
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package org.dcache.gplazma.pyscript;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasItems;

import java.security.Principal;
import java.util.*;

import org.dcache.auth.UserNamePrincipal;
import org.dcache.gplazma.AuthenticationException;
import org.junit.Before;
import org.junit.Test;

public class PyscriptPluginTest {
/*
Test the gplazma2-pyscript module
This module reads the Python (2.7) files provided and runs each auth attempt
through the auth function defined in each file. Authentication may fail and
throw an AuthenticationException, or it may pass and add new principals.
In any case, the principals are only added to, never removed from.
The internal logic of the "sample_plugin.py" file is the following
AUTHENTICATE:
Condition:
- Either "Tom" is in the public credentials
- Or "Rose" is in the set of private credentials
Result:
- the username principal "Joad" is added (as string "username:joad")
- we return True
REFUSE:
Condition:
- Either "Connie" is in either one of the credentials
- No passing condition from above is met
Result:
- we return False
These JUnit tests are written accordingly.
*/

private static PyscriptPlugin plugin;

@Before
public void setUp() {
plugin = new PyscriptPlugin(new Properties());
}

@Test
public void passesWithPublicCredential() throws Exception {
Set<Object> publicCredentials = new HashSet<>();
publicCredentials.add("Tom");
Set<Principal> principals = new HashSet<>();
principals.add(new UserNamePrincipal("Casy"));
plugin.authenticate(
publicCredentials,
Collections.emptySet(),
principals
);
assertThat(principals, hasItems(
new UserNamePrincipal("Joad"), // new principal added by plugin
new UserNamePrincipal("Casy") // old principal still included
));
}

@Test
public void passesWithPrivateCredential() throws Exception {
Set<Object> privateCredentials = new HashSet<>();
privateCredentials.add("Rose");
Set<Principal> principals = new HashSet<>();
principals.add(new UserNamePrincipal("Casy"));
plugin.authenticate(
Collections.emptySet(),
privateCredentials,
principals
);
assertThat(principals, hasItems(
new UserNamePrincipal("Joad"), // new principal added by plugin
new UserNamePrincipal("Casy") // old principal still included
));
}

@Test(expected = AuthenticationException.class)
public void failsOnNoParameters() throws Exception{
plugin.authenticate(
Collections.emptySet(),
Collections.emptySet(),
Collections.emptySet()
);
}

@Test(expected = AuthenticationException.class)
public void failsOnNoParametersDespitePrincipals() throws Exception {
Set<Principal> principals = new HashSet<>();
principals.add(new UserNamePrincipal("Ruthie"));
principals.add(new UserNamePrincipal("Casy"));
plugin.authenticate(
Collections.emptySet(),
Collections.emptySet(),
principals
);
}
}
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,7 @@
<module>modules/gplazma2-kpwd</module>
<module>modules/gplazma2-ldap</module>
<module>modules/gplazma2-htpasswd</module>
<module>modules/gplazma2-pyscript</module>
<module>modules/gplazma2-oidc</module>
<module>modules/gplazma2-oidc-te</module>
<module>modules/gplazma2-omnisession</module>
Expand Down

0 comments on commit 7b2b3df

Please sign in to comment.