Skip to content

Тенанты, пользователи и аутентификация/авторизация ​

Рабочее пространство — это граница ресурсов и прав aihelpme.ru: базы знаний, модели, агенты, сессии и квоты хранилища принадлежат пространству. Один пользователь может входить в несколько пространств и иметь в каждом из них разные роли. Организации служат для связи нескольких пространств и общего доступа к базам знаний и агентам. Во внутренней модели пространство представлено сущностью Tenant.

Ниже перечислены точки входа для приглашения участников, общего доступа к ресурсам и доступа через API. Для управления всем развёртыванием требуются отдельные права платформы.

ДействиеТочка входа и требования
Пригласить участников командыНастройки пространства → Участники → Пригласить, задав приглашённому роль (Owner / Admin / Contributor / Viewer)
Поделиться базой знаний с другой командойСоздать организацию → добавить в неё оба пространства → в базе знаний выбрать «Поделиться с организацией»
Подключиться к APIНастройки пространства → API Key, при необходимости отметить возможности (поиск / вопросы-ответы / загрузка / управление) и ограничить доступные базы знаний
Управлять всем развёртыванием (глобальные настройки, очередь задач, аудит между пространствами)Требуется роль системного администратора, которая выдаётся независимо от Owner пространства; см. Управление платформой и системный администратор
Удалить всё пространствоВ настройках пространства запускает Owner (DELETE /tenants/:id); пространство и все связи участников помечаются как удалённые (soft delete), участники немедленно теряют доступ, восстановить через интерфейс нельзя

Viewer может просматривать и задавать вопросы, Contributor может создавать базы знаний и загружать документы, Admin управляет участниками и настройками пространства, Owner также может удалить или передать пространство. Изменение ресурсов дополнительно ограничено правами владения или общего доступа; матрица ролей приведена в справочном разделе.

Пользователи могут входить по паролю или через единый вход OIDC; программы получают доступ по API Key. Права вошедшего пользователя определяются ролью в пространстве и принадлежностью ресурсов, а API Key проверяется по выданным возможностям и области баз знаний.

Приглашение участников и назначение ролей ​

В разделе «Участники» настроек пространства пригласите пользователей и назначьте роли в соответствии с их рабочим диапазоном. После принятия приглашённый присоединяется к текущему пространству; когда публичная регистрация отключена, зарегистрироваться можно по действующему приглашению. Существующие пользователи также могут принять приглашение и присоединиться к новому пространству, не создавая второй аккаунт.

Роли участников пространства и роли участников организации управляются отдельно. Когда нужно поделиться материалами, следует одновременно проверить права общего доступа к базе знаний, роль принимающего пространства в организации и роль самого пользователя в пространстве.

Общий доступ к базам знаний и агентам ​

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

Настройка API Key для программ ​

Создайте API Key в настройках пространства и выберите для задачи возможности поиска, вопросов-ответов, загрузки или управления; если нужно ограничить диапазон материалов, укажите доступные базы знаний. Программа передаёт учётные данные в заголовке X-API-Key; конкретные возможности и ограничения ресурсов приведены в справочном разделе.

Управление пространством и правами платформы ​

Пространство удаляет Owner. Запись пространства и связи участников помечаются как удалённые, и все участники сразу теряют доступ; данные внутри пространства — базы знаний, модели и т. п. — не удаляются физически немедленно, но начиная с v0.8.2 задачи Wiki в очереди удалённого пространства больше не вызывают модель. Глобальными настройками, очередью задач платформы и аудитом между пространствами управляет системный администратор, подробнее см. Управление платформой и системный администратор.

Обзор концепций ​

Ключевые моменты:

  • Один User может одновременно принадлежать нескольким Tenant через таблицу tenant_members, причём у каждой связи участника своя роль.
  • Связь участника организации имеет уровень тенанта (OrganizationTenantMember с единицей tenant_id), и совместный доступ тоже устроен как «некоторый тенант делится KB с некоторой организацией».
  • API Key — это полностью независимый машинный субъект, отдельный от JWT-пользователя, не переиспользующий лестницу ролей тенанта.

Справочник по аутентификации и авторизации ​

Регистрация и вход ​

Режимы регистрации (invite-only) ​

internal/handler/auth.go + internal/config/config.go:

go
type AuthConfig struct {
    RegistrationMode  string // "self_serve" (по умолчанию, публичная регистрация) | "invite_register" (регистрация только по приглашению) | "invite_only" (регистрация запрещена, сохраняется старое значение конфигурации)
    DefaultTenantMode string // "create_personal" (по умолчанию, автоматически создаётся личный тенант) | "tenantless" (без тенанта, в ожидании приглашения)
}

func (c *AuthConfig) IsInviteOnly() bool {
    return c != nil && c.RegistrationMode == AuthRegistrationModeInviteOnly
}

Проверка делится на два уровня: понимание этого объясняет, почему «изменил env, а эффекта нет»:

При запуске (applyAuthAndTenantDefaults()) синтезируется cfg.Auth.RegistrationMode: DISABLE_REGISTRATION=true напрямую переписывает его в invite_only, перекрывая значение из YAML. Env перекрывает YAML для того, чтобы два барьера — «интерфейс отклоняет регистрацию» и «фронтенд скрывает вход в регистрацию» (фронтенд читает /auth/config) — были согласованы, иначе остаётся кнопка, нажатие на которую даёт 403.

При каждом запросе (resolveRegistrationMode()) сравниваются только два источника: строка auth.registration_mode в базе данных system_settings > синтезированное выше значение cfg > жёстко зашитый запасной вариант self_serve. DISABLE_REGISTRATION при этом не перечитывается на каждый запрос.

Следствие: после того как системный администратор установит auth.registration_mode в self_serve через интерфейс, публичная регистрация будет открыта, даже если в развёртывании всё ещё указано DISABLE_REGISTRATION=true. Чтобы полностью отключить её, нужно сбросить эту строку в базе данных (DELETE /system/admin/settings/auth.registration_mode).

