RocketMQ:剖析ACL权限控制原理、实现和应用

前言

        RocketMQ 作为一款优秀的中间件,应用领域非常广泛,金融、电商、电信、医疗、社科、安保等不同的领域都有其大规模的应用,无疑安全性很受质疑,因为内部没有安全相关的业务模块,消息的发送和消费得不到很好的安全管控需要业务方自己去封装安全模块,无形中增加了使用成本。

        在 RocketMQ 4.4.0 版本升级中加入了ACL权限管控,这个功能的完善直接推动了RocketMQ在各个领域的推广使用,特别是金融、电商、安保等安全要求较高的领域。
        

一、什么是ACL?

        ACL 是Access Control List的简称,俗称访问控制列表。访问控制,基本上会涉及到用户、资源、权限、角色等概念,那在 RocketMQ 中上述会对应哪些对象呢?

        用户:用户是访问控制的基础要素,RocketMQ ACL必然也会引入用户的概念,即支持用户名、密码。

        资源:需要保护的对象,消息发送涉及的Topic、消息消费涉及的消费组,应该进行保护,故可以抽象成资源。

        权限:针对资源,能进行的操作。

        角色:RocketMQ中,只定义两种角色:是否是管理员。
        

二、RocketMQ 中的ACL

        该特性主要为 RocketMQ提供权限访问控制。

        其中,用户可以通过yaml配置文件来定义权限访问的相关属性,包括白名单IP地址、用户的AK/SK访问秘钥对、Topic和ConsumerGroup的访问权限。这样,Topic资源之间也就具备了一定的隔离性,用户无法访问没有权限的Topic资源。

        同时,开源用户使用带有ACL鉴权信息的开源客户端可以无缝对接云MQ,而无需对业务系统进行任何的其他改造。
        

1、ACL基本流程

RocketMQ:剖析ACL权限控制原理、实现和应用_第1张图片
        通过上图,我们对RocketMQ的ACL有大概的认识,具体在后文继续分析。
        

2、如何配置ACL

        acl默认的配置文件名:plain_acl.yml,需要放在${ROCKETMQ_HOME}/store/config目录下。

        需要使用acl必须在服务端开启此功能,在Broker的配置文件中配置,aclEnable = true开启此功能。

        plain_acl.yml 配置文件示例如下:

globalWhiteRemoteAddresses:
- 10.10.103.*
- 192.168.0.*

accounts:
- accessKey: RocketMQ
  secretKey: 12345678
  whiteRemoteAddress:
  admin: false
  defaultTopicPerm: DENY
  defaultGroupPerm: SUB
  topicPerms:
  - topicA=DENY
  - topicB=PUB|SUB
  - topicC=SUB
  groupPerms:
  # the group should convert to retry topic
  - groupA=DENY
  - groupB=PUB|SUB
  - groupC=SUB

- accessKey: rocketmq2
  secretKey: 12345678
  whiteRemoteAddress: 192.168.1.*
  # if it is admin, it could access all resources
  admin: true

        下面我们介绍一下plain_acl.yml文件中相关的参数含义及使用。

字段 取值 含义
globalWhiteRemoteAddresses ;192.168..*;192.168.0.1 全局IP白名单
accessKey 字符串 Access Key 用户名
secretKey 字符串 Secret Key 密码
whiteRemoteAddress ;192.168..*;192.168.0.1 用户IP白名单
admin true;false 是否管理员账户
defaultTopicPerm DENY;PUB;SUB;PUB SUB
defaultGroupPerm DENY;PUB;SUB;PUB SUB
topicPerms topic=权限 各个Topic的权限
groupPerms group=权限 各个ConsumerGroup的权限

        权限标识符的含义:

权限 含义
DENY 拒绝
ANY PUB 或者 SUB 权限
PUB 发送权限
SUB 订阅权限

        处理流程如下:
RocketMQ:剖析ACL权限控制原理、实现和应用_第2张图片

