Перейти к содержимому

CrewsForge — Шаг 4: Модель авторизации

Статус: черновик на подтверждение · Шаг 4 из 9 Основание: ФТ разделы 2, 8.1; правила O-1, O-2, A-1, A-2, F-1, T-1, Q-3, D-5, D-6; решения шагов 1–3


УровеньКтоТокен / стратегияРезолв прав
Клиент-заказчикFounderjwt-customer (существующий)владение проектом: project.customerId == token.customerId
Клиент-исполнительучастник Teamjwt-employee (существующий)членство: активный TeamMember в project.teamId; внутри — оси role и canSign/canSubmit
ПлатформаOperator / Administrator / Arbiterjwt-admin + claim platformRoleроль из claim’а; выдаётся при логине по RoleType пользователя; домен-allowlist на все три роли

Изменение в auth-api: admin-auth.service при логине кладёт в payload platformRole: OPERATOR | ADMIN | ARBITER из UserOnRole. Один пользователь платформы = одна платформенная роль (совмещение ролей запрещено на уровне выдачи — иначе O-1 теряет смысл: оператор «по совместительству администратор» видит деньги). Guard’ы: OperatorGuard, AdministratorGuard, ArbiterGuard поверх общей admin-стратегии.

AI-консультант — не субъект прав. У Walrider нет токена и нет прямых переходов: его выводы материализуются как Specification/черновик плана с authorType: AI, а переходы всегда выполняет человек или SYSTEM. Это прямое требование 2.1 («не принимает решений, влияющих на состояние объектов») — зафиксировано отсутствием AI в ActorSpec.

2. Единый резолвер актора — общий для HTTP и машин

Заголовок раздела «2. Единый резолвер актора — общий для HTTP и машин»

Ключевое решение шага: проверка «кто имеет право» существует в одном месте — в ActorSpec переходов машины (шаг 3), и её исполняет либа state-machine. HTTP-guard’ы решают только аутентификацию и грубый скоуп (свой проект / своя команда / роль платформы); финальную проверку «эта сторона, с этим полномочием, может этот переход» делает executor перед CAS.

Следствия:

  • Невозможно «обойти контроллер»: admin-api, project-api и billing-api зовут одни и те же core-модули, и правило актора едет вместе с переходом, а не с маршрутом.
  • Ошибка авторизации на переходе — 403 с кодом ACTOR_NOT_ALLOWED и записью попытки в журнал (metadata.denied = true) — попытки несанкционированных действий тоже доказательства.

Резолвер стороны сделки:

resolveDealActor(user, project): DealActor | null
// FOUNDER: token.customerId == project.customerId
// TEAM: активный TeamMember(employeeId = token.employeeId, teamId = project.teamId)
// → { side: TEAM, memberId, role, canSign, canSubmit }

Требование authority: 'canSubmit' в ActorSpec проверяется по членству на момент перехода (снятое полномочие действует немедленно).

3. O-1 / O-2 / Q-3 — оператор и деньги: три механизма

Заголовок раздела «3. O-1 / O-2 / Q-3 — оператор и деньги: три механизма»

O-1 («ноль доступа к денежным объектам — ни чтение, ни агрегаты, ни очереди») исполняется тремя независимыми слоями; отказ любого одного не открывает деньги.

Слой 1 — изоляция кода (деплоймент). В admin-api не импортируется ни один billing-модуль (правило границ Nx: enforce-module-boundaries запрещает тег scope:billing в scope:admin). Эндпоинтов с денежными данными в admin-api не существует физически; денежные операции администратора живут в billing-api за AdministratorGuard.

Слой 2 — операторские DTO без денежных полей. Отдельный набор Operator*Dto (не переиспользуем административные DTO с «выключенными» полями — выключатель однажды забудут). Благодаря глобальной стратегии excludeAll поле без @Expose() не утечёт; но мы не полагаемся и на это: операторские DTO не содержат денежных полей как класс-полей вообще. Это распространяется на: суммы этапов, бюджеты проекта, вилки спецификации, totalAmountMinor плана, всё из Hold/Settlement, комиссию.

Слой 3 — денежный линтер в CI. Unit-тест обходит все классы Operator*Dto рефлексией и падает при поле, совпадающем с /amount|minor|budget|fee|commission|total|price|hold|settlement/i. Плюс e2e: снапшот операторских ответов на фикстуре с деньгами — ни одного денежного значения в теле. Инвариант 7 перестаёт зависеть от внимательности ревьюера.