У режима регистрации есть три ступени, переключаемые в глобальных настройках на лету:

РежимПубличная регистрацияРегистрация по действующей ссылке-приглашениюСуществующий аккаунт принимает приглашение
self_serve (открытая регистрация)РазрешенаРазрешенаРазрешена
invite_register (регистрация только по приглашению)ЗапрещенаРазрешенаРазрешена
invite_only (регистрация запрещена, сохраняется старое значение конфигурации)ЗапрещенаЗапрещенаРазрешена

DISABLE_REGISTRATION=true по-прежнему отображается в исходный invite_only. Чтобы приглашённые новые пользователи могли зарегистрироваться, измените глобальную настройку на invite_register. Когда регистрация запрещена, POST /auth/register-by-invite также возвращает 403, что согласуется с поведением страницы входа.

Эти ступени управляют регистрацией по паролю. Первый вход через OIDC по-прежнему определяется политиками видимости приложения в IdP, группами пользователей и т. п.; создание аккаунта администратором на эту настройку также не влияет.

Регистрация/вход по паролю ​

  • POST /auth/register: {username(2-50), email, password}; в соответствии с DefaultTenantMode решается, создавать ли личный тенант автоматически (TenantProvisioningCreatePersonal / TenantProvisioningTenantless).
  • POST /auth/login: {email, password}, возвращает LoginResponse{user, active_tenant, memberships[], token, refresh_token}; активный тенант восстанавливается по Preferences.LastActiveTenantID.
  • Регистрация, регистрация по приглашению, изменение пароля и установка нового пароля администратором подчиняются единой политике паролей: по умолчанию 8–32 символа, минимум буквы и цифры. GET /auth/config возвращает текущее complex_password_enabled; при включении дополнительно требуются буквы разного регистра и специальные символы.
  • Системный администратор может настроить это через auth.complex_password_enabled, при отсутствии значения в базе происходит откат к WEKNORA_AUTH_COMPLEX_PASSWORD_ENABLED. Изменение политики ограничивает только пароли, создаваемые или изменяемые после этого, и не заставляет существующие аккаунты немедленно менять пароль.
  • Самостоятельная смена пароля в профиле требует ввода старого пароля, новый пароль не может совпадать; после успеха все сессии пользователя отзываются, требуется повторный вход. Ошибки интерфейса и параметры см. в API аутентификации.

Регистрация по приглашению (register-by-invite) ​

internal/handler/auth_register_by_invite.go. Общая ссылка-приглашение (share link), создаваемая Owner тенанта (см. Общая ссылка-приглашение (invite link)), содержит token; страница регистрации завершает регистрацию по token, поддерживаются режимы self_serve и invite_register:

go
// POST /auth/register-by-invite
type registerByInviteRequest struct {
    Token    string `binding:"required"`
    Email    string `binding:"required,email"` // заполняется регистрирующимся, с token не связан
    Username string `binding:"required"`
    Password string `binding:"required"`       // дополнительно проверяется единой политикой паролей (8–32 символа, буквы и цифры)
}

Процесс: проверка token (LookupByToken) → проверка, что email не зарегистрирован (если зарегистрирован — 409) → создание пользователя в режиме tenantless → установка тенанта-приглашающего как первого тенанта пользователя → AcceptByToken создаёт строку tenant_members (статус active, роль берётся из приглашения).

Сопутствующий эндпоинт POST /auth/invitations/lookup (без аутентификации) возвращает контекст приглашения {tenant_id, tenant_name, role, expires_at} для отображения на странице регистрации; token передаётся в теле POST-запроса, чтобы не попадать в журналы доступа по URL; недействительный или отозванный token возвращает 410.

Присоединение зарегистрированного пользователя по ссылке-приглашению ​

В развёртываниях invite_only страница приглашения предлагает пользователю сначала войти, а затем отправить token в POST /me/invitations/accept-by-token, чтобы присоединиться к пространству; создавать аккаунт для уже зарегистрированного email не нужно. Пользователь без пространства по умолчанию после первого присоединения получает это пространство как пространство по умолчанию. В режиме invite_register новый пользователь может зарегистрироваться по действующему приглашению; существующий аккаунт может переключиться на форму входа, чтобы присоединиться, и переключиться обратно на форму регистрации.

Приглашение по email для уже зарегистрированного пользователя дополнительно управляется tenant.auto_accept_invitation: по умолчанию false — создаётся приглашение в состоянии pending и ожидается подтверждение из входящих; при true пользователь присоединяется сразу, возвращается активный участник, а существующие приглашения в состоянии pending обрабатываются. Фронтенд узнаёт об этом переключателе из capabilities.auto_accept_invitation в GET /auth/me. Он не превращает произвольную общую ссылку во вход без аутентификации.

Участники тенанта, приглашения и ссылки-приглашения ​

Управление участниками и адресные приглашения ​

Handler: internal/handler/tenant_member.go, tenant_invitation.go. Группа /tenants/:id единообразно вешает PathTenantMatch() (тенант из URL должен совпадать с активным тенантом в token, кроме суперпользователя).

ЭндпоинтМинимальная рольОписание
GET /tenants/:id/membersViewerПостраничный список активных участников, q фильтрует по email/имени пользователя нечётко
POST /tenants/:id/membersOwnerНапрямую добавить существующего пользователя {email, role}
PUT /tenants/:id/members/:user_idOwnerИзменить роль
DELETE /tenants/:id/members/:user_idOwnerУдалить участника
POST /tenants/:id/invitationsOwnerАдресное приглашение существующего пользователя {email, role, message}
GET /tenants/:id/invitationsViewerСписок приглашений
DELETE /tenants/:id/invitations/:inv_idOwnerОтозвать приглашение
GET /me/invitationsСам пользовательВходящие приглашения
POST /me/invitations/:inv_id/accept / .../declineСам пользовательПринять / отклонить

