在设计多租户系统时,最常见的隔离方法是在数据表的每一行增加一个 tenant_id
字段。所有的数据查询都必须带上 WHERE tenant_id = ?
的子句。这种方式简单直接,但在实践中极为脆弱。一个疏忽的后端开发者,一次忘记添加 WHERE
条件的复杂联表查询,就可能导致灾难性的数据泄露。这种依赖于“人不出错”的隔离机制,在安全层面是不可接受的。
我们的技术痛点很明确:需要一种即使在应用程序代码出现逻辑漏洞时,也能保证租户数据不被越权访问的强隔离机制。思路从逻辑隔离转向了物理或密码学隔离。最终,我们选择了一个被称为“加密分片”(Crypto-Sharding)的方案:为每个租户生成一个独立的数据加密密钥(DEK),该租户的所有持久化数据都使用其专属密钥加密。这样一来,即使查询逻辑出错,取到了属于其他租户的加密数据,由于没有正确的密钥,也无法解密,数据依然安全。
这个方案的核心在于一个高性能、高安全性的密钥管理与加解密服务。Rust 凭借其内存安全、无GC的特性,成为构建这个安全核心的理想选择。我们使用 Tonic 框架来构建基于 gRPC 的服务,它能提供强类型、高性能的跨语言通信能力。前端则需要一个能够清晰、响应式地管理当前会话上下文(如 tenant_id
)的状态管理库,Valtio 以其极简的 Proxy 模型和对 React Hooks 的良好支持,成为我们的首选。
架构设计与技术选型决策
整个系统的核心是一个 gRPC 服务,我们称之为 CryptoShardingService
。它负责处理两件关键的事情:
- 密钥管理:为新租户创建、存储和管理其专属的 DEK。在真实项目中,这部分会与硬件安全模块(HSM)或云厂商的 KMS(如 AWS KMS, Google Cloud KMS)集成。为了演示,我们将在服务内部实现一个简化的、内存中的密钥管理器。
- 数据加解密:提供 RPC 接口,接收明文数据和
tenant_id
,在服务端使用该租户的 DEK 进行加密后返回密文;反之,接收密文和tenant_id
,解密后返回明文。
前端应用(React)通过 gRPC-Web 与后端通信。用户登录后,其 tenant_id
和认证凭证将被存储在 Valtio 的全局状态中。所有后续的数据操作请求,都会自动从 Valtio store 中获取 tenant_id
,并发送给 CryptoShardingService
。
下面是整个流程的交互图:
sequenceDiagram participant FE as React App (Valtio Store) participant GW as gRPC-Web Proxy participant BE as Tonic Service (Rust) participant KMS as Key Management Service (In-Memory) participant DB as Database FE->>BE: 1. Login Request Note over BE: Authenticate user, get tenant_id BE->>FE: 2. Return auth_token, tenant_id Note over FE: Store context in Valtio Proxy state FE->>GW: 3. SetData(tenant_id, plaintext_data) GW->>BE: 4. gRPC SetData Request BE->>KMS: 5. Get DEK for tenant_id KMS-->>BE: 6. Return Tenant's DEK Note over BE: Encrypt plaintext_data with DEK BE->>DB: 7. Store encrypted_data DB-->>BE: 8. Confirm storage BE-->>GW: 9. Return Success GW-->>FE: 10. Confirm Success FE->>GW: 11. GetData(tenant_id, data_reference) GW->>BE: 12. gRPC GetData Request BE->>DB: 13. Fetch encrypted_data DB-->>BE: 14. Return encrypted_data BE->>KMS: 15. Get DEK for tenant_id KMS-->>BE: 16. Return Tenant's DEK Note over BE: Decrypt encrypted_data with DEK BE-->>GW: 17. Return plaintext_data GW-->>FE: 18. Return plaintext_data
第一步:定义 gRPC 服务接口
我们需要一个 .proto
文件来定义服务契约。这是前后端通信的基础。
proto/crypto_sharding.proto
:
syntax = "proto3";
package crypto_sharding;
// 核心数据服务
service TenantDataService {
// 创建一个新租户,并为其生成一个加密密钥
rpc CreateTenant(CreateTenantRequest) returns (CreateTenantResponse);
// 加密并存储一个租户的数据
rpc SetData(SetDataRequest) returns (SetDataResponse);
// 获取并解密一个租户的数据
rpc GetData(GetDataRequest) returns (GetDataResponse);
}
message CreateTenantRequest {
string tenant_name = 1;
}
message CreateTenantResponse {
string tenant_id = 1;
}
message SetDataRequest {
// 必须提供租户ID以确定使用哪个密钥
string tenant_id = 1;
// 数据的唯一标识符,用于后续检索
string data_key = 2;
// 待加密的原始数据
bytes raw_data = 3;
}
message SetDataResponse {
// 确认操作成功
bool success = 1;
}
message GetDataRequest {
string tenant_id = 1;
string data_key = 2;
}
message GetDataResponse {
// 解密后的原始数据
bytes raw_data = 1;
}
这里的关键点在于 SetDataRequest
和 GetDataResponse
中的 raw_data
字段类型是 bytes
。这意味着我们的服务是数据无关的,它可以加密任何二进制数据,无论是 JSON 字符串、图片还是其他文件。
第二步:实现 Tonic 后端服务
这是整个系统的核心。我们将使用 Rust 构建这个 gRPC 服务。
项目结构:
.
├── Cargo.toml
├── build.rs
├── proto/
│ └── crypto_sharding.proto
└── src/
├── server.rs
└── crypto.rs
Cargo.toml
依赖项:
[package]
name = "crypto-sharding-service"
version = "0.1.0"
edition = "2021"
[dependencies]
tonic = "0.10"
prost = "0.12"
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
uuid = { version = "1.6", features = ["v4"] }
aes-gcm = "0.10.3"
rand = "0.8.5"
hex = "0.4.3"
dashmap = "5.5.3" # 用于线程安全的内存存储
thiserror = "1.0.50"
anyhow = "1.0.75"
log = "0.4"
env_logger = "0.10"
config = "0.13"
[build-dependencies]
tonic-build = "0.10"
build.rs
文件用于在编译时从 .proto
文件生成 Rust 代码:
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(true)
.build_client(false) // 我们只在这个 crate 中构建服务端
.compile(&["proto/crypto_sharding.proto"], &["proto/"])?;
Ok(())
}
加密与密钥管理模块 (src/crypto.rs
)
这个模块封装了所有密码学操作和密钥存储。在生产环境中,KeyManagementService
会与外部 KMS 通信。这里我们用 DashMap
模拟一个线程安全的内存数据库来存储租户ID到加密密钥的映射。
// src/crypto.rs
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Nonce, Key,
};
use dashmap::DashMap;
use std::sync::Arc;
use thiserror::Error;
use uuid::Uuid;
const KEY_SIZE: usize = 32; // 256 bits for AES-256
const NONCE_SIZE: usize = 12; // 96 bits for GCM
#[derive(Error, Debug)]
pub enum CryptoError {
#[error("Encryption failed: {0}")]
EncryptionError(String),
#[error("Decryption failed: {0}")]
DecryptionError(String),
#[error("Key not found for tenant: {0}")]
KeyNotFound(String),
}
// 定义一个类型别名,方便使用
type TenantKey = Key<Aes256Gcm>;
// 模拟的密钥管理服务
#[derive(Clone, Debug, Default)]
pub struct KeyManagementService {
// 使用 DashMap 来实现线程安全的并发读写
// Key: tenant_id (String)
// Value: Tenant's Data Encryption Key (DEK)
tenant_keys: Arc<DashMap<String, TenantKey>>,
}
impl KeyManagementService {
pub fn new() -> Self {
Self {
tenant_keys: Arc::new(DashMap::new()),
}
}
/// 为新租户生成并存储一个新的DEK
pub fn generate_key_for_tenant(&self, tenant_id: &str) {
let key = Aes256Gcm::generate_key(&mut OsRng);
self.tenant_keys.insert(tenant_id.to_string(), key);
log::info!("Generated and stored new DEK for tenant_id: {}", tenant_id);
}
/// 根据租户ID获取其DEK
fn get_key(&self, tenant_id: &str) -> Result<TenantKey, CryptoError> {
self.tenant_keys
.get(tenant_id)
.map(|kv| *kv.value())
.ok_or_else(|| CryptoError::KeyNotFound(tenant_id.to_string()))
}
}
// 核心加解密逻辑
#[derive(Clone)]
pub struct CryptoService {
kms: KeyManagementService,
}
impl CryptoService {
pub fn new(kms: KeyManagementService) -> Self {
Self { kms }
}
/// 使用指定租户的密钥加密数据
/// 返回的 Vec<u8> 结构是: [nonce (12 bytes) || ciphertext]
pub fn encrypt(&self, tenant_id: &str, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let key = self.kms.get_key(tenant_id)?;
let cipher = Aes256Gcm::new(&key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| CryptoError::EncryptionError(e.to_string()))?;
// 将 nonce 和密文拼接在一起存储,解密时需要
let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
result.extend_from_slice(nonce.as_slice());
result.extend_from_slice(&ciphertext);
Ok(result)
}
/// 使用指定租户的密钥解密数据
pub fn decrypt(&self, tenant_id: &str, encrypted_data: &[u8]) -> Result<Vec<u8>, CryptoError> {
if encrypted_data.len() < NONCE_SIZE {
return Err(CryptoError::DecryptionError("Invalid encrypted data length".to_string()));
}
let key = self.kms.get_key(tenant_id)?;
let cipher = Aes256Gcm::new(&key);
// 从存储的数据中分离 nonce 和 ciphertext
let (nonce_bytes, ciphertext) = encrypted_data.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| CryptoError::DecryptionError(e.to_string()))?;
Ok(plaintext)
}
}
gRPC 服务实现 (src/server.rs
)
这里我们将实现 .proto
文件中定义的服务。
// src/server.rs
use tonic::{transport::Server, Request, Response, Status};
use uuid::Uuid;
use dashmap::DashMap;
use std::sync::Arc;
use std::net::SocketAddr;
mod crypto;
use crypto::{CryptoService, KeyManagementService, CryptoError};
// 引入自动生成的代码
pub mod crypto_sharding {
tonic::include_proto!("crypto_sharding");
}
use crypto_sharding::{
tenant_data_service_server::{TenantDataService, TenantDataServiceServer},
CreateTenantRequest, CreateTenantResponse,
SetDataRequest, SetDataResponse,
GetDataRequest, GetDataResponse,
};
// 模拟一个持久化存储,存储加密后的数据
// Key: data_key, Value: encrypted_data
type TenantDB = Arc<DashMap<String, Vec<u8>>>;
#[derive(Debug)]
pub struct MyTenantDataService {
crypto_service: CryptoService,
// Key: tenant_id, Value: TenantDB
// 每个租户有自己的数据 "命名空间"
storage: Arc<DashMap<String, TenantDB>>,
kms: KeyManagementService,
}
impl MyTenantDataService {
fn new(crypto_service: CryptoService, kms: KeyManagementService) -> Self {
Self {
crypto_service,
storage: Arc::new(DashMap::new()),
kms,
}
}
}
// 实现 gRPC 服务 trait
#[tonic::async_trait]
impl TenantDataService for MyTenantDataService {
async fn create_tenant(
&self,
request: Request<CreateTenantRequest>,
) -> Result<Response<CreateTenantResponse>, Status> {
let tenant_name = &request.get_ref().tenant_name;
let tenant_id = Uuid::new_v4().to_string();
log::info!("Creating new tenant '{}' with id: {}", tenant_name, tenant_id);
// 为新租户生成密钥
self.kms.generate_key_for_tenant(&tenant_id);
// 为新租户创建数据存储空间
self.storage.insert(tenant_id.clone(), Arc::new(DashMap::new()));
let response = CreateTenantResponse { tenant_id };
Ok(Response::new(response))
}
async fn set_data(
&self,
request: Request<SetDataRequest>,
) -> Result<Response<SetDataResponse>, Status> {
let req = request.into_inner();
log::info!("SetData request for tenant_id: {}, data_key: {}", req.tenant_id, req.data_key);
let tenant_storage = self.storage.get(&req.tenant_id).ok_or_else(|| {
log::error!("Attempted to set data for non-existent tenant: {}", req.tenant_id);
Status::not_found(format!("Tenant with id {} not found", req.tenant_id))
})?;
// 核心加密逻辑
let encrypted_data = self.crypto_service.encrypt(&req.tenant_id, &req.raw_data)
.map_err(|e| match e {
CryptoError::KeyNotFound(_) => Status::not_found(e.to_string()),
_ => Status::internal(e.to_string()),
})?;
// 存储密文
tenant_storage.insert(req.data_key, encrypted_data);
Ok(Response::new(SetDataResponse { success: true }))
}
async fn get_data(
&self,
request: Request<GetDataRequest>,
) -> Result<Response<GetDataResponse>, Status> {
let req = request.into_inner();
log::info!("GetData request for tenant_id: {}, data_key: {}", req.tenant_id, req.data_key);
let tenant_storage = self.storage.get(&req.tenant_id).ok_or_else(|| {
log::error!("Attempted to get data for non-existent tenant: {}", req.tenant_id);
Status::not_found(format!("Tenant with id {} not found", req.tenant_id))
})?;
let encrypted_data = tenant_storage.get(&req.data_key).ok_or_else(|| {
Status::not_found(format!("Data with key {} not found for tenant {}", req.data_key, req.tenant_id))
})?;
// 核心解密逻辑
let raw_data = self.crypto_service.decrypt(&req.tenant_id, &encrypted_data)
.map_err(|e| {
log::error!("Decryption failed for tenant {}: {}", req.tenant_id, e);
Status::internal("Decryption failed. Data might be corrupted or key is incorrect.")
})?;
Ok(Response::new(GetDataResponse { raw_data }))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let addr: SocketAddr = "127.0.0.1:50051".parse()?;
let kms = KeyManagementService::new();
let crypto_service = CryptoService::new(kms.clone());
let service = MyTenantDataService::new(crypto_service, kms);
let server = TenantDataServiceServer::new(service);
log::info!("Crypto Sharding Service listening on {}", addr);
Server::builder()
.add_service(server)
.serve(addr)
.await?;
Ok(())
}
这个实现包含了完整的错误处理。例如,如果请求一个不存在的租户,会返回 Status::not_found
。如果解密失败(可能是因为数据损坏或密钥错误),会返回 Status::internal
,并记录详细的错误日志。这是生产级代码的基本要求。
第三步:构建 React 前端与 Valtio 状态管理
现在我们转向前端。我们需要设置 gRPC-Web 客户端,并使用 Valtio 来管理应用的全局状态,特别是认证和租户上下文。
项目初始化与依赖安装:
npx create-react-app crypto-sharding-ui --template typescript
cd crypto-sharding-ui
npm install valtio @improbable-eng/grpc-web protobuf google-protobuf ts-protoc-gen
生成 gRPC-Web 客户端代码:
我们需要从 .proto
文件生成 TypeScript 客户端代码。
protoc \
--plugin="protoc-gen-ts=./node_modules/.bin/protoc-gen-ts" \
--js_out="import_style=commonjs,binary:./src/proto" \
--ts_out="service=grpc-web:./src/proto" \
../proto/crypto_sharding.proto
Valtio 状态管理 (src/store.ts
)
我们将创建一个简单的 store 来存放当前用户的会话信息。Valtio 的美妙之处在于其简洁性,你只需导出一个普通的对象,它就会被 proxy
包装成响应式状态。
// src/store.ts
import { proxy } from 'valtio';
interface AuthState {
isAuthenticated: boolean;
tenantId: string | null;
tenantName: string | null;
authToken: string | null; // In a real app, this would be a JWT or similar
}
export const authStore = proxy<AuthState>({
isAuthenticated: false,
tenantId: null,
tenantName: null,
authToken: null,
});
// 定义一些 action 来修改 state,这不是必须的,但有助于组织代码
export const authActions = {
login: (tenantId: string, tenantName: string, token: string) => {
authStore.isAuthenticated = true;
authStore.tenantId = tenantId;
authStore.tenantName = tenantName;
authStore.authToken = token;
},
logout: () => {
authStore.isAuthenticated = false;
authStore.tenantId = null;
authStore.tenantName = null;
authStore.authToken = null;
},
};
API 客户端封装 (src/apiClient.ts
)
封装 gRPC 调用,使其更易于在组件中使用。
// src/apiClient.ts
import { grpc } from '@improbable-eng/grpc-web';
import { TenantDataServiceClient } from './proto/crypto_sharding_pb_service';
import {
CreateTenantRequest,
SetDataRequest,
GetDataRequest,
} from './proto/crypto_sharding_pb';
// 假设 gRPC-Web 代理运行在 8080 端口
const host = "http://localhost:8080";
export const client = new TenantDataServiceClient(host);
export const api = {
createTenant: async (tenantName: string): Promise<string> => {
const request = new CreateTenantRequest();
request.setTenantName(tenantName);
return new Promise((resolve, reject) => {
client.createTenant(request, (err, response) => {
if (err) {
return reject(err);
}
if (response) {
resolve(response.getTenantId());
} else {
reject(new Error("No response received"));
}
});
});
},
setData: async (tenantId: string, key: string, data: Uint8Array): Promise<boolean> => {
const request = new SetDataRequest();
request.setTenantId(tenantId);
request.setDataKey(key);
request.setRawData(data);
return new Promise((resolve, reject) => {
client.setData(request, (err, response) => {
if (err) return reject(err);
resolve(response?.getSuccess() || false);
});
});
},
getData: async (tenantId: string, key: string): Promise<Uint8Array> => {
const request = new GetDataRequest();
request.setTenantId(tenantId);
request.setDataKey(key);
return new Promise((resolve, reject) => {
client.getData(request, (err, response) => {
if (err) return reject(err);
resolve(response?.getRawData_asU8() || new Uint8Array());
});
});
},
};
React 组件实现 (src/App.tsx
)
这个组件将演示创建租户、设置和获取数据的完整流程。它使用 useSnapshot
hook 来订阅 Valtio store 的变化,当 authStore
中的状态改变时,组件会自动重新渲染。
// src/App.tsx
import React, { useState, useEffect } from 'react';
import { useSnapshot } from 'valtio';
import { authStore, authActions } from './store';
import { api } from './apiClient';
import { TextEncoder, TextDecoder } from 'text-encoding'; // Polyfill for some environments
function App() {
const authSnap = useSnapshot(authStore);
const [tenantNameToCreate, setTenantNameToCreate] = useState('');
const [dataKey, setDataKey] = useState('profile');
const [dataValue, setDataValue] = useState('{"name": "Alice", "role": "admin"}');
const [retrievedData, setRetrievedData] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const handleCreateTenant = async () => {
if (!tenantNameToCreate) return;
try {
setStatusMessage(`Creating tenant ${tenantNameToCreate}...`);
const tenantId = await api.createTenant(tenantNameToCreate);
// 模拟登录
authActions.login(tenantId, tenantNameToCreate, 'fake-jwt-token');
setStatusMessage(`Tenant created and logged in! ID: ${tenantId}`);
} catch (error: any) {
setStatusMessage(`Error: ${error.message}`);
}
};
const handleSetData = async () => {
if (!authSnap.tenantId || !dataKey || !dataValue) return;
try {
setStatusMessage('Encrypting and setting data...');
// 必须将字符串转换为 Uint8Array
const encoder = new TextEncoder();
const rawData = encoder.encode(dataValue);
await api.setData(authSnap.tenantId, dataKey, rawData);
setStatusMessage(`Data for key '${dataKey}' stored successfully.`);
} catch (error: any) {
setStatusMessage(`Error: ${error.message}`);
}
};
const handleGetData = async () => {
if (!authSnap.tenantId || !dataKey) return;
try {
setStatusMessage('Fetching and decrypting data...');
const rawData = await api.getData(authSnap.tenantId, dataKey);
// 将返回的 Uint8Array 转换回字符串
const decoder = new TextDecoder();
const decodedString = decoder.decode(rawData);
setRetrievedData(decodedString);
setStatusMessage(`Data for key '${dataKey}' retrieved.`);
} catch (error: any) {
setRetrievedData('');
setStatusMessage(`Error: ${error.message}`);
}
};
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Crypto-Sharding Demo</h1>
<hr />
{!authSnap.isAuthenticated ? (
<div>
<h2>1. Create a Tenant</h2>
<input
type="text"
placeholder="Tenant Name (e.g., acme-corp)"
value={tenantNameToCreate}
onChange={(e) => setTenantNameToCreate(e.target.value)}
/>
<button onClick={handleCreateTenant}>Create and Login</button>
</div>
) : (
<div>
<h2>Welcome, {authSnap.tenantName} (ID: {authSnap.tenantId})</h2>
<button onClick={authActions.logout}>Logout</button>
<hr />
<h2>2. Set Tenant Data (JSON)</h2>
<div>
<label>Data Key: </label>
<input value={dataKey} onChange={e => setDataKey(e.target.value)} />
</div>
<div>
<label>Data Value: </label>
<textarea
rows={3}
cols={50}
value={dataValue}
onChange={e => setDataValue(e.target.value)}
/>
</div>
<button onClick={handleSetData}>Set Data</button>
<hr />
<h2>3. Get Tenant Data</h2>
<div>
<label>Data Key to Get: </label>
<input value={dataKey} onChange={e => setDataKey(e.target.value)} />
</div>
<button onClick={handleGetData}>Get Data</button>
<h3>Retrieved & Decrypted Data:</h3>
<pre style={{ border: '1px solid #ccc', padding: '10px', background: '#f5f5f5' }}>
{retrievedData || '(no data retrieved yet)'}
</pre>
</div>
)}
<hr />
<h3>Status Log:</h3>
<p><i>{statusMessage}</i></p>
</div>
);
}
export default App;
这个前端应用清晰地展示了整个流程。当用户未登录时,只能创建租户。创建成功后,authStore
被更新,isAuthenticated
变为 true
,UI 响应式地切换到数据操作界面。所有的操作都强依赖于 authStore.tenantId
,确保了请求的上下文正确性。
方案的局限性与未来迭代路径
我们实现的这套基于 Tonic 和 Valtio 的加密分片架构,有效地将租户数据隔离从应用逻辑层面提升到了密码学层面。然而,这并非一个没有代价的银弹。
首先,当前实现的密钥管理是内存级的,这在生产环境中是完全不可接受的。下一步必须将其替换为与专业 KMS 系统的集成,例如 HashiCorp Vault 或云厂商提供的服务。这会引入网络延迟和额外的成本,但却是保障密钥安全的必要步骤。
其次,所有数据都加密存储,这意味着我们失去了在数据库层面进行查询、索引和聚合的能力。对加密数据的任何操作都必须先将数据拉取到服务端,解密后再在内存中处理,这对于大数据量的分析场景是低效的。解决方案包括可搜索加密(Searchable Encryption)或为需要索引的字段保留明文或哈希索引,但这会削弱一部分安全性,需要在具体业务场景中进行权衡。
最后,密钥轮换(Key Rotation)是一个未被覆盖的关键安全实践。需要设计一套机制,能够定期更换每个租户的DEK,并将旧数据用新密钥重新加密,或者采用信封加密的变体来简化这个过程。这增加了系统的复杂性,但对长期的数据安全至关重要。