《Java 服务端通信工程》10.3 授权模型与租户隔离

认证只回答「你是谁」,本节回答「你能做什么」:把 x-tenant-id 设为必填并校验租户归属,用 RBAC 加 ABAC 的简化模型做资源级授权,拒绝时返回 PERMISSION_DENIED 与 ErrorInfo(TENANT_FORBIDDEN),并防住跨租户 ID 枚举。

本节目标:把「租户」从一句口号变成请求路径上的硬约束:x-tenant-id 必填且与 token 一致,资源查询必须带租户条件,拒绝时给出可机器识别的 ErrorInfo(TENANT_FORBIDDEN),跨租户访问不泄露资源是否存在。
适用版本:JDK 25、gRPC-Java 1.76.0、Protobuf 4.33.2(com.google.rpc.ErrorInfo)。

承接的问题

10.2 结束时,linkd-v3 能确认「这个请求带着一个签名有效、未过期的 token」,Principal 里有 subject、tenant、role。但这只完成了认证。真正的越权事故通常不是「token 是假的」,而是「token 是真的,人也是对的,但他读到了别的租户的书」。

linkd-v3 是多租户的:同一个 9093 进程服务多个图书馆,每个馆有自己的书目、库存和读者。租户边界要落在三个位置,缺一个都会漏:

位置要解决的问题本节手段
请求入口这次请求声称属于哪个租户x-tenant-id 必填,且与 token 的 tenant 一致
业务方法这个身份能不能做这个动作RBAC 角色 + ABAC 资源属性
数据访问查询有没有被租户条件限制所有查询强制带 tenant_id,禁止按主键裸查

只做第一层,等于相信调用方;只做第二层,缓存和 SQL 仍可能串租户;只做第三层,错误信息又会泄露资源是否存在。三层一起做,才谈得上隔离。

核心机制一:x-tenant-id 必填与租户一致性

x-tenant-id 是 ASCII metadata,格式先收窄:小写字母、数字、连字符,长度 2–32。格式校验不是为了好看,而是为了防止它被拼进缓存键、日志字段或 SQL 片段时产生歧义。

校验分四步:

  1. 缺失 → 拒绝;
  2. 多值 → 拒绝(代理拼接会造成取第一个还是最后一个的歧义);
  3. 格式不合法 → 拒绝;
  4. 与 Principal.tenantId() 不一致 → 拒绝。

四步都返回同一个对外错误:PERMISSION_DENIED,details 里带 ErrorInfo.reason = "TENANT_FORBIDDEN"。对外消息不复述具体是「缺失」还是「不匹配」,避免把内部策略暴露给调用方;具体分类放在 ErrorInfo.metadata 里,供网关和日志使用。

package com.plume.linkd.v3.server;

import com.google.protobuf.Any;
import com.google.rpc.ErrorInfo;
import io.grpc.Metadata;
import io.grpc.Status;
import io.grpc.protobuf.StatusProto;
import io.grpc.StatusRuntimeException;

import java.util.List;
import java.util.regex.Pattern;

public final class TenantGuard {

    public static final Metadata.Key<String> TENANT =
            Metadata.Key.of("x-tenant-id", Metadata.ASCII_STRING_MARSHALLER);

    private static final Pattern TENANT_ID =
            Pattern.compile("[a-z0-9][a-z0-9-]{1,31}");

    private TenantGuard() {
    }

    public static String require(Metadata headers, Principal principal) {
        List<String> values = headers.getAll(TENANT);
        if (values.isEmpty()) {
            throw tenantForbidden("missing");
        }
        if (values.size() > 1) {
            throw tenantForbidden("duplicate");
        }
        String tenant = values.get(0);
        if (!TENANT_ID.matcher(tenant).matches()) {
            throw tenantForbidden("malformed");
        }
        if (!tenant.equals(principal.tenantId())) {
            throw tenantForbidden("mismatch");
        }
        return tenant;
    }

