Вместе веселее
Тенанты в Cube
Содержание

Когда-то мы делали сотни ненужных дашбордов.
Дашборды статичны, в лучшем случае дают пофильтровать данные, а как быть, если у заказчика меняются потребности и ему становятся интересны другие группировки и метрики из датасета? А если примерно этот же датасет, но немного отличающийся нужен ещё сотне заказчиков? И при этом каждый должен видеть свои столбцы и свои строки.
Сперва мы сделали интерактивный конструктор отчетов на базе 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.jsvar 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.jsschemaVersion: 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.jscontextToAppId: ({ securityContext }) => { return `CUBE_APP_${securityContext.clientId}`},
Наконец переходим к моделям! По ТЗ у всех моделей есть общая часть, для удобства я вынес её в темплейт, и это обычный куб.
// template.jsexport const template = cube({ sql: '', title: '', data_source: '', meta: {}, joins: {}, dimensions: {}, measures: {},})
Этот темплейт я могу подтянуть в файле с логикой, и наследоваться от него с помощью extends. В модели можно задействовать асинхронные методы, обернув её в asyncModule(), ну и конкретно в моём кейсе из приложения импортируется не вся схема, а только уникальные для тенанта метрики с помощью getMeasures().
// multitenant_cube.jsimport { 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.jsexport 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.jsaccess_policy: [ { role: `khozyain`, row_level: { allow_all: true, }, }, { role: `gost`, row_level: { filters: [ { or: [ { member: `allowed_client_id`, operator: `equals`, values: [securityContext.clientId], }, ], }, ], }, },],
Выглядит спорно, но работает 😀. Сами роли тоже задаются по информации из контекста.
// cube.jscontextToRoles: ({ 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 порту.
Ну и надо конечно проверки в коде добавлять 😅.