| /** |
| * 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.sentry.provider.db.generic.service.thrift; |
| |
| import static org.apache.sentry.provider.common.ProviderConstants.AUTHORIZABLE_JOINER; |
| import static org.apache.sentry.provider.common.ProviderConstants.KV_JOINER; |
| |
| import java.lang.reflect.Constructor; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Set; |
| |
| import org.apache.hadoop.conf.Configuration; |
| import org.apache.sentry.SentryUserException; |
| import org.apache.sentry.core.common.Authorizable; |
| import org.apache.sentry.core.model.db.AccessConstants; |
| import org.apache.sentry.provider.db.SentryAccessDeniedException; |
| import org.apache.sentry.provider.db.SentryAlreadyExistsException; |
| import org.apache.sentry.provider.db.SentryInvalidInputException; |
| import org.apache.sentry.provider.db.SentryNoSuchObjectException; |
| import org.apache.sentry.provider.db.generic.service.persistent.PrivilegeObject; |
| import org.apache.sentry.provider.db.generic.service.persistent.SentryStoreLayer; |
| import org.apache.sentry.provider.db.generic.service.persistent.PrivilegeObject.Builder; |
| import org.apache.sentry.provider.db.service.persistent.CommitContext; |
| import org.apache.sentry.provider.db.service.thrift.PolicyStoreConstants; |
| import org.apache.sentry.provider.db.service.thrift.SentryConfigurationException; |
| import org.apache.sentry.provider.db.service.thrift.SentryPolicyStoreProcessor; |
| import org.apache.sentry.service.thrift.ServiceConstants.ServerConfig; |
| import org.apache.sentry.service.thrift.Status; |
| import org.apache.sentry.service.thrift.TSentryResponseStatus; |
| import org.apache.thrift.TException; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Splitter; |
| import com.google.common.base.Strings; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Sets; |
| |
| public class SentryGenericPolicyProcessor implements SentryGenericPolicyService.Iface { |
| private static final Logger LOGGER = LoggerFactory.getLogger(SentryGenericPolicyProcessor.class); |
| private final Configuration conf; |
| private final ImmutableSet<String> adminGroups; |
| private final SentryStoreLayer store; |
| private final NotificationHandlerInvoker handerInvoker; |
| |
| public static final String SENTRY_GENERIC_SERVICE_NAME = "SentryGenericPolicyService"; |
| |
| public SentryGenericPolicyProcessor(Configuration conf) throws Exception { |
| this.store = createStore(conf); |
| this.handerInvoker = new NotificationHandlerInvoker(createHandlers(conf)); |
| this.conf = conf; |
| adminGroups = ImmutableSet.copyOf(toTrimedLower(Sets.newHashSet(conf.getStrings( |
| ServerConfig.ADMIN_GROUPS, new String[]{})))); |
| } |
| |
| @VisibleForTesting |
| public SentryGenericPolicyProcessor(Configuration conf, SentryStoreLayer store) throws Exception { |
| this.store = store; |
| this.handerInvoker = new NotificationHandlerInvoker(createHandlers(conf)); |
| this.conf = conf; |
| adminGroups = ImmutableSet.copyOf(toTrimedLower(Sets.newHashSet(conf.getStrings( |
| ServerConfig.ADMIN_GROUPS, new String[]{})))); |
| } |
| |
| private void authorize(String requestorUser, Set<String> requestorGroups) |
| throws SentryAccessDeniedException { |
| if (!inAdminGroups(requestorGroups)) { |
| String msg = "User: " + requestorUser + " is part of " + requestorGroups + |
| " which does not, intersect admin groups " + adminGroups; |
| LOGGER.warn(msg); |
| throw new SentryAccessDeniedException("Access denied to " + requestorUser); |
| } |
| } |
| |
| private Set<String> toTrimedLower(Set<String> s) { |
| if (null == s) return new HashSet<String>(); |
| Set<String> result = Sets.newHashSet(); |
| for (String v : s) { |
| result.add(v.trim().toLowerCase()); |
| } |
| return result; |
| } |
| |
| private String toTrimedLower(String s) { |
| if (Strings.isNullOrEmpty(s)){ |
| return ""; |
| } |
| return s.trim().toLowerCase(); |
| } |
| |
| public static Set<String> getRequestorGroups(Configuration conf, String userName) throws SentryUserException { |
| return SentryPolicyStoreProcessor.getGroupsFromUserName(conf, userName); |
| } |
| |
| private boolean inAdminGroups(Set<String> requestorGroups) { |
| requestorGroups = toTrimedLower(requestorGroups); |
| if (Sets.intersection(adminGroups, requestorGroups).isEmpty()) { |
| return false; |
| } else return true; |
| } |
| |
| public static SentryStoreLayer createStore(Configuration conf) throws SentryConfigurationException { |
| SentryStoreLayer storeLayer = null; |
| String Store = conf.get(PolicyStoreConstants.SENTRY_GENERIC_POLICY_STORE, |
| PolicyStoreConstants.SENTRY_GENERIC_POLICY_STORE_DEFAULT); |
| |
| if (Strings.isNullOrEmpty(Store)) { |
| throw new SentryConfigurationException("the parameter configuration for sentry.generic.policy.store can't be empty"); |
| } |
| try { |
| storeLayer = createInstance(Store, conf, SentryStoreLayer.class); |
| } catch (Exception e) { |
| throw new SentryConfigurationException("Create sentryStore error: " + e.getMessage(), e); |
| } |
| return storeLayer; |
| } |
| |
| public static List<NotificationHandler> createHandlers(Configuration conf) throws SentryConfigurationException { |
| |
| List<NotificationHandler> handlers = Lists.newArrayList(); |
| Iterable<String> notificationHandlers = Splitter.onPattern("[\\s,]").trimResults() |
| .omitEmptyStrings().split(conf.get(PolicyStoreConstants.SENTRY_GENERIC_POLICY_NOTIFICATION, "")); |
| try { |
| for (String notificationHandler : notificationHandlers) { |
| handlers.add(createInstance(notificationHandler, conf, NotificationHandler.class)); |
| } |
| } catch (Exception e) { |
| throw new SentryConfigurationException("Create notificationHandlers error: " + e.getMessage(), e); |
| } |
| return handlers; |
| } |
| |
| @SuppressWarnings("unchecked") |
| public static <T> T createInstance(String className, Configuration conf, Class<T> iface) throws Exception { |
| T result; |
| try { |
| Class clazz = Class.forName(className); |
| if (!iface.isAssignableFrom(clazz)) { |
| throw new IllegalArgumentException("Class " + clazz + " is not a " + |
| iface.getName()); |
| } |
| Constructor<T> meth = (Constructor<T>)clazz.getDeclaredConstructor(Configuration.class); |
| meth.setAccessible(true); |
| result = meth.newInstance(new Object[]{conf}); |
| } catch (Exception e) { |
| throw new RuntimeException(e); |
| } |
| return result; |
| } |
| |
| private <T> Response<T> requestHandle(RequestHandler<T> handler) { |
| Response<T> response = new Response<T>(); |
| try { |
| response = handler.handle(); |
| } catch (SentryAccessDeniedException e) { |
| LOGGER.error(e.getMessage(), e); |
| response.status = Status.AccessDenied(e.getMessage(), e); |
| } catch (SentryAlreadyExistsException e) { |
| LOGGER.error(e.getMessage(), e); |
| response.status = Status.AlreadyExists(e.getMessage(), e); |
| } catch (SentryNoSuchObjectException e) { |
| LOGGER.error(e.getMessage(), e); |
| response.status = Status.NoSuchObject(e.getMessage(), e); |
| } catch (SentryInvalidInputException e) { |
| String msg = "Invalid input privilege object"; |
| LOGGER.error(msg, e); |
| response.status = Status.InvalidInput(msg, e); |
| } catch (Exception e) { |
| String msg = "Unknown error:" + e.getMessage(); |
| LOGGER.error(msg, e); |
| response.status = Status.RuntimeError(msg, e); |
| } |
| return response; |
| } |
| |
| private PrivilegeObject toPrivilegeObject(TSentryPrivilege tSentryPrivilege) { |
| Boolean grantOption; |
| if (tSentryPrivilege.getGrantOption().equals(TSentryGrantOption.TRUE)) { |
| grantOption = true; |
| } else if (tSentryPrivilege.getGrantOption().equals(TSentryGrantOption.FALSE)) { |
| grantOption = false; |
| } else { |
| grantOption = null; |
| } |
| return new Builder().setComponent(tSentryPrivilege.getComponent()) |
| .setService(tSentryPrivilege.getServiceName()) |
| .setAuthorizables(toAuthorizables(tSentryPrivilege.getAuthorizables())) |
| .setAction(tSentryPrivilege.getAction()) |
| .withGrantOption(grantOption) |
| .build(); |
| } |
| |
| private TSentryPrivilege fromPrivilegeObject(PrivilegeObject privilege) { |
| |
| TSentryPrivilege tPrivilege = new TSentryPrivilege(privilege.getComponent(), privilege.getService(), |
| fromAuthorizable(privilege.getAuthorizables()), |
| privilege.getAction()); |
| if (privilege.getGrantOption() == null) { |
| tPrivilege.setGrantOption(TSentryGrantOption.UNSET); |
| } else if (privilege.getGrantOption()) { |
| tPrivilege.setGrantOption(TSentryGrantOption.TRUE); |
| } else { |
| tPrivilege.setGrantOption(TSentryGrantOption.FALSE); |
| } |
| return tPrivilege; |
| } |
| |
| private List<TAuthorizable> fromAuthorizable(List<? extends Authorizable> authorizables) { |
| List<TAuthorizable> tAuthorizables = Lists.newArrayList(); |
| for (Authorizable authorizable : authorizables) { |
| tAuthorizables.add(new TAuthorizable(authorizable.getTypeName(), authorizable.getName())); |
| } |
| return tAuthorizables; |
| } |
| |
| private List<? extends Authorizable> toAuthorizables(List<TAuthorizable> tAuthorizables) { |
| List<Authorizable> authorizables = Lists.newArrayList(); |
| if (tAuthorizables == null) { |
| return authorizables; |
| } |
| for (final TAuthorizable tAuthorizable : tAuthorizables) { |
| authorizables.add(new Authorizable() { |
| @Override |
| public String getTypeName() { |
| return tAuthorizable.getType(); |
| } |
| @Override |
| public String getName() { |
| return tAuthorizable.getName(); |
| } |
| }); |
| } |
| return authorizables; |
| } |
| |
| private Set<String> buildPermissions(Set<PrivilegeObject> privileges) { |
| Set<String> permissions = Sets.newHashSet(); |
| for (PrivilegeObject privilege : privileges) { |
| List<String> hierarchy = Lists.newArrayList(); |
| for (Authorizable authorizable : privilege.getAuthorizables()) { |
| hierarchy.add(KV_JOINER.join(authorizable.getTypeName(),authorizable.getName())); |
| } |
| hierarchy.add(KV_JOINER.join("action", privilege.getAction())); |
| permissions.add(AUTHORIZABLE_JOINER.join(hierarchy)); |
| } |
| return permissions; |
| } |
| |
| @Override |
| public TCreateSentryRoleResponse create_sentry_role( |
| final TCreateSentryRoleRequest request) throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| authorize(request.getRequestorUserName(), |
| getRequestorGroups(conf, request.getRequestorUserName())); |
| CommitContext context = store.createRole(request.getComponent(), request.getRoleName(), request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(), context); |
| } |
| }); |
| |
| TCreateSentryRoleResponse tResponse = new TCreateSentryRoleResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.create_sentry_role(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| @Override |
| public TDropSentryRoleResponse drop_sentry_role(final TDropSentryRoleRequest request) |
| throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| authorize(request.getRequestorUserName(), |
| getRequestorGroups(conf, request.getRequestorUserName())); |
| CommitContext context = store.dropRole(request.getComponent(), request.getRoleName(), request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(), context); |
| } |
| }); |
| |
| TDropSentryRoleResponse tResponse = new TDropSentryRoleResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.drop_sentry_role(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| @Override |
| public TAlterSentryRoleGrantPrivilegeResponse alter_sentry_role_grant_privilege( |
| final TAlterSentryRoleGrantPrivilegeRequest request) throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| CommitContext context = store.alterRoleGrantPrivilege(request.getComponent(), request.getRoleName(), |
| toPrivilegeObject(request.getPrivilege()), |
| request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(), context); |
| } |
| }); |
| |
| TAlterSentryRoleGrantPrivilegeResponse tResponse = new TAlterSentryRoleGrantPrivilegeResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.alter_sentry_role_grant_privilege(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| @Override |
| public TAlterSentryRoleRevokePrivilegeResponse alter_sentry_role_revoke_privilege( |
| final TAlterSentryRoleRevokePrivilegeRequest request) throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| CommitContext context = store.alterRoleRevokePrivilege(request.getComponent(), request.getRoleName(), |
| toPrivilegeObject(request.getPrivilege()), |
| request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(), context); |
| } |
| }); |
| |
| TAlterSentryRoleRevokePrivilegeResponse tResponse = new TAlterSentryRoleRevokePrivilegeResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.alter_sentry_role_revoke_privilege(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| @Override |
| public TAlterSentryRoleAddGroupsResponse alter_sentry_role_add_groups( |
| final TAlterSentryRoleAddGroupsRequest request) throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| authorize(request.getRequestorUserName(), |
| getRequestorGroups(conf, request.getRequestorUserName())); |
| CommitContext context = store.alterRoleAddGroups( |
| request.getComponent(), request.getRoleName(), request.getGroups(), |
| request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(), context); |
| } |
| }); |
| |
| TAlterSentryRoleAddGroupsResponse tResponse = new TAlterSentryRoleAddGroupsResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.alter_sentry_role_add_groups(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| @Override |
| public TAlterSentryRoleDeleteGroupsResponse alter_sentry_role_delete_groups( |
| final TAlterSentryRoleDeleteGroupsRequest request) throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| authorize(request.getRequestorUserName(), |
| getRequestorGroups(conf, request.getRequestorUserName())); |
| CommitContext context = store.alterRoleDeleteGroups( |
| request.getComponent(), request.getRoleName(), request.getGroups(), |
| request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(), context); |
| } |
| }); |
| |
| TAlterSentryRoleDeleteGroupsResponse tResponse = new TAlterSentryRoleDeleteGroupsResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.alter_sentry_role_delete_groups(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| @Override |
| public TListSentryRolesResponse list_sentry_roles_by_group( |
| final TListSentryRolesRequest request) throws TException { |
| Response<Set<TSentryRole>> respose = requestHandle(new RequestHandler<Set<TSentryRole>>() { |
| @Override |
| public Response<Set<TSentryRole>> handle() throws Exception { |
| Set<String> groups = getRequestorGroups(conf, request.getRequestorUserName()); |
| if (AccessConstants.ALL.equalsIgnoreCase(request.getGroupName())) { |
| //check all groups which requestorUserName belongs to |
| } else { |
| boolean admin = inAdminGroups(groups); |
| //Only admin users can list all roles in the system ( groupname = null) |
| //Non admin users are only allowed to list only groups which they belong to |
| if(!admin && (request.getGroupName() == null || !groups.contains(request.getGroupName()))) { |
| throw new SentryAccessDeniedException("Access denied to " + request.getRequestorUserName()); |
| } |
| groups.clear(); |
| groups.add(request.getGroupName()); |
| } |
| |
| Set<String> roleNames = store.getRolesByGroups(request.getComponent(), groups); |
| Set<TSentryRole> tSentryRoles = Sets.newHashSet(); |
| for (String roleName : roleNames) { |
| Set<String> groupsForRoleName = store.getGroupsByRoles(request.getComponent(), Sets.newHashSet(roleName)); |
| tSentryRoles.add(new TSentryRole(roleName, groupsForRoleName)); |
| } |
| return new Response<Set<TSentryRole>>(Status.OK(), tSentryRoles); |
| } |
| }); |
| TListSentryRolesResponse tResponse = new TListSentryRolesResponse(); |
| tResponse.setStatus(respose.status); |
| tResponse.setRoles(respose.content); |
| return tResponse; |
| } |
| |
| @Override |
| public TListSentryPrivilegesResponse list_sentry_privileges_by_role( |
| final TListSentryPrivilegesRequest request) throws TException { |
| Response<Set<TSentryPrivilege>> respose = requestHandle(new RequestHandler<Set<TSentryPrivilege>>() { |
| @Override |
| public Response<Set<TSentryPrivilege>> handle() throws Exception { |
| Set<String> groups = getRequestorGroups(conf, request.getRequestorUserName()); |
| if (!inAdminGroups(groups)) { |
| Set<String> roleNamesForGroups = toTrimedLower(store.getRolesByGroups(request.getComponent(), groups)); |
| if (!roleNamesForGroups.contains(toTrimedLower(request.getRoleName()))) { |
| throw new SentryAccessDeniedException("Access denied to " + request.getRequestorUserName()); |
| } |
| } |
| Set<PrivilegeObject> privileges = store.getPrivilegesByProvider(request.getComponent(), |
| request.getServiceName(), |
| Sets.newHashSet(request.getRoleName()), |
| null, |
| toAuthorizables(request.getAuthorizables())); |
| Set<TSentryPrivilege> tSentryPrivileges = Sets.newHashSet(); |
| for (PrivilegeObject privilege : privileges) { |
| tSentryPrivileges.add(fromPrivilegeObject(privilege)); |
| } |
| return new Response<Set<TSentryPrivilege>>(Status.OK(), tSentryPrivileges); |
| } |
| }); |
| TListSentryPrivilegesResponse tResponse = new TListSentryPrivilegesResponse(); |
| tResponse.setStatus(respose.status); |
| tResponse.setPrivileges(respose.content); |
| return tResponse; |
| } |
| |
| @Override |
| public TListSentryPrivilegesForProviderResponse list_sentry_privileges_for_provider( |
| final TListSentryPrivilegesForProviderRequest request) throws TException { |
| Response<Set<String>> respose = requestHandle(new RequestHandler<Set<String>>() { |
| @Override |
| public Response<Set<String>> handle() throws Exception { |
| Set<String> activeRoleNames = toTrimedLower(request.getRoleSet().getRoles()); |
| Set<String> roleNamesForGroups = store.getRolesByGroups(request.getComponent(), request.getGroups()); |
| Set<String> rolesToQuery = request.getRoleSet().isAll() ? roleNamesForGroups : Sets.intersection(activeRoleNames, roleNamesForGroups); |
| Set<PrivilegeObject> privileges = store.getPrivilegesByProvider(request.getComponent(), |
| request.getServiceName(), |
| rolesToQuery, null, |
| toAuthorizables(request.getAuthorizables())); |
| return new Response<Set<String>>(Status.OK(), buildPermissions(privileges)); |
| } |
| }); |
| TListSentryPrivilegesForProviderResponse tResponse = new TListSentryPrivilegesForProviderResponse(); |
| tResponse.setStatus(respose.status); |
| tResponse.setPrivileges(respose.content); |
| return tResponse; |
| } |
| |
| @Override |
| public TDropPrivilegesResponse drop_sentry_privilege( |
| final TDropPrivilegesRequest request) throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| authorize(request.getRequestorUserName(), |
| getRequestorGroups(conf, request.getRequestorUserName())); |
| CommitContext context = store.dropPrivilege(request.getComponent(), |
| toPrivilegeObject(request.getPrivilege()), |
| request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(), context); |
| } |
| }); |
| |
| TDropPrivilegesResponse tResponse = new TDropPrivilegesResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.drop_sentry_privilege(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| @Override |
| public TRenamePrivilegesResponse rename_sentry_privilege( |
| final TRenamePrivilegesRequest request) throws TException { |
| Response<Void> respose = requestHandle(new RequestHandler<Void>() { |
| @Override |
| public Response<Void> handle() throws Exception { |
| authorize(request.getRequestorUserName(), |
| getRequestorGroups(conf, request.getRequestorUserName())); |
| CommitContext context = store.renamePrivilege(request.getComponent(), request.getServiceName(), |
| toAuthorizables(request.getOldAuthorizables()), |
| toAuthorizables(request.getNewAuthorizables()), |
| request.getRequestorUserName()); |
| return new Response<Void>(Status.OK(),context); |
| } |
| }); |
| |
| TRenamePrivilegesResponse tResponse = new TRenamePrivilegesResponse(respose.status); |
| if (Status.OK.getCode() == respose.status.getValue()) { |
| handerInvoker.rename_sentry_privilege(respose.context, request, tResponse); |
| } |
| return tResponse; |
| } |
| |
| private static class Response<T> { |
| TSentryResponseStatus status; |
| CommitContext context; |
| T content; |
| |
| Response() { |
| } |
| |
| Response(TSentryResponseStatus status, CommitContext context) { |
| this(status,context,null); |
| } |
| |
| Response(TSentryResponseStatus status, T content) { |
| this(status,null,content); |
| } |
| |
| Response(TSentryResponseStatus status, CommitContext context, T content) { |
| this.status = status; |
| this.context = context; |
| this.content = content; |
| } |
| } |
| private interface RequestHandler <T>{ |
| public Response<T> handle() throws Exception ; |
| } |
| } |