Skip to content
Merged
6 changes: 1 addition & 5 deletions distribution/tools/entitlement-runtime/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,12 @@ apply plugin: 'elasticsearch.publish'

dependencies {
compileOnly project(':libs:elasticsearch-core') // For @SuppressForbidden
compileOnly project(":libs:elasticsearch-x-content") // for parsing policy files
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not an objection; just a thought...

Beyond the initialization phase, we don't need the ability to parse policy files anymore, so for 99% of its lifetime, the runtime library doesn't need this. This suggests that the parsing might be better in another module whose job is to generate (by whatever means) a policy object that is used by the runtime.

This sort of separation could enhance testing too, because the interface between them is an immutable data structure (records?). The parser module tests would feed in text and assert that the right structures are generated; the runtime module tests would feed in data structures and ensure that the right permissions are granted.

I'm not sure exactly what I'm suggesting here though. 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to moving it for sure, but for now this still seems like the best starting point to me.

compileOnly project(':server') // To access the main server module for special permission checks
compileOnly project(':distribution:tools:entitlement-bridge')

testImplementation project(":test:framework")
}

tasks.named('forbiddenApisMain').configure {
replaceSignatureFiles 'jdk-signatures'
}

tasks.named('forbiddenApisMain').configure {
replaceSignatureFiles 'jdk-signatures'
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

module org.elasticsearch.entitlement.runtime {
requires org.elasticsearch.entitlement.bridge;
requires org.elasticsearch.xcontent;
requires org.elasticsearch.server;

exports org.elasticsearch.entitlement.runtime.api;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.entitlement.runtime.policy;

/**
* Marker interface to ensure that only {@link Entitlement} are
* part of a {@link Policy}. All entitlement classes should implement
* this.
*/
public interface Entitlement {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some javadocs would be nice for other devs to know this is a marker interface

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.entitlement.runtime.policy;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* This annotation indicates an {@link Entitlement} is available
* to "external" classes such as those used in plugins. Any {@link Entitlement}
* using this annotation is considered parseable as part of a policy file
* for entitlements.
*/
@Target(ElementType.CONSTRUCTOR)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExternalEntitlement {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

javadoc?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also @Target.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added both documentation and an @target for constructor.


/**
* This is the list of parameter names that are
* parseable in {@link PolicyParser#parseEntitlement(String, String)}.
* The number and order of parameter names much match the number and order
* of constructor parameters as this is how the parser will pass in the
* parsed values from a policy file. However, the names themselves do NOT
* have to match the parameter names of the constructor.
*/
String[] parameterNames() default {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar here, javadocs to explain why this parameterNames exists at all

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.entitlement.runtime.policy;

import java.util.List;
import java.util.Objects;

/**
* Describes a file entitlement with a path and actions.
*/
public class FileEntitlement implements Entitlement {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this could be a record? That would remove more boilerplate. But that could be in a followup.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will leave this as a follow up because I'm not sure we don't want to add some kind of entitlement comparison in each one where this one would take in a path and an action to see if it's permitted. (Not a new object.)


public static final int READ_ACTION = 0x1;
public static final int WRITE_ACTION = 0x2;

private final String path;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to access these, so they should be public or have accessors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This depends on the future design if we end up doing entitlement checks as part of each entitlement class or not. I would prefer to change this in a follow up to match the design we decide on moving forward.

private final int actions;

@ExternalEntitlement(parameterNames = { "path", "actions" })
public FileEntitlement(String path, List<String> actionsList) {
this.path = path;
int actionsInt = 0;

for (String actionString : actionsList) {
if ("read".equals(actionString)) {
if ((actionsInt & READ_ACTION) == READ_ACTION) {
throw new IllegalArgumentException("file action [read] specified multiple times");
}
actionsInt |= READ_ACTION;
} else if ("write".equals(actionString)) {
if ((actionsInt & WRITE_ACTION) == WRITE_ACTION) {
throw new IllegalArgumentException("file action [write] specified multiple times");
}
actionsInt |= WRITE_ACTION;
} else {
throw new IllegalArgumentException("unknown file action [" + actionString + "]");
}
}

this.actions = actionsInt;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
FileEntitlement that = (FileEntitlement) o;
return actions == that.actions && Objects.equals(path, that.path);
}

@Override
public int hashCode() {
return Objects.hash(path, actions);
}

@Override
public String toString() {
return "FileEntitlement{" + "path='" + path + '\'' + ", actions=" + actions + '}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.entitlement.runtime.policy;

import java.util.Collections;
import java.util.List;
import java.util.Objects;

/**
* A holder for scoped entitlements.
*/
public class Policy {

public final String name;
public final List<Scope> scopes;

public Policy(String name, List<Scope> scopes) {
this.name = Objects.requireNonNull(name);
this.scopes = Collections.unmodifiableList(Objects.requireNonNull(scopes));
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Policy policy = (Policy) o;
return Objects.equals(name, policy.name) && Objects.equals(scopes, policy.scopes);
}

@Override
public int hashCode() {
return Objects.hash(name, scopes);
}

@Override
public String toString() {
return "Policy{" + "name='" + name + '\'' + ", scopes=" + scopes + '}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.entitlement.runtime.policy;

import org.elasticsearch.xcontent.ParseField;
import org.elasticsearch.xcontent.XContentParser;
import org.elasticsearch.xcontent.XContentParserConfiguration;
import org.elasticsearch.xcontent.yaml.YamlXContent;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import static org.elasticsearch.entitlement.runtime.policy.PolicyParserException.newPolicyParserException;

/**
* A parser to parse policy files for entitlements.
*/
public class PolicyParser {

protected static final ParseField ENTITLEMENTS_PARSEFIELD = new ParseField("entitlements");

protected static final String entitlementPackageName = Entitlement.class.getPackage().getName();

protected final XContentParser policyParser;
protected final String policyName;

public PolicyParser(InputStream inputStream, String policyName) throws IOException {
this.policyParser = YamlXContent.yamlXContent.createParser(XContentParserConfiguration.EMPTY, Objects.requireNonNull(inputStream));
this.policyName = policyName;
}

public Policy parsePolicy() {
try {
if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
throw newPolicyParserException("expected object <scope name>");
}
List<Scope> scopes = new ArrayList<>();
while (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
if (policyParser.currentToken() != XContentParser.Token.FIELD_NAME) {
throw newPolicyParserException("expected object <scope name>");
}
String scopeName = policyParser.currentName();
Scope scope = parseScope(scopeName);
scopes.add(scope);
}
return new Policy(policyName, scopes);
} catch (IOException ioe) {
throw new UncheckedIOException(ioe);
}
}

protected Scope parseScope(String scopeName) throws IOException {
try {
if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
throw newPolicyParserException(scopeName, "expected object [" + ENTITLEMENTS_PARSEFIELD.getPreferredName() + "]");
}
if (policyParser.nextToken() != XContentParser.Token.FIELD_NAME
|| policyParser.currentName().equals(ENTITLEMENTS_PARSEFIELD.getPreferredName()) == false) {
throw newPolicyParserException(scopeName, "expected object [" + ENTITLEMENTS_PARSEFIELD.getPreferredName() + "]");
}
if (policyParser.nextToken() != XContentParser.Token.START_ARRAY) {
throw newPolicyParserException(scopeName, "expected array of <entitlement type>");
}
List<Entitlement> entitlements = new ArrayList<>();
while (policyParser.nextToken() != XContentParser.Token.END_ARRAY) {
if (policyParser.currentToken() != XContentParser.Token.START_OBJECT) {
throw newPolicyParserException(scopeName, "expected object <entitlement type>");
}
if (policyParser.nextToken() != XContentParser.Token.FIELD_NAME) {
throw newPolicyParserException(scopeName, "expected object <entitlement type>");
}
String entitlementType = policyParser.currentName();
Entitlement entitlement = parseEntitlement(scopeName, entitlementType);
entitlements.add(entitlement);
if (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
throw newPolicyParserException(scopeName, "expected closing object");
}
}
if (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
throw newPolicyParserException(scopeName, "expected closing object");
}
return new Scope(scopeName, entitlements);
} catch (IOException ioe) {
throw new UncheckedIOException(ioe);
}
}

protected Entitlement parseEntitlement(String scopeName, String entitlementType) throws IOException {
Class<?> entitlementClass;
try {
entitlementClass = Class.forName(
entitlementPackageName
+ "."
+ Character.toUpperCase(entitlementType.charAt(0))
+ entitlementType.substring(1)
+ "Entitlement"
);
} catch (ClassNotFoundException cnfe) {
throw newPolicyParserException(scopeName, "unknown entitlement type [" + entitlementType + "]");
}
if (Entitlement.class.isAssignableFrom(entitlementClass) == false) {
throw newPolicyParserException(scopeName, "unknown entitlement type [" + entitlementType + "]");
}
Constructor<?> entitlementConstructor = entitlementClass.getConstructors()[0];
ExternalEntitlement entitlementMetadata = entitlementConstructor.getAnnotation(ExternalEntitlement.class);
if (entitlementMetadata == null) {
throw newPolicyParserException(scopeName, "unknown entitlement type [" + entitlementType + "]");
}

if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
throw newPolicyParserException(scopeName, entitlementType, "expected entitlement parameters");
}
Map<String, Object> parsedValues = policyParser.map();

Class<?>[] parameterTypes = entitlementConstructor.getParameterTypes();
String[] parametersNames = entitlementMetadata.parameterNames();
Object[] parameterValues = new Object[parameterTypes.length];
for (int parameterIndex = 0; parameterIndex < parameterTypes.length; ++parameterIndex) {
String parameterName = parametersNames[parameterIndex];
Object parameterValue = parsedValues.remove(parameterName);
if (parameterValue == null) {
throw newPolicyParserException(scopeName, entitlementType, "missing entitlement parameter [" + parameterName + "]");
}
Class<?> parameterType = parameterTypes[parameterIndex];
if (parameterType.isAssignableFrom(parameterValue.getClass()) == false) {
throw newPolicyParserException(
scopeName,
entitlementType,
"unexpected parameter type [" + parameterType.getSimpleName() + "] for entitlement parameter [" + parameterName + "]"
);
}
parameterValues[parameterIndex] = parameterValue;
}
if (parsedValues.isEmpty() == false) {
throw newPolicyParserException(scopeName, entitlementType, "extraneous entitlement parameter(s) " + parsedValues);
}

try {
return (Entitlement) entitlementConstructor.newInstance(parameterValues);
} catch (InvocationTargetException | InstantiationException | IllegalAccessException e) {
throw new IllegalStateException("internal error");
}
}

protected PolicyParserException newPolicyParserException(String message) {
return PolicyParserException.newPolicyParserException(policyParser.getTokenLocation(), policyName, message);
}

protected PolicyParserException newPolicyParserException(String scopeName, String message) {
return PolicyParserException.newPolicyParserException(policyParser.getTokenLocation(), policyName, scopeName, message);
}

protected PolicyParserException newPolicyParserException(String scopeName, String entitlementType, String message) {
return PolicyParserException.newPolicyParserException(
policyParser.getTokenLocation(),
policyName,
scopeName,
entitlementType,
message
);
}
}
Loading