O-2 — две операции. Операторский контроллер каждого типа задач публикует ровно два мутирующих маршрута: approve (вызывает соответствующий переход, actor OPERATOR) и reject (перехода нет: создаётся задача администратору + строка журнала metadata.operatorReject). Других мутаций в операторской поверхности не существует.

Q-3. QueueItem не содержит денежных колонок (схема шага 2). Администратор и арбитр видят суммы в своих DTO, обогащаемых из доменных таблиц; оператор получает тот же элемент очереди без обогащения.

Правило: фаундер никогда не видит первоначальную цену команды и историю согласования. Механизмы:

Канал утечкиЗакрытие
REST планаFounder-эндпоинты отдают PlanVersion только в статусах SENT / ACCEPTED / REJECTED / SUPERSEDED-после-SENT; версии DRAFT / INTERNAL_REVIEW в выборку не попадают (WHERE в репозитории founder-фичи, не фильтр в маппере)
Поле internalNotesОтсутствует во всех клиентских DTO (и фаундера, и команды); присутствует только в административном DTO
Diff версий (C-6)Diff строится только между версиями, которые фаундер имел право видеть (SENT+). Первая видимая версия diff’а не имеет
УведомленияPayload уведомлений фаундеру не содержит сумм из невидимых версий; триггеры на INTERNAL_REVIEW-события фаундеру не адресуются (проверка адресности в notification-dispatcher)
Выгрузки/логи (L-4)Экспортных founder-эндпоинтов по контрактингу нет; журнал переходов (StateTransition) клиентам не отдаётся вообще — только платформе

Симметрично для команды ничего не скрывается — торг ведётся с ней (D-4: декомпозиция суммы раскрывается команде на планировании).

5. T-1 — взаимная слепота доказательств в споре

Заголовок раздела «5. T-1 — взаимная слепота доказательств в споре»

Правило видимости в dispute-фиче: пока dispute.state != RESOLVED, сторона видит свою позицию и доказательства полностью, чужие — только факт подачи (submittedAt), без содержимого. После RESOLVED — обе позиции целиком обеим сторонам. Арбитр и администратор видят всё всегда. Реализация — WHERE по side в репозитории founder/team-поверхности + отдельные DTO (DisputeOwnViewDto / DisputeResolvedViewDto); симметрия покрыта параметризованным e2e (тот же тест для обеих сторон).

6. A-1 / A-2 / D-5 / D-6 — деньги: решает один, исполняет другой, суммы считает система

Заголовок раздела «6. A-1 / A-2 / D-5 / D-6 — деньги: решает один, исполняет другой, суммы считает система»

Уточнение, усиливающее A-2 и X-2: администратор не вводит суммы Settlement руками — никогда. Расчёт выполняет billing по основанию:

ОснованиеРасчёт
DUAL_APPROVAL (M11)команда = amount − fee(commissionRateBps), фаундер = 0, платформа = fee
ARBITER_DECISIONиз dispute.outcome: TEAM_FAVOR → как DUAL_APPROVAL; FOUNDER_FAVOR → фаундеру всё, fee = 0; PARTIAL → по teamShareBps арбитра
REVIEW_VERDICTкак DUAL_APPROVAL (вердикт CRITERIA_MET_NEW_SCOPE = приёмка)
TERMINATION_ASSESSMENTпо completedCriteriaBps и basis (правило X-3 о комиссии)

Поток исполнения (D-6): администратор открывает задачу SETTLEMENT_EXECUTION → billing показывает рассчитанный сплит + основание + перечисление необратимых последствий → администратор подтверждает (второй явный шаг, Settlement.status PENDING → EXECUTED) → transfer/refund у провайдера. Администратор может отклонить исполнение (например, видит техническую проблему) — это создаёт эскалацию, но не право изменить сумму. Арбитр к биллинг-поверхности не имеет доступа вовсе (ArbiterGuard в billing-api не принимается — только AdministratorGuard).

Так замыкается треугольник: арбитр решает (D04) → система считает → администратор исполняет — и ни у одной роли нет двух вершин.

Обозначения: R — чтение, W — мутации через переходы, ∅ — нет доступа, вкл. чтение.