特殊的请求例如 UPDATE_AND_CREATE_TOPIC 等,只能由 admin 账户进行操作。
        
对于某个资源,如果有显性配置权限,则采用配置的权限;如果没有显性配置权限,则采用默认的权限。
        
RocketMQ 的权限控制存储的默认实现是基于yml配置文件。用户可以动态修改权限控制定义的属性,而不需重新启动Broker服务节点,因为 Broker 端有文件监听机制,每隔 500ms 监听、处理、加载文件的变更内容。
        
如果ACL与高可用部署(Master/Slave架构)同时启用,那么需要在Broker Master节点的${ROCKETMQ_HOME}/store/conf/plain_acl.yml配置文件中 设置全局白名单信息,即为将Slave节点的ip地址设置至Master节点plain_acl.yml配置文件的全局白名单中。

        

3、代码示例

         Broker 添加好权限控制配置,并开启 aclEnable = true 。

3.1、基于 Spring Boot

        生产方,在 appliction.properties 配置文件中添加配置:

rocketmq.producer.accessKey=xxx
rocketmq.producer.secretKey=xxx

        在应用启动期间,会根据配置对 DefaultMQProducer 示例添加ACL钩子。

@Bean
@ConditionalOnMissingBean(DefaultMQProducer.class)
@ConditionalOnProperty(prefix = "rocketmq", value = {"name-server", "producer.group"})
public DefaultMQProducer defaultMQProducer(RocketMQProperties rocketMQProperties) {
   	//忽略非核心代码...
   	
    DefaultMQProducer producer;
    String ak = rocketMQProperties.getProducer().getAccessKey();
    String sk = rocketMQProperties.getProducer().getSecretKey();
    if (!StringUtils.isEmpty(ak) && !StringUtils.isEmpty(sk)) {
        producer = new DefaultMQProducer(groupName, new AclClientRPCHook(new SessionCredentials(ak, sk)),
            rocketMQProperties.getProducer().isEnableMsgTrace(),
            rocketMQProperties.getProducer().getCustomizedTraceTopic());
        producer.setVipChannelEnabled(false);
    } else {
        producer = new DefaultMQProducer(groupName, rocketMQProperties.getProducer().isEnableMsgTrace(),
            rocketMQProperties.getProducer().getCustomizedTraceTopic());
    }
    
    return producer;
}

        消费方,在application.properteis添加配置,或者在@RocketMQMessageListener注解添加具体值或占位符表达式:

rocketmq.consumer.access-key=xxx
rocketmq.consumer.secret-key=xxx

        在应用启动期间,会根据配置为 DefaultMQPushConsumer 添加钩子。

org.apache.rocketmq.spring.autoconfigure.ListenerContainerConfiguration
org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer#initRocketMQPushConsumer 代码片段
private void initRocketMQPushConsumer() throws MQClientException {
  	//忽略非核心代码...
  	
    RPCHook rpcHook = RocketMQUtil.getRPCHookByAkSk(applicationContext.getEnvironment(),
        this.rocketMQMessageListener.accessKey(), this.rocketMQMessageListener.secretKey());
    boolean enableMsgTrace = rocketMQMessageListener.enableMsgTrace();
    if (Objects.nonNull(rpcHook)) {
        consumer = new DefaultMQPushConsumer(consumerGroup, rpcHook, new AllocateMessageQueueAveragely(),
            enableMsgTrace, this.applicationContext.getEnvironment().
            resolveRequiredPlaceholders(this.rocketMQMessageListener.customizedTraceTopic()));
        consumer.setVipChannelEnabled(false);
        consumer.setInstanceName(RocketMQUtil.getInstanceName(rpcHook, consumerGroup));
    } else {
        log.debug("Access-key or secret-key not configure in " + this + ".");
        consumer = new DefaultMQPushConsumer(consumerGroup, enableMsgTrace,
                this.applicationContext.getEnvironment().
                resolveRequiredPlaceholders(this.rocketMQMessageListener.customizedTraceTopic()));
    }
}

        

