cube.js 多租户的实践
几个问题
安全
- 应该开启checkAuth 处理
同时基于此进行用户相关资源的配置(部分信息可以放jwt token 中,也可以基于用户配置后边查询)
module.exports = {
checkAuth: (req, auth) => {
console.log("authinfo")
console.log("jwt",auth)
if (auth) {
var decoded = jwt.verify(auth, "secret");
if (decoded) {
// 同时可以进行一些扩展操作,比如添加新的token信息
req.authInfo = decoded
}
}
else{
throw new Error(`Unauthorized`);
}
},
}
资源配置问题
主要是关于数据库链接、用户appid、用户schema查找定义,关于appid 信息主要是通过jwt 生成以及包含的
对于jwt 的信息,cube.js 会自动进行数据的处理,同时包含到请求上下文的authInfo
- 请求上下文的签名为
RequestContext {
authInfo: Object,
requestId: String
}
- appid 问题
appid在支持多租户的模型中有很重要的作用,对于schema 的获取以及schema 的编译处理都是依赖此appid的
部分参考代码
public getCompilerApi(context: RequestContext) {
const appId = this.contextToAppId(context);
let compilerApi = this.compilerCache.get(appId);
const currentSchemaVersion = this.options.schemaVersion && (() => this.options.schemaVersion(context));
if (!compilerApi) {
compilerApi = this.createCompilerApi(
this.repositoryFactory(context), {
dbType: (dataSourceContext) => this.contextToDbType({
externalDbType: this.contextToExternalDbType(context),
dialectClass: (dialectContext) => this.options.dialectFactory &&
this.options.dialectFactory({
externalDialectClass: this.options.externalDialectFactory && this.options.externalDialectFactory(context),
schemaVersion: currentSchemaVersion,
preAggregationsSchema: this.preAggregationsSchema(context),
context,
allowJsDuplicatePropsInSchema: this.options.allowJsDuplicatePropsInSchema
}
);
this.compilerCache.set(appId, compilerApi);
}
compilerApi.schemaVersion = currentSchemaVersion;
return compilerApi;
}
上下文appid 的处理, 可以参考此配置说明,可以基于用户id也可以通过jwt 扩展
module.exports = {
contextToAppId: ({ authInfo }) => `CUBEJS_APP_${authInfo.user_id}`,
};
注意此处官方的代码是有问题的,通过代码查看官方的用意应该很明确是定时预处理的,
依赖了一个scheduledRefreshContexts: async () => [null]
的配置,但是对于多租户处理有点问题
默认使用了一个,默认的一般是不配置contextToAppId,但是如果配置了之后就会依赖,因为没有
合理的进行数据状态存储,造成此对象的数据为空(也不是,至少有一个[null],问题代码
@cubejs-backend/server-core/src/core/server.ts (注意是当前版本)
if (scheduledRefreshTimer) {
this.scheduledRefreshTimerInterval = shared_1.createCancelableInterval(async () => {
const contexts = await options.scheduledRefreshContexts();
console.log(contexts,"from core server context")
if (contexts.length < 1) {
this.logger('Refresh Scheduler Error', {
error: 'At least one context should be returned by scheduledRefreshContexts'
});
}
await Promise.all(contexts.map(async (context) => {
const queryingOptions = { concurrency: options.scheduledRefreshConcurrency };
if (options.scheduledRefreshTimeZones) {
queryingOptions.timezones = options.scheduledRefreshTimeZones;
}
await this.runScheduledRefresh(context, queryingOptions);
}));
}, {
interval: scheduledRefreshTimer,
onDuplicatedExecution: (intervalId) => this.logger('Refresh Scheduler Interval Error', {
error: `Previous interval #${intervalId} was not finished with ${scheduledRefreshTimer} interval`
}),
onDuplicatedStateResolved: (intervalId, elapsed) => this.logger('Refresh Scheduler Long Execution', {
warning: `Interval #${intervalId} finished after ${shared_1.formatDuration(elapsed)}`
})
});
}
解决方法
1. 固定配置几个预定的参数,关于多租户appid 以及schema 关联信息的
2. 基于API 进行数据的统一管理
3. 手工进行runScheduledRefresh 的处理
固定模式参考
module.exports = {
devServer: false,
dbType: ({ dataSource } = {}) => {
return 'postgres';
},
contextToAppId: ({ authInfo }) => {
console.log("contextToAppId:", authInfo)
return `CUBEJS_APP_${authInfo.myappid}`
},
contextToOrchestratorId: ({ authInfo }) => {
console.log("contextToOrchestratorId:", authInfo)
return `CUBEJS_APP_${authInfo.myappid}`
},
scheduledRefreshContexts: async () => {
// 固定的几个app信息,同时关联了schema 信息(基于u,可以自己扩展)
return [{
authInfo: {
myappid: "demoappid",
u:{
bucket: "demo"
}
}
}]
},
preAggregationsSchema: ({ authInfo }) => `pre_aggregations_${authInfo.myappid}`,
- 一个参考实现
jwt token demo (注意jwt payload 的格式很重要)
const jwt = require('jsonwebtoken');
const CUBE_API_SECRET = 'b2db7688e328d316d85e924d8b9a0737d87162a9f2cf36325f1ca0ae08dbdaa990520750847226cf8dcbb1fb4c07afe1087c7cb03b8f9f05b9abad3eb4058f3f';
const cubejsToken = jwt.sign({ u: { user_id: 42 ,bucket:"demo"},myappid:"demoappid" }, CUBE_API_SECRET, {
expiresIn: '30d',
});
const cubejsToken2 = jwt.sign({ u: { user_id: 43 ,bucket:"demo2"},myappid:"demoappid2" }, CUBE_API_SECRET, {
expiresIn: '30d',
});
console.log(cubejsToken)
console.log(cubejsToken2)
cube.js 配置(基于appid支持多租户)
// Cube.js configuration options: https://cube.dev/docs/config
const PostgresDriver = require("@cubejs-backend/postgres-driver");
const CubeStoreDriver = require("@cubejs-backend/cubestore-driver")
const myS3FileRepository = require("@dalongrong/cube-s3repository")
const fetch = require('node-fetch');
const jwt = require('jsonwebtoken');
module.exports = {
devServer: false,
dbType: ({ dataSource } = {}) => {
return 'postgres';
},
// 基于token 获取的数据,请求header Authorization
contextToAppId: ({ authInfo }) => {
return `CUBEJS_APP_${authInfo.myappid}`
},
// 基于token 获取的数据,请求header Authorization
contextToOrchestratorId: ({ authInfo }) => {
return `CUBEJS_APP_${authInfo.myappid}`
},
// 此处解决官方的bug 问题,造成scheuder 数据异常
scheduledRefreshContexts: async () => {
return [{
authInfo: {
myappid: "demoappid",
u: {
bucket: "demo"
}
}
},
{
authInfo: {
myappid: "demoappid2",
u: {
bucket: "demo2"
}
}
}]
},
preAggregationsSchema: ({ authInfo }) => {
return `pre_aggregations_${authInfo.myappid}`
},
checkAuth: (req, auth) => {
console.log("checkAuth=====",auth)
if (auth) {
var decoded = jwt.verify(auth, "b2db7688e328d316d85e924d8b9a0737d87162a9f2cf36325f1ca0ae08dbdaa990520750847226cf8dcbb1fb4c07afe1087c7cb03b8f9f05b9abad3eb4058f3f");
if (decoded) {
req.authInfo = decoded
}
}
else {
throw new Error(`Unauthorized`);
}
},
telemetry: false,
apiSecret: "b2db7688e328d316d85e924d8b9a0737d87162a9f2cf36325f1ca0ae08dbdaa990520750847226cf8dcbb1fb4c07afe1087c7cb03b8f9f05b9abad3eb4058f3f",
driverFactory: ({ dataSource } = {}) => {
return new PostgresDriver({
user: "postgres",
database: "postgres",
password: "dalong",
port: 5432,
host: "127.0.0.1",
readOnly: true
});
},
repositoryFactory: ({ authInfo }) => {
// 不同租户使用不同的s3 bucket
console.log("repositoryFactory=====",authInfo);
if (authInfo && authInfo.u.bucket) {
return new myS3FileRepository.S3FileRepository(authInfo.u.bucket)
}
return new myS3FileRepository.S3FileRepository()
},
externalDbType: 'cubestore',
externalDriverFactory: () => new CubeStoreDriver({
user: "root",
port: 3306,
host: "127.0.0.1"
})
};
其他模式的多租户数据处理
- 相同数据库&&不同schema
可以基于USER_CONTEXT 进行扩展,同时支持row级别的数据访问控制
cube(`Products`, {
sql: `select * from products where ${USER_CONTEXT.categoryId.filter('categoryId')}`
})
COMPILE_CONTEXT 可以支持不同的数据库模式
const { authInfo: { tenantId } } = COMPILE_CONTEXT;
cube(`Products`, {
sql: `select * from ${tenantId}.products`
})
- 不同数据库的支持
这个我们可以基于appid 模式解决,使用不同的数据库驱动
说明
目前的多租户在数据预聚合调度上是点问题的,因为内部调度处理依赖了scheduledRefreshContexts,但是代码处理有异常
参考资料
https://cube.dev/docs/multitenancy-setup
https://cube.dev/docs/cube#context-variables-user-context
https://cube.dev/docs/security
https://cube.dev/docs/deployment/production-checklist