РесурсFounderTeamOperatorAdministratorArbiter
Проект (свой скоуп)R/WR/WR (без денежных полей)R/WR (в рамках спора)
СпецификацияR/W (принятие)R (после мэтчинга)R
PlanVersion SENT+R/W (принять/отклонить)RR/W
PlanVersion DRAFT/INTERNAL, internalNotes∅ (F-1)R/W (свои версии)R/W
Этап: статусы, критерии, артефактыR/W (приёмка)R/W (работа, сдача)R (без сумм)R/WR (спорный этап)
Суммы этапов, бюджетыRR∅ (O-1/Q-3)RR (спорный этап)
Договоры (свои)R/W (подпись B)R/W (подпись A, canSign)R/WR (в споре)
Hold / SettlementR (свои холды, статус)R (свои, статус «в пути»)R/W (исполнение, без ввода сумм)R (суммы холда спора)
Спор: своя позицияR/WR/WRR/W (ведение)
Спор: чужие доказательства до RESOLVED∅ (T-1)∅ (T-1)RR
Решение спора (outcome)R (после)R (после)R (исполняет, не меняет — A-2)W (выносит)
РасторжениеR/W (инициатива, возражение)R/WR/W (оценка, исполнение)R (если ушло в спор)
Очередь задачR/W (своя, без денег)R/W (своя)R/W (своя)
Журнал переходовRR (по субъекту спора)
Команда: состав, роли, полномочияR (публичный состав, если команда его не скрыла — см. ниже)R/W (по role)R/W (онбординг, передача admin)
Команда: публичная карточка (имя, слаг, описание, внешний опыт)RR/W (по role)R/W
PayoutAccountR (статус, Y-4)R

Правка 2026-08-31 (решение №32, единица T4). Строка про состав команды изначально давала фаундеру — исходя из того, что состав виден только изнутри команды. Stark решил иначе: у команды есть публичная карточка по слагу, а состав в ней показывается, если команда не скрыла его в своих настройках (флаг на Team, по умолчанию состав показывается).

Границы правки, чтобы она не разъела соседние правила:

  • Публично видны люди, а не их права: имя, аватар, специализация и роль внутри команды. Полномочия canSign / canSubmit наружу не отдаются никогда — публиковать, кто вправе подписать договор, значит выдавать цель для социальной инженерии. Внутрикомандный контур (/api/v1/teams/:teamId/...) остаётся закрытым TeamMembershipGuard и отдаёт полномочия по-прежнему.
  • Ушедшие участники (status = REMOVED) в публичный состав не попадают.
  • P-1 не ослабляется. Публичная карточка — это витрина, а не связь с проектом: она не показывает, над какими проектами команда работает и кому предложена. Запрет «фаундер видит предложенную команду до её согласия» живёт в машине оффера (P2) и остаётся в силе.
ПоверхностьПрефиксGuard-цепочка
Фаундер/api/v1/customers/me/projects/...Customer JWT → владение проектом → ActorSpec перехода
Команда/api/v1/teams/:teamId/... и /api/v1/teams/:teamId/projects/:projectId/...Employee JWT → активное членство → role/authorities → ActorSpec
Операторadmin-api /api/v1/operator/...Admin JWT → platformRole = OPERATOR
Администраторadmin-api /api/v1/administrator/... + billing-api /api/v1/settlements/...Admin JWT → platformRole = ADMIN
Арбитрadmin-api /api/v1/arbiter/...Admin JWT → platformRole = ARBITER
Вебхукиbilling-api /webhooks/stripe, project-api /webhooks/signing, /webhooks/trackerподпись провайдера (Stripe-Signature и аналоги), без JWT

Матрица раздела 7 — не документация, а фикстура: табличный e2e-раннер перебирает (роль × эндпоинт × ожидание 200/403/404-скрытие) по всем строкам. Отдельные обязательные наборы: денежный линтер операторских DTO (раздел 3), симметричный T-1-тест, негативные тесты «арбитр в billing-api» и «администратор меняет outcome». Матрица меняется только вместе с этим файлом — расхождение теста и таблицы = красный CI.

  1. Запрет совмещения платформенных ролей одним пользователем (раздел 1) — жёстче ФТ, но без него O-1 декоративен.
  2. Администратор не вводит суммы — расчёт всегда системный по основанию (раздел 6). Усиление A-2/X-2 сверх буквы ФТ.
  3. Журнал переходов недоступен клиентам (фаундер/команда видят события своих объектов через уведомления и статусы, но не сырой журнал) — L-4 закрывается радикально.
  4. Тест-матрица как CI-гейт (раздел 9) — обязательство процесса, не только кода.