三、源码分析

1、Broker端ACL原理图

RocketMQ:剖析ACL权限控制原理、实现和应用_第3张图片
        根据该原理图,下文我们走读下具体的实现。

2、Broker初始化时ACL相关操作

        Broker 服务启动时创建 BrokerController 并初始化initialize()时调用acl相关的初始化方法initialAcl()。

org.apache.rocketmq.broker.BrokerController#initialAcl 代码片段
private void initialAcl() {
	//broker配置文件中是否开启ACL功能,默认关闭
    if (!this.brokerConfig.isAclEnable()) {
        log.info("The broker dose not enable acl");
        return;
    }
	//获取权限访问校验器的列表,加载的META-INF/service/org.apache.rocketmq.acl.AccessValidator文件中指向
	//org.apache.rocketmq.acl.plain.PlainAccessValidator,默认只有一个
    List<AccessValidator> accessValidators = ServiceProvider.load(ServiceProvider.ACL_VALIDATOR_ID, AccessValidator.class);
    if (accessValidators == null || accessValidators.isEmpty()) {
        log.info("The broker dose not load the AccessValidator");
        return;
    }

    for (AccessValidator accessValidator: accessValidators) {
        final AccessValidator validator = accessValidator;
        accessValidatorMap.put(validator.getClass(),validator);
        //注册服务端就的“钩子”对象,对权限进行校验
        this.registerServerRPCHook(new RPCHook() {

            @Override
            public void doBeforeRequest(String remoteAddr, RemotingCommand request) {
                //解析、验证请求
                validator.validate(validator.parse(request, remoteAddr));
            }

            @Override
            public void doAfterResponse(String remoteAddr, RemotingCommand request, RemotingCommand response) {
            }
        });
    }
}

        通过初始化对服务端注册钩子后,即可对服务端的入站请求进行权限控制校验处理。

3、PlainAccessValidator权限验证器

        PlainAccessValidator 在Broker初始化期间,通过 SPI 加载,然后实例化 PlainPermissionManager 完成 权限控制配置内容的载入。

org.apache.rocketmq.acl.plain.PlainPermissionManager 代码片段
public PlainPermissionManager() {
	//加载 plain_acl.yml 文件中 权限控制配置
    load();
    //开启线程每500ms检测权限文件是否改变,若改变则执行load()从新加载权限文件
    watch();
}

        PlainAccessValidator.parse(),根据客户端不同的请求 Code,其需要的检验资源也不一样。