Машина состояний TenantInvitation: pending → accepted / declined / revoked / expired (истечение выполняется ленивой очисткой с аудитом rbac.invitation_expired). Весь жизненный цикл участников и приглашений сопровождается событиями аудита: rbac.member_added / member_removed / member_role_changed / member_left / invitation_sent / invitation_accepted / invitation_declined / invitation_revoked (internal/types/audit_log.go).

internal/handler/tenant_invite_link.go. Хранится в той же таблице, что и адресные приглашения: пустой InviteeUserID — общая ссылка (могут использовать многие, считает AcceptedCount), непустой — адресное приглашение.

  • POST /tenants/:id/invite-links (Owner): {role, message} → возвращает invite_url ({FrontendBaseURL}/register?token=..., где FrontendBaseURL берётся из YAML frontend_base_url → переменной окружения FRONTEND_BASE_URL → относительного пути как запасного варианта);
  • Ссылки-приглашения и адресные приглашения хранятся в одной таблице; список — GET /tenants/:id/invitations (Viewer), отзыв — DELETE /tenants/:id/invitations/:inv_id (Owner).

Ссылка действует до истечения срока или отзыва; вместе с регистрацией по приглашению (register-by-invite) она замыкает цикл создания аккаунта в режиме invite-only.

Организации и общие пространства ​

Жизненный цикл организации ​

internal/application/service/organization.go:

  • При создании организации генерируется уникальный InviteCode со сроком действия invite_code_validity_days ∈ {0(бессрочно), 1, 7, 30}, по умолчанию 7 дней (белый список ValidInviteCodeValidityDays, недопустимое значение возвращает ErrInvalidValidityDays);
  • GetOrganizationByInviteCode вступает в группу по коду-приглашению (различает ErrInviteCodeNotFound / ErrInviteCodeExpired); при RequireApproval=true создаётся запрос на присоединение, ожидающий одобрения;
  • Организации с Searchable=true могут быть найдены через SearchSearchableOrganizations;
  • Код-приглашение и число ожидающих одобрения видны только «администратору или владельцу организации» (проверка isAdmin || isOwner в internal/handler/organization.go).

Кандидаты для приглашения: точное разрешение по ID пространства ​

Приглашение в организацию нацелено на рабочее пространство. GET /organizations/:id/search-tenants?q=<ID пространства> может вызывать только администратор организации; начиная с v0.8.2 кандидаты разрешаются только по точному ID пространства, без поиска по имени между пространствами (чтобы не давать возможности перечислять названия других пространств): если q не является допустимым ID, пространство уже в организации или не существует — возвращается пустой список, иначе возвращается единственный {tenant_id, tenant_name}. Приглашаемая сторона может узнать ID пространства в своих настройках пространства и сообщить его администратору организации; также можно по-прежнему присоединиться по коду-приглашению или ссылке-приглашению организации.

Старый эндпоинт GET /organizations/:id/search-users сохранён как совместимый псевдоним с тем же поведением, что и search-tenants.

POST /organizations/:id/invite (только администратор организации) добавляет участника напрямую: предпочтительно через tenant_id, с совместимостью со старым путём user_id из SDK (обратный поиск пространства по умолчанию этого пользователя). Прямое добавление больше не прикладывает представительного пользователя (representative_user_id игнорируется); список участников организации возвращает email представительного пользователя только для собственного пространства вызывающего.

Модель совместного доступа к KB и расчёт прав ​

internal/types/organization.go + internal/application/service/kbshare.go:

go
type KnowledgeBaseShare struct {
    ID              string
    KnowledgeBaseID string
    OrganizationID  string
    SharedByUserID  string
    SourceTenantID  uint64        // тенант-источник общего доступа
    Permission      OrgMemberRole // максимальные права, выданные общим доступом (viewer/editor/admin)
}
// Структура AgentShare имеет тот же вид, но для Agent.

Предусловия для общего доступа (ShareKnowledgeBase): тенант вызывающего должен владеть этим KB (kb.TenantID == tenantID), а его роль в целевой организации должна быть editor+. Повторный общий доступ превращается в обновление прав.

Кто может управлять общим доступом (canManageShare, общий для KB и Agent, используется для смены прав/отзыва):

  1. Изначальный «расшаривший», всё ещё действующий из тенанта-источника, с ролью в тенанте Contributor+;
  2. Admin+ тенанта-источника (владение уровнем тенанта: после ухода «расшарившего» Admin тенанта всё ещё может управлять);
  3. Тенант с ролью admin в целевой организации: его пользователи Admin+ могут отозвать общий доступ или понизить права, но не могут поднять их выше текущего значения, иначе это передало бы право записи в чужой KB всем участникам-editor.

share_id в маршруте должен принадлежать KB / Agent из пути, иначе возвращается 404.

Ограничения получателя на настройку общей KB: только пространство, где находится KB, может вызывать POST /initialization/initialize/:kbId; другим пространствам для изменения настроек KB (PUT /initialization/config/:kbId) нужен общий доступ уровня admin, и они не могут менять привязку хранилища; при чтении настроек получатель видит только, настроены ли учётные данные, но не видит Base URL модели, расположение бакета и т. п. Детали KB также больше не возвращают встроенные в старую версию учётные данные хранилища/VLM.

Когда общий доступ перестаёт действовать: он действует только пока организация не удалена и тенант-источник остаётся участником организации. Когда тенант-источник выходит или удалён, его KB и Agent, расшаренные в эту организацию, отзываются вместе с ним; сторона запросов также фильтруется по связям участника тенанта-источника, и общий доступ, оставшийся от старых версий, тоже больше не действует.

Действующие права = пересечение слоёв (берётся минимум):

