diff --git a/demos/README.md b/demos/README.md index 92b6ee9ec78..305373e6b0d 100644 --- a/demos/README.md +++ b/demos/README.md @@ -11,3 +11,6 @@ Holds a docker load test image packaging for Janssen. This image can load test u ## [Janssen Tarp](janssen-tarp) A Relying Party tool in form of a Browser Extension for convenient testing of authentication flows on a browser. +## [Opensearch cedarling](opensearch-cedarling) + +An opensearch plugin that integrates Cedarling for token-based access control to data queries. diff --git a/demos/opensearch-cedarling/README.md b/demos/opensearch-cedarling/README.md new file mode 100644 index 00000000000..26068f7c9d1 --- /dev/null +++ b/demos/opensearch-cedarling/README.md @@ -0,0 +1,188 @@ +# OpenSearch Cedarling demo plugin + +This is a demo plugin aimed at integrating token-based access control into [OpenSearch](https://opensearch.org). Specifically it is focused on filtering search results obtained in response to search queries sent to any of the endpoints listed [here](https://docs.opensearch.org/docs/latest/api-reference/search-apis/search/). Filtering takes place based on the Cedarling policy provided in the plugin settings. + +## Requisites + +- OpenSearch 3.6.0 +- [Jans Server](https://github.com/JanssenProject/jans/releases) 1.16.0 +- A browser with tarp extension installed +- Basic Cedar and OpenSearch knowledge +- Java 21 and `git` for development + +**Notes:** + +- All commands given in this document were tested using Ubuntu 22. Accommodate to your specific OS +- The OpenSearch installation used to test here was single node and packaged-based. Installers can be found [here](https://docs.opensearch.org/docs/latest/install-and-configure/) for several OSes. Keep the admin password at hand + +### Create an .netrc file + +To avoid typing the OpenSearch password in `curl` commands over and over, create a `~/.netrc` [file](https://everything.curl.dev/usingcurl/netrc.html). Here is how it might look: + +``` +machine localhost login admin password secret +``` + +### Run the health check + +With `curl`, issue a request to the cluster health [endpoint](https://docs.opensearch.org/docs/latest/api-reference/cluster-api/cluster-health/) to check status. By default all API requests are served through port `9200`, e.g. `https://localhost:9200`. It may take some administrative work to properly issue requests from the development machine, however sending `curl` requests directly from the server where OpenSearch resides is OK for testing purposes. + +In this document, occurrences of `https://oshost` will refer to the root URL where OpenSearch HTTP API can be reached. + +## Create and test a cedar policy + +Here, the intention is to create a policy that looks like: + + +``` +@id("alumni_restricted_access") +permit( + principal, + action in Jans::Action::"Search", + resource is Jans::student +) +when { + resource.grad_year < 2026 || + ( + context has tokens.jans_userinfo_token && + context.tokens.jans_userinfo_token.hasTag("role") && + context.tokens.jans_userinfo_token.getTag("role").contains("AdmissionsCounselor") + ) +}; +``` + +with the `student` entity type in the schema: + +``` +{ + "shape": { + "type": "Record", + "attributes": { + "name": { + "type": "String" + }, + "grad_year": { + "type": "Long" + } + } + } +} +``` + + +For this, follow the steps found [here](https://docs.jans.io/head/cedarling/quick-start/cedarling-quick-start/#implement-rbac-using-signed-tokens-tbac) as a guide. Note it is highly recommended to use Agama lab's policy designer in this case as well as Tarp for quickly testing the policy. Ensure `student` is added to the `Search` action. + + + +For the short of time, there is a readily available policy store [here](https://github.com/jgomer2001/CedarlingQuickstart/releases/download/v0.0.2/tarpDemo.cjar). + +## Plugin deployment + +In the development machine, clone this repository (a shallow clone is recommended). `cd` to the directory where this README resides and run `./gradlew assemble -Dopensearch.version=3.6.0`. Ensure `JAVA_HOME` environment points to a Java 21 installation, e.g. `export JAVA_HOME=/path/to/corretto-21`. + +`cd` to `build/distributions`. And run: + +- `unzip cedarling.zip 'cedarling-java*'` +- `zip -q -d cedarling-java-0.0.0-nightly.jar 'com/sun/jna/*'` +- `zip -u cedarling.zip cedarling-java-0.0.0-nightly.jar` + +Transfer the file `cedarling.zip` to the OpenSearch server. In a terminal run the below: + +- `/usr/share/opensearch/bin/opensearch-plugin remove cedarling` (not required for first time deployment) +- `/usr/share/opensearch/bin/opensearch-plugin install file:///path/to/cedarling.zip` +- `systemctl restart opensearch.service` + +Verify the plugin was effectively deployed by running `curl -n https://oshost/_cat/plugins`. There should be an entry for `cedarling` in the table. + +## Plugin configuration + +OpenSearch provides endpoints for handling configuration settings of plugins as well as Java classes for reading those. However when it comes to large, complex, and nested JSON hierarchies, like the settings this plugin may require, OpenSearch facilities are not convenient. For this purpose, an additional endpoint was implemented in order to retrieve and supply configuration settings. This allows to pass complex JSON objects without hassle. Under the hood everything is converted into a big string and stored as a single setting in Opensearch. + +Check the file [settings.json](https://raw.githubusercontent.com/jgomer2001/pipelines-plugin/refs/heads/main/settings.json) and fill the value corresponding to the policy store URI. + +With a complete settings file, transfer it to the server and call the endpoint `/_plugins/cedarling/settings`: + +``` +curl -n -H 'Content-Type: application/json' -d @settings.json -X PUT https://oshost/_plugins/cedarling/settings +``` + +The response will contain a boolean value indicating the operation success. The current settings can be retrieved issuing a `GET` to the same endpoint. To check the actual settings OpenSearch stores, use a request like: + +``` +curl -n https://oshost/_cluster/settings?pretty +``` + +This plugin settings are dynamic and persistent which means they can be altered any time and survive server restarts. Check this [page](https://docs.opensearch.org/docs/latest/install-and-configure/configuring-opensearch/index/) for more information in these concepts. + +## Setup testing data + +In the example policy, the `student` entity type is part of the schema. It is expected all resources referenced by policies exist as OpenSearch indices in equivalence. For this, some "students" should be added: + +``` +curl -n -H 'Content-Type: application/json' --data-binary @records.txt https://oshost/student/_bulk +``` + +[Here](records.txt) is a sample `records.txt` file. Insert more similar documents varying the `grad_year`. Learn more about Opensearch bulk requests [here](https://docs.opensearch.org/latest/api-reference/document-apis/bulk/). + +Issue a request to retrieve the documents added so far: + +``` +curl -n -H 'Content-Type: application/json' -d @query.json https://oshost/student/_search?pretty +``` + +[query.json](query.json) contains a [search request](https://docs.opensearch.org/docs/latest/query-dsl/) that matches all documents in the index. + +Note that in real world scenarios, indices already exist and policies are built in conformance afterwards. Every resource to add in the schema should resemble existing indices structures. More specifically, resources should at least contain the attributes which are needed for policy evaluation. + +## Setup a search pipeline + +This plugins implements a search response processor which must be "attached" to a [search pipeline](https://docs.opensearch.org/docs/latest/search-plugins/search-pipelines/index/). Transfer the file [pipeline.json](pipeline.json) to the OpenSearch server and run: + +``` +curl -n -H 'Content-Type: application/json' -d @pipeline.json -X PUT https://oshost/_search/pipeline/cedarling_search?pretty +``` + +This action needs to be performed only **once** regardless of how many plugin redeployments take place. + +With this pipeline, search results may now be filtered as per defined Cedarling policies. See the next section. + +## Test + +Open Tarp. If this is the first time you use it, add a client there beforehand. Then, in the "Authentication Flow" tab, trigger an authentication flow with the following: + +- Acr: `basic` +- Scope: `openid` and `profile` +- Check "Display tokens" + +After logging in, copy the UserInfo token and paste it in the corresponding section inside file [query_ext.json](query_ext.json). This is an "extended" search query the plugin will have access to so the Cedarling engine can be supplied with tokens and contextual data to make decisions. + +Then, run: + +``` +curl -n -H 'Content-Type: application/json' -d @query_ext.json 'https://oshost/student/_search?pretty&search_pipeline=cedarling_search' +``` + +The `search_pipeline` is required so the response to the query is intercepted and processed by the plugin which in turn will invoke Cedarling. The response will probably contain less hits than the query issued [earlier](#setup-testing-data) and will come with an `ext` section that reports: + +- The amount of hits that passed authorization +- The average decision time per hit + +## About development + +Once the work to get all of the pieces running is done, making changes to the plugin is rather straightforward: the Java code is in `src` directory and compilation is a matter of issuing `./gradlew compileJava`. + +In package-based installations, OpenSearch log is found at `/var/log/opensearch/opensearch.log`. To be able to see the logging statements produced by this plugin, add a line like the below to `/etc/opensearch/opensearch.yml` and restart opensearch (`systemctl restart opensearch.service`): + +``` +logger.io.jans.cedarling: trace +``` + +## Benchmarking + +See this [page](./benchmark.md). diff --git a/demos/opensearch-cedarling/benchmark.md b/demos/opensearch-cedarling/benchmark.md new file mode 100644 index 00000000000..7c3f2cfb3c9 --- /dev/null +++ b/demos/opensearch-cedarling/benchmark.md @@ -0,0 +1,44 @@ +# Benchmarks + +To compare the performance of regular queries vs. queries filtered via this plugin, benchmarking tests were designed. This is what a test does: + +- Removes the index in question entirely (e.g. `student`) +- Loads in bulk a set of JSON documents. Every document is generated with a (short) random name, a random graduation year (uniformly distributed between 2024 and 2027), and a GPA (random floating number between 0 and 5) +- Runs five queries that return documents with GPAs matching the intervals [0, 1), [1, 2), etc. +- The average query response time is computed. This does not include network latency - only server-side processing + +The test is run twice, one without Cedarling (regular OpenSearch query), and one with the plugin. To avoid bias due to caching or other factors, the database is restarted before executing every test. The average decision time per document is also reported when using the plugin. + +Details like the server to point to, index name, and the number of random entries to generate, among others, are parameterizable. + +## Performance data + +The following data were obtained in a setup using a [Digital Ocean](https://www.digitalocean.com/products/droplets/) Basic VM (s-4vcpu-8gb) with Ubuntu 22, OpenSearch 3.6.0 (single node, package-based installation with default configuration), and (local) Jans Server 1.16.0 (AS and database components only): + +|Measurement|Value| +|-|-| +|Average query response time (regular)|114.6 ms| +|Average query response time (plugin)|4837.5 ms| +|Ratio|42.21| +|Average Cedarling authz time per document|2.2ms| + +The number of documents (bulk size) per test was 10,000. Given the uniform distribution of documents among GPA values, each of the five queries returned approximately 2,000 hits, which leads to the following average query response times per document: + +- Regular: 0.0573 ms +- Plugin: 2.41875 ms + +The above shows that most of the processing time is due to Cedarling authorization while the rest of plugin code only accounts for 9% of the overhead (0.21875 ms). + +These tests force the retrieval of all matching documents at once every time, however in practice many apps will process query responses in small pages - the default search endpoint page size in OpenSearch is 10. This means despite the big ratio obtained (42.21), using Cedarling authorization is promising, specifically when retrieving data in small batches of documents is fine. For example 40 documents could be retrieved in approximately 97 milliseconds in the circumstances described in this document. + +## How to run + +For the interested, the below are the instructions to run a test: + +- Ensure to deploy and configure the plugin as explained in the [README](./README.md) +- `cd` to the directory where this document resides +- Edit the file `src/test/resources/testng.properties` accordingly. For `entries`, a value like `10000` (ten thousand documents) is OK. Using a higher value may require tweaking OpenSearch `index.max_result_window` property, see [index settings](https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/index-settings/) +- If the plugin will be in use for this particular test, set property `useCedarling` to `true`, and edit the accompanying file `query.json` supplying the tokens - these can be obtained via Tarp as mentioned in the README file +- Restart OpenSearch +- Run `./gradlew test`. Ensure the certificate keystore of Java trusts the certificate that protects the OpenSearch REST API endpoints +- The report in HTML format can be found under `build/reports/tests`. The last lines of the standard output will contain relevant metrics diff --git a/demos/opensearch-cedarling/build.gradle b/demos/opensearch-cedarling/build.gradle new file mode 100644 index 00000000000..d7a627d7ccc --- /dev/null +++ b/demos/opensearch-cedarling/build.gradle @@ -0,0 +1,156 @@ +buildscript { + ext { + opensearch_version = System.getProperty("opensearch.version", "3.6.0") + } + + repositories { + mavenLocal() + maven { url "https://aws.oss.sonatype.org/content/repositories/snapshots" } + mavenCentral() + maven { url "https://plugins.gradle.org/m2/" } + } + + dependencies { + classpath "org.opensearch.gradle:build-tools:${opensearch_version}" + } +} + +import org.opensearch.gradle.test.RestIntegTestTask + +apply plugin: 'java' +apply plugin: 'idea' +apply plugin: 'eclipse' +apply plugin: 'opensearch.opensearchplugin' +apply plugin: 'opensearch.yaml-rest-test' +apply plugin: 'opensearch.pluginzip' + +def pluginName = 'cedarling' +def pluginDescription = 'A plugin featuring TBAC security to database operations' +def packagePath = 'io.jans' +def pathToPlugin = 'cedarling.opensearch' +def pluginClassName = 'CedarlingPlugin' +group = "io.jans" + +java { + targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_21 +} + +tasks.register("preparePluginPathDirs") { + mustRunAfter clean + doLast { + def newPath = pathToPlugin.replace(".", "/") + mkdir "src/main/java/$packagePath/$newPath" + mkdir "src/test/java/$packagePath/$newPath" + mkdir "src/yamlRestTest/java/$packagePath/$newPath" + } +} + +publishing { + publications { + pluginZip(MavenPublication) { publication -> + pom { + name = pluginName + description = pluginDescription + licenses { + license { + name = "The Apache License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + } + developers { + developer { + name = "OpenSearch" + url = "https://github.com/opensearch-project/opensearch-plugin-template-java" + } + } + } + } + } +} + +opensearchplugin { + name pluginName + description pluginDescription + classname "${packagePath}.${pathToPlugin}.${pluginClassName}" + version "1.0.0-SNAPSHOT" +} + +// This requires an additional Jar not published as part of build-tools +loggerUsageCheck.enabled = false + +// No need to validate pom, as we do not upload to maven/sonatype +validateNebulaPom.enabled = false + +repositories { + mavenLocal() + maven { url "https://aws.oss.sonatype.org/content/repositories/snapshots" } + mavenCentral() + maven { url "https://plugins.gradle.org/m2/" } + maven { url "https://maven.jans.io/maven" } +} + +dependencies { + // JSON processing + implementation "org.json:json:20231013" + // Logging + implementation "org.apache.logging.log4j:log4j-api:2.25.3" + implementation "org.apache.logging.log4j:log4j-core:2.25.3" + // Cedarling FFI + implementation "io.jans:cedarling-java:2.0.0" + + // Testing dependencies + testImplementation "org.testng:testng:7.11.0" + testImplementation "org.jcommander:jcommander:2.0" + testImplementation "com.nimbusds:oauth2-oidc-sdk:11.26.1" + testImplementation "com.nimbusds:content-type:2.3" +} + +test { + include '**/*Test.class' + + useTestNG() { + suites 'src/test/resources/suite.xml' + } + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } +} + +task integTest(type: RestIntegTestTask) { + description = "Run tests against a cluster" + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath +} +tasks.named("check").configure { dependsOn(integTest) } + +integTest { + // The --debug-jvm command-line option makes the cluster debuggable; this makes the tests debuggable + if (System.getProperty("test.debug") != null) { + jvmArgs '-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005' + } +} + +testClusters.integTest { + testDistribution = "INTEG_TEST" + + // This installs our plugin into the testClusters + plugin(project.tasks.bundlePlugin.archiveFile) +} + +run { + useCluster testClusters.integTest +} + +// updateVersion: Task to auto update version to the next development iteration +task updateVersion { + onlyIf { System.getProperty('newVersion') } + doLast { + ext.newVersion = System.getProperty('newVersion') + println "Setting version to ${newVersion}." + // String tokenization to support -SNAPSHOT + ant.replaceregexp(file:'build.gradle', match: '"opensearch.version", "\\d.*"', replace: '"opensearch.version", "' + newVersion.tokenize('-')[0] + '-SNAPSHOT"', flags:'g', byline:true) + } +} + diff --git a/demos/opensearch-cedarling/gradle.properties b/demos/opensearch-cedarling/gradle.properties new file mode 100644 index 00000000000..7717686e6e9 --- /dev/null +++ b/demos/opensearch-cedarling/gradle.properties @@ -0,0 +1,11 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# +# The OpenSearch Contributors require contributions made to +# this file be licensed under the Apache-2.0 license or a +# compatible open source license. +# + +org.gradle.caching=true +org.gradle.warning.mode=none +org.gradle.parallel=true diff --git a/demos/opensearch-cedarling/gradlew b/demos/opensearch-cedarling/gradlew new file mode 100755 index 00000000000..f5feea6d6b1 --- /dev/null +++ b/demos/opensearch-cedarling/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/demos/opensearch-cedarling/pipeline.json b/demos/opensearch-cedarling/pipeline.json new file mode 100644 index 00000000000..265d5539ba9 --- /dev/null +++ b/demos/opensearch-cedarling/pipeline.json @@ -0,0 +1,11 @@ +{ + "response_processors": [ + { + "cedarling" : { + "tag" : "cedarling_response", + "description" : "Demo processor", + "ignore_failure": false + } + } + ] +} diff --git a/demos/opensearch-cedarling/query.json b/demos/opensearch-cedarling/query.json new file mode 100644 index 00000000000..43d8ddf74a3 --- /dev/null +++ b/demos/opensearch-cedarling/query.json @@ -0,0 +1,5 @@ +{ + "query": { + "match_all": { } + } +} diff --git a/demos/opensearch-cedarling/query_ext.json b/demos/opensearch-cedarling/query_ext.json new file mode 100644 index 00000000000..0a3cd82f4e8 --- /dev/null +++ b/demos/opensearch-cedarling/query_ext.json @@ -0,0 +1,14 @@ +{ + "query":{ + "match_all":{ + } + }, + "ext": { + "tbac": { + "tokens": { + "Jans::Userinfo_token": "eyJraWQ...blah...blah..." + }, + "context": { } + } + } +} diff --git a/demos/opensearch-cedarling/records.txt b/demos/opensearch-cedarling/records.txt new file mode 100644 index 00000000000..f07c0ddf6ac --- /dev/null +++ b/demos/opensearch-cedarling/records.txt @@ -0,0 +1,6 @@ +{ "create": {} } +{ "name": "Jim Dunlop", "grad_year": 2021 } +{ "create": {} } +{ "name": "John Doe", "grad_year": 2023 } +{ "create": {} } +{ "name": "Aguacate Hass", "grad_year": 2025 } diff --git a/demos/opensearch-cedarling/settings.gradle b/demos/opensearch-cedarling/settings.gradle new file mode 100644 index 00000000000..16eb6f35a74 --- /dev/null +++ b/demos/opensearch-cedarling/settings.gradle @@ -0,0 +1,10 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * + * Detailed information about configuring a multi-project build in Gradle can be found + * in the user manual at https://docs.gradle.org/6.5.1/userguide/multi_project_builds.html + */ + +rootProject.name = 'opensearch-cedarling' diff --git a/demos/opensearch-cedarling/settings.json b/demos/opensearch-cedarling/settings.json new file mode 100644 index 00000000000..8ff7ae3c9bf --- /dev/null +++ b/demos/opensearch-cedarling/settings.json @@ -0,0 +1,19 @@ +{ + "bootstrapProperties": { + "CEDARLING_APPLICATION_NAME": "Cedarling OpenSearch demo", + "CEDARLING_JWT_SIG_VALIDATION": "enabled", + "CEDARLING_JWT_STATUS_VALIDATION": "enabled", + "CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED": [ + "HS256", + "RS256" + ], + "CEDARLING_LOG_TYPE": "memory", + "CEDARLING_LOG_LEVEL": "INFO", + "CEDARLING_LOG_TTL": 60, + "CEDARLING_POLICY_STORE_URI": "https://path/to/cjar" + }, + "searchActionName": "Jans::Action::\"Search\"", + "schemaPrefix": "Jans", + "enabled": true, + "logCedarlingLogs": true +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingPlugin.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingPlugin.java new file mode 100644 index 00000000000..b1b3bf9a103 --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingPlugin.java @@ -0,0 +1,109 @@ +package io.jans.cedarling.opensearch; + +import io.jans.cedarling.opensearch.rest.SettingsRestHandler; + +import java.util.*; +import java.util.function.Supplier; + +import org.opensearch.cluster.service.*; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.node.DiscoveryNodes; +import org.opensearch.common.settings.*; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.env.*; +import org.opensearch.plugins.*; +import org.opensearch.plugins.SearchPipelinePlugin.Parameters; +import org.opensearch.rest.*; +import org.opensearch.search.pipeline.*; +import org.opensearch.repositories.RepositoriesService; +import org.opensearch.script.ScriptService; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.*; +import org.opensearch.watcher.ResourceWatcherService; +import org.json.*; + +public class CedarlingPlugin extends Plugin implements SearchPlugin, SearchPipelinePlugin, ActionPlugin { + + public static final String NAME = "cedarling"; + public static final String SETTINGS_KEY = "plugins." + NAME + ".settings"; + public static final String LAST_UPDATED_KEY = "plugins." + NAME + ".updatedAt"; + + private static volatile ClusterService cs; + private static volatile Client localClient; + + public static ClusterService getClusterService() { + return cs; + } + + public static ClusterAdminClient getClusterAdminClient() { + return localClient.admin().cluster(); + } + + @Override + public Map> getResponseProcessors(Parameters parameters) { + return Map.of(CedarlingSearchResponseProcessor.TYPE, new CedarlingSearchResponseProcessor.Factory()); + } + + @Override + public List> getSearchExts() { + + return List.of( + new SearchExtSpec<>( + CedarlingSearchExtBuilder.PARAM_FIELD_NAME, + in -> new CedarlingSearchExtBuilder(in), + parser -> CedarlingSearchExtBuilder.parse(parser) + ) + ); + + } + + @Override + public Collection createComponents( + Client localClient, + ClusterService clusterService, + ThreadPool threadPool, + ResourceWatcherService resourceWatcherService, + ScriptService scriptService, + NamedXContentRegistry xContentRegistry, + Environment environment, + NodeEnvironment nodeEnvironment, + NamedWriteableRegistry namedWriteableRegistry, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier repositoriesServiceSupplier) { + + this.cs = clusterService; + this.localClient = localClient; + return super.createComponents(localClient, clusterService, threadPool, resourceWatcherService, + scriptService, xContentRegistry, environment, nodeEnvironment, namedWriteableRegistry, + indexNameExpressionResolver, repositoriesServiceSupplier); + } + + @Override + public List> getSettings() { + //All settings are stored in a single bulky string property: handling complex JSON content + //for settings in Opensearch is weird and awkward. A separate endpoint was created for config + //management. It gives the illusion of proper JSON management. The endpoint serializes + //everything to a string before populating settings in the cluster service + Setting.Property[] properties = new Setting.Property[] { Setting.Property.Dynamic, Setting.Property.NodeScope }; + return List.of( + Setting.simpleString(SETTINGS_KEY, properties), + Setting.longSetting(LAST_UPDATED_KEY, 0, properties) + ); + + } + + @Override + public List getRestHandlers( + Settings settings, + RestController restController, + ClusterSettings clusterSettings, + IndexScopedSettings indexScopedSettings, + SettingsFilter settingsFilter, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier nodesInCluster) { + + return List.of(new SettingsRestHandler()); + } + +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchExtBuilder.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchExtBuilder.java new file mode 100644 index 00000000000..15df7f794d3 --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchExtBuilder.java @@ -0,0 +1,81 @@ +package io.jans.cedarling.opensearch; + +import java.io.IOException; +import java.util.*; + +import org.opensearch.core.common.io.stream.*; +import org.opensearch.core.xcontent.*; +import org.opensearch.search.SearchExtBuilder; + +public class CedarlingSearchExtBuilder extends SearchExtBuilder { + + public final static String PARAM_FIELD_NAME = "tbac"; + + protected Map params; + + public CedarlingSearchExtBuilder(Map params) { + this.params = params; + } + + public CedarlingSearchExtBuilder(StreamInput in) throws IOException { + params = in.readMap(); + } + + public Map getParams() { + return params; + } + + @Override + public String getWriteableName() { + return PARAM_FIELD_NAME; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeMap(params); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + for (String key : this.params.keySet()) { + builder.field(key, this.params.get(key)); + } + return builder; + } + + @Override + public int hashCode() { + return Objects.hash(this.getClass(), this.params); + } + + @Override + public boolean equals(Object obj) { + return (obj instanceof CedarlingSearchExtBuilder) && params.equals(((CedarlingSearchExtBuilder) obj).params); + } + + /** + * Pick out the first CedarlingSearchExtBuilder from a list of SearchExtBuilders + * @param builders list of SearchExtBuilders + * @return the CedarlingSearchExtBuilder + */ + public static CedarlingSearchExtBuilder fromExtBuilderList(List builders) { + Optional b = builders.stream().filter(CedarlingSearchExtBuilder.class::isInstance).findFirst(); + if (b.isPresent()) { + return (CedarlingSearchExtBuilder) b.get(); + } else { + return null; + } + } + + /** + * Parse XContent to CedarlingSearchExtBuilder + * @param parser parser parsing this searchExt + * @return CedarlingSearchExtBuilder represented by this searchExt + * @throws IOException if problems parsing + */ + public static CedarlingSearchExtBuilder parse(XContentParser parser) throws IOException { + CedarlingSearchExtBuilder ans = new CedarlingSearchExtBuilder((Map) parser.map()); + return ans; + } + +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchResponse.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchResponse.java new file mode 100644 index 00000000000..671a78a0949 --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchResponse.java @@ -0,0 +1,50 @@ +package io.jans.cedarling.opensearch; + +import java.io.IOException; +import java.util.Map; + +import org.opensearch.action.search.*; +import org.opensearch.core.xcontent.XContentBuilder; + +public class CedarlingSearchResponse extends SearchResponse { + + private static final String EXT_SECTION_NAME = "ext"; + + private Map params; + + public CedarlingSearchResponse( + Map params, + SearchResponseSections internalResponse, + String scrollId, + int totalShards, + int successfulShards, + int skippedShards, + long tookInMillis, + PhaseTook phaseTook, + ShardSearchFailure[] shardFailures, + Clusters clusters, + String pointInTimeId) { + + super(internalResponse, scrollId, totalShards, successfulShards, skippedShards, + tookInMillis, phaseTook, shardFailures, clusters, pointInTimeId); + this.params = params; + + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + + builder.startObject(); + innerToXContent(builder, params); + + if (this.params != null) { + builder.startObject(EXT_SECTION_NAME); + builder.field(CedarlingSearchResponseProcessor.TYPE, this.params); + builder.endObject(); + } + builder.endObject(); + return builder; + + } + +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchResponseProcessor.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchResponseProcessor.java new file mode 100644 index 00000000000..5be3a2dfee8 --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingSearchResponseProcessor.java @@ -0,0 +1,181 @@ +package io.jans.cedarling.opensearch; + +import java.util.*; + +import org.apache.logging.log4j.*; +import org.json.*; +import org.opensearch.search.*; +import org.opensearch.search.pipeline.*; +import org.opensearch.action.search.*; +import org.opensearch.search.profile.*; + +import uniffi.cedarling_uniffi.*; + +public class CedarlingSearchResponseProcessor extends AbstractProcessor implements SearchResponseProcessor { + + public static final String TYPE = "cedarling"; + + private Logger logger = LogManager.getLogger(getClass()); + + @Override + public String getType() { + return TYPE; + } + + private CedarlingSearchResponseProcessor(String tag, String description, boolean ignoreFailure) { + super(tag, description, ignoreFailure); + logger.info("Instantiating CedarlingSearchResponseProcessor"); + } + + @Override + public SearchResponse processResponse(SearchRequest request, SearchResponse response) throws Exception { + /* + request.source() is expected to be like: + + { + "query": { ... } + "ext" { + "tbac": { + "tokens": { + "Jans::Userinfo_token": "ey..." + //more token mappings here if needed according to policy + }, + "context": { ... } + } + } + } + */ + long startedAt = System.currentTimeMillis(); + + PluginSettings pluginSettings = SettingsService.getInstance().getSettings(); + if (!pluginSettings.isEnabled()) { + logger.debug("Cedarling processing is disabled"); + return response; + } + + CedarlingService cedarlingService = CedarlingService.getInstance(); + if (!cedarlingService.isStarted()) { + logger.debug("Cedarling service did not start properly"); + return response; + } + + SearchSource source = request.source(); + if (source == null) { + logger.debug("No search source in request"); + return response; + } + + List exts = source.ext(); + if (exts.isEmpty()) { + logger.warn("No 'ext' in request"); + return response; + } + + try { + SearchResponseSections sections = response.getInternalResponse(); + Map empty = Collections.emptyMap(); + int authorizedHitsCount = 0; + long avgDecisionTime = -1; + + CedarlingSearchExtBuilder cseb = CedarlingSearchExtBuilder.fromExtBuilderList(exts); + SearchHits searchHits = response.getHits(); + Iterator it = searchHits.iterator(); + + if (it.hasNext()) { + List authorized = new ArrayList<>(); + Map tbac = cseb.getParams(); + String action = pluginSettings.getSearchActionName(); + + Map tokens = Optional.ofNullable( + tbac.get("tokens")).map(Map.class::cast).orElse(empty); + JSONObject context = new JSONObject(Optional.ofNullable( + tbac.get("context")).map(Map.class::cast).orElse(empty)); + + long decisionsTook = 0; + do { + SearchHit hit = it.next(); + Map map = Optional.ofNullable(hit.getSourceAsMap()) + .map(HashMap::new).orElse(new HashMap<>()); + + try { + appendExtraAttributes(map, pluginSettings.getSchemaPrefix(), hit.getIndex(), hit.getId()); + long temp = System.nanoTime(); + boolean allowed = cedarlingService.authorize(tokens, action, map, context); + decisionsTook += (System.nanoTime() - temp); + + if (allowed) { + authorized.add(hit); + } + } catch (Exception e) { + //include the result when Cedarling cannot handle it?, ie. authorized.add(hit); + logger.error(e.getMessage(), e); + } + } while (it.hasNext()); + + //override the hits, the rest remains all the same + SearchHit[] noHits = new SearchHit[0]; + SearchHits mySearchHits = new SearchHits( + //Use skipHits = true in the plugin config to avoid big response (it's useful for testing) + pluginSettings.isSkipHits() ? noHits : authorized.toArray(noHits), + searchHits.getTotalHits(), searchHits.getMaxScore(), searchHits.getSortFields(), + searchHits.getCollapseField(), searchHits.getCollapseValues()); + + Map shardResults = sections.profile(); + sections = new SearchResponseSections(mySearchHits, + sections.aggregations(), sections.suggest(), sections.timedOut(), sections.terminatedEarly(), + shardResults.isEmpty() ? null : new SearchProfileShardResults(shardResults), + sections.getNumReducePhases(), sections.getSearchExtBuilders()); + + authorizedHitsCount = authorized.size(); + //compute average decision time per document in micro seconds + avgDecisionTime = Math.round(decisionsTook / (1000.0d * searchHits.getHits().length)); + } + + return new CedarlingSearchResponse( + Map.of( + "authorized_hits_count", authorizedHitsCount, + "average_decision_time", avgDecisionTime + ), + sections, response.getScrollId(), response.getTotalShards(), + response.getSuccessfulShards(), response.getSkippedShards(), + System.currentTimeMillis() - startedAt + response.getTook().getMillis(), response.getPhaseTook(), + response.getShardFailures(), response.getClusters(), response.pointInTimeId() + ); + + } catch (Exception e) { + logger.error("Error parsing 'ext' in request", e); + throw e; + } + + } + + private void appendExtraAttributes(Map map, String prefix, String indexName, String id) { + + map.putAll( + Map.of("cedar_entity_mapping", + Map.of( + "entity_type", prefix + "::" + indexName, + "id", id + ) + ) + ); + + } + + static class Factory implements Processor.Factory { + + @Override + public CedarlingSearchResponseProcessor create( + Map> processorFactories, + String tag, + String description, + boolean ignoreFailure, + Map config, + PipelineContext pipelineContext) { + + return new CedarlingSearchResponseProcessor(tag, description, ignoreFailure); + } + + } + +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingService.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingService.java new file mode 100644 index 00000000000..2597fe0639c --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/CedarlingService.java @@ -0,0 +1,74 @@ +package io.jans.cedarling.opensearch; + +import io.jans.cedarling.binding.wrapper.CedarlingAdapter; + +import java.util.*; + +import org.apache.logging.log4j.*; +import org.json.JSONObject; + +import uniffi.cedarling_uniffi.*; + +public class CedarlingService { + + private CedarlingAdapter cedarlingAdapter; + private Logger logger = LogManager.getLogger(getClass()); + private boolean started; + private boolean useLogging; + + private static CedarlingService instance = new CedarlingService(); + + private CedarlingService() { + cedarlingAdapter = new CedarlingAdapter(); + } + + public static CedarlingService getInstance() { + return instance; + } + + public void init(JSONObject bootstrapProperties, boolean useLogging) { + + try { + started = false; + logger.info("Initializing Cedarling..."); + cedarlingAdapter.loadFromJson(bootstrapProperties.toString()); + + if (useLogging) { + List initLogs = cedarlingAdapter.getLogsByTag("System"); + initLogs.forEach(line -> logger.debug(" {}", line)); + } + + started = true; + this.useLogging = useLogging; + logger.info("Done"); + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + + } + + public boolean isStarted() { + return started; + } + + public boolean authorize(Map tokens, String action, Map resource, + JSONObject context) throws Exception { + + List tokenInputs = new ArrayList<>(); + tokens.entrySet().forEach(e -> tokenInputs.add(new TokenInput(e.getKey(), e.getValue()))); + + MultiIssuerAuthorizeResult res = cedarlingAdapter.authorizeMultiIssuer(tokenInputs, action, + new JSONObject(resource), context); + boolean authorized = res.getDecision(); + + if (!authorized && useLogging) { + List decisionLogs = cedarlingAdapter.getLogsByRequestId(res.getRequestId()); + + logger.debug("Unauthorized decision{}", decisionLogs.isEmpty() ? ". No logs available" : ""); + decisionLogs.forEach(line -> logger.trace(" {}", line)); + } + return authorized; + + } + +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/PluginSettings.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/PluginSettings.java new file mode 100644 index 00000000000..08300e01010 --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/PluginSettings.java @@ -0,0 +1,88 @@ +package io.jans.cedarling.opensearch; + +import java.util.Map; + +import org.apache.logging.log4j.*; +import org.json.*; + +public class PluginSettings { + + private static Logger logger = LogManager.getLogger(PluginSettings.class); + + private long lastUpdated; + private boolean enabled; + private boolean skipHits; + private boolean logCedarlingLogs; + private JSONObject bootstrapProperties; + private String searchActionName; + private String schemaPrefix; + + public static PluginSettings from(JSONObject job, long lastUdpated) { + + PluginSettings ps = new PluginSettings(); + ps.enabled = job.optBoolean("enabled", true); + ps.skipHits = job.optBoolean("skipHits", false); + ps.logCedarlingLogs = job.optBoolean("logCedarlingLogs", true); + + ps.bootstrapProperties = job.optJSONObject("bootstrapProperties"); + if (ps.bootstrapProperties == null) { + logger.warn("Undefined 'bootstrapProperties'"); + return null; + } + + ps.searchActionName = job.optString("searchActionName", null); + if (ps.searchActionName == null) { + logger.warn("Undefined 'searchActionName'"); + return null; + } + + ps.schemaPrefix = job.optString("schemaPrefix", null); + if (ps.schemaPrefix == null) { + logger.warn("Undefined 'schemaPrefix'"); + return null; + } + + ps.lastUpdated = lastUdpated; + return ps; + + } + + @JSONPropertyIgnore + public long getLastUpdated() { + return lastUpdated; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isSkipHits() { + return skipHits; + } + + public boolean isLogCedarlingLogs() { + return logCedarlingLogs; + } + + public JSONObject getBootstrapProperties() { + return bootstrapProperties; + } + + public String getSearchActionName() { + return searchActionName; + } + + public String getSchemaPrefix() { + return schemaPrefix; + } + +/* + public void setLastUdpated(long lastUdpated) { + this.lastUdpated = lastUdpated; + } + */ + public Map asMap() { + return new JSONObject(this).toMap(); + } + +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/SettingsService.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/SettingsService.java new file mode 100644 index 00000000000..c31410955d7 --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/SettingsService.java @@ -0,0 +1,86 @@ +package io.jans.cedarling.opensearch; + +import java.util.*; +import java.time.Instant; + +import org.apache.logging.log4j.*; +import org.json.*; +import org.opensearch.common.settings.*; + +public class SettingsService { + + private static SettingsService instance = new SettingsService(); + + private Logger logger = LogManager.getLogger(getClass()); + private PluginSettings pluginSettings; + + public static SettingsService getInstance() { + return instance; + } + + private SettingsService() { } + + public PluginSettings getSettings() { + + AbstractScopedSettings clSettings = getClusterSettings(); + if (pluginSettings == null) { + reloadPluginSettings(clSettings); + } else { + long lastUpdated = getLastUpdated(clSettings); + + if (lastUpdated <= 0) { + logger.warn("Plugin settings seem to have been wiped. Previous settings are retained"); + } else if (pluginSettings.getLastUpdated() < lastUpdated) { + //Current "in memory" settings are not in sync with database settings + reloadPluginSettings(clSettings); + } + } + return pluginSettings; + + } + + public void reloadPluginSettings() { + reloadPluginSettings(getClusterSettings()); + } + + private void reloadPluginSettings(AbstractScopedSettings settings) { + + try { + logger.info("Reloading Cedarling plugin settings..."); + + long lastUpdated = getLastUpdated(settings); + if (lastUpdated <= 0) { + logger.warn("Plugin settings not set yet"); + return; + } + logger.debug("Last updated on {}", Instant.ofEpochMilli(lastUpdated).toString()); + + String key = CedarlingPlugin.SETTINGS_KEY; + JSONObject job = Optional.ofNullable(settings.get(settings.get(key))) + .map(Object::toString).map(JSONObject::new).orElse(null); + + if (job == null) { + logger.warn("Plugin settings have not been set yet. {} is missing", key); + return; + } + + pluginSettings = PluginSettings.from(job, lastUpdated); + CedarlingService.getInstance() + .init(pluginSettings.getBootstrapProperties(), pluginSettings.isLogCedarlingLogs()); + + } catch (Exception e) { + logger.error("Error trying to parse Cedarling plugin settings", e); + } + + } + + private AbstractScopedSettings getClusterSettings() { + return CedarlingPlugin.getClusterService().getClusterSettings(); + } + + private long getLastUpdated(AbstractScopedSettings settings) { + return Optional.ofNullable(settings.get(settings.get(CedarlingPlugin.LAST_UPDATED_KEY))) + .map(Long.class::cast).orElse(0L); + } + +} diff --git a/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/rest/SettingsRestHandler.java b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/rest/SettingsRestHandler.java new file mode 100644 index 00000000000..f73d0746d53 --- /dev/null +++ b/demos/opensearch-cedarling/src/main/java/io/jans/cedarling/opensearch/rest/SettingsRestHandler.java @@ -0,0 +1,129 @@ +package io.jans.cedarling.opensearch.rest; + +import io.jans.cedarling.opensearch.*; + +import java.io.IOException; +import java.util.*; +import java.net.URLEncoder; + +import org.json.*; +import org.opensearch.action.admin.cluster.settings.*; +import org.opensearch.common.action.*; +import org.opensearch.common.xcontent.json.*; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.rest.*; +import org.opensearch.rest.*; +import org.opensearch.transport.client.*; +import org.opensearch.transport.client.node.NodeClient; + +import static io.jans.cedarling.opensearch.CedarlingPlugin.NAME; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.opensearch.rest.RestRequest.Method.*; + +public class SettingsRestHandler extends BaseRestHandler { + + private static final String PATH = "/_plugins/" + URLEncoder.encode(NAME, UTF_8) + "/settings"; + private static final long TIMEOUT = 1500; //1.5 seconds + + @Override + public String getName() { + return "cedarling_settings_handler"; + } + + @Override + public List routes() { + return List.of(new RestHandler.Route(GET, PATH), new RestHandler.Route(PUT, PATH)); + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + + RestRequest.Method method = request.method(); + switch (method) { + case RestRequest.Method.PUT: + return handlePut(request); + case RestRequest.Method.GET: + return handleGet(request); + default: + return ch -> ch.sendResponse( + new BytesRestResponse(RestStatus.METHOD_NOT_ALLOWED, "Method not allowed:" + method.toString())); + } + + } + + private RestChannelConsumer handlePut(RestRequest request) { + + return channel -> { + try { + logger.info("Handling PUT request"); + boolean hasJsonHeader = Optional.ofNullable(request.header("Content-Type")) + .map(h -> h.contains("application/json")).orElse(false); + BytesRestResponse response; + + if (hasJsonHeader) { + String payload = request.content().utf8ToString(); + logger.debug("Payload size is {}", payload.length()); + + JSONObject job = new JSONObject(payload); //ensure it is really JSON content + Long now = System.currentTimeMillis(); + + ClusterUpdateSettingsRequest cusr = new ClusterUpdateSettingsRequest(); + cusr.persistentSettings(Map.of( + CedarlingPlugin.SETTINGS_KEY, (Object) payload, CedarlingPlugin.LAST_UPDATED_KEY, (Object) now + )); + + ClusterAdminClient caca = CedarlingPlugin.getClusterAdminClient(); + logger.debug("Sending update settings request to cluster..."); + + ClusterUpdateSettingsResponse updateResponse = caca.updateSettings(cusr).actionGet(TIMEOUT); + boolean acknowledged = updateResponse.isAcknowledged(); + logger.info("Response acknowledged: {}", acknowledged); + + response = new BytesRestResponse( + acknowledged ? RestStatus.OK : RestStatus.INTERNAL_SERVER_ERROR, + "application/json", + BytesReference.bytes(JsonXContent.contentBuilder().map(Map.of("acknowledged", acknowledged))) + ); + + } else { + response = new BytesRestResponse(RestStatus.NOT_ACCEPTABLE, "Unexpected Content-Type header"); + } + + logger.debug("Sending PUT response..."); + channel.sendResponse(response); + } catch (Exception e) { + channel.sendResponse(new BytesRestResponse(channel, e)); + } + }; + + } + + + private RestChannelConsumer handleGet(RestRequest request) { + + return channel -> { + try { + logger.info("Handling GET request"); + Map map = Optional.ofNullable(SettingsService.getInstance().getSettings()) + .map(PluginSettings::asMap).orElse(Collections.emptyMap()); + + if (map.isEmpty()) { + logger.warn("There was a problem retrieving Cedarling plugin settings, or they have not been defined yet"); + } + + BytesRestResponse response = new BytesRestResponse( + RestStatus.OK, + "application/json", + BytesReference.bytes(JsonXContent.contentBuilder().map(map)) + ); + + logger.debug("Sending GET response..."); + channel.sendResponse(response); + } catch (Exception e) { + channel.sendResponse(new BytesRestResponse(channel, e)); + } + }; + + } + +} diff --git a/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/AlterSuiteListener.java b/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/AlterSuiteListener.java new file mode 100644 index 00000000000..b7e5a3bf90a --- /dev/null +++ b/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/AlterSuiteListener.java @@ -0,0 +1,45 @@ +package io.jans.cedarling.opensearch; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +import org.apache.logging.log4j.*; +import org.testng.IAlterSuiteListener; +import org.testng.xml.XmlSuite; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class AlterSuiteListener implements IAlterSuiteListener { + + private Logger logger = LogManager.getLogger(getClass()); + + @Override + public void alter(List suites) { + + try { + XmlSuite suite = suites.get(0); + Path propertiesFilePath = Paths.get(suite.getParameter("propertiesFile")); + + Properties prop = new Properties(); + prop.load(Files.newBufferedReader(propertiesFilePath, UTF_8)); + + Map parameters = new Hashtable<>(); + //do not bother about empty keys... but + //If a value is found null, this will throw a NPE since we are using a Hashtable + prop.forEach((Object key, Object value) -> parameters.put(key.toString(), value.toString())); + + //query file assumed to be in the same directory of properties file + String p = "queryFile"; + Path queryFilePath = Path.of(propertiesFilePath.getParent().toString(), parameters.get(p)); + //overwrite file name with actual contents + parameters.put(p, Files.readString(queryFilePath, UTF_8)); + + suite.setParameters(parameters); + } catch (IOException e) { + logger.error(e.getMessage()); + } + + } + +} \ No newline at end of file diff --git a/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/BenchmarkTest.java b/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/BenchmarkTest.java new file mode 100644 index 00000000000..f51cab7de59 --- /dev/null +++ b/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/BenchmarkTest.java @@ -0,0 +1,173 @@ +package io.jans.cedarling.opensearch; + +import java.util.*; +import java.security.*; + +import org.apache.logging.log4j.*; +import org.json.*; +import org.testng.annotations.*; +import org.testng.ITestContext; + +import static org.testng.Assert.*; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class BenchmarkTest { + + private static final int MAX_GPA = 5; + + private Logger logger = LogManager.getLogger(getClass()); + private NetworkUtil nu = null; + private Random ranma = new SecureRandom(); + + private int entries; + private String indexName; + private String bulkEntryTemplate; + private String queryTemplate; + private boolean useCedarling; + + @BeforeClass + public void initTestSuite(ITestContext context) throws Exception { + + //See AlterSuiteListener class first please + Map params = context.getSuite().getXmlSuite().getParameters(); + String user = params.get("user"); + String pwd = params.get("password"); + + byte[] bytes = Base64.getEncoder().encode((user + ":" + pwd).getBytes()); + nu = new NetworkUtil(params.get("apiBase"), "Basic " + new String(bytes, UTF_8)); + + entries = Integer.parseInt(params.get("entries")); + indexName = params.get("indexName"); + bulkEntryTemplate = params.get("bulkEntryTemplate"); + queryTemplate = params.get("queryFile"); + useCedarling = Boolean.valueOf(params.get("useCedarling")); + + } + + @Test + public void dropIndex() throws Exception { + + logger.info("Deleting index {}...", indexName); + JSONObject obj = nu.sendDelete(indexName, 200, 404); + + logger.debug("Checking result of delete operation"); + if (obj.optInt("status") != 404) { + assertEquals(obj.getBoolean("acknowledged"), true); + } + + } + + @Test(dependsOnMethods="dropIndex") + public void fillIndex() throws Exception { + + logger.info("Creating documents..."); + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < entries; i++) { + sb.append(String.format(bulkEntryTemplate, getAString(), + getADecimal(2024, 2028), ranma.nextFloat() * MAX_GPA)); + } + + String payload = sb.toString(); + logger.info("Payload of {} {} documents generated ({} bytes)", entries, indexName, payload.getBytes().length); + JSONObject obj = nu.sendPost(indexName + "/_bulk?refresh=true&filter_path=-items", 200, payload); + //refresh param allows the inserted documents to be immediately available for search after the POST is submitted + //filter_path elides the metadata associated to every insertion attempt, reducing the response size considerably + + logger.debug("Checking result of bulk operation"); + assertFalse(obj.getBoolean("errors")); + + } + + @Test(dependsOnMethods="fillIndex") + public void runQueries() throws Exception { + + int perfectScorers = warmUpQuery(); + if (useCedarling) { + cedarlingQueries(perfectScorers); + } else { + regularQueries(); + } + + } + + //The very first query after the bulk is run tends to be very slow. Hence, a dummy query is issued and discarded + public int warmUpQuery() throws Exception { + + //This one will likely produce zero results, however in practice MAX_GPA multiplied by a random float + //(see method fillIndex) may yield exactly MAX_GPA + String query = String.format(queryTemplate, MAX_GPA, MAX_GPA + 1); + logger.info("Sending warmup query..."); + + JSONObject obj = nu.sendPost(indexName + "/_search?size=" + entries, 200, query); + //filter_path=-hits.hits can be used to elide the actual document hits from the response reducing its size considerably + logger.info("Took {}ms \n", obj.getInt("took")); + return obj.getJSONObject("hits").getJSONObject("total").getInt("value"); + + } + + public void regularQueries() throws Exception { + + int queryTookMs = 0; + //Issue several different queries and compute average "took" time + for (int i = 0; i < MAX_GPA; i++) { + String query = String.format(queryTemplate, i, i + 1); + logger.info("Sending regular query #{}...", i + 1); + + JSONObject obj = nu.sendPost(indexName + "/_search?size=" + entries, 200, query); + queryTookMs += obj.getInt("took"); + } + logger.info(""); + logger.info("Average regular query time (ms): {}", String.format("%.3f", 1.0f*queryTookMs / MAX_GPA)); + + } + + public void cedarlingQueries(int perfectScorers) throws Exception { + + long decisionTime = 0; + int queryTookMs = 0; + int totalResults = 0, emptyResultSets = 0; + //Issue several different queries and compute average "took" and decision time + for (int i = 0; i < MAX_GPA; i++) { + String query = String.format(queryTemplate, i, i + 1); + logger.info("Sending regular query #{}...", i + 1); + + JSONObject obj = nu.sendPost(indexName + "/_search?search_pipeline=cedarling_search&size=" + entries, 200, query); + queryTookMs += obj.getInt("took"); + + long adt = obj.getJSONObject("ext").getJSONObject("cedarling").getInt("average_decision_time"); + int res = obj.getJSONObject("hits").getJSONObject("total").getInt("value"); + + if (adt == -1) { + //No decisions performed, ie. empty result set. This may occur when the amount of generated documents is small + emptyResultSets++; + assertEquals(res, 0); + } else { + decisionTime += adt; + totalResults += res; + } + } + + assertEquals(totalResults + perfectScorers, entries); + logger.info(""); + logger.info("Average plugin query time (ms): {}", String.format("%.3f", 1.0f*queryTookMs / MAX_GPA)); + logger.info("Average Cedarling Java decision time per document (ms): {}", + String.format("%.3f", decisionTime / ((MAX_GPA - emptyResultSets) * 1000.0f))); + + } + + private String getAString() { + + //radix 36 entails characters: 0-9 plus a-z + String path = Integer.toString(ranma.nextInt(), Math.min(36, Character.MAX_RADIX)); + //path will have at most 6 chars in practice + return path.substring(path.charAt(0) == '-' ? 1 : 0); + + } + + private int getADecimal(int min, int max) { + //Pick a uniformly distributed random number from the range [min, max) + return ranma.nextInt(max - min) + min; + } + +} diff --git a/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/NetworkUtil.java b/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/NetworkUtil.java new file mode 100644 index 00000000000..423553f8a12 --- /dev/null +++ b/demos/opensearch-cedarling/src/test/java/io/jans/cedarling/opensearch/NetworkUtil.java @@ -0,0 +1,71 @@ +package io.jans.cedarling.opensearch; + +import com.nimbusds.oauth2.sdk.http.*; +import com.nimbusds.common.contenttype.ContentType; + +import java.util.*; +import java.net.URL; + +import org.apache.logging.log4j.*; +import org.json.JSONObject; + +public class NetworkUtil { + + private static final int RESPONSE_TRUNCATE_LEN = 180; + private Logger logger = LogManager.getLogger(getClass()); + + private int connectionTimeout; + private int readTimeout; + private String host; + private String authzHeader; + + public NetworkUtil(String host, String authzHeader) { + this(host, authzHeader, 4500, 30000); + } + + public NetworkUtil(String host, String authzHeader, int connectionTimeout, int readTimeout) { + this.host = host; + this.authzHeader = authzHeader; + this.connectionTimeout = connectionTimeout; + this.readTimeout = readTimeout; + } + + public JSONObject sendDelete(String uri, int... expectedStatus) throws Exception { + return send(uri, expectedStatus, HTTPRequest.Method.DELETE, null); + } + + public JSONObject sendPost(String uri, int expectedStatus, String jsonPayload) throws Exception { + return send(uri, new int[] { expectedStatus }, HTTPRequest.Method.POST, jsonPayload); + } + + private JSONObject send(String uri, int[] expectedStatus, HTTPRequest.Method method, String jsonPayload) + throws Exception { + + HTTPRequest request = new HTTPRequest(method, new URL(host + "/" + uri)); + request.setConnectTimeout(connectionTimeout); + request.setReadTimeout(readTimeout); + request.setHeader("Authorization", authzHeader); + + if (jsonPayload != null) { + request.setBody(jsonPayload); + request.setHeader("Content-Type", "application/json"); + } + + HTTPResponse response = request.send(); + response.ensureStatusCode(expectedStatus); + response.ensureEntityContentType(ContentType.APPLICATION_JSON); + + //response.getHeaderValue("Content-Type"); + String body = response.getBody(); + String truncated = body; + + if (body.length() > RESPONSE_TRUNCATE_LEN) { + truncated = body.substring(0, RESPONSE_TRUNCATE_LEN) + " ..."; + } + + logger.info("HTTP response [{}] {}", response.getStatusCode(), truncated); + return new JSONObject(body); + + } + +} diff --git a/demos/opensearch-cedarling/src/test/resources/log4j2-test.xml b/demos/opensearch-cedarling/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000000..9ad9926d953 --- /dev/null +++ b/demos/opensearch-cedarling/src/test/resources/log4j2-test.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/demos/opensearch-cedarling/src/test/resources/query.json b/demos/opensearch-cedarling/src/test/resources/query.json new file mode 100644 index 00000000000..453e9ee4e51 --- /dev/null +++ b/demos/opensearch-cedarling/src/test/resources/query.json @@ -0,0 +1,17 @@ +{ + "query":{ + "bool": { + "filter": [ + { "range": { "gpa": { "gte": %d, "lt": %d }}} + ] + } + }, + "ext": { + "tbac": { + "tokens": { + "Jans::Userinfo_token": "eyJraW...blah...blah..." + }, + "context": { } + } + } +} diff --git a/demos/opensearch-cedarling/src/test/resources/suite.xml b/demos/opensearch-cedarling/src/test/resources/suite.xml new file mode 100644 index 00000000000..2fdf06f7021 --- /dev/null +++ b/demos/opensearch-cedarling/src/test/resources/suite.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/demos/opensearch-cedarling/src/test/resources/testng.properties b/demos/opensearch-cedarling/src/test/resources/testng.properties new file mode 100644 index 00000000000..db59ce4fdaa --- /dev/null +++ b/demos/opensearch-cedarling/src/test/resources/testng.properties @@ -0,0 +1,12 @@ + +apiBase = https://localhost:9200 + +user = admin +password = basura + +indexName = student +entries = 10000 +bulkEntryTemplate = { "create": {} }\n{ "name": "%s", "grad_year": %d, "gpa": %.2f }\n +queryFile = query.json + +useCedarling = false