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
1. Три уровня субъектов и токены
Заголовок раздела «1. Три уровня субъектов и токены»| Уровень | Кто | Токен / стратегия | Резолв прав |
|---|---|---|---|
| Клиент-заказчик | Founder | jwt-customer (существующий) | владение проектом: project.customerId == token.customerId |
| Клиент-исполнитель | участник Team | jwt-employee (существующий) | членство: активный TeamMember в project.teamId; внутри — оси role и canSign/canSubmit |
| Платформа | Operator / Administrator / Arbiter | jwt-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, обогащаемых из доменных таблиц; оператор получает тот же элемент очереди без обогащения.
4. F-1 / L-4 — фаундер и история торга
Заголовок раздела «4. F-1 / L-4 — фаундер и история торга»Правило: фаундер никогда не видит первоначальную цену команды и историю согласования. Механизмы:
| Канал утечки | Закрытие |
|---|---|
| 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) → система считает → администратор исполняет — и ни у одной роли нет двух вершин.
7. Матрица доступа (сводная)
Заголовок раздела «7. Матрица доступа (сводная)»Обозначения: R — чтение, W — мутации через переходы, ∅ — нет доступа, вкл. чтение.
| Ресурс | Founder | Team | Operator | Administrator | Arbiter |
|---|---|---|---|---|---|
| Проект (свой скоуп) | R/W | R/W | R (без денежных полей) | R/W | R (в рамках спора) |
| Спецификация | R/W (принятие) | R (после мэтчинга) | ∅ | R | ∅ |
| PlanVersion SENT+ | R/W (принять/отклонить) | R | ∅ | R/W | ∅ |
PlanVersion DRAFT/INTERNAL, internalNotes | ∅ (F-1) | R/W (свои версии) | ∅ | R/W | ∅ |
| Этап: статусы, критерии, артефакты | R/W (приёмка) | R/W (работа, сдача) | R (без сумм) | R/W | R (спорный этап) |
| Суммы этапов, бюджеты | R | R | ∅ (O-1/Q-3) | R | R (спорный этап) |
| Договоры (свои) | R/W (подпись B) | R/W (подпись A, canSign) | ∅ | R/W | R (в споре) |
| Hold / Settlement | R (свои холды, статус) | R (свои, статус «в пути») | ∅ | R/W (исполнение, без ввода сумм) | R (суммы холда спора) |
| Спор: своя позиция | R/W | R/W | ∅ | R | R/W (ведение) |
| Спор: чужие доказательства до RESOLVED | ∅ (T-1) | ∅ (T-1) | ∅ | R | R |
| Решение спора (outcome) | R (после) | R (после) | ∅ | R (исполняет, не меняет — A-2) | W (выносит) |
| Расторжение | R/W (инициатива, возражение) | R/W | ∅ | R/W (оценка, исполнение) | R (если ушло в спор) |
| Очередь задач | ∅ | ∅ | R/W (своя, без денег) | R/W (своя) | R/W (своя) |
| Журнал переходов | ∅ | ∅ | ∅ | R | R (по субъекту спора) |
| Команда: состав, роли, полномочия | R (публичный состав, если команда его не скрыла — см. ниже) | R/W (по role) | ∅ | R/W (онбординг, передача admin) | ∅ |
| Команда: публичная карточка (имя, слаг, описание, внешний опыт) | R | R/W (по role) | ∅ | R/W | ∅ |
| PayoutAccount | ∅ | R (статус, Y-4) | ∅ | R | ∅ |
Правка 2026-08-31 (решение №32, единица
T4). Строка про состав команды изначально давала фаундеру∅— исходя из того, что состав виден только изнутри команды. Stark решил иначе: у команды есть публичная карточка по слагу, а состав в ней показывается, если команда не скрыла его в своих настройках (флаг наTeam, по умолчанию состав показывается).Границы правки, чтобы она не разъела соседние правила:
- Публично видны люди, а не их права: имя, аватар, специализация и роль внутри команды. Полномочия
canSign/canSubmitнаружу не отдаются никогда — публиковать, кто вправе подписать договор, значит выдавать цель для социальной инженерии. Внутрикомандный контур (/api/v1/teams/:teamId/...) остаётся закрытымTeamMembershipGuardи отдаёт полномочия по-прежнему.- Ушедшие участники (
status = REMOVED) в публичный состав не попадают.- P-1 не ослабляется. Публичная карточка — это витрина, а не связь с проектом: она не показывает, над какими проектами команда работает и кому предложена. Запрет «фаундер видит предложенную команду до её согласия» живёт в машине оффера (
P2) и остаётся в силе.
8. Скоупинг маршрутов
Заголовок раздела «8. Скоупинг маршрутов»| Поверхность | Префикс | 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 |
9. Тестирование матрицы
Заголовок раздела «9. Тестирование матрицы»Матрица раздела 7 — не документация, а фикстура: табличный e2e-раннер перебирает (роль × эндпоинт × ожидание 200/403/404-скрытие) по всем строкам. Отдельные обязательные наборы: денежный линтер операторских DTO (раздел 3), симметричный T-1-тест, негативные тесты «арбитр в billing-api» и «администратор меняет outcome». Матрица меняется только вместе с этим файлом — расхождение теста и таблицы = красный CI.
10. На подтверждение
Заголовок раздела «10. На подтверждение»- Запрет совмещения платформенных ролей одним пользователем (раздел 1) — жёстче ФТ, но без него O-1 декоративен.
- Администратор не вводит суммы — расчёт всегда системный по основанию (раздел 6). Усиление A-2/X-2 сверх буквы ФТ.
- Журнал переходов недоступен клиентам (фаундер/команда видят события своих объектов через уведомления и статусы, но не сырой журнал) — L-4 закрывается радикально.
- Тест-матрица как CI-гейт (раздел 9) — обязательство процесса, не только кода.