go
// Итоговые права = Min(Permission из записи общего доступа, OrgMemberRole тенанта вызывающего в организации)
// Затем накладывается потолок по роли тенанта:
func applyTenantRoleCap(p types.OrgMemberRole, callerTenantRole types.TenantRole) types.OrgMemberRole {
    // Пользователь, который в тенанте только Viewer, даже если на стороне общего доступа дано editor+, понижается до viewer
    if callerTenantRole == types.TenantRoleViewer && p.HasPermission(types.OrgRoleEditor) {
        return types.OrgRoleViewer
    }
    return p
}

Операции, связанные с общим доступом, пишутся в ленту активности KB: kb.share_added / kb.share_permission_changed / kb.share_removed.

Дополнительные правила совместного доступа к Agent (internal/application/service/agent_share.go, agent_share_scope.go):

  • Встроенные агенты нельзя расшаривать: в каждом пространстве есть встроенный агент с тем же ID, и после расшаривания получатель не сможет их различить. Запросы диалога без указания исходного пространства всегда сначала используют агента своего пространства.
  • Совместный доступ к Agent в режиме только для чтения открывает его область KB участникам организации, поэтому «расшаривший» должен иметь право напрямую делиться этими KB, то есть быть создателем KB или Admin+ тенанта. kb_selection_mode: all автоматически включает KB, созданные впоследствии, и делиться ими может только Admin+. При редактировании уже расшаренного Agent к вновь добавленным в область KB применяется то же правило.
  • При совместном выполнении, если у Agent не задан режим выбора MCP, он трактуется как «не использовать», что соответствует отображению в области общего доступа; в режиме быстрых вопросов-ответов веб-поиск должен быть включён у самого Agent, а summary_model_id в запросе игнорируется.
  • Получатель видит у расшаренного Agent без системного промпта и ID пользователя-создателя, но возможности и область баз знаний по-прежнему видны.
  • После расшаривания Agent с включёнными навыками навыки выполняются в песочнице пространства-источника и получают переменные окружения, заданные администратором для навыка (например, API Key); участники могут заставить агента прочитать эти значения. Страница настроек общего доступа предупреждает об этом.

RBAC: роли, владение и матрица охранников ​

Авторизация состоит из трёх ортогональных механизмов, все они сходятся в rbacGuards в internal/router/rbac.go:

  1. Охранник роли (role-only): Viewer() / Contributor() / Admin() / Owner() / SystemAdmin(), спрашивает «какова роль вызывающего в этом тенанте».
  2. Охранник владения (ownership-or-role): OwnedKBOrAdmin() и т. п., спрашивает «является ли вызывающий создателем именно этого ресурса или как минимум Admin+».
  3. Охранник доступа к KB (KB-access): KBAccessRead() / KBAccessWrite(), спрашивает «может ли тенант вызывающего дотянуться до этого KB» (собственный / общий в организации / видимый через расшаренный Agent).

Матрица возможностей ролей ​

ВозможностьOwner (40)Admin (30)Contributor (20)Viewer (10)
Удаление тенанта / передача владения / управление API Key✓✗✗✗
Добавление / удаление участников, смена ролей, отправка приглашений✓✗ (в handler ограничено Owner)✗✗
Настройка инфраструктуры тенанта (модели / векторные хранилища / IM / MCP / веб-поиск / бэкенды хранилища / источники данных)✓✓✗✗
Очистка содержимого базы знаний (DELETE /knowledge-bases/:id/knowledge)✓✓✗✗
Изменение / удаление KB / Agent / знаний / chunk / Wiki / тегов, созданных другими✓✓✗✗
Создание KB / Agent; копирование Agent себе✓✓✓✗
Изменение / удаление созданных собой KB и их дочерних ресурсов✓✓✓✗
Создание/управление своими сессиями, запуск вопросов-ответов (/sessions, /knowledge-chat, /agent-chat — все Viewer+)✓✓✓✓
Просмотр списка участников / приглашений / списка KB / знаний / поиска / предпросмотра✓✓✓✓

Проектный комментарий в начале internal/router/rbac.go резюмирует продуктовую семантику:

  • Owner / Admin: управляют всем в тенанте;
  • Contributor: управляет созданными собой ресурсами, чужие ресурсы для него эквивалентны «только чтение»;
  • Viewer: всё только для чтения;
  • Для создания нового ресурса нужен как минимум Contributor; для настройки инфраструктуры тенанта нужен Admin+.

Две легко упускаемые исключения: добавление/удаление участников, смена ролей и отправка приглашений — прерогатива только Owner, Admin это тоже не может (в routes_auth_tenant.go повешен g.Owner(), а список участников — Viewer+); Viewer не означает «не может создать ничего» — сессии принадлежат его рабочим данным, Viewer тоже может создавать сессии и задавать вопросы, просто не может создавать базы знаний и Agent.

Правила выбора охранника (Q1 / Q2) ​

rbac.go явно задаёт метод выбора охранника для новых маршрутов:

  • Q1: есть ли у ресурса creator? Есть (KB, Agent, документ знаний, Chunk, WikiPage, запись FAQ, тег KB) → для маршрутов изменения используется OwnedXxxOrAdmin; нет (Model, VectorStore, канал IM, WebSearchProvider, DataSource, MCPService и прочая инфраструктура уровня тенанта) → используется Admin(); точка входа создания (ресурс ещё не существует) → Contributor().
  • Q2: побочный эффект приватный или публичный? Приватный (например, POST /agents/:id/copy копирует только себе) → достаточно Contributor(); публичный (расшарить KB в организацию, отключить Agent всего тенанта, передать владение) → OwnedXxxOrAdmin или Admin.

Список охранников владения ​

