HBASE-11384 - [Visibility Controller]Check for users covering
authorizations for every mutation (Ram)
This commit is contained in:
parent
7b3d0add60
commit
bf87170f50
|
@ -49,4 +49,7 @@ public final class VisibilityConstants {
|
|||
public static final byte[] SORTED_ORDINAL_SERIALIZATION_FORMAT =
|
||||
new byte[] { VISIBILITY_SERIALIZATION_VERSION };
|
||||
|
||||
public static final String CHECK_AUTHS_FOR_MUTATION =
|
||||
"hbase.security.visibility.mutations.checkauths";
|
||||
|
||||
}
|
||||
|
|
|
@ -1261,6 +1261,14 @@ possible configurations would overwhelm and obscure the important.
|
|||
The default StaticUserWebFilter add a user principal as defined by the
|
||||
hbase.http.staticuser.user property.
|
||||
</description>
|
||||
</property>
|
||||
<property>
|
||||
<name>hbase.security.visibility.mutations.checkauths</name>
|
||||
<value>false</value>
|
||||
<description>
|
||||
This property if enabled, will check whether the labels in the visibility expression are associated
|
||||
with the user issuing the mutation
|
||||
</description>
|
||||
</property>
|
||||
<property>
|
||||
<name>hbase.http.max.threads</name>
|
||||
|
|
|
@ -35,6 +35,7 @@ import java.util.HashMap;
|
|||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
@ -52,11 +53,11 @@ import org.apache.hadoop.hbase.HTableDescriptor;
|
|||
import org.apache.hadoop.hbase.KeyValue;
|
||||
import org.apache.hadoop.hbase.KeyValue.Type;
|
||||
import org.apache.hadoop.hbase.KeyValueUtil;
|
||||
import org.apache.hadoop.hbase.MetaTableAccessor;
|
||||
import org.apache.hadoop.hbase.NamespaceDescriptor;
|
||||
import org.apache.hadoop.hbase.ServerName;
|
||||
import org.apache.hadoop.hbase.TableName;
|
||||
import org.apache.hadoop.hbase.Tag;
|
||||
import org.apache.hadoop.hbase.MetaTableAccessor;
|
||||
import org.apache.hadoop.hbase.client.Append;
|
||||
import org.apache.hadoop.hbase.client.Delete;
|
||||
import org.apache.hadoop.hbase.client.Get;
|
||||
|
@ -114,6 +115,7 @@ import org.apache.hadoop.hbase.security.visibility.expression.LeafExpressionNode
|
|||
import org.apache.hadoop.hbase.security.visibility.expression.NonLeafExpressionNode;
|
||||
import org.apache.hadoop.hbase.security.visibility.expression.Operator;
|
||||
import org.apache.hadoop.hbase.util.ByteRange;
|
||||
import org.apache.hadoop.hbase.util.ByteStringer;
|
||||
import org.apache.hadoop.hbase.util.Bytes;
|
||||
import org.apache.hadoop.hbase.util.Pair;
|
||||
import org.apache.hadoop.hbase.util.SimpleMutableByteRange;
|
||||
|
@ -122,7 +124,6 @@ import org.apache.hadoop.hbase.zookeeper.ZooKeeperWatcher;
|
|||
import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.MapMaker;
|
||||
import com.google.protobuf.ByteString;
|
||||
import org.apache.hadoop.hbase.util.ByteStringer;
|
||||
import com.google.protobuf.RpcCallback;
|
||||
import com.google.protobuf.RpcController;
|
||||
import com.google.protobuf.Service;
|
||||
|
@ -155,6 +156,7 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
private boolean acOn = false;
|
||||
private Configuration conf;
|
||||
private volatile boolean initialized = false;
|
||||
private boolean checkAuths = false;
|
||||
/** Mapping of scanner instances to the user who created them */
|
||||
private Map<InternalScanner,String> scannerOwners =
|
||||
new MapMaker().weakKeys().makeMap();
|
||||
|
@ -620,6 +622,8 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
initialize(e);
|
||||
}
|
||||
} else {
|
||||
checkAuths = e.getEnvironment().getConfiguration()
|
||||
.getBoolean(VisibilityConstants.CHECK_AUTHS_FOR_MUTATION, false);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
@ -692,6 +696,11 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
}
|
||||
// TODO this can be made as a global LRU cache at HRS level?
|
||||
Map<String, List<Tag>> labelCache = new HashMap<String, List<Tag>>();
|
||||
Set<Integer> auths = null;
|
||||
User user = getActiveUser();
|
||||
if (checkAuths && user != null && user.getShortName() != null) {
|
||||
auths = this.visibilityManager.getAuthsAsOrdinals(user.getShortName());
|
||||
}
|
||||
for (int i = 0; i < miniBatchOp.size(); i++) {
|
||||
Mutation m = miniBatchOp.getOperation(i);
|
||||
CellVisibility cellVisibility = null;
|
||||
|
@ -717,7 +726,7 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
List<Tag> visibilityTags = labelCache.get(labelsExp);
|
||||
if (visibilityTags == null) {
|
||||
try {
|
||||
visibilityTags = createVisibilityTags(labelsExp, true);
|
||||
visibilityTags = createVisibilityTags(labelsExp, true, auths, user.getShortName());
|
||||
} catch (ParseException e) {
|
||||
miniBatchOp.setOperationStatus(i,
|
||||
new OperationStatus(SANITY_CHECK_FAILURE, e.getMessage()));
|
||||
|
@ -777,7 +786,7 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
if (cellVisibility != null) {
|
||||
String labelsExp = cellVisibility.getExpression();
|
||||
try {
|
||||
visibilityTags = createVisibilityTags(labelsExp, false);
|
||||
visibilityTags = createVisibilityTags(labelsExp, false, null, null);
|
||||
} catch (ParseException e) {
|
||||
throw new IOException("Invalid cell visibility expression " + labelsExp, e);
|
||||
} catch (InvalidLabelException e) {
|
||||
|
@ -911,8 +920,9 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
return true;
|
||||
}
|
||||
|
||||
private List<Tag> createVisibilityTags(String visibilityLabelsExp, boolean addSerializationTag)
|
||||
throws IOException, ParseException, InvalidLabelException {
|
||||
private List<Tag> createVisibilityTags(String visibilityLabelsExp, boolean addSerializationTag,
|
||||
Set<Integer> auths, String userName) throws IOException, ParseException,
|
||||
InvalidLabelException {
|
||||
ExpressionNode node = null;
|
||||
node = this.expressionParser.parse(visibilityLabelsExp);
|
||||
node = this.expressionExpander.expand(node);
|
||||
|
@ -926,7 +936,7 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
tags.add(VisibilityUtils.VIS_SERIALIZATION_TAG);
|
||||
}
|
||||
if (node.isSingleNode()) {
|
||||
getLabelOrdinals(node, labelOrdinals);
|
||||
getLabelOrdinals(node, labelOrdinals, auths, userName);
|
||||
writeLabelOrdinalsToStream(labelOrdinals, dos);
|
||||
tags.add(new Tag(VisibilityUtils.VISIBILITY_TAG_TYPE, baos.toByteArray()));
|
||||
baos.reset();
|
||||
|
@ -934,14 +944,14 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
NonLeafExpressionNode nlNode = (NonLeafExpressionNode) node;
|
||||
if (nlNode.getOperator() == Operator.OR) {
|
||||
for (ExpressionNode child : nlNode.getChildExps()) {
|
||||
getLabelOrdinals(child, labelOrdinals);
|
||||
getLabelOrdinals(child, labelOrdinals, auths, userName);
|
||||
writeLabelOrdinalsToStream(labelOrdinals, dos);
|
||||
tags.add(new Tag(VisibilityUtils.VISIBILITY_TAG_TYPE, baos.toByteArray()));
|
||||
baos.reset();
|
||||
labelOrdinals.clear();
|
||||
}
|
||||
} else {
|
||||
getLabelOrdinals(nlNode, labelOrdinals);
|
||||
getLabelOrdinals(nlNode, labelOrdinals, auths, userName);
|
||||
writeLabelOrdinalsToStream(labelOrdinals, dos);
|
||||
tags.add(new Tag(VisibilityUtils.VISIBILITY_TAG_TYPE, baos.toByteArray()));
|
||||
baos.reset();
|
||||
|
@ -958,8 +968,8 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
}
|
||||
}
|
||||
|
||||
private void getLabelOrdinals(ExpressionNode node, List<Integer> labelOrdinals)
|
||||
throws IOException, InvalidLabelException {
|
||||
private void getLabelOrdinals(ExpressionNode node, List<Integer> labelOrdinals,
|
||||
Set<Integer> auths, String userName) throws IOException, InvalidLabelException {
|
||||
if (node.isSingleNode()) {
|
||||
String identifier = null;
|
||||
int labelOrdinal = 0;
|
||||
|
@ -970,12 +980,14 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
LOG.trace("The identifier is "+identifier);
|
||||
}
|
||||
labelOrdinal = this.visibilityManager.getLabelOrdinal(identifier);
|
||||
checkAuths(auths, userName, labelOrdinal, identifier);
|
||||
} else {
|
||||
// This is a NOT node.
|
||||
LeafExpressionNode lNode = (LeafExpressionNode) ((NonLeafExpressionNode) node)
|
||||
.getChildExps().get(0);
|
||||
identifier = lNode.getIdentifier();
|
||||
labelOrdinal = this.visibilityManager.getLabelOrdinal(identifier);
|
||||
checkAuths(auths, userName, labelOrdinal, identifier);
|
||||
labelOrdinal = -1 * labelOrdinal; // Store NOT node as -ve ordinal.
|
||||
}
|
||||
if (labelOrdinal == 0) {
|
||||
|
@ -985,11 +997,21 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
} else {
|
||||
List<ExpressionNode> childExps = ((NonLeafExpressionNode) node).getChildExps();
|
||||
for (ExpressionNode child : childExps) {
|
||||
getLabelOrdinals(child, labelOrdinals);
|
||||
getLabelOrdinals(child, labelOrdinals, auths, userName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void checkAuths(Set<Integer> auths, String userName, int labelOrdinal, String identifier)
|
||||
throws IOException {
|
||||
if (checkAuths && !isSystemOrSuperUser()) {
|
||||
if (auths == null || (!auths.contains(labelOrdinal))) {
|
||||
throw new AccessDeniedException("Visibility label " + identifier
|
||||
+ " not authorized for the user " + userName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RegionScanner preScannerOpen(ObserverContext<RegionCoprocessorEnvironment> e, Scan scan,
|
||||
RegionScanner s) throws IOException {
|
||||
|
@ -1231,6 +1253,11 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
if (cellVisibility == null) {
|
||||
return newCell;
|
||||
}
|
||||
Set<Integer> auths = null;
|
||||
User user = getActiveUser();
|
||||
if (checkAuths && user != null && user.getShortName() != null) {
|
||||
auths = this.visibilityManager.getAuthsAsOrdinals(user.getShortName());
|
||||
}
|
||||
// Adding all other tags
|
||||
Iterator<Tag> tagsItr = CellUtil.tagsIterator(newCell.getTagsArray(), newCell.getTagsOffset(),
|
||||
newCell.getTagsLength());
|
||||
|
@ -1242,7 +1269,8 @@ public class VisibilityController extends BaseRegionObserver implements MasterOb
|
|||
}
|
||||
}
|
||||
try {
|
||||
tags.addAll(createVisibilityTags(cellVisibility.getExpression(), true));
|
||||
tags.addAll(createVisibilityTags(cellVisibility.getExpression(), true, auths,
|
||||
user.getShortName()));
|
||||
} catch (ParseException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
|
|
@ -172,6 +172,21 @@ public class VisibilityLabelsManager {
|
|||
return auths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of ordinals of authentications associated with the user
|
||||
*
|
||||
* @param user
|
||||
* @return the list of ordinals
|
||||
*/
|
||||
public Set<Integer> getAuthsAsOrdinals(String user) {
|
||||
this.lock.readLock().lock();
|
||||
try {
|
||||
return userAuths.get(user);
|
||||
} finally {
|
||||
this.lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the labels data to zookeeper node.
|
||||
* @param data
|
||||
|
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
package org.apache.hadoop.hbase.security.visibility;
|
||||
|
||||
import static org.apache.hadoop.hbase.security.visibility.VisibilityConstants.LABELS_TABLE_NAME;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.PrivilegedExceptionAction;
|
||||
|
||||
import org.apache.hadoop.conf.Configuration;
|
||||
import org.apache.hadoop.hbase.HBaseTestingUtility;
|
||||
import org.apache.hadoop.hbase.HColumnDescriptor;
|
||||
import org.apache.hadoop.hbase.HConstants;
|
||||
import org.apache.hadoop.hbase.HTableDescriptor;
|
||||
import org.apache.hadoop.hbase.MediumTests;
|
||||
import org.apache.hadoop.hbase.TableName;
|
||||
import org.apache.hadoop.hbase.client.Append;
|
||||
import org.apache.hadoop.hbase.client.HBaseAdmin;
|
||||
import org.apache.hadoop.hbase.client.HTable;
|
||||
import org.apache.hadoop.hbase.client.Put;
|
||||
import org.apache.hadoop.hbase.protobuf.generated.VisibilityLabelsProtos.VisibilityLabelsResponse;
|
||||
import org.apache.hadoop.hbase.security.User;
|
||||
import org.apache.hadoop.hbase.util.Bytes;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.Assert;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.experimental.categories.Category;
|
||||
import org.junit.rules.TestName;
|
||||
|
||||
@Category(MediumTests.class)
|
||||
/**
|
||||
* Test visibility by setting 'hbase.security.visibility.mutations.checkauths' to true
|
||||
*/
|
||||
public class TestVisibilityWithCheckAuths {
|
||||
private static final String TOPSECRET = "TOPSECRET";
|
||||
private static final String PUBLIC = "PUBLIC";
|
||||
public static final HBaseTestingUtility TEST_UTIL = new HBaseTestingUtility();
|
||||
private static final byte[] row1 = Bytes.toBytes("row1");
|
||||
private final static byte[] fam = Bytes.toBytes("info");
|
||||
private final static byte[] qual = Bytes.toBytes("qual");
|
||||
private final static byte[] value = Bytes.toBytes("value");
|
||||
public static Configuration conf;
|
||||
|
||||
@Rule
|
||||
public final TestName TEST_NAME = new TestName();
|
||||
public static User SUPERUSER;
|
||||
public static User USER;
|
||||
@BeforeClass
|
||||
public static void setupBeforeClass() throws Exception {
|
||||
// setup configuration
|
||||
conf = TEST_UTIL.getConfiguration();
|
||||
conf.setBoolean(HConstants.DISTRIBUTED_LOG_REPLAY_KEY, false);
|
||||
conf.setInt("hfile.format.version", 3);
|
||||
conf.set("hbase.coprocessor.master.classes", VisibilityController.class.getName());
|
||||
conf.set("hbase.coprocessor.region.classes", VisibilityController.class.getName());
|
||||
conf.setBoolean(VisibilityConstants.CHECK_AUTHS_FOR_MUTATION, true);
|
||||
conf.setClass(VisibilityUtils.VISIBILITY_LABEL_GENERATOR_CLASS, SimpleScanLabelGenerator.class,
|
||||
ScanLabelGenerator.class);
|
||||
conf.set("hbase.superuser", "admin");
|
||||
TEST_UTIL.startMiniCluster(2);
|
||||
SUPERUSER = User.createUserForTesting(conf, "admin", new String[] { "supergroup" });
|
||||
USER = User.createUserForTesting(conf, "user", new String[]{});
|
||||
// Wait for the labels table to become available
|
||||
TEST_UTIL.waitTableEnabled(LABELS_TABLE_NAME.getName(), 50000);
|
||||
addLabels();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void tearDownAfterClass() throws Exception {
|
||||
TEST_UTIL.shutdownMiniCluster();
|
||||
}
|
||||
|
||||
public static void addLabels() throws Exception {
|
||||
PrivilegedExceptionAction<VisibilityLabelsResponse> action =
|
||||
new PrivilegedExceptionAction<VisibilityLabelsResponse>() {
|
||||
public VisibilityLabelsResponse run() throws Exception {
|
||||
String[] labels = { TOPSECRET };
|
||||
try {
|
||||
VisibilityClient.addLabels(conf, labels);
|
||||
} catch (Throwable t) {
|
||||
throw new IOException(t);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
SUPERUSER.runAs(action);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testVerifyAccessDeniedForInvalidUserAuths() throws Exception {
|
||||
PrivilegedExceptionAction<VisibilityLabelsResponse> action =
|
||||
new PrivilegedExceptionAction<VisibilityLabelsResponse>() {
|
||||
public VisibilityLabelsResponse run() throws Exception {
|
||||
try {
|
||||
return VisibilityClient.setAuths(conf, new String[] { TOPSECRET },
|
||||
USER.getShortName());
|
||||
} catch (Throwable e) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
SUPERUSER.runAs(action);
|
||||
TableName tableName = TableName.valueOf(TEST_NAME.getMethodName());
|
||||
HBaseAdmin hBaseAdmin = TEST_UTIL.getHBaseAdmin();
|
||||
HColumnDescriptor colDesc = new HColumnDescriptor(fam);
|
||||
colDesc.setMaxVersions(5);
|
||||
HTableDescriptor desc = new HTableDescriptor(tableName);
|
||||
desc.addFamily(colDesc);
|
||||
hBaseAdmin.createTable(desc);
|
||||
HTable table = null;
|
||||
try {
|
||||
TEST_UTIL.getHBaseAdmin().flush(tableName.getNameAsString());
|
||||
PrivilegedExceptionAction<Void> actiona = new PrivilegedExceptionAction<Void>() {
|
||||
public Void run() throws Exception {
|
||||
HTable table = null;
|
||||
try {
|
||||
table = new HTable(conf, TEST_NAME.getMethodName());
|
||||
Put p = new Put(row1);
|
||||
p.setCellVisibility(new CellVisibility(PUBLIC + "&" + TOPSECRET));
|
||||
p.add(fam, qual, 125l, value);
|
||||
table.put(p);
|
||||
Assert.fail("Testcase should fail with AccesDeniedException");
|
||||
} catch (Throwable t) {
|
||||
assertTrue(t.getMessage().contains("AccessDeniedException"));
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
USER.runAs(actiona);
|
||||
} catch (Exception e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLabelsWithAppend() throws Throwable {
|
||||
PrivilegedExceptionAction<VisibilityLabelsResponse> action =
|
||||
new PrivilegedExceptionAction<VisibilityLabelsResponse>() {
|
||||
public VisibilityLabelsResponse run() throws Exception {
|
||||
try {
|
||||
return VisibilityClient.setAuths(conf, new String[] { TOPSECRET },
|
||||
USER.getShortName());
|
||||
} catch (Throwable e) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
SUPERUSER.runAs(action);
|
||||
TableName tableName = TableName.valueOf(TEST_NAME.getMethodName());
|
||||
HTable table = null;
|
||||
try {
|
||||
table = TEST_UTIL.createTable(tableName, fam);
|
||||
final byte[] row1 = Bytes.toBytes("row1");
|
||||
final byte[] val = Bytes.toBytes("a");
|
||||
PrivilegedExceptionAction<Void> actiona = new PrivilegedExceptionAction<Void>() {
|
||||
public Void run() throws Exception {
|
||||
HTable table = null;
|
||||
try {
|
||||
table = new HTable(conf, TEST_NAME.getMethodName());
|
||||
Put put = new Put(row1);
|
||||
put.add(fam, qual, HConstants.LATEST_TIMESTAMP, val);
|
||||
put.setCellVisibility(new CellVisibility(TOPSECRET));
|
||||
table.put(put);
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
USER.runAs(actiona);
|
||||
actiona = new PrivilegedExceptionAction<Void>() {
|
||||
public Void run() throws Exception {
|
||||
HTable table = null;
|
||||
try {
|
||||
table = new HTable(conf, TEST_NAME.getMethodName());
|
||||
Append append = new Append(row1);
|
||||
append.add(fam, qual, Bytes.toBytes("b"));
|
||||
table.append(append);
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
USER.runAs(actiona);
|
||||
actiona = new PrivilegedExceptionAction<Void>() {
|
||||
public Void run() throws Exception {
|
||||
HTable table = null;
|
||||
try {
|
||||
table = new HTable(conf, TEST_NAME.getMethodName());
|
||||
Append append = new Append(row1);
|
||||
append.add(fam, qual, Bytes.toBytes("c"));
|
||||
append.setCellVisibility(new CellVisibility(PUBLIC));
|
||||
table.append(append);
|
||||
Assert.fail("Testcase should fail with AccesDeniedException");
|
||||
} catch (Throwable t) {
|
||||
assertTrue(t.getMessage().contains("AccessDeniedException"));
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
USER.runAs(actiona);
|
||||
} finally {
|
||||
if (table != null) {
|
||||
table.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue