Commit Graph
238 Commits
Author SHA1 Message Date
AndreyandClaude Opus 4.8 2b97f08b19 🔒 feat(channels): enforce policy invariants P1-P5
SPEC-HUB-0027 §3.2/§12, этап 3. Инварианты включаются на изменении, а не
только на создании: непродуктовый канал больше не может нести checkout,
attribution или обязательную идентичность.

Проверка идёт по итоговому состоянию, а не по переданным полям. Поэтому
снять продукт и выключить коммерческие флаги можно одним запросом, а запрос,
оставляющий канал в запрещённой комбинации, отклоняется целиком — имя из
того же PATCH тоже не сохраняется.

Данные приведены заранее, иначе первый же PATCH имени упёрся бы в ошибку
политики. Отчёт нашёл один нарушитель — непродуктовый канал edevs с
включёнными checkout и attribution. По решению владельца оба флага сняты,
продукт не назначался. Та же миграция чинит дефолты модели, которые сами
нарушали P1-P2: канал создаётся без продукта, а attribution и checkout были
включены по умолчанию.

Нарушения P3-P5 миграция не исправляет, а останавливает выкат с перечнем
каналов: снятие обязательной идентичности меняет смысл канала.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:06:20 +03:00
Andrey cedd7d44f7 Merge branch 'main' into feat/channels-management 2026-07-21 12:41:15 +03:00
AndreyandClaude Opus 4.8 44bffecdd0 🚑 fix(tenancy): guard-функции C04 как SECURITY DEFINER
Триггерные функции enforce_tenant_fk/_user/_pair исполнялись как SECURITY
INVOKER, поэтому их внутренний SELECT родительской строки подчинялся RLS.
На кросс-тенантном пути без выставленного custocrm.organization_id
(claim_next_outbox_event берёт событие любой организации) родитель не был
виден, parent_org = NULL, и сравнение IS DISTINCT FROM ложно срабатывало:
легитимный UPDATE падал с `cross-tenant relation on
events_outboxevent.membership_id`.

На проде это увело воркер в краш-петлю (1022 рестарта, ~18 часов простоя):
первое же outbox-событие с membership_id заклинило очередь, а вместе с ней
доставку приглашений на звонок в TG/MAX, поллинг входящих сообщений и
серверные таймауты звонков. Веб-канал не пострадал — там приглашение
проставляется синхронно, минуя outbox.

Проверка не ослабляется: функции исполняются от custocrm_schema, настоящее
нарушение по-прежнему приводит к RAISE. search_path закреплён, как того
требует безопасность SECURITY DEFINER.

Дополнительно claim_next_outbox_event обёрнут в try/except, чтобы
отравленное событие впредь не роняло процесс целиком.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:51:53 +03:00
AndreyandClaude Opus 4.8 58cad0d104 ✨ feat(channels): add channel management API
SPEC-HUB-0027 §6-§7, этап 2. Канал создаётся, изменяется, удаляется и
принимает подключения через API — без доступа к серверу и Django admin.

Добавлены детальный GET, POST, расширенный PATCH (продукт, статус, пять
флагов политики), DELETE со списком блокирующих связей, привязка и отвязка
подключений с переносом по force, счётчики за период. Список отдаёт срез по
channels.view: department-scoped сотрудник не видит чужие отделы и не видит
канал без отдела.

Авторизация вынесена в channels/authorization.py: часть операций требует
organization scope, потому что затрагивает коммерческую границу. Смена
отдела, где одна сторона null, тоже требует organization scope — иначе
снятием отдела сотрудник вывел бы канал из собственной видимости.

Инварианты P1-P5 включены на создании. На изменении они остаются
выключенными до этапа 3: в существующих данных есть канал edevs без
продукта с checkout и attribution, и первый же PATCH имени упёрся бы в
ошибку политики (SPEC §12).

conversations/stats.py переведён на channels_in_organization: обзор диалогов
авторизуется собственной capability и не должен фильтроваться по
channels.view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:16:43 +03:00
AndreyandClaude Opus 4.8 7405c573ba ✨ feat(auth): add channels.view and channels.manage capabilities
SPEC-HUB-0027 §5, этап 1. Право разрешить checkout и attribution
отделяется от права настраивать AI: канал — коммерческая граница,
а не настройка агента (ADR-HUB-0037 §9).

