Когда-то мы делали сотни ненужных дашбордов.
Дашборды статичны, в лучшем случае дают пофильтровать данные, а как быть, если у заказчика меняются потребности и ему становятся интересны другие группировки и метрики из датасета? А если примерно этот же датасет, но немного отличающийся нужен ещё сотне заказчиков? И при этом каждый должен видеть свои столбцы и свои строки.
Сперва мы сделали интерактивный конструктор отчетов на базе Cube , позволяющий самостоятельно смотреть любые срезы, по сути окно к данным. А потом добавили в него мультитенантность. Первое слишком просто, поэтому пост про второе.
Задача
- Клиент видит только разрешенные строки
- Клиент видит общий набор полей в датасете, а также дополнительные из своего домена
Реализация
RLS в кубе раньше решалась через queryRewrite(), а сейчас прописывается в access_policy. Тут ничего сложного, хоть и не без нюансов. А вот для второго потребуется наследовать и переопределять схемы в зависимости от тенанта.
Я как обычно пишу всё на Старшей Речи, но подозреваю, что для работы с кубом перспективнее парселтанг 🐍.
The Веб-приложение
Для аутентификации в кубе при отправке в него запросов через REST API передаётся токен. В этот токен мы засунем айди клиента (тенанта), можно и что-то ещё.
import jwt from 'jsonwebtoken'
import cube from '@cubejs-client/core'
const token = jwt.sign({ clientId }, CUBE_API_SECRET, {
expiresIn: '30d',
})
const cubeApi = cube(token, {
apiUrl: CUBE_API_URL
})
const resultSet = await cubeApi.load(query, {
castNumerics: true
})
В веб-приложении определенно должна быть возможность влиять на схему датасетов для клиентов, и тогда нам понадобятся два эндпоинта:
- /getSchema возвратит схему датасета для тенанта.
- /getSchemaVersion возвратит номер версии, чтобы куб понимал, обновилась ли схема, и надо ли вообще её запрашивать. Этот адрес куб будет бомбить постоянно, поэтому важно озаботиться кэшем. Например, с первым запросом достать
max(created_at)из базы и поместить в память, обновлять там значение вместе с обновлением схемы в UI, и брать оттуда по запросу куба.
The Куб
Когда куб получает запрос данных из веб-приложения, во-первых, он должен проверить токен и пополнить контекст.
// cube.js
var jwt = require('jsonwebtoken')
checkAuth: (ctx, token) => {
try {
let decoded = jwt.verify(token, process.env.CUBE_API_SECRET)
return { security_context: { clientId: decoded.clientId } }
} catch (e) {
throw new Error(e)
}
},
Перед тем как что-то считать, куб проверяет, не изменилась ли схема данных для тенанта. Тут у меня необычный fetch(), потому что мы в приложении используем Chord. Эта библиотека упрощает взаимодействие фронта и бэка и помогает с кэшированием.
// cube.js
schemaVersion: async ({ securityContext }) => {
let schemaVersion = 0
try {
const res =
await fetch(process.env.WEB_APP_ADDRESS + '/cube_methods_endpoint', {
method: 'POST', body: JSON.stringify({
"jsonrpc": "2.0",
"method": "CubeSchema.getSchemaVersion",
"params": [securityContext.clientId],
"id": 1
})
})
const json = await res.json()
schemaVersion = json.result
}
catch (e) {
console.log('== FAILED schemaVersion request ', e)
}
return schemaVersion
},
Скомпилированные схемы и версии куб должен где-то хранить, и, как я понимаю, для этого используется сущность приложения. Приложения должны быть разными для тенантов, поэтому зададим их исходя из принятого контекста.
// cube.js
contextToAppId: ({ securityContext }) => {
return `CUBE_APP_${securityContext.clientId}`
},
Наконец переходим к моделям! По ТЗ у всех моделей есть общая часть, для удобства я вынес её в темплейт, и это обычный куб.
// template.js
export const template = cube({
sql: '',
title: '',
data_source: '',
meta: {},
joins: {},
dimensions: {},
measures: {},
})
Этот темплейт я могу подтянуть в файле с логикой, и наследоваться от него с помощью extends. В модели можно задействовать асинхронные методы, обернув её в asyncModule(), ну и конкретно в моём кейсе из приложения импортируется не вся схема, а только уникальные для тенанта метрики с помощью getMeasures().
// multitenant_cube.js
import { template } from './templates'
import { getMeasures } from './utils'
asyncModule(async () => {
const {
securityContext: { user },
} = COMPILE_CONTEXT
measures = await getMeasures(clientId)
cube(`multitenant_cube`, {
extends: template
measures: {
...measures,
},
})
})
// utils.js
export const getMeasures = async () => {
const res = await fetch(WEB_APP_ADDRESS + '/cube_methods_endpoint', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'CubeSchema.getMeasures',
params: [clientId],
id: 1,
}),
})
const json = await res.json()
const measures = json.result.measures || []
return measures.reduce((raw, c) => {
m['conv' + c.id + '_total'] = {
sql: () => `arrayFirst(x -> x['id'] = ${c.id}, conversions)['total']`,
title: `(${c.id}) ${c.title})`,
type: 'sum',
}
return m
}, {})
}
Обратите внимание, что в атрибут sql передаётся именно функция. Само sql выражение не важно, у меня оно такое заковыристое в связи с особенностями хранения частных метрик в нашем ClickHouse.
В итоге для каждого тенанта мы получаем свой вариант multitenant_cube, который будет компилироваться на лету при необходимости. Для пользователя UI это происходит буквально за секунду.
The Политика
Осталось порешать с RLS, чтобы показывать клиентам только их часть данных из общего датасета. Можно указать роль *, но мы сделаем поинтереснее.
// multitenant_cube.js
access_policy: [
{
role: `khozyain`,
row_level: {
allow_all: true,
},
},
{
role: `gost`,
row_level: {
filters: [
{
or: [
{
member: `allowed_client_id`,
operator: `equals`,
values: [securityContext.clientId],
},
],
},
],
},
},
],
Выглядит спорно, но работает 😀. Сами роли тоже задаются по информации из контекста.
// cube.js
contextToRoles: ({ securityContext }) => {
let roles = []
if (securityContext.clientId === MAGIC_CONSTANT) {
roles.push('khozyain')
} else {
roles.push('gost')
}
return roles
},
Есть несколько нюансов, замеченные мной на момент ковыряния с access_policy (куб быстро развивается, может быть неактуально):
- member_level распространяется только на видимость в мете кубов и мне не пригодился
- в conditions нельзя писать выражения, только указывать конкретный признак типа boolean
- все проверки соединяются через OR, надо хорошо думать как писать
- при использовании extends правила access_policy надо выносить в верхний куб, из темплейта у меня не подтянулись. Возможно это было связано с тем, что я проверял признак, приходящий из другого куба через join
- нельзя указывать пути, например, member:
${clients.client_id} - если какая-то роль не указана в политиках, то она ничего не увидит
Эпилог
Самое сложное при реализации - тестирование. Приходится поднимать и куб и веб-приложение, логиниться под разными учетками. Изменения в cube.js требуют рестарта сервиса куба. Логи очень быстро спамятся в консоль, так что лишние модели лучше выкинуть. Помогает возможность подменять контекст в плейграунде по 4000 порту.
Ну и надо конечно проверки в коде добавлять 😅.