🔀 merge: установка одной командой, разбор аудита, линтер в CI

Ветка feat/one-command-release целиком:

- установка на чистый хост одной командой (compose.yaml со страницы релиза);
- 19 правок по аудиту: 2FA, сессии при сбросе пароля, TLS на домене
  установки, WebSocket под TLS, права на файлы, дедупликация входящих,
  смена адреса, SSRF через редирект, домен портала, пароль прокси, health;
- код приведён к своей же конфигурации ruff, линтер включён в CI;
- переводы строк в .py нормализованы, GitLab и .claude убраны из репозитория.

Проверено: backend pytest 739 passed, playwright 8, vitest 82, CLI 22,
typecheck, ruff — всё зелёное; установка поднята и проверена через шлюз.
This commit is contained in:
Andrey committed 2026-09-09 18:09:08 +03:00
commit 5f7bd03cb5
156 files changed
+2946 -1596

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "internal-ui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--workspace", "@chatballs/internal-ui", "--", "--host", "127.0.0.1"],
"cwd": ".",
"port": 5173
}
]
}
+15
View File
@@ -5,6 +5,21 @@
*.sh text eol=lf
chatballs text eol=lf
# Python читают и переписывают линуксовые инструменты (ruff в контейнере, CI).
# На CRLF-чекауте ruff принимал за перевод строки одиночный возврат каретки и
# писал его в вывод: файл оставался рабочим, но обрастал лишними CR. Двадцать
# один такой файл успел доехать до репозитория.
*.py text eol=lf
# Dockerfile и манифесты стека читают Linux-инструменты: CR в них ломает
# RUN-строки и heredoc'и ровно так же, как шебанг.
Dockerfile text eol=lf
Dockerfile.* text eol=lf
*.Dockerfile text eol=lf
compose*.yaml text eol=lf
Caddyfile text eol=lf
*.sql text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
+60
View File
@@ -0,0 +1,60 @@
# Быстрые проверки на каждый push и pull request.
#
# До этого на GitHub был только релизный workflow по тегу, то есть до первого
# тега ничего не проверялось вовсе. Здесь — то, что стоит секунды и ловит
# ошибки раньше человека: линтер бэкенда и тесты deployment CLI (они держат
# свойство «установка — один compose.yaml без host-mount'ов»).
#
# Бэкенд-сьют и e2e сюда не вынесены намеренно: им нужны Postgres, Redis и
# полчаса, их место — отдельный пайплайн, а не проверка на каждый коммит.
name: checks
on:
push:
branches: ["**"]
pull_request:
permissions:
contents: read
jobs:
lint:
name: ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Установить ruff той же версии, что и в образе
run: |
set -eu
# Единственный источник версии — requirements бэкенда, чтобы CI и
# контейнер не разъезжались.
constraint="$(grep -E '^ruff' apps/backend/requirements.txt)"
python -m pip install --disable-pip-version-check "$constraint"
ruff --version
# Конфигурация линтера лежит в корневом pyproject.toml (src = apps/backend).
- name: ruff check
run: ruff check --output-format=github apps/backend
cli:
name: deployment CLI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --disable-pip-version-check pytest pyyaml
# Тесты гоняют настоящий bash-скрипт chatballs с замоканными docker и
# flock, плюс проверяют, что production-манифест остаётся
# самодостаточным (tests/cli/test_release_compose.py).
- run: python -m pytest -q tests/cli
+102 -29
View File
@@ -1,9 +1,16 @@
# Публикация образов Chatballs в GitHub Container Registry.
# Публикация Chatballs: образы в GitHub Container Registry + релизный compose.yaml.
#
# Смысл: человек не должен собирать продукт, чтобы его запустить. По тегу vX.Y.Z
# workflow собирает backend и frontend, публикует их в ghcr.io и прикладывает к
# релизу release.env — файл со ссылками на образы по digest. С ним запуск
# сводится к «docker compose up» без единой сборки.
# Смысл: человек не должен ни собирать продукт, ни распаковывать бандл, чтобы
# его запустить. По тегу vX.Y.Z workflow собирает четыре образа (backend,
# frontend, gateway с Caddyfile внутри, postgres с init-скриптами внутри),
# публикует их в ghcr.io и прикладывает к релизу один файл — compose.yaml с
# ссылками по digest. Установка на чистый хост:
#
# curl -fsSL <ссылка на compose.yaml> -o compose.yaml
# docker compose up -d --wait
#
# release.env кладётся рядом для тех, кто разворачивает через `chatballs`
# (закрытый контур): там нужны те же digest'ы отдельным файлом.
#
# Ничего настраивать не нужно: путь образов выводится из github.repository,
# публикация идёт встроенным GITHUB_TOKEN.
@@ -28,9 +35,9 @@ env:
APP_DIR: .
REGISTRY: ghcr.io
# Сторонние образы пинуются тем же способом, что и свои: по digest.
POSTGRES_IMAGE: pgvector/pgvector:pg16
POSTGRES_BASE_IMAGE: pgvector/pgvector:pg16
GATEWAY_BASE_IMAGE: caddy:2.8.4
REDIS_IMAGE: redis:7-alpine
GATEWAY_IMAGE: caddy:2.8.4
COTURN_IMAGE: coturn/coturn:4.6
jobs:
@@ -58,6 +65,20 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Базовые образы закрепляем до сборки: тогда digest наших образов
# однозначно отвечает известному входу, а не плавающему тегу.
- name: Digest базовых образов
id: bases
run: |
set -eu
digest_of() {
docker buildx imagetools inspect "$1" --format '{{ "{{json .Manifest.Digest}}" }}' | tr -d '"'
}
echo "postgres=${POSTGRES_BASE_IMAGE}@$(digest_of "$POSTGRES_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
- name: Backend
id: backend
uses: docker/build-push-action@v6
@@ -84,40 +105,92 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: release.env со ссылками по digest
# Шлюз и база — свои образы: Caddyfile и init-скрипты живут внутри них,
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
- name: Gateway
id: gateway
uses: docker/build-push-action@v6
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
build-args: |
CHATBALLS_GATEWAY_BASE_IMAGE=${{ steps.bases.outputs.gateway }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/gateway:${{ steps.version.outputs.value }}
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/gateway:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Postgres
id: postgres
uses: docker/build-push-action@v6
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
build-args: |
CHATBALLS_POSTGRES_BASE_IMAGE=${{ steps.bases.outputs.postgres }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/postgres:${{ steps.version.outputs.value }}
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/postgres:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Релизный compose.yaml и release.env
id: artifacts
run: |
set -eu
version="${{ steps.version.outputs.value }}"
repo="${{ steps.version.outputs.repo }}"
prefix="${{ env.REGISTRY }}/$repo"
prefix="${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}"
# Digest своих образов отдаёт сам build-push-action; сторонние
# спрашиваем у реестра — :latest в релизе недопустим.
third_party_digest() {
docker buildx imagetools inspect "$1" --format '{{ "{{json .Manifest.Digest}}" }}' | tr -d '"'
}
backend="$prefix/backend:$version@${{ steps.backend.outputs.digest }}"
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
redis="${{ steps.bases.outputs.redis }}"
coturn="${{ steps.bases.outputs.coturn }}"
python3 scripts/pin-release-compose.py \
--source compose.yaml \
--output dist/compose.yaml \
--version "$version" \
--pin "CHATBALLS_BACKEND_IMAGE=$backend" \
--pin "CHATBALLS_FRONTEND_IMAGE=$frontend" \
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
{
echo "# release.env — сгенерирован ${{ github.workflow }} для $version."
echo "# Все образы закреплены по digest: :latest источником релиза не является."
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
echo "# Тем, кто ставит одной командой, он не нужен: всё уже внутри compose.yaml."
echo "CHATBALLS_VERSION=$version"
echo "CHATBALLS_BACKEND_IMAGE=$prefix/backend:$version@${{ steps.backend.outputs.digest }}"
echo "CHATBALLS_FRONTEND_IMAGE=$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
echo "CHATBALLS_POSTGRES_IMAGE=${POSTGRES_IMAGE}@$(third_party_digest "$POSTGRES_IMAGE")"
echo "CHATBALLS_REDIS_IMAGE=${REDIS_IMAGE}@$(third_party_digest "$REDIS_IMAGE")"
echo "CHATBALLS_GATEWAY_IMAGE=${GATEWAY_IMAGE}@$(third_party_digest "$GATEWAY_IMAGE")"
echo "CHATBALLS_COTURN_IMAGE=${COTURN_IMAGE}@$(third_party_digest "$COTURN_IMAGE")"
} > release.env
echo "CHATBALLS_BACKEND_IMAGE=$backend"
echo "CHATBALLS_FRONTEND_IMAGE=$frontend"
echo "CHATBALLS_GATEWAY_IMAGE=$gateway"
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
echo "CHATBALLS_REDIS_IMAGE=$redis"
echo "CHATBALLS_COTURN_IMAGE=$coturn"
} > dist/release.env
cat release.env
# Файл, который скачает человек, обязан быть валидным сам по себе —
# без переменных окружения и без чего-либо рядом.
( cd dist && docker compose -f compose.yaml config -q )
cat dist/compose.yaml
- uses: actions/upload-artifact@v4
with:
name: release.env
path: release.env
name: release-compose
path: |
dist/compose.yaml
dist/release.env
- name: Приложить release.env к релизу
- name: Приложить к релизу
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: release.env
files: |
dist/compose.yaml
dist/release.env
+3
View File
@@ -26,3 +26,6 @@ data/
# Архив, создаваемый pack.bat
Chatballs.zip
# Служебный каталог агента: локальные настройки запуска, а не часть продукта.
.claude/
-29
View File
@@ -1,29 +0,0 @@
# GitLab CI/CD — canonical pipeline Chatballs (ADR-HUB-0028 / PLAN-CHATBALLS-0002).
# Application orchestration is owned only by the release-bundled `chatballs` CLI.
stages:
- validate
- test
- build
- release
- deploy
variables:
APP_DIR: "."
DOCKER_TLS_CERTDIR: ""
DOCKER_DRIVER: overlay2
BACKEND_IMAGE: "$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA"
FRONTEND_IMAGE: "$CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHA"
BACKEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/backend:latest"
FRONTEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/frontend:latest"
POSTGRES_IMAGE: "pgvector/pgvector:pg16"
REDIS_IMAGE: "redis:7-alpine"
GATEWAY_IMAGE: "caddy:2.8.4"
COTURN_IMAGE: "coturn/coturn:4.6"
include:
- local: "/.gitlab/ci/validate-test.yml"
- local: "/.gitlab/ci/build-release.yml"
- local: "/.gitlab/ci/deploy.yml"
-125
View File
@@ -1,125 +0,0 @@
images:build:
stage: build
image: docker:27
services:
- name: docker:27-dind
alias: docker
needs:
- backend:test
- frontend:build
- gateway:validate
- cli:test
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- docker buildx version
script:
- |
set -eu
VERSION="${CI_COMMIT_TAG:-$CI_COMMIT_SHA}"
case "$VERSION" in
*[!A-Za-z0-9._-]*|'')
echo "Unsupported release version: $VERSION" >&2
exit 1
;;
esac
docker build \
-f "$APP_DIR/apps/backend/Dockerfile.production" \
-t "$BACKEND_IMAGE" \
-t "$BACKEND_LATEST_IMAGE" \
"$APP_DIR"
docker build \
-f "$APP_DIR/deploy/docker/frontend.Dockerfile" \
-t "$FRONTEND_IMAGE" \
-t "$FRONTEND_LATEST_IMAGE" \
"$APP_DIR"
docker push "$BACKEND_IMAGE"
docker push "$FRONTEND_IMAGE"
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
docker push "$BACKEND_LATEST_IMAGE"
docker push "$FRONTEND_LATEST_IMAGE"
fi
resolve_digest_ref() {
image="$1"
manifest="$(mktemp)"
docker buildx imagetools inspect --raw "$image" > "$manifest"
test -s "$manifest"
digest="sha256:$(sha256sum "$manifest" | awk '{print $1}')"
rm -f "$manifest"
printf '%s@%s' "$image" "$digest"
}
mkdir -p "$APP_DIR/.ci"
cat > "$APP_DIR/.ci/release.env" <<EOF
CHATBALLS_VERSION=$VERSION
CHATBALLS_BACKEND_IMAGE=$(resolve_digest_ref "$BACKEND_IMAGE")
CHATBALLS_FRONTEND_IMAGE=$(resolve_digest_ref "$FRONTEND_IMAGE")
CHATBALLS_POSTGRES_IMAGE=$(resolve_digest_ref "$POSTGRES_IMAGE")
CHATBALLS_REDIS_IMAGE=$(resolve_digest_ref "$REDIS_IMAGE")
CHATBALLS_GATEWAY_IMAGE=$(resolve_digest_ref "$GATEWAY_IMAGE")
CHATBALLS_COTURN_IMAGE=$(resolve_digest_ref "$COTURN_IMAGE")
EOF
artifacts:
paths:
- "$APP_DIR/.ci/release.env"
expire_in: 1 day
when: on_success
rules:
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_TAG'
release:bundle:
stage: release
image: alpine:3.20
needs:
- job: images:build
artifacts: true
before_script:
- apk add --no-cache coreutils findutils tar
script:
- |
set -eu
cd "$APP_DIR"
VERSION="${CI_COMMIT_TAG:-$CI_COMMIT_SHA}"
BUNDLE_DIR="chatballs-${VERSION}"
DIST_DIR="dist"
test "$(awk -F= '$1 == "CHATBALLS_VERSION" {print $2; exit}' .ci/release.env)" = "$VERSION"
rm -rf "$BUNDLE_DIR" "$DIST_DIR"
mkdir -p "$BUNDLE_DIR/deploy/cli" "$BUNDLE_DIR/deploy/postgres" "$DIST_DIR"
cp compose.yaml compose.dev.yaml Caddyfile "$BUNDLE_DIR/"
cp .ci/release.env "$BUNDLE_DIR/release.env"
cp chatballs "$BUNDLE_DIR/"
chmod +x "$BUNDLE_DIR/chatballs"
cp -R deploy/cli/lib "$BUNDLE_DIR/deploy/cli/"
cp -R deploy/postgres/. "$BUNDLE_DIR/deploy/postgres/"
(
cd "$BUNDLE_DIR"
find . -type f ! -name checksums.txt -print0 \
| LC_ALL=C sort -z \
| xargs -0 sha256sum > checksums.txt
sha256sum -c checksums.txt
)
tar -czf "$DIST_DIR/chatballs-release.tar.gz" "$BUNDLE_DIR"
printf 'CHATBALLS_RELEASE_VERSION=%s\n' "$VERSION" > "$DIST_DIR/release-metadata.env"
printf 'CHATBALLS_RELEASE_ARCHIVE=%s/dist/chatballs-release.tar.gz\n' "$APP_DIR" >> "$DIST_DIR/release-metadata.env"
artifacts:
name: "chatballs-$CI_COMMIT_REF_SLUG"
paths:
- "$APP_DIR/dist/chatballs-release.tar.gz"
- "$APP_DIR/dist/release-metadata.env"
reports:
dotenv: "$APP_DIR/dist/release-metadata.env"
expire_in: 1 year
when: on_success
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_COMMIT_TAG'
-129
View File
@@ -1,129 +0,0 @@
deploy:production:
stage: deploy
image: alpine:3.20
needs:
- job: release:bundle
artifacts: true
resource_group: production
interruptible: false
variables:
DEPLOY_ROOT: "/opt/chatballs"
before_script:
- apk add --no-cache bash openssh-client
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- |
set -eu
REMOTE_ARCHIVE="/tmp/chatballs-${CI_PIPELINE_ID}-${CI_JOB_ID}.tar.gz"
scp -P "${DEPLOY_PORT:-22}" \
"$CHATBALLS_RELEASE_ARCHIVE" \
"$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_ARCHIVE"
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
bash -s -- "$DEPLOY_ROOT" "$CHATBALLS_RELEASE_VERSION" "$REMOTE_ARCHIVE" <<'REMOTE'
set -Eeuo pipefail
root="$1"
version="$2"
archive="$3"
release_name="chatballs-$version"
target="$root/releases/$release_name"
incoming=""
cleanup() {
if [[ -n "$incoming" ]]; then
rm -rf "$incoming"
fi
rm -f "$archive"
}
trap cleanup EXIT
sudo mkdir -p \
"$root/releases" \
"$root/instance/state" \
"$root/instance/data" \
"$root/instance/backups" \
"$root/instance/logs"
sudo chown "$(id -u):$(id -g)" \
"$root" \
"$root/releases" \
"$root/instance" \
"$root/instance/state" \
"$root/instance/data" \
"$root/instance/backups" \
"$root/instance/logs"
incoming="$(mktemp -d "$root/releases/.incoming-${release_name}.XXXXXX")"
tar -xzf "$archive" -C "$incoming"
source_dir="$incoming/$release_name"
[[ -d "$source_dir" ]] || {
echo "Release directory is missing in bundle: $release_name" >&2
exit 1
}
[[ -f "$source_dir/checksums.txt" ]] || {
echo "checksums.txt is missing in release bundle" >&2
exit 1
}
(
cd "$source_dir"
sha256sum -c checksums.txt
)
if [[ -e "$target" ]]; then
[[ -f "$target/checksums.txt" ]] || {
echo "Existing release has no checksums: $target" >&2
exit 1
}
(
cd "$target"
sha256sum -c checksums.txt
)
incoming_manifest="$(sha256sum "$source_dir/checksums.txt" | awk '{print $1}')"
target_manifest="$(sha256sum "$target/checksums.txt" | awk '{print $1}')"
[[ "$incoming_manifest" = "$target_manifest" ]] || {
echo "Release $version already exists with different content" >&2
exit 1
}
else
mv "$source_dir" "$target"
fi
chmod +x "$target/chatballs"
REMOTE
- |
set -eu
printf '%s' "$CI_REGISTRY_PASSWORD" \
| ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin"
- |
set -eu
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
bash -s -- "$DEPLOY_ROOT" "$CHATBALLS_RELEASE_VERSION" <<'REMOTE'
set -Eeuo pipefail
root="$1"
version="$2"
release_dir="$root/releases/chatballs-$version"
CHATBALLS_INSTANCE_DIR="$root/instance" \
"$release_dir/chatballs" deploy --non-interactive
ln -sfnT "releases/chatballs-$version" "$root/current"
REMOTE
after_script:
- |
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker logout '$CI_REGISTRY' >/dev/null 2>&1 || true" || true
environment:
name: production
url: https://${CHATBALLS_APP_DOMAIN}
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
-86
View File
@@ -1,86 +0,0 @@
frontend:build:
stage: validate
image: node:22-alpine
before_script:
- cd "$APP_DIR"
- npm ci
script:
- npm run typecheck
- npm run build
rules:
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_TAG'
gateway:validate:
stage: validate
image: docker:27
services:
- name: docker:27-dind
alias: docker
before_script:
- mkdir -p "$APP_DIR/.ci-instance/data/caddy" "$APP_DIR/.ci-instance/data/caddy-config"
script:
- |
set -eu
# Никакого .env: продукт поднимается со значениями по умолчанию, и
# проверять его надо ровно так же, как он ставится у человека.
export CHATBALLS_INSTANCE_DIR="$CI_PROJECT_DIR/$APP_DIR/.ci-instance"
export CHATBALLS_RELEASE_DIR="$CI_PROJECT_DIR/$APP_DIR"
docker compose \
--project-directory "$CHATBALLS_INSTANCE_DIR" \
-f "$APP_DIR/compose.yaml" \
config -q
docker compose \
--project-directory "$CHATBALLS_INSTANCE_DIR" \
-f "$APP_DIR/compose.yaml" \
run --rm --no-deps gateway \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
rules:
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_TAG'
backend:test:
stage: test
image: python:3.12-slim
services:
- name: pgvector/pgvector:pg16
alias: postgres
- name: redis:7-alpine
alias: redis
variables:
CHATBALLS_DEBUG: "true"
CHATBALLS_SECRET_KEY: "ci-test-secret"
CHATBALLS_FIELD_ENCRYPTION_KEY: ""
POSTGRES_DB: "chatballs_test"
POSTGRES_USER: "chatballs"
POSTGRES_PASSWORD: "chatballs"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
REDIS_URL: "redis://redis:6379/0"
before_script:
- cd "$APP_DIR/apps/backend"
- python -m pip install --upgrade pip
- pip install -r requirements.txt
script:
- python manage.py check
- pytest
rules:
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_TAG'
cli:test:
stage: test
image: python:3.12-slim
before_script:
- apt-get update
- apt-get install -y --no-install-recommends bash coreutils util-linux
- rm -rf /var/lib/apt/lists/*
- python -m pip install --no-cache-dir pytest
script:
- cd "$APP_DIR"
- pytest -c tests/cli/pytest.ini tests/cli
rules:
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_TAG'
+3 -1
View File
@@ -1,10 +1,12 @@
repos:
# Тот же линтер и та же конфигурация, что в CI (.github/workflows/checks.yml).
# ruff-format здесь намеренно нет: он переписал бы 374 файла разом, и это
# отдельное решение, а не побочный эффект установки хука.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
+82 -38
View File
@@ -2,51 +2,71 @@
Canonical implementation workspace for Chatballs.
## Быстрый старт (одна минута)
## Установка (одна команда)
Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose
на Linux). Ни одной переменной задавать не нужно и негде: у продукта нет `.env`.
Организацию, владельца, домены, интеграции, почту и хранилище человек настраивает
в интерфейсе.
Нужен только Docker с плагином Compose. Ни одной переменной задавать не нужно и
негде: у продукта нет `.env`. Организацию, владельца, домены, интеграции, почту и
хранилище человек настраивает в интерфейсе.
Весь дистрибутив — один файл `compose.yaml` со страницы релиза: ссылки на образы
в нём закреплены по digest, а Caddyfile, init-скрипты базы и генератор секретов
лежат внутри образов. Рядом с файлом ничего лежать не должно.
Linux / macOS:
```bash
curl -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml
docker compose up -d --wait
```
Windows (PowerShell):
```powershell
curl.exe -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml
docker compose up -d --wait
```
`--wait` держит команду до готовности стека: когда она вернула управление,
установка отвечает. Откройте **http://localhost** (или адрес сервера) — вместо
входа система покажет **мастер первого запуска**: название организации, ваше имя,
e-mail и пароль владельца, переключатель «Установить демо-данные». После кнопки
«Начать» вы сразу в приложении под владельцем. Мастер доступен только пока в
системе нет ни одной организации; после создания владельца он закрывается
навсегда.
Секреты инстанса (ключ подписи, пароли ролей БД) генерирует сам первый старт и
держит в томе `chatballs-secrets`. Состояние установки живёт в именованных томах
`chatballs-*` — установка не зависит от того, из какого каталога её запустили.
Обновление — тот же файл новой версии и та же команда:
```bash
curl -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml
docker compose up -d --wait
```
Миграции прогоняет one-shot сервис `init` на каждом старте. Откат — прежняя
копия `compose.yaml` и снова та же команда.
### Запуск из исходников (разработка)
Тем, кто правит код, релизный файл не нужен: стек собирается локально.
```powershell
git clone <URL репозитория> chatballs
cd chatballs
.\scripts\start.ps1
```
Linux / macOS:
```bash
git clone <URL репозитория> chatballs
cd chatballs
./scripts/start.sh
```
Настраивать нечего: файла `.env` у продукта нет, секреты инстанса (ключ
подписи, пароли ролей БД) генерирует сам первый старт и держит их в томе
`chatballs-secrets`.
Если положить рядом со скриптом `release.env` со страницы релиза, образы
скачаются из реестра по digest и сборки не будет — это самый быстрый путь.
Без `release.env` стек собирается из исходников: так работают те, кто правит
код. Когда в логах появится готовность, откройте
**http://localhost** — вместо входа система покажет **мастер первого запуска**:
название организации, ваше имя, e-mail и пароль владельца, переключатель
«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под
владельцем. Мастер доступен только пока в системе нет ни одной организации;
после создания владельца он закрывается навсегда.
### Доступ по http и переход на TLS
Свежая установка отвечает по обычному http — по адресу сервера, пока домена и
сертификата ещё нет. Продукт не уводит себя на https принудительно: этим
занимается шлюз, когда у него появляется настоящий домен и сертификат.
Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https —
cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные
имена без `Secure`. Настраивать для этого нечего.
Первая сборка занимает минуты (`npm ci` + `pip install`). Dev-контур держит
состояние в `./data`, публикует порты Postgres/Redis и подменяет frontend на
Vite с HMR — см. `compose.dev.yaml`.
### Демо-данные
@@ -79,7 +99,11 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
### Режим поставки
По умолчанию локально запускается облачный режим. Коробочный режим — тем же
По умолчанию коробка считает себя `SELF_HOSTED`; облачный контур выставляет
`CHATBALLS_DELIVERY_MODE=CLOUD` явно. Приложение не определяет режим по домену,
числу организаций или данным. Задавать что-либо при установке не нужно и негде.
Локально `scripts/start.ps1` поднимает облачный режим; коробочный — тем же
контуром с явным признаком поставки:
```powershell
@@ -87,22 +111,17 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
.\scripts\start.ps1 -Mode SelfHosted
```
Приложение не определяет режим по домену, числу организаций или данным.
Коробка по умолчанию считает себя `SELF_HOSTED`; облачный контур выставляет
`CHATBALLS_DELIVERY_MODE=CLOUD` явно. Задавать что-либо при установке не
нужно и негде — файла с переменными у продукта нет.
### Что поднимается
- изолированные Django-рантаймы app, platform и loopback-only admin;
- фоновый worker (outbox, поллинг мессенджеров, установка демо);
- PostgreSQL и Redis;
- Internal Hub UI и Web Chat UI;
- локальный Nginx reverse proxy.
- Internal Hub UI и Web Chat UI одним nginx-образом;
- шлюз Caddy — единственная публичная граница (80/443).
Секретов production в репозитории нет.
Локальные адреса:
Локальные адреса dev-контура:
- Приложение: `http://localhost` (то же — `http://app.localhost/`)
- Health платформы: `http://platform.localhost/api/v1/health/live/`
@@ -111,8 +130,33 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
- Web Chat: `http://localhost:5175`
- App API напрямую: `http://localhost:8010/api/v1`
### Доступ по http и переход на TLS
Свежая установка отвечает по обычному http — по адресу сервера, пока домена и
сертификата ещё нет. Продукт не уводит себя на https принудительно.
Когда у установки появляется домен, владелец вписывает его в **Настройки →
Адрес установки**. С этого момента шлюз выписывает на него сертификат сам, при
первом же запросе по https: он спрашивает разрешение у самой установки, и она
подтверждает свой адрес и адреса опубликованных порталов помощи. Прежний адрес
остаётся принятым, чтобы смена не выбросила того, кто её делает.
Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https —
cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные
имена без `Secure`. Настраивать для этого нечего.
## Tests
Линтер бэкенда (та же конфигурация, что в CI — корневой `pyproject.toml`):
```bash
ruff check apps/backend
```
`ruff format` в репозитории не принят: он переписал бы 374 файла, поэтому
длина строки (`E501`) из проверок исключена — всё остальное из `E`, `F`, `I`,
`UP`, `B` и `DJ` обязано быть зелёным. Миграции не проверяются: их пишет Django.
All suites run in Docker, so no manual environment is required — the test
runners auto-detect themselves and relax production hardening (secret-key
fail-fast, SSL redirect, throttling) for the duration of the run.
+4
View File
@@ -9,6 +9,10 @@ COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt
COPY apps/backend /app/apps/backend
# Тот же генератор секретов, что и в production-образе: сервис secrets
# крутится на backend-образе и в dev, и в коробке.
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
WORKDIR /app/apps/backend
+9 -1
View File
@@ -12,7 +12,10 @@ COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt
COPY apps/backend /app/apps/backend
COPY content /app/content
# Генератор секретов инстанса живёт в образе, а не монтируется с хоста:
# установка — один compose.yaml, рядом с ним ничего лежать не должно.
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
# Build-time secret нужен только чтобы settings загрузились в production-режиме;
@@ -30,6 +33,11 @@ RUN cd apps/backend && CHATBALLS_SECRET_KEY=collectstatic-build CHATBALLS_DEBUG=
POSTGRES_MIGRATION_USER=build-migration \
python manage.py collectstatic --noinput
# Каталог локальных файлов создаётся в образе и принадлежит hub: том
# наследует владельца из образа, поэтому загрузки работают и на Linux,
# где bind-mount достался бы контейнеру как root:root.
RUN mkdir -p /app/apps/backend/media
RUN chown -R hub:hub /app
WORKDIR /app/apps/backend
-1
View File
@@ -13,7 +13,6 @@ from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
from django.utils.text import slugify
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.ai.serializers import agent_portal_article_payload
from chatballs.channels.models import Channel
from chatballs.channels.services import (
@@ -225,7 +225,6 @@ class AgentCardTestChatView(APIView):
permission_classes = [HasCapability]
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
required_capability = "ai.manage"
require_organization_scope = True
def post(self, request: Request, agent_id: int) -> Response:
try:
+1 -1
View File
@@ -30,7 +30,7 @@ def _store_fragments(*, organization, chunks: list[str], **source) -> list[Knowl
embedding=vector,
**source,
)
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
for index, (chunk, vector) in enumerate(zip(chunks, vectors, strict=False))
]
return KnowledgeFragment.objects.bulk_create(fragments)
@@ -3,8 +3,8 @@ from __future__ import annotations
from django.core.exceptions import PermissionDenied
from django.db.models import QuerySet
from chatballs.identity.policy import has_capability_any_scope
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.identity.policy import has_capability_any_scope
from chatballs.tenancy.context import TenantContext
AI_VIEW = "ai.view"
+32 -32
View File
@@ -1,32 +1,32 @@
from django.conf import settings␍
from django.db.models import Sum␍
from django.utils import timezone␍
␍
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus␍
␍
␍
class LimitExceeded(Exception):␍
pass␍
␍
␍
def _day_start():␍
now = timezone.localtime()␍
return now.replace(hour=0, minute=0, second=0, microsecond=0)␍
␍
␍
def daily_cost_micros(channel=None) -> int:␍
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)␍
if channel is not None:␍
queryset = queryset.filter(channel=channel)␍
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0␍
␍
␍
def assert_within_limits(channel, agent) -> None:␍
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS␍
if global_limit and daily_cost_micros() >= global_limit:␍
raise LimitExceeded("Global daily AI cost limit reached")␍
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается␍
# в micro-USD. 1 цент = 10 000 micro-USD.␍
channel_limit = (agent.limits or {}).get("dailyCostUsd")␍
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:␍
raise LimitExceeded("Channel daily AI cost limit reached")␍
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
class LimitExceeded(Exception):
pass
def _day_start():
now = timezone.localtime()
return now.replace(hour=0, minute=0, second=0, microsecond=0)
def daily_cost_micros(channel=None) -> int:
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
if channel is not None:
queryset = queryset.filter(channel=channel)
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
def assert_within_limits(channel, agent) -> None:
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
if global_limit and daily_cost_micros() >= global_limit:
raise LimitExceeded("Global daily AI cost limit reached")
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
# в micro-USD. 1 цент = 10 000 micro-USD.
channel_limit = (agent.limits or {}).get("dailyCostUsd")
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
raise LimitExceeded("Channel daily AI cost limit reached")
+8 -23
View File
@@ -1,19 +1,11 @@
import uuid
from django.core.exceptions import ValidationError
from django.db import models
from pgvector.django import VectorField
from chatballs.tenancy.models import TenantRelationModel
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
@@ -88,13 +80,9 @@ class Knowledge(models.Model):
def clean(self) -> None:
def __str__(self) -> str:
super().clean()
if self.category_id is not None and self.category.organization_id != self.organization_id:
raise ValidationError({"category": "Category belongs to another organization"})
return f"knowledge:{self.organization_id}/{self.title}"
@@ -106,9 +94,13 @@ class Knowledge(models.Model):
def __str__(self) -> str:
def clean(self) -> None:
return f"knowledge:{self.organization_id}/{self.title}"
super().clean()
if self.category_id is not None and self.category.organization_id != self.organization_id:
raise ValidationError({"category": "Category belongs to another organization"})
@@ -119,15 +111,10 @@ class Knowledge(models.Model):
# models after Knowledge exists so they are registered without growing this file.
from chatballs.ai.knowledge_models import ( # noqa: E402, F401
KnowledgeCategory,
)
def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str:
organization = instance.knowledge.organization
@@ -198,8 +185,6 @@ class KnowledgeAttachment(TenantRelationModel):
from django.urls import reverse
from chatballs.identity.instance_settings import public_base_url
+17 -17
View File
@@ -1,17 +1,17 @@
from django.conf import settings␍
␍
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.␍
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).␍
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.␍
DEFAULT_PRICING = {␍
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},␍
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},␍
}␍
␍
␍
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:␍
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}␍
price = table.get(model)␍
if not price:␍
return 0␍
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])␍
from django.conf import settings
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
DEFAULT_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
}
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
price = table.get(model)
if not price:
return 0
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
@@ -1,9 +1,6 @@
from chatballs.ai.provider.openrouter import OpenRouterProvider
class CustomProvider(OpenRouterProvider):
"""Generic OpenAI-compatible BYOK adapter (ADR-CHATBALLS-0034).
+7 -1
View File
@@ -15,7 +15,13 @@ import hashlib
import math
import re
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider, ProviderError
from chatballs.ai.provider.base import (
ChatMessage,
ChatResult,
EmbeddingResult,
LLMProvider,
ProviderError,
)
EMBEDDING_DIM = 16
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
@@ -32,26 +32,15 @@ do not duplicate it. Adapters stay responsible for their own product semantics
from __future__ import annotations
import http.client
import json
import urllib.error
import urllib.request
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
from chatballs.integrations.proxy import build_opener
def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
"""POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
@@ -1,5 +1,5 @@
import time
from typing import Callable
from collections.abc import Callable
from chatballs.ai.provider.base import ProviderError
@@ -1,7 +1,6 @@
import json
from django.test import TestCase
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.channels.models import Channel
@@ -17,6 +16,7 @@ from chatballs.integrations.models import (
IntegrationKind,
IntegrationProvider,
)
from chatballs.testing import TenantAPIClient as APIClient
class AgentCardTestCase(TestCase):
@@ -8,10 +8,17 @@ from chatballs.ai.provider.routing import _provider_from_integration
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
IntegrationStatus,
)
from chatballs.integrations.services import (
IntegrationInput,
create_integration,
)
from chatballs.integrations.services import (
# Алиас обязателен: имя test_* на уровне модуля pytest собирает как тест
# и падает на ненайденных фикстурах (как в integrations/tests.py).
test_integration as run_integration_test,
+7 -2
View File
@@ -3,13 +3,14 @@ import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from chatballs.testing import TenantAPIClient as APIClient, system_tenant_context
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import system_tenant_context
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
@@ -284,7 +285,11 @@ class ResilienceTests(TestCase):
def test_circuit_breaker_opens_after_threshold(self) -> None:
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.resilience import CircuitBreaker, CircuitBreakerOpen, call_with_resilience
from chatballs.ai.provider.resilience import (
CircuitBreaker,
CircuitBreakerOpen,
call_with_resilience,
)
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
+2 -2
View File
@@ -5,7 +5,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.ai.api_errors import validation_error_response
from chatballs.api.pagination import page_payload, paginate
from chatballs.ai.indexing import reindex_knowledge
from chatballs.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
from chatballs.ai.knowledge_import import import_knowledge_documents
from chatballs.ai.knowledge_policy import (
@@ -19,7 +19,6 @@ from chatballs.ai.knowledge_services import (
delete_knowledge,
update_knowledge,
)
from chatballs.ai.indexing import reindex_knowledge
from chatballs.ai.models import Knowledge
from chatballs.ai.selectors import (
apply_knowledge_filters,
@@ -29,6 +28,7 @@ from chatballs.ai.selectors import (
writable_knowledge_item_for_employee,
)
from chatballs.ai.serializers import attachment_payload, knowledge_payload
from chatballs.api.pagination import page_payload, paginate
from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import AuditEvent
+1 -4
View File
@@ -1,17 +1,14 @@
from typing import Any
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
def _flatten(data: Any) -> str:
if isinstance(data, str):
return data
if isinstance(data, dict):
return "; ".join(_flatten(value) for value in data.values())
if isinstance(data, (list, tuple)):
if isinstance(data, list | tuple):
return "; ".join(_flatten(item) for item in data)
return str(data)
@@ -12,6 +12,11 @@ class HasCapability(BasePermission):
Views declare ``required_capability`` or a method keyed
``required_capabilities`` mapping. Object/resource scope is still checked by the
view after loading the canonical resource.
Организационная область не объявляется вьюхой и не отключается: без
членства в организации проверка не проходит вообще. Раньше рядом стоял
атрибут ``require_organization_scope = True``, который никто не читал — он
выглядел как переключатель там, где переключателя нет.
"""
message = "Required capability is missing"
+1 -2
View File
@@ -17,10 +17,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
from chatballs.calls import signaling
from chatballs.calls.errors import CallTokenError
from chatballs.calls.models import TERMINAL_CALL_STATUSES
from chatballs.calls.models import TERMINAL_CALL_STATUSES, ParticipantSide
from chatballs.calls.permissions import staff_call_access_valid
from chatballs.calls.services import authorize_call_access_context
from chatballs.calls.models import ParticipantSide
from chatballs.tenancy.database import run_tenant_operation
logger = logging.getLogger(__name__)
@@ -12,7 +12,6 @@ from chatballs.calls.models import (
ParticipantSide,
)
from chatballs.conversations.models import Message, MessageAuthor
from chatballs.tenancy.context import TenantContext
ALLOWED_TRANSITIONS = {
CallStatus.REQUESTED: {
+1 -1
View File
@@ -46,7 +46,7 @@ def record_call_metric(
local = _sanitize_candidate_type(local_candidate_type)
remote = _sanitize_candidate_type(remote_candidate_type)
rtt: int | None = None
if isinstance(round_trip_ms, (int, float)) and not isinstance(round_trip_ms, bool):
if isinstance(round_trip_ms, int | float) and not isinstance(round_trip_ms, bool):
rtt = max(0, min(_MAX_ROUND_TRIP_MS, int(round_trip_ms)))
CallMetric.objects.update_or_create(
call_session_id=call_session_id,
+3 -3
View File
@@ -113,15 +113,15 @@ class CallSession(models.Model):
]
indexes = [models.Index(fields=["organization", "status"])]
def __str__(self) -> str:
return f"call:{self.id}/{self.status}"
@property
def duration_seconds(self) -> int | None:
if self.connected_at is None or self.ended_at is None:
return None
return max(0, int((self.ended_at - self.connected_at).total_seconds()))
def __str__(self) -> str:
return f"call:{self.id}/{self.status}"
class CallInvite(TenantRelationModel):
tenant_relation_fields = ("call_session", "connection_identity")
+5 -1
View File
@@ -1,7 +1,11 @@
from django.urls import path
from chatballs.calls.consumers import CallSignalingConsumer
from chatballs.http.ws_middleware import SameOriginWebSocketMiddleware
# Сигналинг звонка аутентифицируется call access token, а не сессией, поэтому
# переименование cookie ему не нужно. Проверка Origin — нужна: страницу звонка
# открывает браузер, и чужой сайт не должен открывать сокет за него.
websocket_urlpatterns = [
path("ws/calls/", CallSignalingConsumer.as_asgi()),
path("ws/calls/", SameOriginWebSocketMiddleware(CallSignalingConsumer.as_asgi())),
]
+1 -1
View File
@@ -14,7 +14,6 @@ from chatballs.calls.errors import (
CallTokenError,
)
from chatballs.calls.lifecycle import transition_call
from chatballs.integrations.features import call_allowed
from chatballs.calls.metrics import record_call_metric
from chatballs.calls.models import (
TERMINAL_CALL_STATUSES,
@@ -53,6 +52,7 @@ from chatballs.conversations.models import (
)
from chatballs.conversations.services import ClaimError, claim_locked_conversation
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.integrations.features import call_allowed
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
+2 -1
View File
@@ -10,16 +10,17 @@ from django.utils import timezone
from chatballs.calls.errors import CallInvalidTransition
from chatballs.calls.lifecycle import finish_call, transition_call
from chatballs.calls.models import (
TERMINAL_CALL_STATUSES,
CallParticipant,
CallSession,
CallStatus,
ParticipantConnectionState,
TERMINAL_CALL_STATUSES,
)
from chatballs.calls.serializers import public_call_state_payload
from chatballs.calls.services import record_call_metric
from chatballs.tenancy.context import TenantContext
def _call(context: TenantContext, call_id) -> CallSession:
return CallSession.objects.select_related("initiated_by").get(
id=call_id, organization=context.organization
@@ -1,11 +1,10 @@
import json
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.calls.models import CallKind, CallSession, CallStatus, ParticipantSide
from chatballs.calls.tests.helpers import CallTestCase, create_call_request
from chatballs.calls.tokens import verify_call_access_token
from chatballs.conversations.models import ControlMode
from chatballs.testing import TenantAPIClient as APIClient
class InternalCallApiTests(CallTestCase):
@@ -3,13 +3,12 @@ TG/MAX через outbox, истечение и системные событи
import json
from datetime import timedelta
from urllib.parse import parse_qs, urlparse
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.utils import timezone
from chatballs.testing import TenantAPIClient as APIClient, tenant_context_for
from chatballs.calls.event_handlers import handle_call_invite_send
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
from chatballs.calls.models import (
CallEndedBy,
CallInvite,
@@ -18,14 +17,16 @@ from chatballs.calls.models import (
InviteDeliveryStatus,
)
from chatballs.calls.services import (
open_call_for_identity,
decline_call_for_identity,
open_call_for_identity,
)
from chatballs.calls.tests.helpers import CallTestCase, create_call_request, expire_stale_calls
from chatballs.calls.tokens import hash_invite_token
from chatballs.conversations.models import Conversation, Message
from chatballs.events.models import OutboxEvent
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import tenant_context_for
class CancelCallApiTests(CallTestCase):
@@ -279,7 +280,7 @@ class MessengerDeliveryTests(CallTestCase):
with mock.patch(
"chatballs.calls.event_handlers.transports.send_call_invite", return_value=False
):
with self.assertRaises(Exception):
with self.assertRaises(CallInviteDeliveryError):
handle_call_invite_send(
{"callSessionId": str(created.call_session.id)},
tenant_context_for(self.owner, self.organization),
@@ -270,7 +270,7 @@ class AuthDeadlineTests(CallDomainMixin, TransactionTestCase):
self.assertEqual(output["code"], consumers.AUTH_TIMEOUT_CLOSE)
def test_authenticated_socket_survives_the_deadline(self) -> None:
created, customer_token = None, None
customer_token = None
def prepare():
create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
@@ -1,5 +1,5 @@
from datetime import timedelta
import uuid
from datetime import timedelta
from django.test import override_settings
from django.utils import timezone
+1 -1
View File
@@ -32,7 +32,7 @@ class CallAccessClaims:
def _derived_secret(purpose: str) -> bytes:
return hmac.new(
(settings.SECRET_KEY or "").encode("utf-8"),
f"hub:{purpose}:v1".encode("utf-8"),
f"hub:{purpose}:v1".encode(),
hashlib.sha256,
).digest()
+8 -8
View File
@@ -1,8 +1,8 @@
from django.apps import AppConfig␍
␍
␍
class ChannelsConfig(AppConfig):␍
default_auto_field = "django.db.models.BigAutoField"␍
label = "channels"␍
name = "chatballs.channels"␍
verbose_name = "Processing channels (AI context)"␍
from django.apps import AppConfig
class ChannelsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "channels"
name = "chatballs.channels"
verbose_name = "Processing channels (AI context)"
+21 -21
View File
@@ -1,21 +1,21 @@
from django.contrib import admin␍
␍
from chatballs.conversations.models import Contact, Conversation, Message␍
␍
␍
@admin.register(Conversation)␍
class ConversationAdmin(admin.ModelAdmin):␍
list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")␍
list_filter = ("lifecycle", "control_mode")␍
␍
␍
@admin.register(Contact)␍
class ContactAdmin(admin.ModelAdmin):␍
list_display = ("id", "name", "organization", "created_at")␍
search_fields = ("name",)␍
␍
␍
@admin.register(Message)␍
class MessageAdmin(admin.ModelAdmin):␍
list_display = ("id", "conversation", "author_type", "created_at")␍
list_filter = ("author_type",)␍
from django.contrib import admin
from chatballs.conversations.models import Contact, Conversation, Message
@admin.register(Conversation)
class ConversationAdmin(admin.ModelAdmin):
list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")
list_filter = ("lifecycle", "control_mode")
@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
list_display = ("id", "name", "organization", "created_at")
search_fields = ("name",)
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ("id", "conversation", "author_type", "created_at")
list_filter = ("author_type",)
@@ -15,10 +15,10 @@ from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.conversations.models import (
ControlMode,
Conversation,
ConversationLabel,
ConversationPriority,
ControlMode,
LifecycleState,
ReplyTemplate,
)
@@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.conversations.models import ConnectionIdentity, ContactMerge, Contact, Conversation
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
from chatballs.identity.audit import record_audit_event
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
@@ -4,7 +4,6 @@ import html
from html.parser import HTMLParser
from urllib.parse import urlsplit
_ALLOWED_TAGS = {
"a",
"b",
+20 -8
View File
@@ -48,15 +48,24 @@ _ROLE = {
def _already_processed(context: TenantContext, source: str, external_id: str, text: str) -> bool:
"""Отметить сообщение обработанным; True — оно уже приходило.
Вставка идёт своей точкой сохранения. Вызывают эту функцию изнутри
транзакции (воркер держит ``tenant_atomic`` на весь цикл поллинга), а
IntegrityError в Postgres обрывает транзакцию целиком: без savepoint
первый же повтор сообщения ронял не дедупликацию, а весь цикл — со всеми
остальными подключениями организации.
"""
payload_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
try:
InboxEvent.objects.create(
source=source,
external_event_id=external_id,
payload_hash=payload_hash,
ownership=EventOwnership.TENANT,
organization=context.organization,
)
with transaction.atomic():
InboxEvent.objects.create(
source=source,
external_event_id=external_id,
payload_hash=payload_hash,
ownership=EventOwnership.TENANT,
organization=context.organization,
)
return False
except IntegrityError:
return True
@@ -89,7 +98,10 @@ class TranscriptionJob:
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.routing import DEFAULT_TRANSCRIPTION_MODEL, resolve_transcription_model
from chatballs.ai.provider.routing import (
DEFAULT_TRANSCRIPTION_MODEL,
resolve_transcription_model,
)
if not message.audio:
return None
@@ -1,17 +1,10 @@
import logging
from datetime import timedelta
from django.utils import timezone
from chatballs.conversations.models import Conversation, LifecycleState
logger = logging.getLogger(__name__)
@@ -1,44 +1,44 @@
# Generated by Django 5.2.15 on 2026-09-04 20:40␍
␍
import chatballs.conversations.models␍
from django.db import migrations, models␍
␍
␍
class Migration(migrations.Migration):␍
␍
dependencies = [␍
('conversations', '0011_conversation_archived_at_conversation_note_and_more'),␍
]␍
␍
operations = [␍
migrations.AddField(␍
model_name='message',␍
name='audio',␍
field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='audio_content_type',␍
field=models.CharField(blank=True, max_length=64),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='duration_seconds',␍
field=models.PositiveIntegerField(default=0),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='transcript',␍
field=models.TextField(blank=True),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='transcript_status',␍
field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),␍
),␍
migrations.AlterField(␍
model_name='message',␍
name='kind',␍
field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),␍
),␍
]␍
# Generated by Django 5.2.15 on 2026-09-04 20:40
import chatballs.conversations.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0011_conversation_archived_at_conversation_note_and_more'),
]
operations = [
migrations.AddField(
model_name='message',
name='audio',
field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path),
),
migrations.AddField(
model_name='message',
name='audio_content_type',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='message',
name='duration_seconds',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='transcript',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='message',
name='transcript_status',
field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),
),
migrations.AlterField(
model_name='message',
name='kind',
field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),
),
]
+38 -38
View File
@@ -1,38 +1,38 @@
import logging␍
␍
from chatballs.conversations import transports␍
from chatballs.conversations.ingest import ingest_inbound␍
from chatballs.integrations.models import Integration␍
␍
logger = logging.getLogger(__name__)␍
␍
␍
def poll_all_messengers(context) -> int:␍
"""Poll every messenger connection bound to a channel; ingest inbound. Returns count."""␍
# Сервисные боты уведомлений поллятся отдельно (notifications.binding).␍
# Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки␍
# без ключа purpose (NULL в SQL), т.е. все клиентские боты.␍
integrations = [␍
integration␍
for integration in Integration.objects.filter(␍
organization=context.organization,␍
provider__in=transports.SUPPORTED_PROVIDERS,␍
is_active=True,␍
channel__isnull=False,␍
channel__is_active=True,␍
).exclude(secret="")␍
if integration.config.get("purpose") != "notifications"␍
]␍
total = 0␍
for integration in integrations:␍
messages, new_marker = transports.poll(integration)␍
for inbound in messages:␍
try:␍
ingest_inbound(integration, inbound)␍
total += 1␍
except Exception: # pragma: no cover␍
logger.exception("Ingest failed for integration %s", integration.id)␍
if new_marker and new_marker != integration.poll_marker:␍
integration.poll_marker = new_marker␍
integration.save(update_fields=["poll_marker", "updated_at"])␍
return total␍
import logging
from chatballs.conversations import transports
from chatballs.conversations.ingest import ingest_inbound
from chatballs.integrations.models import Integration
logger = logging.getLogger(__name__)
def poll_all_messengers(context) -> int:
"""Poll every messenger connection bound to a channel; ingest inbound. Returns count."""
# Сервисные боты уведомлений поллятся отдельно (notifications.binding).
# Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки
# без ключа purpose (NULL в SQL), т.е. все клиентские боты.
integrations = [
integration
for integration in Integration.objects.filter(
organization=context.organization,
provider__in=transports.SUPPORTED_PROVIDERS,
is_active=True,
channel__isnull=False,
channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
]
total = 0
for integration in integrations:
messages, new_marker = transports.poll(integration)
for inbound in messages:
try:
ingest_inbound(integration, inbound)
total += 1
except Exception: # pragma: no cover
logger.exception("Ingest failed for integration %s", integration.id)
if new_marker and new_marker != integration.poll_marker:
integration.poll_marker = new_marker
integration.save(update_fields=["poll_marker", "updated_at"])
return total
@@ -1,16 +1,15 @@
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from django.core.exceptions import ValidationError
from chatballs.api.pagination import page_payload, paginate
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import Contact
from chatballs.identity.models import EmployeeRole
from chatballs.conversations.stats import sales_overview_stats
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import EmployeeRole
class ConversationStatsView(ConversationViewBase):
@@ -2,13 +2,18 @@ from channels.auth import AuthMiddlewareStack
from django.urls import path
from chatballs.conversations.consumers import ConversationEventsConsumer
from chatballs.http.ws_middleware import websocket_boundary
# Оповещения о диалогах аутентифицируются сессией того же SPA — отдельного
# токена, как у сигналинга звонков, здесь не нужно: сокет открывает тот же
# браузер. Организация стоит в адресе, как и во всём HTTP-слое.
#
# websocket_boundary снаружи AuthMiddlewareStack: он приводит имена cookie к
# тем, по которым Channels ищет сессию (по TLS браузер держит __Host-…), и
# отбивает хендшейк с чужим Origin.
websocket_urlpatterns = [
path(
"ws/organizations/<uuid:organization_public_id>/conversations/",
AuthMiddlewareStack(ConversationEventsConsumer.as_asgi()),
websocket_boundary(AuthMiddlewareStack(ConversationEventsConsumer.as_asgi())),
),
]
@@ -1,6 +1,5 @@
from django.db.models import Count, Max, Q
from chatballs.integrations.features import features_payload
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
@@ -9,6 +8,7 @@ from chatballs.conversations.models import (
MessageKind,
)
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import IntegrationProvider
@@ -1,39 +1,21 @@
from django.db import transaction
from django.utils import timezone
from chatballs.conversations import transports
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
Message,
MessageAuthor,
MessageKind,
)
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
@@ -15,8 +15,8 @@ from django.utils import timezone
from chatballs.ai.models import LlmInvocation
from chatballs.channels.selectors import channels_in_organization
from chatballs.conversations.models import (
Conversation,
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
Message,
@@ -1,5 +1,4 @@
from django.test import TestCase
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.channels.models import Channel
from chatballs.conversations.models import Contact, Conversation
@@ -10,6 +9,7 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient as APIClient
class ConversationVisibilityTests(TestCase):
@@ -2,15 +2,14 @@ import json
from django.test import TestCase
from django.utils import timezone
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
Contact,
ControlMode,
Conversation,
ConversationLabel,
ConversationPriority,
ControlMode,
LifecycleState,
ReplyTemplate,
)
@@ -21,6 +20,7 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient as APIClient
class ChatExtrasTestCase(TestCase):
@@ -5,7 +5,7 @@ from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
from chatballs.conversations.models import ConnectionIdentity, Contact, Conversation
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
@@ -3,39 +3,22 @@
from email import message_from_bytes, policy
from email.message import EmailMessage as MimeMessage
from unittest import mock
from django.http import QueryDict
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.selectors import conversation_messages
from chatballs.conversations.serializers import conversation_payload, message_payload
from chatballs.conversations.transports import email as email_transport
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
EMAIL_CONFIG = {
"email": "support@example.com",
@@ -499,7 +482,6 @@ class EmailIngestThreadMetaTests(TestCase):
def _ingest(self, *, external_id: str, subject: str, message_id: str) -> None:
from chatballs.conversations.ingest import ingest_inbound
from chatballs.conversations.transports.base import InboundMessage
@@ -0,0 +1,53 @@
"""Повторная доставка входящего не должна ломать транзакцию.
Дедупликация ставит запись в inbox и ловит IntegrityError на повторе. Ловить
его без точки сохранения нельзя: Postgres обрывает транзакцию целиком, и
следующий же запрос падает с TransactionManagementError. А вызывают это
изнутри транзакции — воркер держит ``tenant_atomic`` на весь цикл поллинга,
так что первый повтор ронял не дедупликацию, а весь цикл организации.
"""
from __future__ import annotations
from django.db import transaction
from django.test import TestCase
from chatballs.conversations.ingest import _already_processed
from chatballs.events.models import InboxEvent
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.tenancy.database import tenant_atomic
from chatballs.testing import system_tenant_context
class InboundDeduplicationTests(TestCase):
def setUp(self) -> None:
result = bootstrap_owner(email="ingest-owner@example.com", password="Owner-Password-2026!")
self.context = system_tenant_context(result.organization)
def test_repeat_is_reported_without_breaking_the_transaction(self) -> None:
with tenant_atomic(self.context):
first = _already_processed(self.context, "telegram:1", "update-42", "привет")
second = _already_processed(self.context, "telegram:1", "update-42", "привет")
self.assertFalse(first)
self.assertTrue(second)
# Главное: транзакция жива и дальше в ней можно работать. Раньше
# именно здесь всё и разваливалось.
self.assertEqual(
InboxEvent.objects.filter(external_event_id="update-42").count(), 1
)
def test_repeat_does_not_roll_back_work_done_earlier(self) -> None:
with transaction.atomic():
with tenant_atomic(self.context):
_already_processed(self.context, "max:7", "update-1", "первое")
_already_processed(self.context, "max:7", "update-1", "первое")
_already_processed(self.context, "max:7", "update-2", "второе")
self.assertEqual(InboxEvent.objects.filter(source="max:7").count(), 2)
def test_different_sources_do_not_collide(self) -> None:
with tenant_atomic(self.context):
self.assertFalse(_already_processed(self.context, "telegram:1", "shared-id", "текст"))
self.assertFalse(_already_processed(self.context, "max:2", "shared-id", "текст"))
@@ -1,57 +1,30 @@
import json
from unittest import mock
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations.ingest import ingest_inbound
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
MessageAuthor,
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import HumanUser, Organization
from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
from chatballs.testing import TenantAPIClient as APIClient
def _connection(channel: Channel) -> Integration:
return Integration.objects.create(
@@ -1,10 +1,9 @@
from unittest import mock
from django.core.files.base import ContentFile
from django.db import connections
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connections
from django.test import TestCase, TransactionTestCase
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.ai.provider.local import LocalProvider
from chatballs.channels.models import Channel
@@ -20,7 +19,6 @@ from chatballs.conversations.models import (
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.tenancy.database import current_tenant_id, tenant_atomic
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
@@ -32,6 +30,8 @@ from chatballs.integrations.models import (
IntegrationKind,
IntegrationProvider,
)
from chatballs.tenancy.database import current_tenant_id, tenant_atomic
from chatballs.testing import TenantAPIClient as APIClient
class VoiceFixtureMixin:
@@ -1,13 +1,11 @@
import json
from unittest import mock
from django.test import TestCase, override_settings
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
ConnectionIdentity,
@@ -19,9 +17,9 @@ from chatballs.conversations.models import (
MessageAuthor,
MessageKind,
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.conversations.transports import max as max_transport
from chatballs.conversations.transports import telegram as telegram_transport
from chatballs.conversations.transports.base import InboundMessage
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import (
EmployeeRole,
@@ -31,6 +29,7 @@ from chatballs.identity.models import (
)
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
from chatballs.testing import TenantAPIClient as APIClient
def _messenger_connection(channel):
@@ -13,7 +13,6 @@ from django.conf import settings
from chatballs.integrations.outbound import ensure_downloadable
from chatballs.integrations.proxy import build_opener
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
@@ -94,9 +93,15 @@ def download_bytes(
``allowed_host`` — хост из ``base_url`` подключения, который владелец
назвал сам.
"""
ensure_downloadable(url, allowed_host=allowed_host, via_proxy=bool(proxy_url))
def guard(candidate: str) -> None:
ensure_downloadable(candidate, allowed_host=allowed_host, via_proxy=bool(proxy_url))
guard(url)
request = urllib.request.Request(url)
with build_opener(proxy_url).open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
# Та же проверка на каждый редирект: провайдер отдаёт адрес данными, и
# 302 увёл бы скачивание туда, куда исходный адрес не пустили.
opener = build_opener(proxy_url, validate_redirect=guard)
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
data = response.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("Файл больше допустимого размера")
@@ -19,7 +19,13 @@ from email.utils import parseaddr
from django.conf import settings
from chatballs.conversations.html_sanitizer import sanitize_email_html
from chatballs.conversations.transports.base import MAX_ATTACHMENT_BYTES, InboundFile, InboundMessage, guess_content_type, safe_filename
from chatballs.conversations.transports.base import (
MAX_ATTACHMENT_BYTES,
InboundFile,
InboundMessage,
guess_content_type,
safe_filename,
)
logger = logging.getLogger(__name__)
+7 -1
View File
@@ -1,6 +1,12 @@
from django.urls import path
from chatballs.conversations import attachment_views, chat_extras_views, reporting_views, views, voice_views
from chatballs.conversations import (
attachment_views,
chat_extras_views,
reporting_views,
views,
voice_views,
)
urlpatterns = [
path("", views.ConversationListView.as_view(), name="conversation-list"),
@@ -1,43 +1,43 @@
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.conversations.models import Conversation
from chatballs.conversations.selectors import conversation_for_context
from chatballs.identity.audit import record_audit_event
from chatballs.identity.policy import require_capability
class ConversationViewBase(APIView):
permission_classes = [HasCapability]
required_capability = "conversations.view"
def _org(self, request: Request):
return request.tenant_context.organization
def _conversation(
self,
request: Request,
conversation_id: int,
capability: str = "conversations.view",
) -> Conversation:
conversation = conversation_for_context(
context=request.tenant_context, conversation_id=conversation_id
)
if not require_capability(
request.tenant_context.membership, capability, conversation
):
raise Conversation.DoesNotExist
return conversation
def _audit(
self, request: Request, action: str, conversation: Conversation
) -> None:
record_audit_event(
action=f"conversations.{action}",
actor=request.user,
organization=self._org(request),
object_type="Conversation",
object_id=str(conversation.id),
request=request,
)
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.conversations.models import Conversation
from chatballs.conversations.selectors import conversation_for_context
from chatballs.identity.audit import record_audit_event
from chatballs.identity.policy import require_capability
class ConversationViewBase(APIView):
permission_classes = [HasCapability]
required_capability = "conversations.view"
def _org(self, request: Request):
return request.tenant_context.organization
def _conversation(
self,
request: Request,
conversation_id: int,
capability: str = "conversations.view",
) -> Conversation:
conversation = conversation_for_context(
context=request.tenant_context, conversation_id=conversation_id
)
if not require_capability(
request.tenant_context.membership, capability, conversation
):
raise Conversation.DoesNotExist
return conversation
def _audit(
self, request: Request, action: str, conversation: Conversation
) -> None:
record_audit_event(
action=f"conversations.{action}",
actor=request.user,
organization=self._org(request),
object_type="Conversation",
object_id=str(conversation.id),
request=request,
)
@@ -2,6 +2,12 @@ from django.db import transaction
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.api.pagination import (
cursor_id,
window,
window_payload,
window_size,
)
from chatballs.conversations.models import (
ControlMode,
Conversation,
@@ -9,12 +15,6 @@ from chatballs.conversations.models import (
LifecycleState,
Message,
)
from chatballs.api.pagination import (
cursor_id,
window,
window_payload,
window_size,
)
from chatballs.conversations.selectors import (
MESSAGES_NEWER_KEYS,
MESSAGES_OLDER_KEYS,
+1 -1
View File
@@ -1,5 +1,5 @@
import logging
from typing import Callable
from collections.abc import Callable
from chatballs.events.models import OutboxEvent
from chatballs.events.services import tenant_context_for_event
@@ -0,0 +1,122 @@
"""WebSocket-обвязка: имена cookie по TLS и проверка Origin.
Установка с сертификатом отдаёт браузеру только ``__Host-``-имена, а Channels
ищет сессию строго по ``settings.SESSION_COOKIE_NAME``. Пока это не сходилось,
каждый сокет на https закрывался как неаутентифицированный — и живые
обновления диалогов молча не работали, притом что REST на той же странице
работал.
"""
from __future__ import annotations
import asyncio
from django.test import SimpleTestCase, override_settings
from chatballs.http.ws_middleware import (
SameOriginWebSocketMiddleware,
TlsAwareCookieASGIMiddleware,
)
PLAIN = "chatballs_app_session"
HARDENED = "__Host-chatballs-app-session"
COOKIE_NAMES = {PLAIN: HARDENED, "chatballs_app_csrftoken": "__Host-chatballs-app-csrf"}
class _Recorder:
"""Внутреннее приложение: запоминает scope, до которого дошёл запрос."""
def __init__(self) -> None:
self.scope: dict | None = None
async def __call__(self, scope, receive, send):
self.scope = scope
def _scope(*, scheme: str = "wss", cookie: str = "", origin: str = "", host: str = "app.example") -> dict:
headers = [(b"host", host.encode())]
if cookie:
headers.append((b"cookie", cookie.encode()))
if origin:
headers.append((b"origin", origin.encode()))
return {"type": "websocket", "scheme": scheme, "headers": headers}
def _cookies(scope: dict) -> dict[str, str]:
raw = next((value for key, value in scope["headers"] if key == b"cookie"), b"")
items = [part.strip() for part in raw.decode().split(";") if part.strip()]
return dict(item.split("=", 1) for item in items)
@override_settings(CHATBALLS_TLS_COOKIE_NAMES=COOKIE_NAMES)
class TlsAwareCookieASGIMiddlewareTests(SimpleTestCase):
def _run(self, scope: dict) -> dict:
inner = _Recorder()
asyncio.run(TlsAwareCookieASGIMiddleware(inner)(scope, None, None))
assert inner.scope is not None
return inner.scope
def test_hardened_cookie_is_delivered_under_the_plain_name(self) -> None:
scope = self._run(_scope(cookie=f"{HARDENED}=session-value"))
self.assertEqual(_cookies(scope)[PLAIN], "session-value")
def test_plain_cookie_alone_is_dropped_over_tls(self) -> None:
"""По TLS обычное имя мы не выдаём — значит пришло оно не от нас."""
scope = self._run(_scope(cookie=f"{PLAIN}=planted"))
self.assertNotIn(PLAIN, _cookies(scope))
def test_hardened_cookie_wins_over_a_planted_plain_one(self) -> None:
scope = self._run(_scope(cookie=f"{PLAIN}=planted; {HARDENED}=real"))
self.assertEqual(_cookies(scope)[PLAIN], "real")
def test_plain_http_scope_is_untouched(self) -> None:
scope = self._run(_scope(scheme="ws", cookie=f"{PLAIN}=session-value"))
self.assertEqual(_cookies(scope)[PLAIN], "session-value")
def test_scope_without_cookies_passes_through(self) -> None:
scope = self._run(_scope())
self.assertEqual(_cookies(scope), {})
class SameOriginWebSocketMiddlewareTests(SimpleTestCase):
def _run(self, scope: dict) -> tuple[dict | None, list[dict]]:
inner = _Recorder()
sent: list[dict] = []
async def send(message):
sent.append(message)
asyncio.run(SameOriginWebSocketMiddleware(inner)(scope, None, send))
return inner.scope, sent
def test_same_origin_handshake_passes(self) -> None:
scope, sent = self._run(_scope(origin="https://app.example", host="app.example"))
self.assertIsNotNone(scope)
self.assertEqual(sent, [])
def test_foreign_origin_is_closed(self) -> None:
scope, sent = self._run(_scope(origin="https://evil.example", host="app.example"))
self.assertIsNone(scope)
self.assertEqual(sent, [{"type": "websocket.close", "code": 4403}])
def test_origin_with_matching_port_passes(self) -> None:
scope, sent = self._run(
_scope(origin="http://localhost:5173", host="localhost:5173")
)
self.assertIsNotNone(scope)
self.assertEqual(sent, [])
def test_client_without_origin_is_allowed(self) -> None:
"""Origin шлёт браузер; клиенты без него — не то, от чего мы защищаемся."""
scope, sent = self._run(_scope(host="app.example"))
self.assertIsNotNone(scope)
self.assertEqual(sent, [])
@@ -0,0 +1,117 @@
"""ASGI-обвязка WebSocket: имена cookie по факту TLS и проверка Origin.
HTTP-слой продукта переименовывает cookie по протоколу запроса
(``chatballs.http.middleware.TlsAwareCookieMiddleware``): по https браузер
держит ``__Host-…``, по http — обычное имя. Django-middleware на
WebSocket-хендшейк не выполняется, а Channels ищет cookie строго по
``settings.SESSION_COOKIE_NAME``. Из-за этого на установке с TLS сокет не
находил сессию вообще: браузер присылал только защищённое имя, и каждое
подключение закрывалось как неаутентифицированное — живые обновления диалогов
молча переставали работать.
Здесь то же правило применяется к scope до ``AuthMiddlewareStack``.
"""
from __future__ import annotations
from collections.abc import Callable
from http.cookies import SimpleCookie
from urllib.parse import urlsplit
from django.conf import settings
def _tls_cookie_pairs() -> tuple[tuple[str, str], ...]:
mapping = getattr(settings, "CHATBALLS_TLS_COOKIE_NAMES", {})
return tuple((plain, hardened) for plain, hardened in mapping.items() if plain != hardened)
def _header(scope: dict, name: bytes) -> bytes:
for key, value in scope.get("headers") or ():
if key.lower() == name:
return value
return b""
def _replace_header(scope: dict, name: bytes, value: bytes) -> None:
headers = [(key, item) for key, item in (scope.get("headers") or ()) if key.lower() != name]
if value:
headers.append((name, value))
scope["headers"] = headers
class TlsAwareCookieASGIMiddleware:
"""По wss отдаёт вглубь защищённые cookie под обычными именами."""
def __init__(self, inner: Callable) -> None:
self.inner = inner
async def __call__(self, scope, receive, send):
if scope.get("type") == "websocket" and scope.get("scheme") in {"wss", "https"}:
self._rewrite(scope)
return await self.inner(scope, receive, send)
@staticmethod
def _rewrite(scope: dict) -> None:
pairs = _tls_cookie_pairs()
if not pairs:
return
raw = _header(scope, b"cookie")
if not raw:
return
jar = SimpleCookie()
jar.load(raw.decode("latin-1"))
values = {key: morsel.value for key, morsel in jar.items()}
changed = False
for plain, hardened in pairs:
if hardened in values:
# Защищённое имя всегда сильнее обычного — то же правило, что и
# в HTTP-слое: cookie с префиксом __Host- браузер принимает
# только с самого хоста и только по TLS.
if values.get(plain) != values[hardened]:
values[plain] = values[hardened]
changed = True
elif plain in values:
# По TLS обычное имя мы не выдаём — значит пришло не от нас.
del values[plain]
changed = True
if not changed:
return
rebuilt = "; ".join(f"{key}={value}" for key, value in values.items())
_replace_header(scope, b"cookie", rebuilt.encode("latin-1"))
class SameOriginWebSocketMiddleware:
"""Отклоняет хендшейк, у которого Origin не совпадает с Host.
Сокет открывает то же SPA, что и REST, поэтому Origin у него всегда наш.
Кросс-сайтовый запрос сейчас и так остаётся без cookie (``SameSite=Lax``),
но полагаться на один барьер не стоит: у cookie этот флаг настраиваемый.
Клиенты без браузера Origin не присылают — их не трогаем.
"""
def __init__(self, inner: Callable) -> None:
self.inner = inner
async def __call__(self, scope, receive, send):
if scope.get("type") == "websocket" and not self._allowed(scope):
await send({"type": "websocket.close", "code": 4403})
return
return await self.inner(scope, receive, send)
@staticmethod
def _allowed(scope: dict) -> bool:
origin = _header(scope, b"origin").decode("latin-1").strip()
if not origin:
return True
host = _header(scope, b"host").decode("latin-1").strip().lower()
if not host:
return False
origin_host = (urlsplit(origin).netloc or "").lower()
return origin_host == host
def websocket_boundary(inner: Callable) -> Callable:
"""Обе проверки одним вызовом — порядок важен, Origin проверяется первым."""
return SameOriginWebSocketMiddleware(TlsAwareCookieASGIMiddleware(inner))
@@ -13,7 +13,6 @@ from chatballs.identity.audit_catalog import (
)
from chatballs.identity.models import AuditEvent, Organization
ORGANIZATION_CHANGE_ACTIONS = (
"administration.organization_updated",
"administration.logo_updated",
@@ -18,7 +18,6 @@ from chatballs.tenancy.storage_quota import (
reserve_storage,
)
MAX_LOGO_BYTES = 2 * 1024 * 1024
@@ -4,8 +4,8 @@ from datetime import datetime, time, timedelta
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils import timezone as django_timezone
from django.http import FileResponse
from django.utils import timezone as django_timezone
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
@@ -50,7 +50,6 @@ class OrganizationSettingsView(APIView):
"GET": "settings.view",
"PATCH": "settings.manage",
}
require_organization_scope = True
def get(self, request: Request) -> Response:
return Response(
@@ -93,7 +92,6 @@ class OrganizationLogoView(APIView):
"POST": "settings.manage",
"DELETE": "settings.manage",
}
require_organization_scope = True
def get_permissions(self):
if self.request.method == "GET":
@@ -173,7 +171,6 @@ class AuditListView(APIView):
permission_classes = [HasCapability]
required_capability = "audit.view"
require_organization_scope = True
def get(self, request: Request) -> Response:
organization_id = request.tenant_context.organization_id
@@ -5,9 +5,9 @@ from chatballs.identity.auth.password_reset import (
PasswordResetValidateView,
)
from chatballs.identity.auth.profile import (
ChangeTemporaryPasswordView,
ProfileAppearanceView,
ProfileAvatarView,
ChangeTemporaryPasswordView,
ProfilePasswordView,
ProfileRevokeOtherSessionsView,
ProfileSessionsView,
@@ -26,6 +26,8 @@ __all__ = [
"PasswordResetValidateView",
"PasswordResetConfirmView",
"ProfileUpdateView",
"ProfileAppearanceView",
"ProfileAvatarView",
"ProfilePasswordView",
"ProfileTotpStartView",
"ProfileTotpDisableView",
@@ -16,6 +16,7 @@ from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.identity.audit import record_audit_event
from chatballs.identity.event_handlers import PASSWORD_RESET_REQUESTED
from chatballs.identity.models import AuditResult, HumanUser
from chatballs.identity.sessions import revoke_user_sessions
def _user_from_reset_link(uid: str, token: str) -> HumanUser | None:
@@ -98,9 +99,15 @@ class PasswordResetConfirmView(APIView):
user.must_change_password = False
user.password_changed_at = timezone.now()
user.save(update_fields=["password", "must_change_password", "password_changed_at"])
# Пароль сбрасывают именно тогда, когда доступ к учётной записи мог
# оказаться у чужого. Оставить его сессии живыми — значит не сделать
# ничего: смена пароля из профиля и админский сброс их завершают,
# этот путь обязан вести себя так же.
revoked = revoke_user_sessions(user.id)
record_audit_event(
action="identity.password_reset_completed",
actor=user,
payload={"revoked": revoked},
request=request,
)
return Response({"ok": True})
return Response({"ok": True, "revoked": revoked})
@@ -11,10 +11,10 @@ from rest_framework.views import APIView
from chatballs.identity.audit import record_audit_event
from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.models import HumanUser
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
class ProfileUpdateView(APIView):
@@ -115,12 +115,24 @@ class ProfilePasswordView(APIView):
class ProfileTotpStartView(APIView):
"""Начало настройки 2FA: выдать пользователю новый секрет.
Выключать этим уже включённую 2FA нельзя. Иначе достаточно было бы
угнанной сессии: отключение (``ProfileTotpDisableView``) спрашивает
текущий пароль и не даёт обойти требование организации, а этот эндпоинт
молча делал ровно то же самое без единой проверки.
"""
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
request.user.totp_enabled = False
if request.user.totp_enabled:
return Response(
{"detail": "TOTP уже включена: сначала отключите её текущим паролем"},
status=409,
)
request.user.totp_secret = ""
request.user.save(update_fields=["totp_enabled", "totp_secret"])
request.user.save(update_fields=["totp_secret"])
record_audit_event(
action="identity.profile_totp_setup_started",
actor=request.user,
+27 -1
View File
@@ -59,8 +59,34 @@ def decrypt_secret(token: str) -> str:
return ""
def ciphertext_length(plaintext_chars: int) -> int:
"""Сколько символов занимает Fernet-токен для строки такой длины.
В колонке лежит не значение, а шифротекст: Fernet добавляет версию,
метку времени, IV и подпись, дополняет до блока AES и кодирует всё в
base64. Строка в 512 символов уже не помещается в varchar(512) — запись
падала бы на длинном пароле SMTP или ключе S3. Считаем по худшему случаю:
4 байта на символ (UTF-8).
"""
payload = plaintext_chars * 4
blocks = payload // 16 + 1 # PKCS#7 всегда добавляет хотя бы один байт
raw = 57 + 16 * blocks # 57 = версия + timestamp + IV + HMAC
return (raw + 2) // 3 * 4 # base64 без переносов
class EncryptedCharField(models.CharField):
"""CharField прозрачно шифрующий значение в БД (Fernet)."""
"""CharField, прозрачно шифрующий значение в БД (Fernet).
``max_length`` описывает открытое значение — то, что вводит человек. Под
колонку берётся длина шифротекста: иначе ограничение поля и ограничение
столбца означают разное, и запись падает уже в базе.
"""
def __init__(self, *args, **kwargs) -> None:
self.plaintext_max_length = kwargs.get("max_length")
if self.plaintext_max_length:
kwargs["max_length"] = ciphertext_length(self.plaintext_max_length)
super().__init__(*args, **kwargs)
def from_db_value(self, value, expression, connection): # noqa: ANN001
if not value:
@@ -11,8 +11,8 @@ from django.core.files.base import ContentFile
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ConversationLabel,
Conversation,
ConversationLabel,
ConversationRead,
Message,
MessageAuthor,
@@ -147,10 +147,11 @@ def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, cur
channel = refs.channels[item["agent"]]
connection = refs.integrations[item["connection"]]
if item.get("webGuest"):
contact, identity, external_chat_id = _web_guest(context, refs, item)
# Identity гостя заводит _web_guest; диалогу она не нужна — его
# единственный источник identity это контакт (Conversation.contact).
contact, _identity, external_chat_id = _web_guest(context, refs, item)
else:
contact = refs.contacts.get(item.get("contact"))
identity = refs.identities.get(item.get("identity"))
external_chat_id = item["externalChatId"]
existing = Conversation.objects.filter(
@@ -30,7 +30,6 @@ class DemoDataView(APIView):
"POST": "company.manage",
"DELETE": "company.manage",
}
require_organization_scope = True
def get(self, request: Request) -> Response:
return Response(service.demo_status(request.tenant_context.organization))
@@ -1,7 +1,7 @@
from rest_framework.request import Request
from chatballs.identity.governance import employee_management_flags
from chatballs.identity.avatars import user_avatar_url
from chatballs.identity.governance import employee_management_flags
from chatballs.identity.models import AuditEvent, OrganizationMembership
@@ -8,6 +8,7 @@ from rest_framework.views import APIView
from chatballs.api.pagination import page_payload, paginate
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.employee_selectors import employees_for
from chatballs.identity.employee_support import employee_payload, get_owned_profile
from chatballs.identity.employee_validation import (
@@ -16,7 +17,6 @@ from chatballs.identity.employee_validation import (
deny_employee_action,
resolve_groups,
)
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
from chatballs.identity.group_models import EmployeeGroupMember
@@ -29,17 +29,17 @@ class EmployeeGroup(models.Model):
)
]
def clean(self) -> None:
self.name = self.name.strip()
if not self.name:
raise ValidationError({"name": "Group name is required"})
def __str__(self) -> str:
return f"{self.organization.slug}/{self.name}"
def save(self, *args, **kwargs) -> None:
self.full_clean()
super().save(*args, **kwargs)
def __str__(self) -> str:
return f"{self.organization.slug}/{self.name}"
def clean(self) -> None:
self.name = self.name.strip()
if not self.name:
raise ValidationError({"name": "Group name is required"})
class EmployeeGroupMember(TenantRelationModel):
@@ -24,6 +24,12 @@ class InstanceSettings(models.Model):
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10».
public_host = models.CharField(max_length=253, blank=True, default="")
# Предыдущий адрес: остаётся принятым, чтобы смена адреса не выбрасывала
# того, кто её делает. Владелец меняет адрес заранее — до того, как домен
# начал резолвиться и получил сертификат, — и сидит при этом на старом.
# Без этого он получал «Invalid host» через десять секунд после
# сохранения, а мастер уже закрыт: вернуться было бы неоткуда.
previous_public_host = models.CharField(max_length=253, blank=True, default="")
# Схема, по которой установку открывают снаружи. Меняется вместе с
# адресом, когда перед установкой появляется домен и сертификат.
public_scheme = models.CharField(max_length=5, blank=True, default="")
@@ -62,7 +68,7 @@ class InstanceSettings(models.Model):
_CACHE_TTL_SECONDS = 10.0
_lock = threading.Lock()
_cached: tuple[float, str] | None = None
_cached: tuple[float, tuple[str, str]] | None = None
def invalidate_cache() -> None:
@@ -71,8 +77,8 @@ def invalidate_cache() -> None:
_cached = None
def public_host() -> str:
"""Адрес установки, запомненный мастером, или пустая строка."""
def _hosts() -> tuple[str, str]:
"""Текущий и предыдущий адрес установки (оба могут быть пустыми)."""
global _cached
now = time.monotonic()
@@ -81,14 +87,26 @@ def public_host() -> str:
return _cached[1]
try:
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
value = row.public_host if row is not None else ""
value = (row.public_host, row.previous_public_host) if row is not None else ("", "")
except Exception: # таблицы ещё нет (первые миграции)
return ""
return ("", "")
with _lock:
_cached = (now, value)
return value
def public_host() -> str:
"""Адрес установки, запомненный мастером, или пустая строка."""
return _hosts()[0]
def accepted_hosts() -> tuple[str, ...]:
"""Адреса, которые установка признаёт своими: текущий и предыдущий."""
return tuple(host for host in _hosts() if host)
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
@@ -88,7 +88,11 @@ class InstanceAddressView(APIView):
{"detail": next(iter(errors.values())), "errors": errors}, status=400
)
fields = ["public_host", "public_scheme", "updated_at"]
fields = ["public_host", "public_scheme", "previous_public_host", "updated_at"]
if host != row.public_host:
# Прежний адрес остаётся принятым: владелец меняет адрес заранее,
# сидя на старом, и не должен выпасть из установки в тот же миг.
row.previous_public_host = row.public_host
row.public_host = host
row.public_scheme = scheme
@@ -1,82 +1,82 @@
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
#
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
# момент выката.
#
# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на
# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не
# трогаем назначения: каждое действующее назначение профиля продолжает работать
# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так
# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.
from django.db import migrations, models
AI_VIEW = "ai.view"
AI_MANAGE = "ai.manage"
CHANNELS_VIEW = "channels.view"
CHANNELS_MANAGE = "channels.manage"
def _grant(apps, *, source: str, target: str) -> None:
AccessProfile = apps.get_model("identity", "AccessProfile")
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
already_granted = set(
AccessProfileCapability.objects.filter(capability_code=target).values_list(
"access_profile_id", flat=True
)
)
# organization_id берётся у профиля, а не выводится: триггер
# chatballs.enforce_tenant_fk требует совпадения с владельцем профиля.
profiles = (
AccessProfile.objects.filter(capability_links__capability_code=source)
.values_list("id", "organization_id")
.distinct()
)
AccessProfileCapability.objects.bulk_create(
[
AccessProfileCapability(
access_profile_id=profile_id,
organization_id=organization_id,
capability_code=target,
)
for profile_id, organization_id in profiles
if profile_id not in already_granted
]
)
def grant_channel_capabilities(apps, schema_editor):
_grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)
_grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)
def revoke_channel_capabilities(apps, schema_editor):
# В отличие от 0010 откат обязан удалить выданные строки: следом
# восстанавливается прежний check-constraint реестра, и строки с кодом вне
# списка сделали бы обратную миграцию невыполнимой.
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
AccessProfileCapability.objects.filter(
capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)
).delete()
class Migration(migrations.Migration):
dependencies = [
('identity', '0015_organization_status'),
]
operations = [
migrations.RemoveConstraint(
model_name='accessprofilecapability',
name='access_profile_capability_registry',
),
migrations.AddConstraint(
model_name='accessprofilecapability',
constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),
),
# Порядок важен: при откате Django исполняет операции в обратном порядке,
# поэтому строки удаляются до восстановления старого constraint.
migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),
]
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
#
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
# момент выката.
#
# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на
# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не
# трогаем назначения: каждое действующее назначение профиля продолжает работать
# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так
# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.
from django.db import migrations, models
AI_VIEW = "ai.view"
AI_MANAGE = "ai.manage"
CHANNELS_VIEW = "channels.view"
CHANNELS_MANAGE = "channels.manage"
def _grant(apps, *, source: str, target: str) -> None:
AccessProfile = apps.get_model("identity", "AccessProfile")
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
already_granted = set(
AccessProfileCapability.objects.filter(capability_code=target).values_list(
"access_profile_id", flat=True
)
)
# organization_id берётся у профиля, а не выводится: триггер
# chatballs.enforce_tenant_fk требует совпадения с владельцем профиля.
profiles = (
AccessProfile.objects.filter(capability_links__capability_code=source)
.values_list("id", "organization_id")
.distinct()
)
AccessProfileCapability.objects.bulk_create(
[
AccessProfileCapability(
access_profile_id=profile_id,
organization_id=organization_id,
capability_code=target,
)
for profile_id, organization_id in profiles
if profile_id not in already_granted
]
)
def grant_channel_capabilities(apps, schema_editor):
_grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)
_grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)
def revoke_channel_capabilities(apps, schema_editor):
# В отличие от 0010 откат обязан удалить выданные строки: следом
# восстанавливается прежний check-constraint реестра, и строки с кодом вне
# списка сделали бы обратную миграцию невыполнимой.
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
AccessProfileCapability.objects.filter(
capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)
).delete()
class Migration(migrations.Migration):
dependencies = [
('identity', '0015_organization_status'),
]
operations = [
migrations.RemoveConstraint(
model_name='accessprofilecapability',
name='access_profile_capability_registry',
),
migrations.AddConstraint(
model_name='accessprofilecapability',
constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),
),
# Порядок важен: при откате Django исполняет операции в обратном порядке,
# поэтому строки удаляются до восстановления старого constraint.
migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),
]
@@ -1,173 +1,173 @@
# Generated by Django 5.2.15 on 2026-09-03 22:23
import django.db.models.deletion
import django.db.models.functions.text
from django.db import migrations, models
# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и
# грантов, которые исходно раздавала tenancy/0003 (она при откате не
# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут
# читать identity_department и migration-тесты падают на старых состояниях.
# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при
# откате (операции разворачиваются в обратном порядке), когда таблицы уже
# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях
# тестами не используется, поэтому политики не восстанавливаем.
_RESTORE_GRANTS_SQL = "\n".join(
f"""
ALTER TABLE {table} OWNER TO chatballs_schema;
GRANT ALL ON {table} TO chatballs_schema;
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
"""
for table in (
"identity_department",
"identity_accessprofile",
"identity_accessprofilecapability",
"identity_employeeaccessassignment",
)
)
class Migration(migrations.Migration):
dependencies = [
('ai', '0014_remove_knowledgedepartment_department_and_more'),
('channels', '0006_remove_channel_department'),
('identity', '0019_drop_sales_capabilities'),
('notifications', '0008_remove_notification_department'),
('support_portals', '0008_remove_supportportal_department'),
]
operations = [
migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),
migrations.CreateModel(
name='EmployeeGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='EmployeeGroupMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
# Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе
# state хранит constraint на несуществующее поле и обратная миграция
# (reverse DeleteModel в migration-тестах) падает при create_model.
migrations.RemoveConstraint(
model_name='accessprofile',
name='uniq_access_profile_org_name_ci',
),
migrations.RemoveConstraint(
model_name='accessprofilecapability',
name='uniq_access_profile_capability',
),
migrations.RemoveConstraint(
model_name='department',
name='uniq_department_org_code',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='access_assignment_scope_department',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='uniq_active_org_access_assignment',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='uniq_active_dept_access_assignment',
),
migrations.RemoveField(
model_name='accessprofile',
name='organization',
),
migrations.RemoveField(
model_name='accessprofilecapability',
name='access_profile',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='access_profile',
),
migrations.RemoveField(
model_name='accessprofilecapability',
name='organization',
),
migrations.RemoveField(
model_name='department',
name='organization',
),
# Сначала снимаем constraint, зависящий от primary_department: дроп
# колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.
migrations.RemoveConstraint(
model_name='organizationmembership',
name='owner_is_company_level',
),
migrations.RemoveField(
model_name='organizationmembership',
name='primary_department',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='department',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='assigned_by',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='employee',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='organization',
),
migrations.AddField(
model_name='employeegroup',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),
),
migrations.AddField(
model_name='employeegroupmember',
name='employee',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),
),
migrations.AddField(
model_name='employeegroupmember',
name='group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),
),
migrations.AddField(
model_name='employeegroupmember',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.DeleteModel(
name='AccessProfile',
),
migrations.DeleteModel(
name='AccessProfileCapability',
),
migrations.DeleteModel(
name='Department',
),
migrations.DeleteModel(
name='EmployeeAccessAssignment',
),
migrations.AddConstraint(
model_name='employeegroup',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),
),
migrations.AddConstraint(
model_name='employeegroupmember',
constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),
),
]
# Generated by Django 5.2.15 on 2026-09-03 22:23
import django.db.models.deletion
import django.db.models.functions.text
from django.db import migrations, models
# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и
# грантов, которые исходно раздавала tenancy/0003 (она при откате не
# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут
# читать identity_department и migration-тесты падают на старых состояниях.
# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при
# откате (операции разворачиваются в обратном порядке), когда таблицы уже
# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях
# тестами не используется, поэтому политики не восстанавливаем.
_RESTORE_GRANTS_SQL = "\n".join(
f"""
ALTER TABLE {table} OWNER TO chatballs_schema;
GRANT ALL ON {table} TO chatballs_schema;
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
"""
for table in (
"identity_department",
"identity_accessprofile",
"identity_accessprofilecapability",
"identity_employeeaccessassignment",
)
)
class Migration(migrations.Migration):
dependencies = [
('ai', '0014_remove_knowledgedepartment_department_and_more'),
('channels', '0006_remove_channel_department'),
('identity', '0019_drop_sales_capabilities'),
('notifications', '0008_remove_notification_department'),
('support_portals', '0008_remove_supportportal_department'),
]
operations = [
migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),
migrations.CreateModel(
name='EmployeeGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='EmployeeGroupMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
# Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе
# state хранит constraint на несуществующее поле и обратная миграция
# (reverse DeleteModel в migration-тестах) падает при create_model.
migrations.RemoveConstraint(
model_name='accessprofile',
name='uniq_access_profile_org_name_ci',
),
migrations.RemoveConstraint(
model_name='accessprofilecapability',
name='uniq_access_profile_capability',
),
migrations.RemoveConstraint(
model_name='department',
name='uniq_department_org_code',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='access_assignment_scope_department',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='uniq_active_org_access_assignment',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='uniq_active_dept_access_assignment',
),
migrations.RemoveField(
model_name='accessprofile',
name='organization',
),
migrations.RemoveField(
model_name='accessprofilecapability',
name='access_profile',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='access_profile',
),
migrations.RemoveField(
model_name='accessprofilecapability',
name='organization',
),
migrations.RemoveField(
model_name='department',
name='organization',
),
# Сначала снимаем constraint, зависящий от primary_department: дроп
# колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.
migrations.RemoveConstraint(
model_name='organizationmembership',
name='owner_is_company_level',
),
migrations.RemoveField(
model_name='organizationmembership',
name='primary_department',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='department',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='assigned_by',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='employee',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='organization',
),
migrations.AddField(
model_name='employeegroup',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),
),
migrations.AddField(
model_name='employeegroupmember',
name='employee',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),
),
migrations.AddField(
model_name='employeegroupmember',
name='group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),
),
migrations.AddField(
model_name='employeegroupmember',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.DeleteModel(
name='AccessProfile',
),
migrations.DeleteModel(
name='AccessProfileCapability',
),
migrations.DeleteModel(
name='Department',
),
migrations.DeleteModel(
name='EmployeeAccessAssignment',
),
migrations.AddConstraint(
model_name='employeegroup',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),
),
migrations.AddConstraint(
model_name='employeegroupmember',
constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),
),
]
@@ -0,0 +1,23 @@
"""Прежний адрес установки остаётся принятым после смены адреса.
Владелец меняет адрес в «Настройках» заранее — до того, как новый домен начал
резолвиться и получил сертификат, — и сидит при этом на старом. Пока принятым
был только новый адрес, сохранение выбрасывало его из установки через десять
секунд (TTL кэша), а мастер первого запуска уже закрыт: вернуться было неоткуда.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0032_remove_organization_tax_regime_and_more"),
]
operations = [
migrations.AddField(
model_name="instancesettings",
name="previous_public_host",
field=models.CharField(blank=True, default="", max_length=253),
),
]
@@ -0,0 +1,34 @@
"""Колонка вмещает шифротекст, а не открытое значение.
``EncryptedCharField`` хранит Fernet-токен: версия, метка времени, IV, подпись,
дополнение до блока AES и base64 поверх всего. Значение в 512 символов
занимает почти 2.9 КБ, и в ``varchar(512)`` не помещалось — длинный пароль
SMTP или ключ S3 ронял запись уже в базе. ``max_length`` поля по-прежнему
описывает открытое значение; ширину столбца считает
``chatballs.identity.crypto.ciphertext_length``.
"""
from django.db import migrations
import chatballs.identity.crypto
class Migration(migrations.Migration):
dependencies = [
("identity", "0033_instance_previous_public_host"),
]
operations = [
migrations.AlterField(
model_name="humanuser",
name="totp_secret",
field=chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1444),
),
migrations.AlterField(
model_name="instancesettings",
name="email_password",
field=chatballs.identity.crypto.EncryptedCharField(
blank=True, default="", max_length=2828
),
),
]
+3 -3
View File
@@ -16,7 +16,7 @@ from chatballs.identity.crypto import EncryptedCharField
class HumanUserManager(UserManager):
use_in_migrations = True
def _create_user(self, email: str, password: str | None, **extra_fields: object) -> "HumanUser":
def _create_user(self, email: str, password: str | None, **extra_fields: object) -> HumanUser:
if not email:
raise ValueError("The email must be set")
email = self.normalize_email(email)
@@ -25,12 +25,12 @@ class HumanUserManager(UserManager):
user.save(using=self._db)
return user
def create_user(self, email: str, password: str | None = None, **extra_fields: object) -> "HumanUser":
def create_user(self, email: str, password: str | None = None, **extra_fields: object) -> HumanUser:
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email: str, password: str | None = None, **extra_fields: object) -> "HumanUser":
def create_superuser(self, email: str, password: str | None = None, **extra_fields: object) -> HumanUser:
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
@@ -0,0 +1,133 @@
"""Границы, которые обходились через соседний эндпоинт.
Два места вели себя не так, как обещает соседняя ручка того же экрана:
- начало настройки 2FA молча выключало уже включённую, минуя и пароль, и
требование организации, — то есть было бесплатным способом снять 2FA для
того, у кого уже есть чужая сессия;
- сброс пароля по письму оставлял чужие сессии живыми, хотя пароль сбрасывают
как раз тогда, когда доступ мог оказаться у чужого.
"""
from __future__ import annotations
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sessions.models import Session
from django.test import TestCase
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from chatballs.identity.auth.totp_utils import _generate_totp_secret
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.testing import TenantAPIClient
PASSWORD = "Owner-Password-2026!"
NEW_PASSWORD = "Owner-Password-2027!"
class TotpSetupStartTests(TestCase):
def setUp(self) -> None:
self.result = bootstrap_owner(email="totp-owner@example.com", password=PASSWORD)
self.owner = self.result.owner
self.client = TenantAPIClient()
self.client.force_authenticate(self.owner)
def test_start_issues_a_secret_while_totp_is_off(self) -> None:
self.owner.totp_secret = "OLDSECRET"
self.owner.save(update_fields=["totp_secret"])
response = self.client.post("/api/v1/auth/profile/totp/start/")
self.assertEqual(response.status_code, 200, response.content)
self.owner.refresh_from_db()
self.assertEqual(self.owner.totp_secret, "")
self.assertFalse(self.owner.totp_enabled)
def test_start_cannot_disable_enabled_totp(self) -> None:
secret = _generate_totp_secret()
self.owner.totp_secret = secret
self.owner.totp_enabled = True
self.owner.save(update_fields=["totp_secret", "totp_enabled"])
response = self.client.post("/api/v1/auth/profile/totp/start/")
self.assertEqual(response.status_code, 409, response.content)
self.owner.refresh_from_db()
self.assertTrue(self.owner.totp_enabled)
self.assertEqual(self.owner.totp_secret, secret)
def test_disable_still_requires_the_current_password(self) -> None:
self.owner.totp_secret = _generate_totp_secret()
self.owner.totp_enabled = True
self.owner.save(update_fields=["totp_secret", "totp_enabled"])
rejected = self.client.post(
"/api/v1/auth/profile/totp/disable/",
{"currentPassword": "wrong-password"},
format="json",
)
self.assertEqual(rejected.status_code, 400, rejected.content)
accepted = self.client.post(
"/api/v1/auth/profile/totp/disable/",
{"currentPassword": PASSWORD},
format="json",
)
self.assertEqual(accepted.status_code, 200, accepted.content)
self.owner.refresh_from_db()
self.assertFalse(self.owner.totp_enabled)
class PasswordResetSessionTests(TestCase):
def setUp(self) -> None:
self.result = bootstrap_owner(email="reset-owner@example.com", password=PASSWORD)
self.owner = self.result.owner
def _reset_link(self) -> tuple[str, str]:
# Токен считается от текущего состояния пользователя (в том числе
# last_login), поэтому берём его после всех входов.
self.owner.refresh_from_db()
return (
urlsafe_base64_encode(force_bytes(self.owner.pk)),
default_token_generator.make_token(self.owner),
)
def _session_keys(self) -> set[str]:
keys = set()
for session in Session.objects.all():
if str(session.get_decoded().get("_auth_user_id", "")) == str(self.owner.pk):
keys.add(session.session_key)
return keys
def test_reset_revokes_existing_sessions(self) -> None:
stolen = TenantAPIClient()
self.assertTrue(stolen.login(email=self.owner.email, password=PASSWORD))
self.assertTrue(self._session_keys())
uid, token = self._reset_link()
anonymous = TenantAPIClient()
response = anonymous.post(
"/api/v1/auth/password-reset/confirm/",
{"uid": uid, "token": token, "newPassword": NEW_PASSWORD},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertGreaterEqual(response.json()["revoked"], 1)
self.assertEqual(self._session_keys(), set())
# Прежняя сессия больше не открывает приложение.
session = stolen.get("/api/v1/auth/session/")
self.assertFalse(session.json()["authenticated"])
def test_reset_still_sets_the_new_password(self) -> None:
uid, token = self._reset_link()
TenantAPIClient().post(
"/api/v1/auth/password-reset/confirm/",
{"uid": uid, "token": token, "newPassword": NEW_PASSWORD},
format="json",
)
self.owner.refresh_from_db()
self.assertTrue(self.owner.check_password(NEW_PASSWORD))
self.assertFalse(self.owner.must_change_password)
@@ -0,0 +1,52 @@
"""Шифротекст должен помещаться в колонку.
``EncryptedCharField`` кладёт в базу Fernet-токен: версия, метка времени, IV,
подпись, дополнение до блока AES и base64 поверх всего. Значение в 512
символов занимает почти 2.9 КБ — в varchar(512) оно не помещалось, и длинный
пароль SMTP или ключ S3 ронял запись уже в базе.
"""
from __future__ import annotations
from django.test import TestCase
from chatballs.identity.crypto import ciphertext_length, decrypt_secret, encrypt_secret
from chatballs.identity.instance_settings import InstanceSettings
from chatballs.tenancy.storage_settings import StorageSettings
class CiphertextLengthTests(TestCase):
def test_estimate_covers_the_real_token(self) -> None:
for plaintext_chars in (1, 16, 255, 512, 1024):
value = "щ" * plaintext_chars # 2 байта на символ в UTF-8
self.assertLessEqual(
len(encrypt_secret(value)),
ciphertext_length(plaintext_chars),
f"оценка мала для {plaintext_chars} символов",
)
def test_round_trip_survives(self) -> None:
value = "п" * 400
self.assertEqual(decrypt_secret(encrypt_secret(value)), value)
class EncryptedColumnWidthTests(TestCase):
def test_long_smtp_password_is_stored(self) -> None:
password = "Пароль-" + "x" * 500
row = InstanceSettings.load()
row.email_host = "smtp.example.test"
row.email_password = password
row.save(update_fields=["email_host", "email_password", "updated_at"])
row.refresh_from_db()
self.assertEqual(row.email_password, password)
def test_long_s3_keys_are_stored(self) -> None:
secret = "S" * 500
row = StorageSettings.load()
row.s3_access_key = "A" * 500
row.s3_secret_key = secret
row.save(update_fields=["s3_access_key", "s3_secret_key"])
row.refresh_from_db()
self.assertEqual(row.s3_secret_key, secret)
@@ -0,0 +1,121 @@
"""Адрес установки: смена не должна выбрасывать того, кто её делает.
Владелец меняет адрес заранее — до того, как новый домен начал резолвиться и
получил сертификат, — и сидит при этом на старом. Пока принятым был только
новый адрес, сохранение отвечало «Invalid host» через десять секунд (TTL
кэша), а мастер первого запуска уже закрыт: вернуться было неоткуда.
Здесь же — запрет вешать портал помощи на адрес самой установки: SPA решает,
что рисовать, по ответу ``/api/v1/help/``, и такой портал подменял бы
сотрудникам приложение своим Help Center.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.instance_settings import (
InstanceSettings,
accepted_hosts,
invalidate_cache,
)
from chatballs.support_portals.models import SupportPortal
from chatballs.testing import TenantAPIClient
PASSWORD = "Owner-Password-2026!"
class InstanceAddressChangeTests(TestCase):
def setUp(self) -> None:
self.result = bootstrap_owner(email="address-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
row = InstanceSettings.load()
row.public_host = "203.0.113.10"
row.public_scheme = "http"
row.previous_public_host = ""
row.save(update_fields=["public_host", "public_scheme", "previous_public_host", "updated_at"])
invalidate_cache()
self.addCleanup(invalidate_cache)
def _patch(self, host: str, scheme: str = "https"):
return self.client.patch(
"/api/v1/company/administration/instance/",
{"publicHost": host, "publicScheme": scheme},
format="json",
)
def test_previous_address_stays_accepted(self) -> None:
response = self._patch("crm.example.test")
self.assertEqual(response.status_code, 200, response.content)
invalidate_cache()
self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"})
def test_old_address_still_answers_after_the_change(self) -> None:
self._patch("crm.example.test")
invalidate_cache()
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="203.0.113.10")
self.assertEqual(response.status_code, 200, response.content)
def test_stranger_host_is_still_rejected(self) -> None:
self._patch("crm.example.test")
invalidate_cache()
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example")
self.assertEqual(response.status_code, 400)
def test_only_one_previous_address_is_kept(self) -> None:
self._patch("first.example.test")
self._patch("second.example.test")
invalidate_cache()
self.assertEqual(set(accepted_hosts()), {"second.example.test", "first.example.test"})
def test_saving_the_same_address_does_not_shift_history(self) -> None:
self._patch("crm.example.test")
self._patch("crm.example.test")
invalidate_cache()
self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"})
class PortalDomainCollisionTests(TestCase):
def setUp(self) -> None:
self.result = bootstrap_owner(email="portal-owner@example.com", password=PASSWORD)
row = InstanceSettings.load()
row.public_host = "crm.example.test"
row.previous_public_host = "203.0.113.10"
row.save(update_fields=["public_host", "previous_public_host", "updated_at"])
invalidate_cache()
self.addCleanup(invalidate_cache)
def _portal(self, custom_domain: str) -> SupportPortal:
return SupportPortal(
organization=self.result.organization,
slug="help",
name="Help",
custom_domain=custom_domain,
)
def test_installation_address_cannot_become_a_portal_domain(self) -> None:
with self.assertRaises(ValidationError) as error:
self._portal("crm.example.test").clean()
self.assertIn("custom_domain", error.exception.message_dict)
def test_previous_installation_address_is_also_refused(self) -> None:
with self.assertRaises(ValidationError):
self._portal("203.0.113.10").clean()
def test_unrelated_domain_is_allowed(self) -> None:
portal = self._portal("help.example.test")
portal.clean() # не должно бросать
self.assertEqual(portal.custom_domain, "help.example.test")
@@ -17,6 +17,8 @@ from chatballs.identity.models import (
OrganizationMembership,
OrganizationStatus,
)
def _pending_org(slug: str = "pending-org") -> Organization:
return Organization.objects.create(
name="Pending Org",
@@ -8,7 +8,6 @@ import tempfile
from django.apps import apps
from django.db import models
from django.test import TestCase, override_settings
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment
from chatballs.conversations.models import (
@@ -27,6 +26,7 @@ from chatballs.identity.models import HumanUser, Organization, OrganizationMembe
from chatballs.identity.setup import SetupInput, complete_setup
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.testing import TenantAPIClient as APIClient
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-demo-media-")
+2 -2
View File
@@ -9,15 +9,14 @@ from django.core import mail
from django.test import Client, TestCase, override_settings
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from chatballs.testing import TenantAPIClient as APIClient
from rest_framework.throttling import ScopedRateThrottle
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.auth.totp_utils import (
TOTP_CHALLENGE_TTL_SECONDS,
TOTP_STARTED_KEY,
_totp_code,
)
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import (
AuditEvent,
EmployeeGroup,
@@ -27,6 +26,7 @@ from chatballs.identity.models import (
OrganizationMembership,
)
from chatballs.identity.policy import ResourceScope, authorize
from chatballs.testing import TenantAPIClient as APIClient
_LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
+11 -11
View File
@@ -1,11 +1,11 @@
from django.contrib import admin
from chatballs.integrations.models import Integration
@admin.register(Integration)
class IntegrationAdmin(admin.ModelAdmin):
list_display = ("name", "provider", "kind", "status", "last_checked_at")
list_filter = ("provider", "kind", "status")
search_fields = ("name",)
readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")
from django.contrib import admin
from chatballs.integrations.models import Integration
@admin.register(Integration)
class IntegrationAdmin(admin.ModelAdmin):
list_display = ("name", "provider", "kind", "status", "last_checked_at")
list_filter = ("provider", "kind", "status")
search_fields = ("name",)
readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")
+8 -8
View File
@@ -1,8 +1,8 @@
from django.apps import AppConfig
class IntegrationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "integrations"
name = "chatballs.integrations"
verbose_name = "Integrations: providers and connections"
from django.apps import AppConfig
class IntegrationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "integrations"
name = "chatballs.integrations"
verbose_name = "Integrations: providers and connections"
@@ -1,38 +1,38 @@
# Generated by Django 5.2.15 on 2026-06-27 21:12
import django.db.models.deletion
import chatballs.identity.crypto
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('identity', '0006_alter_employeeprofile_totp_secret'),
]
operations = [
migrations.CreateModel(
name='Integration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),
('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),
('name', models.CharField(max_length=255)),
('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),
('config', models.JSONField(blank=True, default=dict)),
('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),
('last_checked_at', models.DateTimeField(blank=True, null=True)),
('last_error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),
],
options={
'ordering': ['provider', 'name'],
'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],
},
),
]
# Generated by Django 5.2.15 on 2026-06-27 21:12
import django.db.models.deletion
import chatballs.identity.crypto
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('identity', '0006_alter_employeeprofile_totp_secret'),
]
operations = [
migrations.CreateModel(
name='Integration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),
('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),
('name', models.CharField(max_length=255)),
('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),
('config', models.JSONField(blank=True, default=dict)),
('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),
('last_checked_at', models.DateTimeField(blank=True, null=True)),
('last_error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),
],
options={
'ordering': ['provider', 'name'],
'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],
},
),
]
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more