Data-миграция выдаёт channels.* профилям с соответствующими ai.*, так что
доступ в момент выката не сужается. Scope сохраняется сам собой: он живёт
на EmployeeAccessAssignment, а capability — на AccessProfile, поэтому
назначения не трогаются вовсе. Обе capability допускают оба scope, так что
allowed_profile_scopes ни одного профиля не сужается.

Откат удаляет выданные строки, и RunPython стоит последним намеренно:
Django откатывает операции в обратном порядке, поэтому строки исчезают
до восстановления прежнего check-constraint реестра.

Эндпоинты каналов пока остаются на ai.*; их переписывает этап 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:45:51 +03:00
AndreyandClaude Opus 4.8 f85883dff2 🔥 chore(seed): remove the Edevs seed contour
Исполнение ADR-HUB-0028 §«Разделение bootstrap-данных»: в облачной модели
арендатора наполняют provisioning и продуктовые API, а не Edevs-specific
команды. Контур был замкнутым островом — единственная точка входа
seed_hub_initial_data, которую не вызывали ни CI, ни compose, ни deploy,
а CLI-тест прямо проверял, что init её не запускает.

Удалены seed_hub_initial_data, _seed_specs, seed_channels, seed_catalog,
seed_orders, seed_support и импортер AI-контента. bootstrap_owner остаётся:
на нём держится тест-сьют. Файлы content/ai-content-*.md сохранены.

Для SPEC-HUB-0027 это снимает половину проблемы этапа 3: унаследованных
seed-каналов, нарушающих P1-P2, больше нет — остаются только дефолты
модели Channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:45:37 +03:00
AndreyandClaude Opus 4.8 11ad4f8eb3 📝 docs(channels): add ADR-HUB-0037 and SPEC-HUB-0027 with design baseline
Канал обработки становится объектом управления: список, карточка,
создание, политика, подключения и счётчики. Разделы AI-агентов и знаний
не затрагиваются — связь с агентом остаётся одной строкой со ссылкой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:45:13 +03:00
Andrey 960cf24238 fix(ci): fix deploy 2026-07-19 13:41:56 +03:00
Andrey 6cfb2ec5e0 🐛 fix(ui): align knowledge layout with design baseline 2026-07-19 05:41:22 +03:00
Andrey d5eb6f5e42 ✨ feat(ui): add knowledge category tree and filters 2026-07-19 04:18:04 +03:00
Andrey ce8fe72049 🔨 refactor(ui): decompose knowledge page 2026-07-19 03:19:36 +03:00
Andrey 09fbb8af2c ✨ feat(ui): add hierarchical knowledge API contracts 2026-07-19 03:11:07 +03:00
Andrey aace9d3c0e ✨ feat(ai): add bulk knowledge operations and import metadata 2026-07-19 02:50:48 +03:00
Andrey 0130f627d7 ✨ feat(ai): enforce agent knowledge scopes 2026-07-19 02:29:03 +03:00
Andrey 2e40eff807 ✨ feat(ai): add category and scoped knowledge APIs 2026-07-19 01:56:23 +03:00
Andrey 2196012875 ✨ feat(ai): centralize scoped knowledge policy 2026-07-19 01:38:19 +03:00
Andrey 8473f29e2a ✨ feat(ai): backfill and require knowledge categories 2026-07-19 01:06:28 +03:00
Andrey 53436693e7 ✨ feat(ai): add knowledge hierarchy and department visibility 2026-07-19 00:38:17 +03:00
Andrey 21c9dbd574 💄 feat(design): add agent knowledge baseline 2026-07-19 00:05:08 +03:00
AndreyandClaude Fable 5 541d016b35 🔧 fix(dev): drop redundant migrate from backend-app dev command
Runtime-роль app (CUS_DB_ROLE=app) не имеет прав на django_migrations
(RLS-модель), поэтому migrate в dev-команде backend-app всегда падал с
permission denied и уводил контейнер в crash-loop на свежем томе.
Миграции выполняет one-shot init (CUS_DB_ROLE=migration), от которого
backend-app уже зависит через service_completed_successfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:22:00 +03:00
AndreyandClaude Fable 5 e1b96a6baf 🔧 fix(ui): resolve CSRF cookie name by protocol for dev over http
Клиент зашивал production-имя __Host-custocrm-app-csrf; по http бэкенд ставит
custocrm_app_csrftoken (CSRF_COOKIE_SECURE выключен) — логин через dev-gateway
падал 403. Имя выбирается по location.protocol, зеркально settings_app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:03:01 +03:00
AndreyandClaude Fable 5 5f8273db46 ✨ feat(integrations): email connection (IMAP/SMTP) and tabbed integrations page
Email как транспорт диалогов (ADR-HUB-0035, SPEC-HUB-0025): провайдер EMAIL,
поллинг IMAP по UID-курсору, SMTP-ответы в тред (Re:/In-Reply-To/References),
check_email (IMAP+SMTP), Conversation.transport_meta. UI: табы
«Подключения/Провайдеры» (UnderlineTabs), фирменные плитки каналов
(Telegram/MAX/Web/Email), раздельные колонки, форма с секциями IMAP/SMTP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:02:52 +03:00
AndreyandClaude Opus 4.8 a2b9ae0471 💄 feat(ui): rename sidebar system item to «Администрирование»
Avoid confusion with user «Настройки» in the sidebar user menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:19:26 +03:00
AndreyandClaude Opus 4.8 6323c41797 🔧 fix(ui): keep sidebar «Настройки» as system-settings placeholder
The sidebar item denotes system settings, not user settings. User
settings stay reachable only via the sidebar user menu. Revert the
nav item to its disabled placeholder state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:18:07 +03:00
AndreyandClaude Opus 4.8 6d36534b24 💄 feat(ui): rename nav labels, add settings page and sidebar user menu
- Rename «Командный центр» → «Обзор», «Уровень компании» → «Управление
  компанией», статус «Нормально» → «Штатно», «Открыть отдел» → «Перейти»