org.apache.rocketmq.acl.plain.PlainAccessValidator#parse 代码片段
public AccessResource parse(RemotingCommand request, String remoteAddr) {
    PlainAccessResource accessResource = new PlainAccessResource();
    if (remoteAddr != null && remoteAddr.contains(":")) {
        accessResource.setWhiteRemoteAddress(remoteAddr.substring(0, remoteAddr.lastIndexOf(':')));
    } else {
        accessResource.setWhiteRemoteAddress(remoteAddr);
    }

    accessResource.setRequestCode(request.getCode());

    if (request.getExtFields() == null) {
        // If request's extFields is null,then return accessResource directly(users can use whiteAddress pattern)
        // The following logic codes depend on the request's extFields not to be null.
        return accessResource;
    }
    accessResource.setAccessKey(request.getExtFields().get(SessionCredentials.ACCESS_KEY));
    accessResource.setSignature(request.getExtFields().get(SessionCredentials.SIGNATURE));
    accessResource.setSecretToken(request.getExtFields().get(SessionCredentials.SECURITY_TOKEN));

    try {
        switch (request.getCode()) {
        	//发送消息需要校验当前的账户的topic是否具有PUB权限
            case RequestCode.SEND_MESSAGE:
                accessResource.addResourceAndPerm(request.getExtFields().get("topic"), Permission.PUB);
                break;
            case RequestCode.SEND_MESSAGE_V2:
                accessResource.addResourceAndPerm(request.getExtFields().get("b"), Permission.PUB);
                break;
            case RequestCode.CONSUMER_SEND_MSG_BACK:
                accessResource.addResourceAndPerm(request.getExtFields().get("originTopic"), Permission.PUB);
                accessResource.addResourceAndPerm(getRetryTopic(request.getExtFields().get("group")), Permission.SUB);
                break;
            //拉取消息时需要知道该consumer账户下拉取的topic是否具有SUB权限,并且还要知道订阅组consumerGroup是否有sub权限
            case RequestCode.PULL_MESSAGE:
                accessResource.addResourceAndPerm(request.getExtFields().get("topic"), Permission.SUB);
                accessResource.addResourceAndPerm(getRetryTopic(request.getExtFields().get("consumerGroup")), Permission.SUB);
                break;
            case RequestCode.QUERY_MESSAGE:
                accessResource.addResourceAndPerm(request.getExtFields().get("topic"), Permission.SUB);
                break;
            case RequestCode.HEART_BEAT:
                HeartbeatData heartbeatData = HeartbeatData.decode(request.getBody(), HeartbeatData.class);
                for (ConsumerData data : heartbeatData.getConsumerDataSet()) {
                    accessResource.addResourceAndPerm(getRetryTopic(data.getGroupName()), Permission.SUB);
                    for (SubscriptionData subscriptionData : data.getSubscriptionDataSet()) {
                        accessResource.addResourceAndPerm(subscriptionData.getTopic(), Permission.SUB);
                    }
                }
                break;
            case RequestCode.UNREGISTER_CLIENT:
                final UnregisterClientRequestHeader unregisterClientRequestHeader =
                    (UnregisterClientRequestHeader) request
                        .decodeCommandCustomHeader(UnregisterClientRequestHeader.class);
                accessResource.addResourceAndPerm(getRetryTopic(unregisterClientRequestHeader.getConsumerGroup()), Permission.SUB);
                break;
            case RequestCode.GET_CONSUMER_LIST_BY_GROUP:
                final GetConsumerListByGroupRequestHeader getConsumerListByGroupRequestHeader =
                    (GetConsumerListByGroupRequestHeader) request
                        .decodeCommandCustomHeader(GetConsumerListByGroupRequestHeader.class);
                accessResource.addResourceAndPerm(getRetryTopic(getConsumerListByGroupRequestHeader.getConsumerGroup()), Permission.SUB);
                break;
            case RequestCode.UPDATE_CONSUMER_OFFSET:
                final UpdateConsumerOffsetRequestHeader updateConsumerOffsetRequestHeader =
                    (UpdateConsumerOffsetRequestHeader) request
                        .decodeCommandCustomHeader(UpdateConsumerOffsetRequestHeader.class);
                accessResource.addResourceAndPerm(getRetryTopic(updateConsumerOffsetRequestHeader.getConsumerGroup()), Permission.SUB);
                accessResource.addResourceAndPerm(updateConsumerOffsetRequestHeader.getTopic(), Permission.SUB);
                break;
            default:
                break;

        }
    } catch (Throwable t) {
        throw new AclException(t.getMessage(), t);
    }

    // Content
    SortedMap<String, String> map = new TreeMap<String, String>();
    for (Map.Entry<String, String> entry : request.getExtFields().entrySet()) {
        if (!SessionCredentials.SIGNATURE.equals(entry.getKey())
                && !MixAll.UNIQUE_MSG_QUERY_FLAG.equals(entry.getKey())) {
            map.put(entry.getKey(), entry.getValue());
        }
    }
    accessResource.setContent(AclUtils.combineRequestContent(request, map));
    return accessResource;
}

        根据request.getCode()获取当前的操作需要的权限标识集合,供后面与系统的权限配置文件plain_acl.yml中的权限标识符校验时使用。

