本节目标:把「租户」从一句口号变成请求路径上的硬约束:
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 片段时产生歧义。
校验分四步:
- 缺失 → 拒绝;
- 多值 → 拒绝(代理拼接会造成取第一个还是最后一个的歧义);
- 格式不合法 → 拒绝;
- 与
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_DENIED | TENANT_FORBIDDEN | 不涉及资源 |
| 本租户内角色不够 | PERMISSION_DENIED | TENANT_FORBIDDEN | 资源存在性已知 |
| 资源属于其他租户 | NOT_FOUND | BOOK_NOT_FOUND | 不暴露 |
| 资源确实不存在 | NOT_FOUND | BOOK_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 文本
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。