    public static StatusRuntimeException tenantForbidden(String detail) {
        com.google.rpc.Status status = com.google.rpc.Status.newBuilder()
                .setCode(Status.PERMISSION_DENIED.getCode())
                .setMessage("tenant policy rejected the request")
                .addDetails(Any.pack(ErrorInfo.newBuilder()
                        .setReason("TENANT_FORBIDDEN")
                        .setDomain("linkd.plume.com")
                        .putMetadata("detail", detail)
                        .build()))
                .build();
        return StatusProto.toStatusRuntimeException(status);
    }
}

StatusProto.toStatusRuntimeException 会把 com.google.rpc.Status 序列化进 grpc-status-details-bin,客户端用同一个 StatusProto.fromThrowable 就能拿回结构化的 ErrorInfo。这比在 Status.description 里拼字符串可靠:网关可以按 reason 做统一映射,不需要解析自然语言。

一个容易忽略的点:x-tenant-id 是「请求声称的租户」,token 里的 tenant 是「凭据证明的租户」。两者一致才有意义。只校验 header,等于让调用方自己声明权限范围;只校验 token,则无法阻止一个合法用户拿自己的 token 去读别人的资源——因为资源查询本身没有租户条件。

核心机制二:RBAC 与 ABAC 的分工

RBAC 回答「这个角色能不能做这类动作」,ABAC 回答「这个具体资源能不能被这个身份操作」。两者不是二选一:

  • 角色是粗粒度的,稳定、易审计,适合放在 token 里。
  • 资源属性是细粒度的,随数据变化,适合在服务端实时判断。

linkd-v3 的角色只有三个:

角色能做不能做
READER查公开书、搜书、订阅库存聊私密房间、读受限书
LIBRARIAN读所有本租户书、维护库存进入读者聊天
ADMIN本租户全部操作跨租户操作(角色不能突破租户)

资源属性有三个:tenantId、ownerSubject、classification(public / restricted)。判定顺序必须是「先租户、再角色、后属性」:

package com.plume.linkd.v3.server;

public final class Authorizer {

    public enum Action { READ_BOOK, SEARCH, WATCH_STOCK, CHAT }

    public enum Role { READER, LIBRARIAN, ADMIN }

    public record Resource(String tenantId, String ownerSubject,
                           String classification) {
    }

    public record Decision(boolean allowed, String reason) {

        public static Decision allow() {
            return new Decision(true, "allowed");
        }

        public static Decision deny(String reason) {
            return new Decision(false, reason);
        }
    }

    public Decision decide(Principal principal, Action action, Resource resource) {
        if (!principal.tenantId().equals(resource.tenantId())) {
            return Decision.deny("resource in another tenant");
        }
        Role role;
        try {
            role = Role.valueOf(principal.role());
        } catch (IllegalArgumentException e) {
            return Decision.deny("unknown role");
        }
        if (role == Role.ADMIN) {
            return Decision.allow();
        }
        if (role == Role.LIBRARIAN) {
            return action == Action.CHAT
                    ? Decision.deny("librarian cannot chat")
                    : Decision.allow();
        }
        return switch (action) {
            case READ_BOOK -> resource.classification().equals("public")
                    || principal.subject().equals(resource.ownerSubject())
                    ? Decision.allow()
                    : Decision.deny("restricted book");
            case SEARCH, WATCH_STOCK -> Decision.allow();
            case CHAT -> principal.subject().equals(resource.ownerSubject())
                    ? Decision.allow()
                    : Decision.deny("chat requires ownership");
        };
    }
}

ADMIN 的 allow 仍然发生在租户检查之后,这一点是刻意的:管理员权限再大,也不能跨租户。角色来自 token,资源属性来自数据库,两者都不由调用方直接提供,才能避免「客户端传一个 role=ADMIN 就放行」的低级错误。

策略表不要散落在业务方法里。把 Action、Role、Resource 定成枚举和记录,新增动作时编译器会强制你处理所有分支;这比在一串 if 里补条件可靠。

核心机制三:资源归属与跨租户枚举防护

租户校验通过后,资源查询仍然可能越界。典型写法是 repository.findByIsbn(isbn),然后才比较 book.tenantId() 与当前租户。这种写法有两个问题:

  • 数据层已经把别的租户的行读出来了,日志、缓存、异常栈都可能带上;
  • 如果返回 PERMISSION_DENIED,攻击者能确认「这个 ISBN 存在,只是不属于我」,从而枚举有效 ISBN。