- Remove «Инструменты/Доска» sidebar section; enable «Настройки» nav item
- Add dropdown user menu (Профиль/Настройки/Выйти) in all sidebars
- Add /settings route and page; move password, notifications, TOTP and
  sessions out of profile, keeping only personal info on the profile page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:15:02 +03:00
AndreyandClaude Fable 5 8caa058282 🔒 fix(tenancy): allow NULL-org audit/outbox INSERT RETURNING under RLS
Django ORM inserts use INSERT ... RETURNING id, and PostgreSQL applies
SELECT policies to the returned row. The app role could INSERT platform-scope
rows (login-failed audit, platform outbox) via the dedicated INSERT policies
but had no SELECT visibility of them, so the write failed with "new row
violates row-level security policy". This was the true root cause of the
post-cutover login failure that was wrongly remediated with a
custocrm_schema membership grant.

- tenancy.0007: narrow SELECT policies mirroring the existing NULL-org
  INSERT policies on identity_auditevent and events_outboxevent
- regression test: app role writes a NULL-org audit event via ORM
- docs: record the 2026-07-17 production remediation (REVOKE applied,
  isolation re-verified operationally, second-organization block lifted)
  and the discovered ASGI idle-connection pool exhaustion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:15:02 +03:00
AndreyandClaude Fable 5 4b89c45560 🚑 fix(db): pool Postgres connections instead of CONN_MAX_AGE under ASGI
Persistent-соединения (CONN_MAX_AGE=60) осиротевали в короткоживущих
потоках asgiref ThreadSensitiveContext: Django закрывает старые соединения
только из потока-владельца, поток умирает после запроса, соединение
остаётся idle навсегда. На prod это дало 85 idle custocrm_app за 4,5 ч и
исчерпание max_connections=100 (500 на /api/v1/auth/login/, 2026-07-17).

Фикс: CONN_MAX_AGE=0 + нативный psycopg pool на процесс (Django 5
OPTIONS["pool"]): на выходе из запроса соединение возвращается в пул
(putconn) независимо от судьбы потока, суммарное число ограничено
max_size. Defaults: min 1 / max 4 / timeout 10s; env-переопределение
CUS_DB_POOL_MIN/MAX/TIMEOUT, CUS_DB_POOL_MAX=0 отключает пул. Worst case
по всем сервисам (app 3w + platform 3w + admin 2w + worker, оба alias)
~72 < 100. В testing пул выключен (pytest-django + TEST MIRROR).