ОхранникПуть разрешенияПрименяемые маршруты
OwnedKBOrAdmin:id → KB.CreatorIDОбновление / удаление / pin / загрузка знаний / CRUD тегов KB
OwnedKBOrAdminFromKbIDParam:kbId → KB.CreatorIDМаршруты настройки KB /initialization/*
OwnedAgentOrAdmin:id → Agent.CreatorID (у встроенного Agent creator пуст, менять может только Admin+)Изменение Agent
OwnedKnowledgeKBOrAdminknowledge :id → CreatorID KB-владельцаОбновление / удаление / перепарсинг / редактирование изображений знания
OwnedChunkKBOrAdmin / ...FromChunkID:knowledge_id или chunk :id → KB.CreatorIDИзменение chunk
OwnedWikiKBOrAdmin:kb_id → KB.CreatorIDCRUD страниц Wiki

Дочерние ресурсы должны наследовать контроль родительского KB (в комментарии прямо названы исправленные баги с подключением FAQ/Tag, agent share, KB share не к той оси).

Семантика middleware (internal/middleware/rbac.go) ​

Порядок проверки в RequireRole / RequireOwnershipOrRole:

  1. Субъект API Key пропускается напрямую (его авторизация идёт через APIKeyGate механизма объявления маршрутов, а синтетический системный пользователь не может совпасть с creator_id);
  2. Роль достаточна → пропуск;
  3. Суперпользователь между тенантами (IsCrossTenantSuperuser) → пропуск;
  4. RBAC не принудительно (tenant.enable_rbac=false, режим постепенного внедрения) → пропуск только с записью в журнал;
  5. Охранник владения выполняет запрос creator: ресурс не существует → пропуск, чтобы handler вернул 404; запрос не удался → 503; creator == текущий пользователь → пропуск;
  6. Иначе 403 + журнал аудита (AuditActionAccessDenied = "rbac.access_denied").

Переключатель принудительного применения TenantConfig.EnableRBAC: nil или true = принудительно (текущее значение по умолчанию), false = только журнал без отказа (для переходного периода выпуска); переопределяется переменной окружения WEKNORA_TENANT_ENABLE_RBAC. Этот переключатель действует только на проверки ролей внутри пространства; охранник доступа к KB (RequireKBAccess) при доступе между пространствами блокирует всегда.

RequireSystemAdmin: JWT-пользователь должен иметь IsSystemAdmin=true; API Key должен быть platform key (tenant key всегда даёт 403).

Охранник доступа к KB (канал общего доступа между тенантами) ​

middleware/kb_access.go (оборачивается серией KBAccess* из rbac.go, правила решения находятся в internal/application/access) объединяет три пути доступа:

text
1. Собственный KB                → эквивалент полного доступа уровня Admin
2. Общий KB организации (Plan 3) → ограничен правами общего доступа
3. Видимый через общий Agent     → только чтение (активируется только на уровне KBAccessRead)

После успеха охранник сохраняет (KB, действующий ID тенанта, права) в context и переписывает ID тенанта запроса на действующий тенант, так что downstream handler не нужно знать, свой это KB или общий. Варианты KBAccessReadFromKnowledgeIDParam / ...FromChunkIDParam позволяют найти KB по knowledge / chunk ID. Минимум для маршрутов чтения — OrgRoleViewer, для маршрутов записи — OrgRoleEditor.

Система API Key ​

Список возможностей (Capabilities) ​

internal/types/tenant_api_key.go. API Key не переиспользует роли тенанта: ключ либо FullAccess, либо несёт явный набор возможностей; маршруты без объявленной политики для API Key по умолчанию отклоняются (default-deny).

ВозможностьОписание
retrieveЧтение / поиск данных баз знаний (список KB, детали знания, hybrid-search и т. д.)
chatПоток сессий: создание session, knowledge-chat / agent-chat, загрузка и удаление сообщений
read_agentsПросмотр и список Agent (без создания и изменения)
ingestЗапись содержимого: загрузка документов, редактирование chunk / FAQ / тегов / Wiki, массовое удаление и перемещение знаний
manage_kbsЖизненный цикл KB: создание / копирование / дублирование / обновление / удаление / начальная настройка
manage_agentsДобавление, удаление, изменение и копирование Agent
message_historyПоиск и просмотр истории чата уровня тенанта (POST /messages/search и т. п., отдельно от chat)
manage_modelsУправление определениями моделей и учётными данными
manage_mcp_servicesУправление сервисами MCP и учётными данными
manage_datasourcesУправление коннекторами источников данных и задачами синхронизации
manage_channelsУправление интеграциями каналов Embed / IM
manage_vector_storesУправление векторными хранилищами и парсерами
manage_storage_backendsУправление бэкендами объектного хранилища
manage_web_searchУправление настройками веб-поиска
run_evaluationsЗапуск и просмотр задач оценки
manage_membersУправление участниками и приглашениями тенанта
manage_spacesУправление связями участников организаций / общих пространств
manage_tenant_settingsЧтение и запись сводных настроек тенанта
system_tenants_read / system_tenants_manageУровень платформы: управление тенантами (только platform key)
system_settings_read / system_settings_manageУровень платформы: системные настройки
system_runtime_read / system_runtime_manageУровень платформы: очереди / задачи времени выполнения
system_audit_readУровень платформы: журнал аудита

Механизм объявления маршрутов ​

В internal/router/rbac.go каждый доступный по API Key маршрут через apiKeyGroup / apiKeyRoute явно регистрирует одну APIKeyRoutePolicy (middleware.APIKeyRouteAuthorizer — единственный источник истины):

go
// Конструкторы политик
apiKeyAny()                    // любой действительный ключ
apiKeyFullAccess()             // только ключ FullAccess
apiKeyPlatform(caps...)        // только platform key + указанные возможности
apiKeyRetrieve(base) / apiKeyChat(base) / apiKeyIngest(base) / ...

При запуске assertAPIKeyPoliciesMatchRoutes проверяет, что каждая объявленная политика соответствует реально зарегистрированному маршруту; расхождение конфигурации приводит к panic. Типичные соответствия, подтверждённые router_api_key_capabilities_test.go:

МаршрутТребуемая возможность
POST /sessions, POST /knowledge-chat/:session_id, POST /agent-chat/:session_id, GET /messages/:session_id/loadchat
GET /agents, GET /agents/:id, GET /agents/:id/suggested-questionsread_agents
POST/PUT/DELETE /agents, POST /agents/:id/copymanage_agents
PUT/DELETE /knowledge-bases/:id, POST /initialization/initialize/:kbIdmanage_kbs
POST /messages/search, GET /messages/chat-history-statsmessage_history (не chat)
GET /system/admin/settingsplatform key + system_settings_read
POST /system/admin/runtime/queues/:queue/tasks/:task_id/actions/:actionplatform key + system_runtime_manage

KB Allow-list ​

Когда KnowledgeBaseIDs непуст, ключ может дотянуться только до KB из списка (подтверждено knowledge_api_key_scope_test.go):

go
// Один KB за пределами области → 403
requireTenantAPIKeyKnowledgeBase(ctx, "kb-2") // scope содержит только kb-1 → forbidden
// Любой KB за пределами области в массовой операции → 403 целиком (частичное пересечение отклоняется)
requireTenantAPIKeyKnowledgeBases(ctx, "kb-1", "kb-2") // → forbidden

Прочие жёсткие ограничения: platform key не может создать другой platform key; субъект API Key не участвует в определении ownership (см. RBAC: роли, владение и матрица охранников).

Единый вход OIDC ​

Конфигурация ​

internal/config/config.go, OIDCAuthConfig:

ПараметрОписание
enableВключён ли OIDC
issuer_urlОжидаемый Issuer, участвует в проверке id_token
jwks_uriАдрес набора открытых ключей подписи; переменная окружения OIDC_AUTH_JWKS_URI
discovery_urlАдрес OpenID Connect Discovery (.well-known/openid-configuration)
provider_display_nameОтображаемое имя кнопки входа
client_id / client_secretУчётные данные клиента (secret сериализуется как json:"-", не отдаётся фронтенду)
authorization_endpoint / token_endpoint / user_info_endpointЯвно заданные эндпоинты
scopesЗапрашиваемые scope (например, openid email profile)
user_info_mapping.username / .emailСопоставление полей claims (по умолчанию name / email)

Эндпоинты авторизации/token можно задать явно; даже если оба заполнены, при неполном issuer или jwks_uri информацию для проверки всё равно нужно дополнить через discovery. При отсутствии надёжной конфигурации проверки нельзя войти, просто разобрав полезную нагрузку id_token.

Маршруты (internal/router/routes_auth_tenant.go):

go
r.GET("/auth/oidc/config",   handler.GetOIDCConfig)           // фронтенд проверяет, включено ли
r.GET("/auth/oidc/url",      handler.GetOIDCAuthorizationURL) // получить URL авторизации
r.GET("/auth/oidc/start",    handler.OIDCStart)              // сразу 302 для начала входа
r.GET("/auth/oidc/callback", handler.OIDCRedirectCallback)    // обратный вызов с кодом авторизации

Корпоративный портал может напрямую ссылаться на /api/v1/auth/oidc/start: бэкенд возвращает 302 с переходом в IdP и строит адрес обратного вызова по origin запроса. При развёртывании за обратным прокси следует корректно передавать внешние scheme/host и зарегистрировать соответствующий адрес обратного вызова в IdP. Этот интерфейс не принимает произвольную цель перехода после входа.

Процесс и проектные решения по безопасности ​

internal/application/service/user.go:

  • GetOIDCAuthorizationURL: генерирует 24-байтовый случайный nonce, с помощью secutils.SignOIDCState подписывает {nonce, redirect_uri} внутрь state (защита от CSRF / повторного воспроизведения / подмены адреса обратного вызова); nonce отдаётся через HttpOnly cookie (в JSON ответа опускается через json:"-").
  • LoginWithOIDC: обмен кода авторизации на token → если используется id_token, сначала проверяется подпись, issuer, audience и срок действия через JWKS → объединяется информация о пользователе с эндпоинта UserInfo (сопоставляется по user_info_mapping) → сопоставление локального пользователя по email; если не найден, provisionOIDCUser автоматически создаёт аккаунт → выдаётся точно такая же локальная пара JWT, как при входе по паролю.

При наличии только access_token личность можно взять из UserInfo; без JWKS нельзя использовать непроверенные claims id_token, но при наличии access_token и эндпоинта UserInfo всё ещё можно пройти через UserInfo. Проверенный id_token может служить запасным вариантом, если запрос к UserInfo не удался.

Детали автоматического создания аккаунта:

  • Режим тенанта берётся из auth.default_tenant_mode (create_personal — автоматически создаётся личный тенант / tenantless — ожидание приглашения);
  • Варианты имени пользователя: OIDC username → префикс email → oidc-user; при конфликте добавляется числовой суффикс -1..-20, при продолжающемся конфликте используется метка времени Unix;
  • Генерируется и записывается случайный пароль из 32 символов (пользователь его не знает и может входить только через OIDC);
  • В ответе есть is_new_user для первичного онбординга в SPA; аккаунт с IsActive=false отклоняется при входе.

Быстрый справочник по конфигурации ​

ПараметрЗначенияПо умолчаниюНазначение
auth.registration_modeself_serve / invite_register / invite_onlyself_serveРежим регистрации по паролю (в system_settings БД можно менять на лету)
auth.default_tenant_modecreate_personal / tenantlesscreate_personalСоздавать ли новому пользователю личный тенант автоматически
tenant.enable_rbactrue / falsetrueПринудительное применение RBAC / режим только журнала
JWT_SECRET (переменная окружения)произвольная строкаслучайные 32 байтаКлюч HMAC для JWT
SYSTEM_AES_KEY (переменная окружения)ключ AESне заданоШифрование открытого текста API Key при сохранении в БД
oidc.*см. КонфигурациявыключеноЕдиный вход OIDC
frontend_base_url / FRONTEND_BASE_URLURLотносительный путьАдрес страницы регистрации по ссылке-приглашению
Tenant.StorageQuotaбайты10737418240 (10GB)Квота хранилища тенанта

Механизм JWT ​

Реализовано в internal/application/service/user.go, используется github.com/golang-jwt/jwt (HMAC-SHA256).

Источник ключа ​

go
func getJwtSecret() string {
    // 1) переменная окружения JWT_SECRET
    // 2) иначе при запуске генерируется безопасный случайный ключ 32 байта (Base64); после перезапуска процесса старые token становятся недействительными
}

Выдача (двойной token Access + Refresh) ​

go
accessClaims := jwt.MapClaims{
    "user_id":   user.ID,
    "email":     user.Email,
    "tenant_id": activeTenantID, // область тенанта запроса жёстко зашита в token
    "exp":       time.Now().Add(24 * time.Hour).Unix(),
    "iat":       time.Now().Unix(),
    "type":      "access",
}
refreshClaims := jwt.MapClaims{
    "user_id": user.ID,
    "exp":     time.Now().Add(7 * 24 * time.Hour).Unix(),
    "type":    "refresh",
}
TokenСрок действияОсновные Claims
Access Token24 часаuser_id / email / tenant_id / type=access
Refresh Token7 днейuser_id / type=refresh (без tenant_id)

Оба token записываются в таблицу auth_tokens для серверного отзыва.

Проверка и обновление ​

Цепочка проверок ValidateToken:

  1. Алгоритм подписи должен принадлежать семейству HMAC (защита от атаки путаницы алгоритмов);
  2. token с type=refresh не может использоваться как access token (isRefreshTokenClaims);
  3. Проверка IsRevoked по таблице auth_tokens (выход = запись об отзыве);
  4. Из claims извлекается user_id для загрузки пользователя, tenant_id как активный тенант.

Переключение тенанта = перевыпуск token: SwitchTenant после проверки активного членства в целевом тенанте (кроме суперпользователя между тенантами) сначала записывает целевое пространство в предпочтение «последний активный тенант» (Preferences.LastActiveTenantID), затем выдаёт пару token с новым claim tenant_id и по мере возможности отзывает старый refresh token. refresh JWT не содержит tenant_id, поэтому следующий вход и refresh попадают по этому предпочтению; если запись предпочтения не удалась, весь перевыпуск считается неудачным. При возврате к home записывается ID home (эквивалентно по семантике текущего места отправке 0 из SPA для очистки предпочтения). Это предпочтение уровня аккаунта: один перевыпуск меняет следующее место входа на всех устройствах.

Модель данных ​

Tenant (тенант / рабочее пространство) ​

internal/types/tenant.go:

go
type Tenant struct {
    ID                      uint64               `json:"id" gorm:"primaryKey"`
    Name                    string               `json:"name"`
    Description             string               `json:"description"`
    Status                  string               `json:"status" gorm:"default:'active'"`
    RetrieverEngines        RetrieverEngines     `json:"retriever_engines" gorm:"type:json"`
    Business                string               `json:"business"`
    StorageQuota            int64                `json:"storage_quota" gorm:"default:10737418240"` // по умолчанию 10GB
    StorageUsed             int64                `json:"storage_used"  gorm:"default:0"`
    ContextConfig           *ContextConfig       `json:"context_config" gorm:"type:jsonb"`
    WebSearchConfig         *WebSearchConfig     `json:"web_search_config" gorm:"type:jsonb"`
    ParserEngineConfig      *ParserEngineConfig  `json:"parser_engine_config" gorm:"type:jsonb"`
    Credentials             *CredentialsConfig   `json:"credentials" gorm:"type:jsonb"`
    StorageEngineConfig     *StorageEngineConfig `json:"storage_engine_config" gorm:"type:jsonb"`
    DefaultStorageBackendID *string              `json:"default_storage_backend_id,omitempty"`
    ChatHistoryConfig       *ChatHistoryConfig   `json:"chat_history_config" gorm:"type:jsonb"`
    RetrievalConfig         *RetrievalConfig     `json:"retrieval_config" gorm:"type:jsonb"`
    APIPrincipalConfig      *APIPrincipalConfig  `json:"-" gorm:"type:jsonb"`
    // CreatedAt / UpdatedAt / DeletedAt (мягкое удаление)
}

Тенант — точка привязки квот (StorageQuota / StorageUsed, по умолчанию 10GB) и разнообразных настроек уровня тенанта (движки поиска, веб-поиск, движки парсинга, учётные данные, движок хранилища, история чата и т. д.).

User (пользователь) ​

internal/types/user.go:

go
type User struct {
    ID                  string          `json:"id" gorm:"type:varchar(36);primaryKey"`
    Username            string          `json:"username" gorm:"uniqueIndex;not null"`
    Email               string          `json:"email" gorm:"uniqueIndex;not null"`
    PasswordHash        string          `json:"-" gorm:"not null"`
    Avatar              string          `json:"avatar"`
    TenantID            uint64          `json:"tenant_id" gorm:"index"` // предпочтительный/по умолчанию тенант
    IsActive            bool            `json:"is_active" gorm:"default:true"`
    CanAccessAllTenants bool            `json:"can_access_all_tenants" gorm:"default:false"` // суперпользователь между тенантами
    IsSystemAdmin       bool            `json:"is_system_admin" gorm:"default:false;index"`  // администратор платформы
    Preferences         UserPreferences `json:"preferences" gorm:"type:jsonb"`
}

type UserPreferences struct {
    // ID последнего активного тенанта, используется при входе для восстановления контекста
    LastActiveTenantID *uint64 `json:"last_active_tenant_id,omitempty"`
}

Два особых флага:

  • CanAccessAllTenants: суперпользователь между пространствами. Действует, только когда истинны оба переключателя — CanAccessAllTenants в строке пользователя и tenant.enable_cross_tenant_access / WEKNORA_TENANT_ENABLE_CROSS_TENANT_ACCESS уровня развёртывания (IsCrossTenantSuperuser() в middleware/access.go сначала проверяет конфигурацию, затем пользователя; при выключенной конфигурации это поле в ответе входа также затирается в false). После активации можно обходить проверки ролей пространства и обращаться к эндпоинтам между пространствами, таким как /tenants/all, /tenants/search. Обратите внимание: POST /tenants (создание пространства) не относится к эндпоинтам между пространствами и доступен любому вошедшему пользователю (с ограничениями по политике самостоятельного создания и квоте).
  • IsSystemAdmin: администратор уровня платформы (system admin), независимый от любой роли тенанта, используется для контрольной плоскости /system/admin/*. Он управляет всем развёртыванием, а не отдельным пространством; как получить первого и что можно делать — см. Управление платформой и системный администратор.

TenantMember и роли тенанта ​

internal/types/tenant_member.go:

go
type TenantRole string

const (
    TenantRoleOwner       TenantRole = "owner"       // полный контроль: удаление тенанта, передача владения, управление API Key и участниками
    TenantRoleAdmin       TenantRole = "admin"       // управление участниками, моделями, векторными хранилищами, MCP, IM и прочей инфраструктурой тенанта
    TenantRoleContributor TenantRole = "contributor" // создание KB / Agent, редактирование созданных собой ресурсов
    TenantRoleViewer      TenantRole = "viewer"      // только чтение
)

var tenantRoleLevel = map[TenantRole]int{
    TenantRoleOwner: 40, TenantRoleAdmin: 30,
    TenantRoleContributor: 20, TenantRoleViewer: 10,
}

func (r TenantRole) HasPermission(required TenantRole) bool {
    return r.Level() >= required.Level()
}
go
type TenantMember struct {
    ID        uint64
    UserID    string
    TenantID  uint64
    Role      TenantRole         // по умолчанию contributor
    Status    TenantMemberStatus // active / invited / suspended
    InvitedBy *string
    JoinedAt  time.Time
}

В ответе входа возвращается список проекций Membership{TenantID, TenantName, Role}, по которому фронтенд отрисовывает переключатель рабочих пространств.

TenantAPIKey (API Key) ​

internal/types/tenant_api_key.go:

go
type TenantAPIKey struct {
    ID               uint64
    TenantID         *uint64         // для platform key равно NULL
    ScopeType        APIKeyScopeType // "tenant" | "platform"
    Name             string
    KeyHash          string      `json:"-" gorm:"uniqueIndex"` // хеш для поиска в таблице
    APIKey           string      // открытый текст (перед сохранением в БД шифруется AES-256-GCM, см. BeforeSave/AfterFind)
    FullAccess       bool        // полный доступ (не ограничивается capabilities)
    KnowledgeBaseIDs StringArray // KB allow-list (пусто = без ограничений)
    Capabilities     StringArray // список возможностей
    LastUsedAt / ExpiresAt / RevokedAt *time.Time
}
  • Шифрование при сохранении: при настроенном SYSTEM_AES_KEY хук BeforeSave сохраняет столбец api_key зашифрованным через AES-GCM, AfterFind автоматически расшифровывает; поиск в таблице всегда идёт по необратимому KeyHash.
  • Процесс проверки: запрос несёт X-API-Key → вычисляется хеш → поиск по KeyHash → проверка RevokedAt / ExpiresAt → в context внедряется TenantAPIKeyScope{KeyID, ScopeType, FullAccess, KnowledgeBaseIDs, Capabilities}, далее читается через types.TenantAPIKeyScopeFromContext.

Organization (организация / общее пространство) ​

internal/types/organization.go:

go
type Organization struct {
    ID                     string
    Name / Description / Avatar string
    OwnerID                string  // пользователь-создатель
    OwnerTenantID          uint64  // тенант, владеющий организацией
    InviteCode             string  `gorm:"uniqueIndex"` // код-приглашение организации
    InviteCodeExpiresAt    *time.Time
    InviteCodeValidityDays int     // допускаются 0 (бессрочно)/1/7/30, по умолчанию 7
    RequireApproval        bool    // для присоединения нужно одобрение
    Searchable             bool    // доступна ли для обнаружения через поиск
    MemberLimit            int     // по умолчанию 50
}

type OrganizationTenantMember struct { // единица участника — «тенант»
    OrganizationID       string
    TenantID             uint64
    Role                 OrgMemberRole // admin / editor / viewer, по умолчанию viewer
    RepresentativeUserID string        // представительный пользователь (информационное поле)
}

const (
    OrgRoleAdmin  OrgMemberRole = "admin"  // полный контроль организации и общих ресурсов
    OrgRoleEditor OrgMemberRole = "editor" // может редактировать содержимое общих KB, но не может менять настройки организации
    OrgRoleViewer OrgMemberRole = "viewer" // только чтение
)

Справочник по реализации ​

Все приведённые ниже пути указаны относительно корня репозитория:

СлойФайл
Модель тенантаinternal/types/tenant.go
Модель пользователяinternal/types/user.go
Участники и роли тенантаinternal/types/tenant_member.go
Приглашения тенантаinternal/types/tenant_invitation.go
Модель API Key и возможностиinternal/types/tenant_api_key.go
Модели организации / общего доступаinternal/types/organization.go
Обработчик регистрации / входаinternal/handler/auth.go
Обработчик регистрации по приглашениюinternal/handler/auth_register_by_invite.go
Обработчики участников / приглашений / ссылок-приглашенийinternal/handler/tenant_member.go, tenant_invitation.go, tenant_invite_link.go
Обработчик организацииinternal/handler/organization.go
Сервис JWT / OIDC / пользователейinternal/application/service/user.go
Сервис организации / общего доступа к KBinternal/application/service/organization.go, kbshare.go
Middleware RBACinternal/middleware/rbac.go
Матрица охранников маршрутов RBACinternal/router/rbac.go
Конфигурация аутентификацииinternal/config/config.go (AuthConfig / OIDCAuthConfig / TenantConfig)

Версия v0.8.3 · MIT License