正确做法是把租户条件下推到查询:

BookRecord record = repository.findByIsbn(tenantId, isbn);
if (record == null) {
    throw bookNotFound();
}

findByIsbn 的 SQL 必须带 WHERE tenant_id = ? AND isbn = ?。缓存键也要带租户:book:{tenantId}:{isbn},不能只用 ISBN。任何按主键的查询都要问一句「这个主键在租户内唯一吗」,如果不是,就必须加租户条件。

对「资源存在但属于别的租户」的返回,本节的策略是 和不存在完全一样:NOT_FOUND,ErrorInfo.reason = "BOOK_NOT_FOUND"。这与入口层的 TENANT_FORBIDDEN 不矛盾:

场景状态码reason是否暴露存在性
租户头缺失/非法/与 token 不一致PERMISSION_DENIEDTENANT_FORBIDDEN不涉及资源
本租户内角色不够PERMISSION_DENIEDTENANT_FORBIDDEN资源存在性已知
资源属于其他租户NOT_FOUNDBOOK_NOT_FOUND不暴露
资源确实不存在NOT_FOUNDBOOK_NOT_FOUND不暴露

枚举防护还包括计数维度:按租户和 sub 对 NOT_FOUND 做速率统计。单租户每秒 200 次 NOT_FOUND 不是正常读者行为,应当触发告警或临时限流。限流键里必须带租户,否则一个租户的枚举流量会把所有租户一起限掉。

核心机制四:把租户放进 Context

服务方法拿不到原始 Metadata,所以租户要在拦截器里校验并放进 Context。顺序是 AuthInterceptor 先产出 Principal,TenantInterceptor 再读 header:

package com.plume.linkd.v3.server;

import io.grpc.Context;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;

public final class TenantInterceptor implements ServerInterceptor {

    public static final Context.Key<String> TENANT_ID =
            Context.key("linkd.tenant");

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        Principal principal = AuthInterceptor.PRINCIPAL.get();
        if (principal == null) {
            throw io.grpc.Status.INTERNAL
                    .withDescription("auth interceptor not installed")
                    .asRuntimeException();
        }
        String tenant = TenantGuard.require(headers, principal);
        Context context = Context.current().withValue(TENANT_ID, tenant);
        ServerCall.Listener<ReqT> delegate =
                context.call(() -> next.startCall(call, headers));
        return new ContextualListener<>(context, delegate);
    }
}

两个细节:

  • 拦截器顺序不能反。TenantInterceptor 依赖 AuthInterceptor.PRINCIPAL,注册时要先 intercept(auth) 再 intercept(tenant)。
  • Context 只覆盖 gRPC 回调线程。业务代码如果把工作丢进自己的线程池,TENANT_ID.get() 会是 null。要么把 tenant 作为参数显式传下去,要么用 Context.current() 捕获后在任务里 attach;不要假设线程池自动继承。

在 linkd-v3 里落地

getBook 的完整路径是:读租户 → 带租户查询 → 判角色与属性 → 返回或拒绝。下面这段把四步串起来:

package com.plume.linkd.v3.server;

import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;

public final class LibraryServiceImpl extends LibraryGrpc.LibraryImplBase {

    private final BookRepository repository;
    private final Authorizer authorizer;

    public LibraryServiceImpl(BookRepository repository, Authorizer authorizer) {
        this.repository = repository;
        this.authorizer = authorizer;
    }

    @Override
    public void getBook(GetBookRequest request, StreamObserver<Book> observer) {
        String tenant = TenantInterceptor.TENANT_ID.get();
        Principal principal = AuthInterceptor.PRINCIPAL.get();
        if (tenant == null || principal == null) {
            observer.onError(Status.INTERNAL
                    .withDescription("missing request context")
                    .asRuntimeException());
            return;
        }
        BookRecord record = repository.findByIsbn(tenant, request.getIsbn());
        if (record == null) {
            observer.onError(bookNotFound());
            return;
        }
        Authorizer.Decision decision = authorizer.decide(
                principal,
                Authorizer.Action.READ_BOOK,
                new Authorizer.Resource(record.tenantId(),
                        record.ownerSubject(), record.classification()));
        if (!decision.allowed()) {
            observer.onError(Status.PERMISSION_DENIED
                    .withDescription(decision.reason())
                    .asRuntimeException());
            return;
        }
        observer.onNext(record.toProto());
        observer.onCompleted();
    }

