Skip to content

Commit

Permalink
feat: add Dismantler constraint eval function (#1059)
Browse files Browse the repository at this point in the history
* add dismantler function impl

* refactoring

* javadoc

* checkstyle

* javaodc

* Apply suggestions from code review

Co-authored-by: Jim Marino <[email protected]>

* cleanup

---------

Co-authored-by: Jim Marino <[email protected]>
  • Loading branch information
paullatzelsperger and jimmarino authored Feb 16, 2024
1 parent 3a5e352 commit 2b67545
Show file tree
Hide file tree
Showing 7 changed files with 808 additions and 58 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/********************************************************************************
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available 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
********************************************************************************/

package org.eclipse.tractusx.edc.policy.cx.common;

import org.eclipse.edc.identitytrust.model.VerifiableCredential;
import org.eclipse.edc.policy.engine.spi.DynamicAtomicConstraintFunction;
import org.eclipse.edc.policy.engine.spi.PolicyContext;
import org.eclipse.edc.policy.model.Operator;
import org.eclipse.edc.policy.model.Permission;
import org.eclipse.edc.spi.agent.ParticipantAgent;
import org.eclipse.edc.spi.result.Result;

import java.util.Arrays;
import java.util.Collection;
import java.util.List;

/**
* This is a base class for dynamically bound Tractus-X constraint evaluation functions that implements some basic common functionality and defines some
* common constants
*/
public abstract class AbstractDynamicConstraintFunction implements DynamicAtomicConstraintFunction<Permission> {
public static final String VC_CLAIM = "vc";
public static final String ACTIVE = "active";
public static final String CREDENTIAL_LITERAL = "Credential";
protected static final Collection<Operator> EQUALITY_OPERATORS = List.of(Operator.EQ, Operator.NEQ);

protected boolean checkOperator(Operator actual, PolicyContext context, Operator... expectedOperators) {
return checkOperator(actual, context, Arrays.asList(expectedOperators));
}

protected boolean checkOperator(Operator actual, PolicyContext context, Collection<Operator> expectedOperators) {
if (!expectedOperators.contains(actual)) {
context.reportProblem("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(EQUALITY_OPERATORS, actual));
return false;
}
return true;
}

/**
* Extracts a {@link List} of {@link VerifiableCredential} objects from the {@link ParticipantAgent}. Credentials must be
* stored in the agent's claims map using the "vc" key.
*/
protected Result<List<VerifiableCredential>> getCredentialList(ParticipantAgent agent) {
var vcListClaim = agent.getClaims().get(VC_CLAIM);

if (vcListClaim == null) {
return Result.failure("ParticipantAgent did not contain a '%s' claim.".formatted(VC_CLAIM));
}
if (!(vcListClaim instanceof List)) {
return Result.failure("ParticipantAgent contains a '%s' claim, but the type is incorrect. Expected %s, received %s.".formatted(VC_CLAIM, List.class.getName(), vcListClaim.getClass().getName()));
}
var vcList = (List<VerifiableCredential>) vcListClaim;
if (vcList.isEmpty()) {
return Result.failure("ParticipantAgent contains a '%s' claim but it did not contain any VerifiableCredentials.".formatted(VC_CLAIM));
}
return Result.success(vcList);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/********************************************************************************
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available 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
********************************************************************************/

package org.eclipse.tractusx.edc.policy.cx.common;

import org.eclipse.edc.identitytrust.model.VerifiableCredential;

import java.util.function.Predicate;

public class CredentialTypePredicate implements Predicate<VerifiableCredential> {
private final String expectedType;

public CredentialTypePredicate(String expectedType) {
this.expectedType = expectedType;
}

@Override
public boolean test(VerifiableCredential credential) {
return credential.getTypes().contains(expectedType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/********************************************************************************
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available 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
********************************************************************************/

package org.eclipse.tractusx.edc.policy.cx.dismantler;

import org.eclipse.edc.identitytrust.model.VerifiableCredential;
import org.eclipse.edc.policy.engine.spi.PolicyContext;
import org.eclipse.edc.policy.model.Operator;
import org.eclipse.edc.policy.model.Permission;
import org.eclipse.edc.spi.agent.ParticipantAgent;
import org.eclipse.tractusx.edc.policy.cx.common.AbstractDynamicConstraintFunction;
import org.eclipse.tractusx.edc.policy.cx.common.CredentialTypePredicate;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.function.Predicate;

import static org.eclipse.edc.policy.model.Operator.EQ;
import static org.eclipse.edc.policy.model.Operator.IN;
import static org.eclipse.edc.policy.model.Operator.IS_ANY_OF;
import static org.eclipse.edc.policy.model.Operator.IS_NONE_OF;
import static org.eclipse.edc.policy.model.Operator.NEQ;
import static org.eclipse.tractusx.edc.iam.ssi.spi.jsonld.CredentialsNamespaces.CX_NS_1_0;

/**
* Enforces a Dismantler constraint. This function can check for these properties:
* <ul>
* <li>presence: whether a Dismantler credential is present or not</li>
* <li>activityType: whether an existing DismantlerCredential permits the activity types required by the constraint</li>
* <li>allowedBrands: whether an existing DismantlerCredential permits the vehicle brands required by the constraint</li>
* </ul>
*/
public class DismantlerConstraintFunction extends AbstractDynamicConstraintFunction {

public static final String ALLOWED_VEHICLE_BRANDS = CX_NS_1_0 + "allowedVehicleBrands";
private static final String DISMANTLER_LITERAL = "Dismantler";
private static final String ALLOWED_ACTIVITIES = CX_NS_1_0 + "activityType";

@Override
public boolean evaluate(Object leftOperand, Operator operator, Object rightOperand, Permission permission, PolicyContext context) {
Predicate<VerifiableCredential> predicate = c -> false;

// make sure the ParticipantAgent is there
var participantAgent = context.getContextData(ParticipantAgent.class);
if (participantAgent == null) {
context.reportProblem("Required PolicyContext data not found: " + ParticipantAgent.class.getName());
return false;
}

// check if the participant agent contains the correct data
var vcListResult = getCredentialList(participantAgent);
if (vcListResult.failed()) { // couldn't extract credential list from agent
context.reportProblem(vcListResult.getFailureDetail());
return false;
}

if (leftOperand.equals(DISMANTLER_LITERAL)) { // only checks for presence
if (!checkOperator(operator, context, EQUALITY_OPERATORS)) {
return false;
}
if (!ACTIVE.equals(rightOperand)) {
context.reportProblem("Right-operand must be equal to '%s', but was '%s'".formatted(ACTIVE, rightOperand));
return false;
}
predicate = new CredentialTypePredicate(DISMANTLER_LITERAL + CREDENTIAL_LITERAL);
if (operator == NEQ) {
predicate = predicate.negate();
}
} else if (leftOperand.equals(DISMANTLER_LITERAL + ".activityType")) {
if (hasInvalidOperand(operator, rightOperand, context)) return false;
predicate = getCredentialPredicate(ALLOWED_ACTIVITIES, operator, rightOperand);
} else if (leftOperand.equals(DISMANTLER_LITERAL + ".allowedBrands")) {
if (hasInvalidOperand(operator, rightOperand, context)) return false;
predicate = getCredentialPredicate(ALLOWED_VEHICLE_BRANDS, operator, rightOperand);
} else {
context.reportProblem("Invalid left-operand: must be 'Dismantler[.activityType | .allowedBrands ], but was '%s'".formatted(leftOperand));
return false;
}

return !vcListResult.getContent().stream().filter(predicate)
.toList().isEmpty();
}

@Override
public boolean canHandle(Object leftOperand) {
return leftOperand instanceof String && ((String) leftOperand).startsWith(DISMANTLER_LITERAL);
}

/**
* Creates a {@link Predicate} based on the {@code rightOperand} that tests whether whatever property is extracted from the {@link VerifiableCredential}
* is valid, according to the operator. For example {@link Operator#IS_ALL_OF} would check that the list from the constraint (= rightOperand) intersects with the list
* stored in the claim identified by {@code credentialSubjectProperty} in the {@link VerifiableCredential#getCredentialSubject()}.
*
* @param credentialSubjectProperty The name of the claim to be extracted from the {@link VerifiableCredential#getCredentialSubject()}
* @param operator the operator
* @param rightOperand The constraint value (i.e. policy expression right-operand)
* @return A predicate that tests a {@link VerifiableCredential} for the constraint
*/
@NotNull
private Predicate<VerifiableCredential> getCredentialPredicate(String credentialSubjectProperty, Operator operator, Object rightOperand) {
Predicate<VerifiableCredential> predicate;
var allowedValues = getList(rightOperand);
// the filter predicate is determined by the operator
predicate = credential -> credential.getCredentialSubject().stream().anyMatch(subject -> {
var claimsFromCredential = getList(subject.getClaims().getOrDefault(credentialSubjectProperty, List.of()));
return switch (operator) {
case EQ -> claimsFromCredential.equals(allowedValues);
case NEQ -> !claimsFromCredential.equals(allowedValues);
case IN ->
new HashSet<>(allowedValues).containsAll(claimsFromCredential); //IntelliJ says Hashset has better performance
case IS_ANY_OF -> !intersect(allowedValues, claimsFromCredential).isEmpty();
case IS_NONE_OF -> intersect(allowedValues, claimsFromCredential).isEmpty();
default -> false;
};
});
return predicate;
}

/**
* Checks whether {@code operator} is valid in the context of {@code rightOperand}. In practice, this means that if {@code rightOperand} is a String, it checks for {@link AbstractDynamicConstraintFunction#EQUALITY_OPERATORS},
* and if its list type, it checks for {@code List.of(EQ, NEQ, IN, IS_ANY_OF, IS_NONE_OF)}
*/
private boolean hasInvalidOperand(Operator operator, Object rightOperand, PolicyContext context) {
if (rightOperand instanceof String) {
return !checkOperator(operator, context, EQUALITY_OPERATORS);
} else if (rightOperand instanceof Iterable<?>) {
return !checkOperator(operator, context, List.of(EQ, NEQ, IN, IS_ANY_OF, IS_NONE_OF));
} else {
context.reportProblem("Invalid right-operand type: expected String or List, but received: %s".formatted(rightOperand.getClass().getName()));
return true;
}
}

/**
* Checks whether two lists "intersect", i.e. that at least one element is found in both lists.
* Caution: {@code list1} may be modified!
*/
private List<?> intersect(List<?> list1, List<?> list2) {
list1.retainAll(list2);
return list1;
}

private List<?> getList(Object object) {
if (object instanceof Iterable<?> iterable) {
var list = new ArrayList<>();
iterable.iterator().forEachRemaining(list::add);
return list;
}
return List.of(object);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@
package org.eclipse.tractusx.edc.policy.cx.framework;

import org.eclipse.edc.identitytrust.model.VerifiableCredential;
import org.eclipse.edc.policy.engine.spi.DynamicAtomicConstraintFunction;
import org.eclipse.edc.policy.engine.spi.PolicyContext;
import org.eclipse.edc.policy.model.Operator;
import org.eclipse.edc.policy.model.Permission;
import org.eclipse.edc.spi.agent.ParticipantAgent;
import org.eclipse.edc.spi.result.Result;
import org.eclipse.tractusx.edc.policy.cx.common.AbstractDynamicConstraintFunction;
import org.eclipse.tractusx.edc.policy.cx.common.CredentialTypePredicate;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Predicate;

Expand All @@ -52,13 +52,9 @@
* policy is considered <strong>not fulfilled</strong>. Note that if the {@code version} is specified, it <strong>must</strong> be satisfied by the <strong>same</strong>
* credential that satisfies the {@code subtype} requirement.
*/
public class FrameworkAgreementConstraintFunction implements DynamicAtomicConstraintFunction<Permission> {
public class FrameworkAgreementConstraintFunction extends AbstractDynamicConstraintFunction {
public static final String CONTRACT_VERSION_PROPERTY = CX_NS_1_0 + "contractVersion";
private static final String VC_CLAIM = "vc";
private static final String ACTIVE = "active";
private static final String FRAMEWORK_AGREEMENT_LITERAL = "FrameworkAgreement";
private static final String CREDENTIAL_LITERAL = "Credential";
private static final Collection<Operator> ALLOWED_OPERATORS = List.of(Operator.EQ, Operator.NEQ);

public FrameworkAgreementConstraintFunction() {
}
Expand All @@ -77,8 +73,7 @@ public FrameworkAgreementConstraintFunction() {
@Override
public boolean evaluate(Object leftValue, Operator operator, Object rightValue, Permission rule, PolicyContext context) {

if (!ALLOWED_OPERATORS.contains(operator)) {
context.reportProblem("Invalid operator: allowed operators are %s, got '%s'.".formatted(ALLOWED_OPERATORS, operator));
if (!checkOperator(operator, context, EQUALITY_OPERATORS)) {
return false;
}

Expand Down Expand Up @@ -131,7 +126,7 @@ public boolean evaluate(Object leftValue, Operator operator, Object rightValue,
}

/**
* Returns {@code true} if the left-operand starts with {@link FrameworkAgreementConstraintFunction#FRAMEWORK_AGREEMENT_LITERAL}, {@link false} otherwise.
* Returns {@code true} if the left-operand starts with {@link FrameworkAgreementConstraintFunction#FRAMEWORK_AGREEMENT_LITERAL}, {@code false} otherwise.
*/
@Override
public boolean canHandle(Object leftValue) {
Expand All @@ -145,26 +140,6 @@ private Predicate<VerifiableCredential> reducePredicates(List<Predicate<Verifiab
predicates.stream().map(Predicate::negate).reduce(Predicate::and).orElse(x -> true);
}

/**
* Extracts a {@link List} of {@link VerifiableCredential} objects from the {@link ParticipantAgent}. Credentials must be
* stored in the agent's claims map using the "vc" key.
*/
private Result<List<VerifiableCredential>> getCredentialList(ParticipantAgent agent) {
var vcListClaim = agent.getClaims().get(VC_CLAIM);

if (vcListClaim == null) {
return Result.failure("ParticipantAgent did not contain a '%s' claim.".formatted(VC_CLAIM));
}
if (!(vcListClaim instanceof List)) {
return Result.failure("ParticipantAgent contains a '%s' claim, but the type is incorrect. Expected %s, got %s.".formatted(VC_CLAIM, List.class.getName(), vcListClaim.getClass().getName()));
}
var vcList = (List<VerifiableCredential>) vcListClaim;
if (vcList.isEmpty()) {
return Result.failure("ParticipantAgent contains a '%s' claim but it did not contain any VerifiableCredentials.".formatted(VC_CLAIM));
}
return Result.success(vcList);
}

/**
* Converts the right-operand (new notation) into either 1 or 2 predicates, depending on whether the version was encoded or not.
*/
Expand Down Expand Up @@ -201,7 +176,7 @@ private Result<List<Predicate<VerifiableCredential>>> getFilterPredicateLegacy(S
@NotNull
private List<Predicate<VerifiableCredential>> createPredicates(String subtype, @Nullable String version) {
var list = new ArrayList<Predicate<VerifiableCredential>>();
list.add(credential -> credential.getTypes().contains(capitalize(subtype) + CREDENTIAL_LITERAL));
list.add(new CredentialTypePredicate(capitalize(subtype) + CREDENTIAL_LITERAL));
if (version != null) {
list.add(credential -> credential.getCredentialSubject().stream().anyMatch(cs -> cs.getClaims().get(CONTRACT_VERSION_PROPERTY).equals(version)));
}
Expand Down
Loading

0 comments on commit 2b67545

Please sign in to comment.