Вместе веселее

Тенанты в Cube

Cube
Тенанты в Cube

Когда-то мы делали сотни ненужных дашбордов.

Дашборды статичны, в лучшем случае дают пофильтровать данные, а как быть, если у заказчика меняются потребности и ему становятся интересны другие группировки и метрики из датасета? А если примерно этот же датасет, но немного отличающийся нужен ещё сотне заказчиков? И при этом каждый должен видеть свои столбцы и свои строки.

Сперва мы сделали интерактивный конструктор отчетов на базе Cube , позволяющий самостоятельно смотреть любые срезы, по сути окно к данным. А потом добавили в него мультитенантность. Первое слишком просто, поэтому пост про второе.

Конструктор отчетов

Задача

  1. Клиент видит только разрешенные строки
  2. Клиент видит общий набор полей в датасете, а также дополнительные из своего домена

Реализация

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,})

В веб-приложении определенно должна быть возможность влиять на схему датасетов для клиентов, и тогда нам понадобятся два эндпоинта:

  1. /getSchema возвратит схему датасета для тенанта.
  2. /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 порту.

Ну и надо конечно проверки в коде добавлять 😅.