Интеграционная проверка: 60 запросов в короткоживущих потоках ->
ровно 4 серверных соединения. RLS не затронут: tenant-контекст ставится
через set_config(..., local=true) внутри транзакции.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:28:34 +03:00
Andrey d5e9bed451 🚑 fix(ai)!: hand off failures and migrate env prefix to CUS_ 2026-07-17 14:10:06 +03:00
Andrey 6406e69740 🔒 fix(ci): restore RLS isolation and stable test teardown 2026-07-17 12:35:48 +03:00
Andrey 4e3cc6e900 💄 feat(auth): add glass panel and update email hint 2026-07-17 10:49:40 +03:00
Andrey 7bbf685735 ✨ feat(ai): add managed CustoAI and BYOK switching 2026-07-17 10:48:57 +03:00
Andrey a0d2d61954 ♻️ refactor(ai): extract shared OpenAI-compatible HTTP layer
OpenRouter, the generic Custom adapter and (in track B) CustoAI all speak the
same Chat Completions shape: POST /chat/completions, POST /embeddings,
Authorization: Bearer <key>, response with choices[0].message.content and usage.
The transport and response parsing were duplicated in OpenRouterProvider and
would be duplicated again by CustoAI.

Move post_json / get_json / chat_completions / embeddings into a new
ai/provider/openai_http.py module that owns the HTTP transport and OpenAI-shape
parsing. Adapters keep their product semantics only:

- OpenRouterProvider delegates chat (with usage.include=true to read usage.cost)
  and embed to openai_http; it is now a thin adapter, not a transport owner.
- CustomProvider inherits the delegation unchanged.

Update OpenRouterProviderProxyTests to mock openai_http.build_opener (the new
transport location) and add the model field to the fake response so the parsed
ChatResult matches.

Verified: 13/13 integrations tests, 47/47 ai tests.
2026-07-17 04:26:46 +03:00
Andrey 8390b31b7a ✨ feat(integrations): generic OpenAI-compatible custom AI provider (BYOK)
Add the Custom BYOK integration per ADR-HUB-0034 and SPEC-HUB-0024 §5: an
organization can connect any OpenAI-compatible provider with three fields —
endpoint (Base URL), API key and a free-text model — without a per-provider
adapter. The OpenRouter integration stays a separate ready integration with
its model catalog and is NOT absorbed by generic mode.

Backend:
- integrations/models.py: add CUSTOM to IntegrationProvider + PROVIDER_KIND
  (LLM_PROVIDER). TextChoices only affect validation; no DB migration needed
  (makemigrations --check: no changes detected).
- integrations/services.py: _normalized_config stores base_url + default_model
  for CUSTOM; _CHECKS wires the new check_custom.
- integrations/checks.py: check_custom verifies the endpoint speaks the OpenAI
  shape via GET /models with Authorization: Bearer. No catalog authority.
- ai/provider/custom.py: CustomProvider reuses the OpenRouter HTTP layer
  verbatim (ADR-HUB-0034 §3) — the contract is identical; only the name differs.
- ai/provider/routing.py: resolve_provider(_and_model) routes through
  Channel.provider_integration explicitly (ADR-HUB-0020:45), scoped to that one
  integration — never the first org-wide. The integration's default_model
  overrides AIAgent.model at runtime, closing the as-built gap where the
  «Модель по умолчанию» field was decorative (SPEC-HUB-0005:388, SPEC #2 #5).
- ai/provider/factory.py: get_provider(channel) takes an optional channel;
  BYOK linked via provider_integration wins over the legacy global path.
- ai/invocation.py: invoke_chat resolves provider+model through routing for a
  BYOK channel.

Frontend:
- integrations/model.ts: CUSTOM in the provider type and PROVIDERS map
  (hasModel/testable/checkable; secretLabel reuses the existing «API-ключ»).
- integrations/IntegrationForm.tsx: the three Custom fields render through the
  existing meta; the OpenRouter-only model placeholder is scoped to OpenRouter.

Tests:
- integrations/tests.py: CustomIntegrationTests (6) — endpoint+model persistence,
  free-text model, check_custom openai-shape success/failure.
- ai/tests.py: ByokRoutingTests (7) — CUSTOM→CustomProvider, OpenRouter mapping,
  default_model runtime override (SPEC #5), fallback, missing integration,
  end-to-end invoke_chat uses the integration model.

Verified: 13/13 integrations tests, 47/47 ai tests, frontend typecheck + vitest.

Acceptance: SPEC-HUB-0024 #1 (Custom with endpoint/key/model), #2 (model field
is read at runtime), #3 (any OpenAI-compatible endpoint), #5 (default_model no
longer decorative). #4 (OpenRouter catalog+check) untouched as-built.

Scope boundary: the CustoAI Managed path, removal of the non-compliant
factory._openrouter_from_integration side-effect token, and the credential_mode
field land in track B (ADR-HUB-0033).
2026-07-17 04:23:07 +03:00
Andrey 8d14e16cd3 💄 feat(ui): drop residual Hub branding in sidebars, auth and notifications
The Hub→CustoCRM rebrand (192497e) missed five user-facing strings and the
matching Playwright selector. The sidebars still offered «Назад в Hub»,
the auth recovery/reset success states still referred to «в Hub», and the
MAX/Telegram notifier hint told staff to open «профиль в Hub».

Replace the last Hub references with the product name CustoCRM (auth,
notifier hint) or drop the qualifier entirely (sidebar back button, which
just returns to the command center). Update the Playwright selector that
asserted the «Назад в Hub» button is absent for operators.

- layout/SalesSidebar.tsx, layout/SupportSidebar.tsx: «Назад в Hub» → «Назад»
- features/auth/AuthPasswordRecovery.tsx, AuthResetPassword.tsx: «в Hub» → «в CustoCRM»
- notifications/binding.py: «профиль в Hub» → «профиль в CustoCRM»
- tests/e2e/internal-ui.spec.ts: selector «Назад в Hub» → «Назад»
2026-07-17 04:04:04 +03:00
Andrey a79015e044 💄 fix(auth): single white card over hero background; cloud wording
The hero background exposed a structural flaw: only the form (.auth-card)
had the white background/border/shadow, while the brand block (.auth-brand)
and the footer note (.auth-note) rendered directly on the image and were
illegible. Move the white surface, border and shadow up to .auth-box so the
brand, form and note form one white card over the background.

Update the login copy for the cloud model (ADR-0031): the app surface is the
Organization Workspace, not the legacy 'internal cabinet' / 'Edevs employees'.
subtitle: 'Рабочая область организации'; note: 'Защищённое соединение'.
2026-07-16 23:57:34 +03:00
Andrey e1f3dfaa94 💄 feat(auth): use hero image as background on auth screens
Auth screens (login, TOTP, password reset) used a flat #f0f2f5 background. Use
the hero image (resources/hero-v2.png) as a full-bleed cover background, and
deepen the auth-card box-shadow so the form stays legible on top of the image.
2026-07-16 23:34:38 +03:00
Andrey 212951209a 🔧 fix(deploy): grant custocrm_schema membership to runtime roles
C04 RLS relies on the permissive custocrm_schema_access policy (USING/WITH
CHECK true, created by tenancy.0003 on every tenant table) to let login roles
perform cross-tenant writes such as a login-failed audit record with
organization_id IS NULL. That policy is attached to custocrm_schema, but
init-runtime-roles.sh never made custocrm_runtime_app / custocrm_runtime_platform
members of custocrm_schema, so the policy did not cover the login roles and the
write failed with 'violates row-level security policy' even under WITH CHECK
true. Reproduced on a clean DB; the missing membership was the sole cause.

Add the two GRANT statements. Document the bug and the post-cutover CSRF-cookie
fix (62bac47) in INVENTORY-0009 and RUNBOOK-0002.
2026-07-16 23:19:01 +03:00
Andrey 62bac47a31 🐛 fix(frontend): read the C01 __Host-prefixed CSRF cookie name
C01 (settings_app) renamed the CSRF cookie to __Host-custocrm-app-csrf under
SESSION_COOKIE_SECURE, but the API client still read the legacy 'csrftoken'
cookie, so mutating requests (login, etc.) sent an empty/short X-CSRFToken and
got 403. Read the matching cookie name; extract it to a named constant.
2026-07-16 22:33:15 +03:00
Andrey 13e714e1be 🔧 fix(build): provide build-time dummy DB users and S3 bucket for collectstatic
C04 added runtime guards to settings_database (distinct DB users) and
settings_storage (S3 bucket required in production). collectstatic runs during
the Docker image build in production mode but never touches the DB or S3, so it
hit these guards and the image build failed.

Pass dummy build-time values for POSTGRES_APP/PLATFORM/MIGRATION_USER and
HUB_S3_BUCKET into the collectstatic RUN step. Runtime values still come from
the instance .env; the guards remain fully enforced in production runtime.

Verified locally: collectstatic completes (163 files).
2026-07-16 19:41:30 +03:00
Andrey c9ac556ae4 📝 docs(cutover): add C04–C07 production cutover runbook and config
RUNBOOK-CUSTOCRM-0002 covers the in-place cutover of the existing Edevs
production DB onto C04–C07: RLS runtime roles, S3 tenant storage, PlanVersion
publication, STARTUP subscription and domain cutover. Owner decisions recorded
2026-07-16 (VK Cloud S3, smtp.mail.ru, web/TURN IPs, turn.custocrm.ru realm).

- INVENTORY-0009: mark storage cycle and membership-count debt resolved
- env.example: document C04 RLS roles and S3 storage backend
- .gitignore: ignore instance/ and *.cutover-draft (production secrets)
2026-07-16 18:49:56 +03:00
Andrey 828dbd9f50 🗃️ feat(subscriptions): derive concurrent_p2p_calls from membership count
C07: STARTUP concurrent_p2p_calls switches from a provisional FIXED limit to a
membership-derived source. The limit equals the active non-OWNER, non-blocked
membership count at read time, so adding/removing members auto-adjusts the call
slots without a quota edit. FREE remains FIXED=0.

- QuotaLimitSource.MEMBERSHIP_COUNT + policy derivation (subscriptions.0005)
- plan_models: membership-source grants allow null limit_value
- default_subscription: STARTUP bootstrap uses _ensure_membership_quota
- data migration flips existing STARTUP drafts to MEMBERSHIP_COUNT
- reservation/plans tests cover the derived limit and exclusion semantics
2026-07-16 18:49:42 +03:00
Andrey 56b72d34a5 🗃️ feat(tenancy): implement C07 storage reserve/finalize/release cycle
SPEC §10 storage quota: reserve the expected size before the write so a
concurrent upload cannot exceed storage_bytes, finalize with the actual size
after the object is persisted, and release on failure. add_attachment now runs
the full cycle with an idempotent reservation key; reads remain ungated.

- StorageReservation model + OrganizationStorageUsage.reserved_bytes (tenancy.0006)
- tenancy/storage_quota.py: reserve_storage/finalize_storage/release_storage
- ai/services.add_attachment: pre-write reserve, release-on-failure, finalize
2026-07-16 18:49:28 +03:00
Andrey a5b6ad2087 🔒 feat(subscriptions): enforce C07 entitlements and quotas across modules
Coverage-matrix operation x entitlement/quota x transport in INVENTORY-CUSTOCRM-0009.

Enforcement infrastructure:
- HasEntitlement DRF permission (org-level feature gate, orthogonal to
  HasCapability); centralized api_exception_handler mapping
  EntitlementRequired->403, QuotaExceeded HARD->409 / RATE+CONCURRENT->429
  (+Retry-After), PolicyUnavailable->503, UsageConflict->409.
- quota_service.check() advisory pre-check.
- UsageReservation lease model + reserve_usage/release_usage/
  expire_stale_reservations for CONCURRENT quotas (SPEC-HUB-0022 section 7);
  worker sweep reaps lapsed leases. QuotaExceeded now carries mode.

Cumulative quota gates (record_usage, mirror ai_agent_slots):
- products (create_product), client_connections (create_integration),
  new_dialogs_per_period (new dialog only, is_new branch in ingest + support),
  managed_ai_credits (successful LlmInvocation, best-effort accounting).

Storage gate: assert_storage_quota pre-write check blocks overage on writes,
reads never gated (PLAN section 12).

Concurrent p2p_calls: reserve on CallSession create, release on terminal
transition in calls/lifecycle.py.

Entitlement gates on views: sales_department, support_department,
product_sales_api (inline), p2p_calls, knowledge_base, managed_ai
(inline handoff on deny).

Deferred (no target module): crm_api, voice_ai, sso_saml,
concurrent_voice_sessions, customer_audit (tracks B/C/C12).

bootstrap_edevs_owner now provisions an active STARTUP subscription
(ensure_default_subscription) since post-C07 every tenant operation requires
a subscription; create_test_subscription reconciles ai_agent_slot counter for
tests that create active agents directly.

Gate (PLAN section 12): coverage-matrix exists; storage overage blocks writes
not reads; downgrade/suspension zero-limit clears entitlements without delete;
Free counts only a brand-new dialog.

