Как организовать тенанты в 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.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 порту.

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