org.apache.rocketmq.acl.plain.PlainPermissionManager#validate 代码片段
public void validate(PlainAccessResource plainAccessResource) {
    //全局的白名单IP进行校验
    for (RemoteAddressStrategy remoteAddressStrategy : globalWhiteRemoteAddressStrategy) {
        if (remoteAddressStrategy.match(plainAccessResource)) {
            return;
        }
    }
	//判断用户名是否为空,null则抛出AclException异常
    if (plainAccessResource.getAccessKey() == null) {
        throw new AclException(String.format("No accessKey is configured"));
    }
	//校验账户是否存在于服务端的权限资源文件中plain_acl.yml,不在则抛出异常
    if (!plainAccessResourceMap.containsKey(plainAccessResource.getAccessKey())) {
        throw new AclException(String.format("No acl config for %s", plainAccessResource.getAccessKey()));
    }
    //检查该账户的白名单IP是否匹配上客户端IP,匹配成功具有所有权限,除UPDATE_AND_CREATE_TOPIC等特殊权限需要管理员权限
    PlainAccessResource ownedAccess = plainAccessResourceMap.get(plainAccessResource.getAccessKey());
    if (ownedAccess.getRemoteAddressStrategy().match(plainAccessResource)) {
        return;
    }
    //校验签名
    String signature = AclUtils.calSignature(plainAccessResource.getContent(), ownedAccess.getSecretKey());
    if (!signature.equals(plainAccessResource.getSignature())) {
        throw new AclException(String.format("Check signature failed for accessKey=%s", plainAccessResource.getAccessKey()));
    }
    //校验账户内的资源权限
    checkPerm(plainAccessResource, ownedAccess);
}

void checkPerm(PlainAccessResource needCheckedAccess, PlainAccessResource ownedAccess) {
	//判断请求的命令的Code是否需要管理员权限,并判断该用户是否是管理员
    if (Permission.needAdminPerm(needCheckedAccess.getRequestCode()) && !ownedAccess.isAdmin()) {
        throw new AclException(String.format("Need admin permission for request code=%d, but accessKey=%s is not", needCheckedAccess.getRequestCode(), ownedAccess.getAccessKey()));
    }
    Map<String, Byte> needCheckedPermMap = needCheckedAccess.getResourcePermMap();
    Map<String, Byte> ownedPermMap = ownedAccess.getResourcePermMap();

    if (needCheckedPermMap == null) {
        // If the needCheckedPermMap is null,then return
        return;
    }

    if (ownedPermMap == null && ownedAccess.isAdmin()) {
        // If the ownedPermMap is null and it is an admin user, then return
        return;
    }

    for (Map.Entry<String, Byte> needCheckedEntry : needCheckedPermMap.entrySet()) {
        String resource = needCheckedEntry.getKey();
        Byte neededPerm = needCheckedEntry.getValue();
        //判断是否是group,在构建resourcePermMap时候,group的key=RETRY_GROUP_TOPIC_PREFIX + consumerGroup
        boolean isGroup = PlainAccessResource.isRetryTopic(resource);
		//系统的权限配置文件中配置项包不含该客户端命令请求需要的权限
        if (ownedPermMap == null || !ownedPermMap.containsKey(resource)) {
            //判断其是否是topic还是group的权限标识,获取该类型的全局的权限是什么
            byte ownedPerm = isGroup ? ownedAccess.getDefaultGroupPerm() :
                ownedAccess.getDefaultTopicPerm();
            //核对权限
            if (!Permission.checkPermission(neededPerm, ownedPerm)) {
                throw new AclException(String.format("No default permission for %s", PlainAccessResource.printStr(resource, isGroup)));
            }
            continue;
        }
        //系统的权限配置文件中配置项包含该客户端命令请求需要的权限,则直接判断其权限
        if (!Permission.checkPermission(neededPerm, ownedPermMap.get(resource))) {
            throw new AclException(String.format("No default permission for %s", PlainAccessResource.printStr(resource, isGroup)));
        }
    }
}

        所有的检验流程如果有一项不满足则抛出AclException异常。


引用

消息轨迹指南(Message Trace)
RocketMQ权限控制

你可能感兴趣的:(RocketMQ)