Targeted tests: reservations 5, quota_service 4, enforcement mapping 6.
Full backend regression green except one pre-existing SOCKS5 test and one
pre-existing membership-migration ProtectedError (both fail on clean HEAD).
UI unchanged. Production migration/deploy and publication of production
PlanVersion are not part of this commit.
2026-07-16 03:11:55 +03:00
Andrey 0d124fbb7c 🔧 feat(platform): add plan publish and platform token management commands
Closes the C06 production prerequisites by providing explicit, runnable
instruments whose execution stays with the owner.

- publish_plan_versions: publishes the current draft PlanVersion for the
  saleable plans (Free, Startup). Idempotent; Business/Corporation stay
  draft. Once published, a version and its grants are immutable at model
  and PostgreSQL trigger level.
- create_platform_token: creates a PlatformOperator and an opaque API
  token. The plaintext token is printed ONCE; only its sha256 hash is
  stored. Rejects unknown capability codes. Credential operation - run
  only by the owner, transfer the plaintext to a secret store immediately.

Running these commands in production is not part of this commit and
requires explicit owner confirmation.
2026-07-15 17:14:30 +03:00
Andrey 549d1faada ✨ feat(platform): implement C06 tenant provisioning and Platform API
Single idempotent write boundary provision_organization coordinates
Organization, OWNER membership or invitation, subscription/usage,
system departments sales/support, audit and transactional outbox in one
transaction under set_local_tenant.

- New hub_platform.platform domain: PlatformOperator, hash-only
  PlatformToken, OrganizationProvisioning process record with state
  machine PENDING/IN_PROGRESS/WAITING_FOR_OWNER/COMPLETED/FAILED.
- Platform API POST platform.custocrm.ru/api/v1/organizations: machine
  token auth + capability platform.organizations.provision, Idempotency-Key
  header, replay/conflict handling, no secrets in response.
- OWNER invitation accept (accept_invitation + auth view) activates
  organization, subscription and first usage period; idempotent re-accept
  does not duplicate membership or period.
- DB write boundary (tenancy/0005): platform INSERT/UPDATE on
  identity_organization and platform DML + tenant-isolation policy on
  identity_department/identity_employeeprofile; Organization.status
  ACTIVE/PENDING_OWNER (identity/0015).
- System department codes centralised in identity/system_departments.

Machine token without MFA is an owner-approved deviation from
ADR-HUB-0031 section 9; human+MFA platform login belongs to track D.
concurrent_p2p_calls derived from membership count is a separate C07
decision (PLAN-CUSTOCRM-0003 section 12).

Gate: idempotent replay creates no second tenant; operator never becomes
OWNER; provisioning creates no AI-agent/product/connection/demo data.

Full backend regression 379 passed (one pre-existing unrelated SOCKS5
test); 19 C06 targeted tests passed. UI unchanged. Production
migration/deploy, publication of production PlanVersion and bootstrap of
the production platform token are not part of this commit.
2026-07-15 16:58:32 +03:00
Andrey 4b90a799ef 🗃️ feat(subscriptions): fill approved Free/Startup quota grants
Add owner-approved (2026-07-15) quota values for Free/Startup plan drafts
required before C06 provisioning: managed_ai_credits, storage_bytes,
crm_api_requests_per_window, concurrent_p2p_calls, concurrent_voice_sessions.

Refactor PLAN_SPECS to a unified quotas dict supporting HARD/RATE/
CONCURRENT/UNLIMITED modes. Business/Corporation remain saleable=false
drafts with only ai_agent_slots.

concurrent_p2p_calls (Startup=3) is a provisional minimum; deriving it
from active membership count is a separate C07 decision.

Plan versions remain unpublished; production migration/deploy and
publication are not part of this commit.
2026-07-15 15:41:49 +03:00
Andrey ae0a805451 ✨ feat(subscriptions): implement C05 subscription and usage core 2026-07-15 06:22:35 +03:00
Andrey 48ece587a9 🔒 feat(tenancy): enforce RLS and storage isolation 2026-07-15 05:23:08 +03:00
Andrey 30f9eb3d1b ✨ feat(tenancy): enforce explicit tenant context 2026-07-15 03:28:25 +03:00
Andrey a134cedfcd ✨ feat(identity): add organization memberships 2026-07-14 23:59:06 +03:00