GroupOSCOREValidator.java
/*******************************************************************************
* Copyright (c) 2025, RISE AB
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*******************************************************************************/
package se.sics.ace.oscore.rs;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import com.upokecenter.cbor.CBORObject;
import com.upokecenter.cbor.CBORType;
import se.sics.ace.AceException;
import se.sics.ace.GroupcommParameters;
import se.sics.ace.Util;
import se.sics.ace.rs.AudienceValidator;
import se.sics.ace.rs.ScopeValidator;
/**
* Audience and scope validator for testing purposes.
*
* This validator expects the scopes to be either Strings as in OAuth 2.0, or
* Byte Arrays for operations at the OSCORE Group Manager as per
* draft-ietf-ace-key-groupcomm-oscore and draft-ietf-ace-oscore-gm-admin
*
* The actions are expected to be integers corresponding to the
* values for RESTful actions in <code>Constants</code>.
*
* @author Marco Tiloca
*
*/
public class GroupOSCOREValidator implements AudienceValidator, ScopeValidator {
/**
* The audiences we recognize
*/
private Set<String> myAudiences;
/**
* The audiences acting as OSCORE Group Managers
* Each of these audiences is also included in the main set "myAudiences"
*/
private Set<String> myGMAudiences;
/**
* The group-membership resources exported by the OSCORE Group Manager to access an OSCORE group.
*
* Each entry of the list contains the full path to a group-membership resource and the last
* path segment is the name of the associated OSCORE group, e.g., ace-group/GROUPNAME
*/
private Set<String> myGroupMembershipResources;
/**
* The group-collections and group-configuration resources
* exported by the OSCORE Group Manager for managing OSCORE groups.
*
* Each entry of the list contains the full path to a group-collection or a group-configuration resource.
* For a group-configuration resource, the last path segment is the name of the associated OSCORE group, e.g., admin/GROUPNAME
*/
private Set<String> myGroupAdminResources;
private String rootGroupMembershipResourcePath;
private String groupCollectionResourcePath;
/**
* Maps the scopes to a map that maps the scope's resources to the actions
* allowed on that resource
*/
private Map<String, Map<String, Set<Short>>> myScopes;
/**
* Constructor.
*
* @param myAudiences the audiences that this validator should accept
* @param myScopes the scopes that this validator should accept
* @param rootGroupMembershipResource the path of the root Group Membership Resource, i.e., "ace-group"
* @param groupCollectionResource the path of the Group Collection Resource, i.e., "manage"
*/
public GroupOSCOREValidator(Set<String> myAudiences,
Map<String, Map<String, Set<Short>>> myScopes,
String rootGroupMembershipResourcePath,
String groupCollectionResourcePath) {
this.myAudiences = new HashSet<>();
this.myGMAudiences = new HashSet<>();
this.myGroupMembershipResources = new HashSet<>();
this.myGroupAdminResources = new HashSet<>();
this.myScopes = new HashMap<>();
if (myAudiences != null) {
this.myAudiences = myAudiences;
} else {
this.myAudiences = Collections.emptySet();
}
if (myScopes != null) {
this.myScopes = myScopes;
} else {
this.myScopes = Collections.emptyMap();
}
this.rootGroupMembershipResourcePath = rootGroupMembershipResourcePath;
this.groupCollectionResourcePath = groupCollectionResourcePath;
}
/**
* Get a string including the common URI path to all group-membership
* resources, i.e. the full URI path minus the group name
*
* @return the common URI path to all group-membership resources
*/
public String getRootGroupMembershipResource() {
return this.rootGroupMembershipResourcePath;
}
/**
* Get a string including the URI path of the group-collection resource
*
* @return the URI path of the group-collection resource
*/
public String getGroupCollectionResource() {
return this.groupCollectionResourcePath;
}
/**
* Get the list of audiences acting as OSCORE Group Managers.
*
* @return the audiences that this validator considers as OSCORE Group Managers
*/
public synchronized Set<String> getAllGMAudiences() {
if (this.myGMAudiences != null) {
return this.myGMAudiences;
}
return Collections.emptySet();
}
/**
* Set the list of audiences acting as OSCORE Group Managers.
* Check that each of those audiences are in the main set "myAudiences".
*
* @param myGMAudiences the audiences that this validator considers as OSCORE Group Managers
*
* @throws AceException if the group manager is not an accepted audience
*/
public synchronized void setGMAudiences(Set<String> myGMAudiences) throws AceException {
if (myGMAudiences != null) {
for (String foo : myGMAudiences) {
if (!this.myAudiences.contains(foo)) {
throw new AceException("This OSCORE Group Manager is not an accepted audience");
}
this.myGMAudiences.add(foo);
}
} else {
this.myGMAudiences = Collections.emptySet();
}
}
/**
* Remove an audience acting as OSCORE Group Manager from "myGMAudiences".
* This method does not remove the audience from the main set "myAudiences".
*
* @param GMAudience the audience acting as OSCORE Group Manager to be removed
*
* @return true if the specified audience was included and has been removed, false otherwise.
*/
public synchronized boolean removeGMAudience(String GMAudience){
if (GMAudience != null)
return this.myGMAudiences.remove(GMAudience);
return false;
}
/**
* Remove all the audiences acting as OSCORE Group Manager from "myGMAudiences".
* This method does not remove the audiences from the main set "myAudiences".
*
*/
public synchronized void removeAllGMAudiences(){
this.myGMAudiences.clear();
}
/**
* Get the list of group-membership resources to access an OSCORE group.
*
* Each entry of the list contains the full path to a group-membership resource, and the last
* path segment is the name of the associated OSCORE group, e.g., ace-group/GROUPNAME
*
* @return the resources that this validator considers as group-membership resources to access an OSCORE group
*/
public synchronized Set<String> getAllGroupMembershipResources() {
if (this.myGroupMembershipResources != null) {
return this.myGroupMembershipResources;
}
return Collections.emptySet();
}
/**
* Set the list of group-membership resources to access an OSCORE group.
*
* Each entry of the list contains the full path to a group-membership resource, and the last
* path segment is the name of the associated OSCORE group, e.g., ace-group/GROUPNAME
*
* @param myGroupMembershipResources the resources that this validator considers as group-membership resources to access an OSCORE group
* .
* @throws AceException FIXME: when thrown?
*/
public synchronized void setGroupMembershipResources(Set<String> myGroupMembershipResources) throws AceException {
if (myGroupMembershipResources != null) {
for (String foo : myGroupMembershipResources)
this.myGroupMembershipResources.add(foo);
} else {
this.myGroupMembershipResources = Collections.emptySet();
}
}
/**
* Remove a group-membership resource to access an OSCORE group from "myGroupMembershipResources".
*
* The group-membership resource to remove is specified by its full path, where the last
* path segment is the name of the associated OSCORE group, e.g., ace-group/GROUPNAME
*
* @param groupMembershipResource the group-membership resource to remove.
*
* @return true if the specified resource was included and has been removed, false otherwise.
*/
public synchronized boolean removeGroupMembershipResource(String groupMembershipResource){
if (groupMembershipResource != null)
return this.myGroupMembershipResources.remove(groupMembershipResource);
return false;
}
/**
* Remove all the group-membership resources to access an OSCORE group from "myGroupMembershipResources".
*
*/
public synchronized void removeAllGroupMembershipResources(){
this.myGroupMembershipResources.clear();
}
/**
* Get the list of group-collection and group-configuration resources for managing OSCORE groups
*
* Each entry of the list contains the full path to a group-collection or a group-configuration resource.
* For a group-configuration resource, the last path segment is the name of the associated OSCORE group, e.g., admin/GROUPNAME
*
* @return the resources that this validator considers as group-collection or group-configuration resources
*/
public synchronized Set<String> getAllGroupAdminResources() {
if (this.myGroupAdminResources != null) {
return this.myGroupAdminResources;
}
return Collections.emptySet();
}
/**
* Set the list of group-collection and group-configuration resources for managing OSCORE groups
*
* Each entry of the list contains the full path to a group-collection or a group-configuration resource.
* For a group-configuration resource, the last path segment is the name of the associated OSCORE group, e.g., admin/GROUPNAME
*
* @param myGroupAdminResources the resources that this validator considers as group-collection or group-configuration resources
* .
* @throws AceException FIXME: when thrown?
*/
public synchronized void setGroupAdminResources(Set<String> myGroupAdminResources) throws AceException {
if (myGroupAdminResources != null) {
for (String foo : myGroupAdminResources)
this.myGroupAdminResources.add(foo);
} else {
this.myGroupAdminResources = Collections.emptySet();
}
}
/**
* Remove a group-collection or group-configuration resource from "myGroupAdminResources".
*
* The group-collection or group-configuration resource to remove is specified by its full path.
* For a group-configuration resource, the last path segment is the name of the associated OSCORE group, e.g., admin/GROUPNAME
*
* @param groupAdminResource the group-collection or group-configuration resource to remove.
*
* @return true if the specified resource was included and has been removed, false otherwise.
*/
public synchronized boolean removeGroupAdminResource(String groupAdminResource){
if (groupAdminResource != null)
return this.myGroupAdminResources.remove(groupAdminResource);
return false;
}
/**
* Remove all the group-configuration and group-collection resources from "myGroupAdminResources".
*
*/
public synchronized void removeAllGroupAdminResources(){
this.myGroupAdminResources.clear();
}
@Override
public boolean match(String aud) {
return this.myAudiences.contains(aud);
}
@Override
public boolean scopeMatch(CBORObject scope, String resourceId, Object actionId)
throws AceException {
if (!scope.getType().equals(CBORType.TextString) && !scope.getType().equals(CBORType.ByteString)) {
throw new AceException("Scope must be a Text String or a Byte String");
}
String scopeStr;
boolean isGroupMembershipResource = false;
boolean isGroupAdminResource = false;
boolean scopeMustBeBinary = false;
boolean scopeForOscoreGroupManager = false;
if (this.myGroupMembershipResources.contains(resourceId))
isGroupMembershipResource = true;
if (this.myGroupAdminResources.contains(resourceId))
isGroupAdminResource = true;
scopeMustBeBinary = isGroupMembershipResource | isGroupAdminResource;
scopeForOscoreGroupManager = isGroupMembershipResource | isGroupAdminResource;
if (scope.getType().equals(CBORType.TextString)) {
if (scopeMustBeBinary)
return false;
String[] scopes = scope.AsString().split(" ");
for (String subscope : scopes) {
Map<String, Set<Short>> resources = this.myScopes.get(subscope);
if (resources == null) {
continue;
}
if (resources.containsKey(resourceId)) {
if (resources.get(resourceId).contains(actionId)) {
return true;
}
}
}
return false;
}
else if (scope.getType().equals(CBORType.ByteString) && scopeForOscoreGroupManager) {
byte[] rawScope = scope.GetByteString();
CBORObject cborScope = CBORObject.DecodeFromBytes(rawScope);
if (!cborScope.getType().equals(CBORType.Array)) {
throw new AceException("Invalid scope format for the AIF-OSCORE-GROUPCOMM data model");
}
for (int entryIndex = 0; entryIndex < cborScope.size(); entryIndex++) {
CBORObject scopeEntry = cborScope.get(entryIndex);
if (!scopeEntry.getType().equals(CBORType.Array)) {
throw new AceException("Invalid scope for entry the AIF-OSCORE-GROUPCOMM data model");
}
if (scopeEntry.size() != 2)
throw new AceException("A scope entry must have two elements, i.e., Toid and Tperm");
if (scopeEntry.get(1).getType() != CBORType.Integer) {
throw new AceException("Tperm must be a CBOR integer");
}
int tperm = scopeEntry.get(1).AsInt32();
if (tperm <= 0) {
throw new AceException("Tperm must have a positive integer value");
}
if ((tperm % 2) == 0) {
// This is a user scope entry
// Retrieve the group name of the OSCORE group
CBORObject scopeElement = scopeEntry.get(0);
if (scopeElement.getType().equals(CBORType.TextString)) {
scopeStr = scopeElement.AsString();
}
else {
throw new AceException("The group name must be a CBOR Text String");
}
// Retrieve the role or list of roles
Set<Integer> roleIdSet = Util.getGroupOSCORERoles(tperm);
for (Integer elem : roleIdSet) {
if (elem.intValue() < GroupcommParameters.GROUP_OSCORE_ROLES.length)
continue;
else {
throw new AceException("Unrecognized role");
}
}
Map<String, Set<Short>> resources = this.myScopes.get(rootGroupMembershipResourcePath + "/" + scopeStr);
if (resources != null && resources.containsKey(resourceId)) {
if (resources.get(resourceId).contains(actionId)) {
return true;
}
}
} // end of handling a user scope entry
else {
// This is an admin scope entry
if (cborScope.get(entryIndex).get(0).getType() != CBORType.TextString &&
cborScope.get(entryIndex).get(0).equals(CBORObject.True) == false) {
throw new AceException("Toid must be a CBOR text string or the CBOR simple value true");
}
// Retrieve the list of admin permissions
Set<Integer> permissionIdSet = Util.getGroupOSCOREAdminPermissions(tperm);
for (Integer elem : permissionIdSet) {
if (elem.intValue() < GroupcommParameters.GROUP_OSCORE_ADMIN_PERMISSIONS.length) {
continue;
}
else {
throw new AceException("Unrecognized admin permission");
}
}
Map<String, Set<Short>> resources = this.myScopes.get(groupCollectionResourcePath);
if (resources != null && resources.containsKey(resourceId)) {
if (resources.get(resourceId).contains(actionId)) {
return true;
}
}
} // end of handling an admin scope entry
} // end of checking all the scope entries in the scope claim of the access token
return false;
}
// This includes the case where the scope is encoded as a CBOR Byte String,
// but the targeted resource is not a group-membership resource, a group-collection resource or a group-configuration resource.
// In fact, no processing for byte string scopes are defined, other than the one implemented above according to
// draft-ietf-ace-key-groupcomm-oscore and draft-ietf-ace-oscore-gm-admin
else if (scope.getType().equals(CBORType.ByteString)) {
throw new AceException("Unknown processing for this byte string scope");
}
return false;
}
@Override
public boolean scopeMatchResource(CBORObject scope, String resourceId)
throws AceException {
if (!scope.getType().equals(CBORType.TextString) && !scope.getType().equals(CBORType.ByteString)) {
throw new AceException("Scope must be a Text String or a Byte String");
}
String scopeStr;
boolean isGroupMembershipResource = false;
boolean isGroupAdminResource = false;
boolean scopeMustBeBinary = false;
boolean scopeForOscoreGroupManager = false;
if (this.myGroupMembershipResources.contains(resourceId))
isGroupMembershipResource = true;
if (this.myGroupAdminResources.contains(resourceId))
isGroupAdminResource = true;
scopeMustBeBinary = isGroupMembershipResource | isGroupAdminResource;
scopeForOscoreGroupManager = isGroupMembershipResource | isGroupAdminResource;
if (scope.getType().equals(CBORType.TextString)) {
if (scopeMustBeBinary)
return false;
String[] scopes = scope.AsString().split(" ");
for (String subscope : scopes) {
Map<String, Set<Short>> resources = this.myScopes.get(subscope);
if (resources == null) {
continue;
}
if (resources.containsKey(resourceId)) {
return true;
}
}
return false;
}
else if (scope.getType().equals(CBORType.ByteString) && scopeForOscoreGroupManager) {
byte[] rawScope = scope.GetByteString();
CBORObject cborScope = CBORObject.DecodeFromBytes(rawScope);
if (!cborScope.getType().equals(CBORType.Array)) {
throw new AceException("Invalid scope format for the AIF-OSCORE-GROUPCOMM data model");
}
for (int entryIndex = 0; entryIndex < cborScope.size(); entryIndex++) {
CBORObject scopeEntry = cborScope.get(entryIndex);
if (!scopeEntry.getType().equals(CBORType.Array)) {
throw new AceException("Invalid scope for entry the AIF-OSCORE-GROUPCOMM data model");
}
if (scopeEntry.size() != 2)
throw new AceException("A scope entry must have two elements, i.e., Toid and Tperm");
if (scopeEntry.get(1).getType() != CBORType.Integer) {
throw new AceException("Tperm must be a CBOR integer");
}
int tperm = scopeEntry.get(1).AsInt32();
if (tperm <= 0) {
throw new AceException("Tperm must have a positive integer value");
}
if ((tperm % 2) == 0) {
// This is a user scope entry
// Retrieve the group name of the OSCORE group
CBORObject scopeElement = scopeEntry.get(0);
if (scopeElement.getType().equals(CBORType.TextString)) {
scopeStr = scopeElement.AsString();
}
else {
throw new AceException("The group name must be a CBOR Text String");
}
// Retrieve the role or list of roles
Set<Integer> roleIdSet = Util.getGroupOSCORERoles(tperm);
for (Integer elem : roleIdSet) {
if (elem.intValue() < GroupcommParameters.GROUP_OSCORE_ROLES.length) {
continue;
}
else {
throw new AceException("Unrecognized role");
}
}
Map<String, Set<Short>> resources = this.myScopes.get(rootGroupMembershipResourcePath + "/" + scopeStr);
if (resources != null && resources.containsKey(resourceId)) {
return true;
}
} // end of handling a user scope entry
else {
// This is an admin scope entry
if (cborScope.get(entryIndex).get(0).getType() != CBORType.TextString &&
cborScope.get(entryIndex).get(0).equals(CBORObject.True) == false) {
throw new AceException("Toid must be a CBOR text string or the CBOR simple value true");
}
// Retrieve the list of admin permissions
Set<Integer> permissionIdSet = Util.getGroupOSCOREAdminPermissions(tperm);
for (Integer elem : permissionIdSet) {
if (elem.intValue() < GroupcommParameters.GROUP_OSCORE_ADMIN_PERMISSIONS.length)
continue;
else {
throw new AceException("Unrecognized admin permission");
}
}
Map<String, Set<Short>> resources = this.myScopes.get(groupCollectionResourcePath);
if (resources != null && resources.containsKey(resourceId)) {
return true;
}
} // end of handling an admin scope entry
} // end of checking all the scope entries in the scope claim of the access token
return false;
}
// This includes the case where the scope is encoded as a CBOR Byte String,
// but the targeted resource is not a group-membership resource, a group-collection resource or a group-configuration resource.
// In fact, no processing for byte string scopes are defined, other than the one implemented above according to
// draft-ietf-ace-key-groupcomm-oscore and draft-ietf-ace-oscore-gm-admin
else if (scope.getType().equals(CBORType.ByteString)) {
throw new AceException("Unknown processing for this byte string scope");
}
return false;
}
@Override
public boolean isScopeMeaningful(CBORObject scope) throws AceException {
if (!scope.getType().equals(CBORType.TextString)) {
throw new AceException("Scope must be a String if no audience is specified");
}
return this.myScopes.containsKey(scope.AsString());
}
@Override
public boolean isScopeMeaningful(CBORObject scope, String aud) throws AceException {
if (!scope.getType().equals(CBORType.TextString) && !scope.getType().equals(CBORType.ByteString)) {
throw new AceException("Scope must be a Text String or a Byte String");
}
String scopeStr;
boolean scopeMustBeBinary = false;
boolean rsOSCOREGroupManager = false;
if (this.myGMAudiences.contains(aud)) {
rsOSCOREGroupManager = true;
}
scopeMustBeBinary = rsOSCOREGroupManager;
if (scope.getType().equals(CBORType.TextString)) {
if (scopeMustBeBinary)
return false;
return this.myScopes.containsKey(scope.AsString());
// The audiences are silently ignored
}
else if (scope.getType().equals(CBORType.ByteString) && rsOSCOREGroupManager) {
byte[] rawScope = scope.GetByteString();
CBORObject cborScope = CBORObject.DecodeFromBytes(rawScope);
if (!cborScope.getType().equals(CBORType.Array)) {
throw new AceException("Invalid scope format for the AIF-OSCORE-GROUPCOMM data model");
}
for (int entryIndex = 0; entryIndex < cborScope.size(); entryIndex++) {
CBORObject scopeEntry = cborScope.get(entryIndex);
if (!scopeEntry.getType().equals(CBORType.Array)) {
throw new AceException("Invalid scope for entry the AIF-OSCORE-GROUPCOMM data model");
}
if (scopeEntry.size() != 2)
throw new AceException("A scope entry must have two elements, i.e., Toid and Tperm");
if (scopeEntry.get(1).getType() != CBORType.Integer) {
throw new AceException("Tperm must be a CBOR integer");
}
int tperm = scopeEntry.get(1).AsInt32();
if (tperm <= 0) {
throw new AceException("Tperm must have a positive integer value");
}
if ((tperm % 2) == 0) {
// This is a user scope entry
// Retrieve the group name of the OSCORE group
CBORObject scopeElement = scopeEntry.get(0);
if (scopeElement.getType().equals(CBORType.TextString)) {
scopeStr = scopeElement.AsString();
}
else {
throw new AceException("The group name must be a CBOR Text String");
}
// Retrieve the role or list of roles
Set<Integer> roleIdSet = Util.getGroupOSCORERoles(tperm);
for (Integer elem : roleIdSet) {
if (elem.intValue() < GroupcommParameters.GROUP_OSCORE_ROLES.length) {
continue;
}
else {
throw new AceException("Unrecognized role");
}
}
if (this.myScopes.containsKey(rootGroupMembershipResourcePath + "/" + scopeStr) == false) {
return false;
}
} // end of handling a user scope entry
else {
// This is an admin scope entry
if (cborScope.get(entryIndex).get(0).getType() != CBORType.TextString &&
cborScope.get(entryIndex).get(0).equals(CBORObject.True) == false) {
throw new AceException("Toid must be a CBOR text string or the CBOR simple value true");
}
// Retrieve the list of admin permissions
Set<Integer> permissionIdSet = Util.getGroupOSCOREAdminPermissions(tperm);
for (Integer elem : permissionIdSet) {
if (elem.intValue() < GroupcommParameters.GROUP_OSCORE_ADMIN_PERMISSIONS.length)
continue;
else {
throw new AceException("Unrecognized admin permission");
}
}
if (this.myScopes.containsKey(groupCollectionResourcePath) == false) {
// More fine-grained checks are not possible at this point in time
// (i.e., upon checking an uploaded access token), since the list of valid
// scopes may not comprise OSCORE groups whose name matches with the Toid
// of some scope entries in the access token, but still have to be created
return false;
}
} // end of handling an admin scope entry
} // end of checking all the scope entries in the scope claim of the access token
return true;
}
// This includes the case where the scope is encoded as a CBOR Byte String,
// but the targeted resource is not a group-membership resource, a group-collection resource or a group-configuration resource.
// In fact, no processing for byte string scopes are defined, other than the one implemented above according to
// draft-ietf-ace-key-groupcomm-oscore and draft-ietf-ace-oscore-gm-admin
else if (scope.getType().equals(CBORType.ByteString)) {
throw new AceException("Unknown processing for this byte string scope");
}
return false;
}
@Override
public CBORObject getScope(String resource, short action) {
// TODO Auto-generated method stub
return null;
}
}