    private static io.grpc.StatusRuntimeException bookNotFound() {
        return Status.NOT_FOUND.withDescription("book not found")
                .asRuntimeException();
    }
}

真实实现里 bookNotFound() 也应当带 ErrorInfo(reason=BOOK_NOT_FOUND),与 1.2 的错误模型保持一致;这里为了聚焦授权,省略了 details 的构造。注意 repository.findByIsbn(tenant, isbn) 的签名:租户是第一个参数,不是可选过滤条件,这样调用方没有「忘记加租户」的写法。

审计日志与业务日志分开:每次拒绝记一条 decision=DENY,字段包含 tenant、sub、action、资源标识、reason、request_id,不包含 token。允许的请求不必逐条打印,但 ADMIN 的写操作要留痕。

常见错误与边界

错误一:只校验 header,不校验 token 里的租户。 调用方可以声称任意租户。两处都校验,且用同一个 tenant 向下传递。

错误二:先按主键查,再在内存里比较租户。 数据已经越界读出来了,缓存和日志都可能污染。租户条件必须进查询和缓存键。

错误三:跨租户资源返回 PERMISSION_DENIED。 这等于告诉攻击者「资源存在」。统一返回 NOT_FOUND,与不存在不可区分。

错误四:把角色判断放在客户端。 客户端隐藏按钮不是授权。服务端每个入口都要判,批量接口和导出接口尤其容易漏。

错误五:ADMIN 绕过租户检查。 管理员是「本租户内权限更大」,不是「跨租户」。把租户检查放在角色判断之前,顺序不能反。

错误六:异步线程里读 Context 得到 null,然后默认放行。 空上下文必须按 INTERNAL 拒绝。10.3 的所有判定都要求上下文完整,缺一个就失败关闭。

边界一:RBAC 表达不了行级权限。 「读者只能看自己借过的书」这类规则需要 ABAC 或数据层策略,不要在角色表里堆几十个角色。

边界二:租户隔离不等于数据库隔离。 共享库、共享 schema 时,应用层过滤是最后一道,不是唯一一道。高敏感租户用独立 schema 或行级安全策略,应用层仍然保留同样的检查。

边界三:枚举防护会牺牲一点可观测性。 对外统一 NOT_FOUND 后,排障要靠内部审计日志区分「不存在」与「越权」。这是有意为之:对外不泄露,对内可追溯。

边界四:策略变更需要缓存失效。 如果角色或资源分类被缓存,撤权不会立刻生效。给授权缓存设短 TTL,并在角色变更时主动失效;不要缓存「允许」结果超过一个业务周期。

小结

  • x-tenant-id 必填、单值、格式受限,并且必须与 token 的 tenant 一致;不一致返回 PERMISSION_DENIED + ErrorInfo(TENANT_FORBIDDEN)。
  • 授权分两层:RBAC 判断角色能做什么,ABAC 判断具体资源的租户、归属和分类;租户检查永远在角色判断之前。
  • 租户条件下推到查询与缓存键;跨租户资源统一返回 NOT_FOUND,避免 ID 枚举。
  • 租户通过 Context 传给服务方法,拦截器顺序是认证在前、租户在后;异步任务要显式传递,缺上下文就失败关闭。
  • 审计日志记录 tenant、sub、action、资源标识、reason、request_id,不记录 token。
  • 应用层隔离是必要但不充分的一道;高敏感租户要配合独立 schema 或行级安全。

安全的三节到这里收口:链路加密、调用者认证、资源授权。下一章转向可观测性,先回答「用什么口径描述服务的健康」,再回答「单个请求出错时怎么把它串起来」。

阅读导航:上一节 10.2 Token 传播与校验 | 下一节 11.1 指标口径与 Prometheus 文本

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收