[105-9] CoopID Эпики 6/8/9 — CASL-авторизация, аудит 152-ФЗ, наблюдаемость (stacked на epic-4) #134

Closed
claude wants to merge 24 commits from feat/coopid-epic-6 into feat/coopid-epic-4
Owner

Подход 4 «Управление и авторизация» — Эпик 6 (CoopID, компонент 42)

Stacked-PR поверх feat/coopid-epic-4 (Эпик 4). После merge нижних веток база будет перетарджечена.

Источник AC: project 13 epics.md, Epic 6 (issue 105-9). Спеки реализации — _bmad-output/implementation-artifacts/spec-6-*.md.

Председатель и совет управляют пайщиками с защитой от ошибок: гибкие роли + capabilities, CASL guards (Layered Authorization Pattern, 4 слоя), критические действия требуют 2 подтверждений (chairman + ≥1 совет), force-recovery невозможен без согласия пайщика/решения собрания.

Переиспользование зачатка с marketplace2: там заложен CASL-совместимый matrix-паттерн с пометкой «Phase 2 → CASL defineAbility». Платформенный фундамент ролей (CoreRole=User|Member|Chairman) поднят на уровень auth-v2 и развит в реальный @casl/ability. Marketplace-matrix остаётся в своей ветке и мигрирует в эту платформенную CASL (контракт в authorization/README.md) — обе ветки мерджатся в dev без конфликтов файлов (работа аддитивна).

Истории Эпика 6

  • 6.1 CASL Ability-factory + Layer 1 (Static Ability). Зависимость @casl/ability@^7 в dependencies (не dev — prune --prod). Платформенный auth-v2/authorization/core-roles.ts (CoreRole=User|Member|Chairman + mapUserRoleToCoreRoles, зеркало marketplace-зачатка). AbilityFactory.createForParticipant({username, role})AppAbility = MongoAbility<[CoopAction, CoopSubject]> по статической матрице Layer 1: Userread/update свои Certificate/Session + manage свою RecoveryStrategy (condition {owner}); Member (наследует) — read-only Participant/VerificationRule/CriticalAction/AuditEvent + confirm CriticalAction (второй подписант 6.8); Chairman (наследует) — manage VerificationRule/CoopSettings, update Participant (роли 6.6), create Capability (6.7), create CriticalAction (инициатор). admin/неизвестная роль → пустая Ability. Сериализация serialize/deserialize через ability.rules для Redis-session (не JWT-claim — нужна инвалидация pub/sub 6.2). Дрейф AC↔код (прав код): роли User/Member/Chairman вместо participant/council_member — существующий канон контроллера, совместимо с marketplace2. Связывание Ability с session-стором — Story 6.4. 10 unit-тестов (все роли + ownership + round-trip).
  • 6.2 CASL Layer 2 — access_rules matrix-driven + Redis pub/sub инвалидация. Таблица access_rules в coop_domain_db (миграция V2.4.7: subject_type role|participant, subject_id, effect allow|deny, action, resource_type, conditions jsonb, expires_at — TTL capabilities 6.7; индекс по принципалу). Доменный порт access-rules.port.ts (enum AccessRuleEffect/AccessRulePrincipalKind; IAccessRulesRepository.findForPrincipal+insert; IAccessRulesInvalidationPublisher + канал coopid:access-rules:invalidate). PostgresAccessRulesRepository (coop_domain_db lazy DataSource; findForPrincipal = правила core-ролей пайщика OR персональные, истёкшие отброшены). AbilityFactory.createForParticipant(user, rules) мерджит Layer 2 поверх Layer 1: allow-первыми, deny-последними (CASL берёт последнее матчащее правило → deny перекрывает статический allow); conditions пробрасываются в can/cannot. createForParticipantWithRules(user) читает репозиторий по core-ролям+username. RedisAccessRulesInvalidationPublisher (ioredis — только infrastructure, hexagonal-инвариант). Дрейф AC↔код (прав код): добавлены effect (allow/deny невыразим без него) и expires_at (forward-compat 6.7). Подписчик инвалидации (сброс кэша Ability активной сессии) — Story 6.4 (точка потребления Ability). 15 unit-тестов (Layer 1: 10 + Layer 2 merge: 4 + publisher: 1).
  • 6.3 CASL Layer 3 — PolicyHandler registry с DB-доступом. policy.types.ts (IPolicyHandler {name; evaluate(ctx):boolean|Promise}, PolicyEvaluationContext {user,action,subject,resource?}). @PolicyHandler(name) (Injectable+SetMetadata). @CheckAbility(action,subject,{policy?}) + CHECK_ABILITY metadata (введён здесь, потребляется guard'ом 6.4). PolicyRegistry (OnModuleInit, DiscoveryService) индексирует все @PolicyHandler-провайдеры по имени; has/get/evaluate; fail-closed (неизвестное имя → throw, не молчаливый allow); дубль имени → throw на старте. SameCoopVotingPolicy @PolicyHandler('same-coop-voting'): DB-lookup членства = существование participant-vault (IVaultRepository.find, без расшифровки — инвариант CoopID); чужой coopname (≠config.coopname) → deny (per-coop); пустой username → deny. AuthorizationModule импортит DiscoveryModule, регистрирует PolicyRegistry+SameCoopVotingPolicy, экспортит PolicyRegistry. Фикс субстрата Layer 2 — миграция V2.4.8: init V2.4.0 создавал access_rules плейсхолдером (старая схема role/action/subject), V2.4.7 (6.2) с CREATE IF NOT EXISTS = no-op на БД где V2.4.0 раньше → старая схема → repo 6.2 упал бы на column "subject_type" does not exist (подтверждено на dev). Forward-миграция (append-only): нет subject_type → DROP пустого плейсхолдера + CREATE правильной; идемпотентно. Дрейф AC↔код (прав код): subject CriticalAction вместо Decision (Decision нет в CoopSubject; голосование по CriticalAction, 6.8). Тесты: 4 registry + 5 policy unit + 1 integration с реальной coop_domain_db (PostgresVaultRepository, seed/cleanup, в контейнере); coopback рестартнул чисто — PolicyRegistry обнаружил same-coop-voting через DiscoveryService без DI-ошибок.
  • 6.4 CASL Layer 4 + единый AuthorizationGuard. authorization-denial.ts (enum AuthorizationDenialReason для лога + публичный authorization_denied → 403; наружу обобщённый код, точная причина — только в server-log). PolicyService.ensure(requirement, user, resource?) (Layer 4) — единый вычислитель всех слоёв: нет user → 403; L1+2 свежая Ability + can(action, asSubject(subject, resource)) для instance-level ownership; L3 политика (вызывается только после прохождения Ability). AuthorizationGuard implements CanActivate — читает @CheckAbility (метод+класс), нет требования → пропуск; server-secret → обход; извлекает user+resource из GraphQL и HTTP REST (общий middleware); делегирует PolicyService.ensure. Дрейф (прав код): L4 = PolicyService.ensure, guard делегирует туда же (нет дубля логики); Ability per-request без session-кэша (изменения access_rules применяются сразу; кэш+инвалидация publisher 6.2 — отдельная перф-оптимизация); единый error_code; guard точечный, не APP_GUARD. Тесты: 6 policy.service + 5 guard unit; coopback рестартнул чисто без DI-ошибок.
  • 6.5 Миграция @AuthRoles@CheckAbility + ESLint-запрет no-authroles-in-authv2. Новый overrides-блок в .eslintrc.json (scope src/application/auth-v2/**, кроме *.spec.ts) через no-restricted-syntax: ImportSpecifier[imported.name='AuthRoles'] + Decorator[expression.callee.name='AuthRoles'] → error; сообщение именует канон-правило и направляет на @CheckAbility. no-restricted-syntax выбран намеренно (а не no-restricted-imports) — тот ключ в override application/** занят wharfkit-баном; разные ключи мерджатся аддитивно. Тест no-authroles-in-authv2.eslint.spec.ts (ESLint Node API): @AuthRoles в auth-v2 → error, в legacy auth/ → нет. Дрейф (прав код): auth-v2 — REST-контроллеры, @AuthRoles не используется → «резолверы используют только @CheckAbility» вакуумно; текущие эндпоинты самообслуживания (JWT+ownership) не роль-гейтятся, первые боевые @CheckAbility — в админ-6.6/6.7 и 6.8; кастомного @coopenomics-плагина нет (как и у wharfkit-бана). Регрессия: eslint auth-v2/** exit 0.
  • 6.6 Admin UI — назначение ролей пайщикам BLOCKED на решениях пользователя: модель ролей (единое user.role vs новый participants.roles[]; Member=член совета вероятно on-chain soviet — канон/архитектура, согласуется ДО реализации), правка core-User/soviet вне auth-v2, Vue-страница (скриншоты пользователя). Первый боевой @CheckAbility('update','Participant') — сюда после решения модели.
  • 6.7 Admin UI — выдача точечных capabilities BLOCKED: Vue admin-страница (скриншоты пользователя); бэкенд (access_rules.expires_at из 6.2 + cron-cleanup) автономен, UI — с пользователем.
  • 6.8 Multi-party critical actions service. Миграция V2.4.9 pending_critical_actions (action_type, actor_id, target_id, payload, status, confirmations [{by,at}], expires_at, finalized_at). Порт (enum CriticalActionType/CriticalActionStatus, repo, ICriticalActionNotifier + канал coopid:critical-action:pending). PostgresPendingCriticalActionsRepository, RedisCriticalActionNotifier (publish события; фан-аут — notification-center downstream). CriticalActionsService: initiate (подпись инициатора + окно 24ч + notify), confirm (404/409 для not-found/not-pending/expired/self-confirm/duplicate; ≥2 подписи → finalized + audit CriticalActionConfirmed с обоими подписантами + payload_hash), @Cron daily expireStaleCriticalActionExpired. Первый боевой @CheckAbility+AuthorizationGuard: POST /coop/critical-actions (create CriticalAction, председатель) + POST /:id/confirm (confirm CriticalAction, член совета) поверх HttpJwtAuthGuard. Тесты: 8 unit; coopback рестартнул чисто, роуты смаплены, @Cron зарегистрирован. Разблокирует Story 4.7.
  • 6.9 Force-recovery rules — запрет единоличного сброса доступа председателем. Порт force-recovery-consent.port.ts (IForceRecoveryConsentStore issue/consume single-use/markGranted/isGranted; IForceRecoveryConsentNotifier + канал coopid:force-recovery:consent-requested). RedisForceRecoveryConsentStore (запрос coopid:fr:req:<token> PX-TTL, consumeRequest атомарен Lua GET→DEL как recovery-токен; отметка coopid:fr:granted:<target>:<initiator> кратким TTL; ioredis только infra). RedisForceRecoveryConsentNotifier (publish события с токеном; доставка письма — notification-center downstream как 6.8). ForceRecoveryService: два независимых канала согласия — (а) пайщик подтверждает magic-link, (б) решение собрания (on-chain tx, assemblyDecisionTxId валидируется как 64-hex). enum ForceRecoveryConsentVia (наружу+audit), внутренний ForceRecoveryDenialReason только в лог (наружу единый force_recovery_denied). authorize: нет granted-отметки и нет валидного tx → deny(no_consent)/403; при стратегии RecoveryStrategy.Council → требует подтверждённый force_recovery critical action 6.8 иначе deny(council_approval_missing); успех → audit ForceRecoveryAuthorized triggered_by: chairman. requestConsent/grantConsent (single-use токен, consent/:token без auth-guard — пайщик потерял доступ). Контроллер /coop/force-recovery: request-consent+authorize @CheckAbility('create','CriticalAction') поверх HttpJwtAuthGuard+AuthorizationGuard. Дрейф (прав код): on-chain верификация решения собрания — Эпик 3 (здесь гейт допуска); Council переиспользует кворум 6.8; финализация (ротация ключа) — recovery-flow Эпик 3 (placeholder 3.3). Тесты: 9 unit; coopback рестартнул чисто, все три роута смаплены.
  • 6.10 Audit critical actions с обоими подписантами + getAuditTrail. Порт IPendingCriticalActionsRepository +listByTarget(targetId) (новые сверху; индекс target_id уже из V2.4.9). PostgresPendingCriticalActionsRepository.listByTarget (SELECT WHERE target_id ORDER BY created_at DESC). CriticalActionsService: экспорт-типы CriticalActionAttribution (initiatorId/initiatedAt/confirmerIds/payloadHash) + CriticalActionAuditEntry; приватный attribution(action) — единый источник полной атрибуции (инициатор=actorId, initiatedAt=timestamp первой подписи, confirmerIds=подтверждения by!==actorId со своими timestamps, payloadHash=sha256); auditConfirmed переписан на него — context чётко разделяет initiator_id+initiated_at и confirmer_ids (только совет; в 6.8 массив включал инициатора); getAuditTrail(targetId) = listByTargetCriticalActionAuditEntry[]. GET /coop/critical-actions/audit-trail/:targetId @CheckAbility('read','CriticalAction') (Member/Chairman = контролирующий орган; отдельный path-сегмент во избежание коллизии с :id/confirm). Дрейф (прав код): confirmer_ids=только совет (AC разводит роли — инициатор в отдельное поле, полнота сохранена); trail из pending_critical_actions (источник истины по составу), не из audit_events; payload_hash вместо payload (не раскрываем чувствительное); REST-метод, не GraphQL (auth-v2 REST-контур, канон 6.5). Тесты: +3 к 8 = 11 unit (полная атрибуция / пустой trail / детерминизм payload_hash + усилена проверка финализации); coopback рестартнул чисто, GET audit-trail смаплен, без DI-ошибок. Закрывает Эпик 6 backend (остаются только UI-6.6/6.7, blocked).

Дополнительно: перенесённая история Эпика 4, разблокированная Эпиком 6

Эти коммиты физически в ветке feat/coopid-epic-6 (story была отложена sprint-планом до появления CASL 6.1–6.4 + multi-party 6.8 — «нет окна единоличного revoke»). При перетарджете base после merge нижних веток diff остаётся корректным.

  • 4.7 Manual revoke endpoint (compromised-key MVP). Миграция V2.4.10 revoked_keys (coop_domain_db: target_id, reason, revoked_by, revoked_at, recovered_at nullable; индекс target_id+recovered_at; recovered_at IS NULL = отзыв активен / pending recovery). Порт key-revocation.port.ts (IKeyRevocationRepository record/findActive/markRecovered). PostgresKeyRevocationRepository (lazy DS как pending-critical-actions). KeyRevocationService.revoke: (1) durable pending-state repo.recordMVP-вариант AC «или с переходом на pending state» вместо on-chain updateauth (реальная ротация — recovery-flow Эпика 3 placeholder 3.3, тот же раздел ответственности что force-recovery 6.9); (2) sessions.revokeAll(target, ip) гасит все сессии; (3) audit KeyRevokedManually (reason, chairman_id, sessions_revoked). isPendingRecovery(target) для downstream-гейта Эпик 3/7. POST /coop/keys/revoke @CheckAbility('update','Participant') поверх HttpJwtAuthGuard+AuthorizationGuard. Дрейф (прав код): MVP=pending-state не on-chain updateauth (AC прямо допускает); право=update Participant без нового CoopAction 'revoke' (добавление глагола в матрицу — канон-решение, согласуется ДО; председатель уже имеет update Participant, новых прав не выдаём); single-chairman action (AC: chairman_id, не список подписантов; compromised-key срочный — контроль через CASL+audit+обратимость recovery; multi-party 6.8 — для не-срочных критических); compromised-key registry с авто-verify при verify — Growth (FR65). Тесты: 4 unit; coopback рестартнул чисто, POST /coop/keys/revoke смаплен, без DI-ошибок.

Дополнительно: автономные backend-истории Эпиков 8 (audit, issue 105-11) и 9 (наблюдаемость, issue 105-12)

Истории без цепи/UI идут на этой же ветке. При перетарджете base остаются корректным diff'ом. Автономный backend названного плана (Подход 1: 1.7–1.11 → 2.3,2.5 → 9.1,9.8,9.9) исчерпан; 9.12 взята как fallback вне плана по согласию пользователя. Заблокированные истории Эпика 8 (8.3 — нужен Эпик 5/OIDC; 8.4 — нужна Story 3.3/решение владельца; 8.6 — модель данных ПДн) — ниже.

  • 8.1 audit_events schema + INSERT-only triggers + monthly partitioning. Append-only audit_events (помесячные партиции PARTITION BY RANGE + DEFAULT, ROW-триггер UPDATE/DELETE + STATEMENT-триггер TRUNCATE с RAISE EXCEPTION 'audit_events is append-only', REVOKE UPDATE,DELETE,TRUNCATE/GRANT INSERT,SELECT) уже создан в V2.4.0 — боевая таблица, ~30 точек записи. 8.1 закрывает остаток AC миграцией V2.4.11: (1) колонка user_agent text (форензика, наполняет 8.2); (2) выделенная read-only роль coop_audit_reader (AC: INSERT приложению / SELECT читателю аудита; в V2.4.0 разделение ролей отложено в прод-плейбук) — создание под EXCEPTION WHEN insufficient_privilege (в рестриктед prod без CREATEROLE роль заведёт плейбук, миграция не падает; GRANT SELECT условный IF EXISTS); (3) форвард-роллинг партиций на текущий+2 мес (идемпотентно); (4) re-assert append-only грантов. Дрейф (прав код): имена колонок event/context/created_at/id bigint/subject_id text сохранены (боевая партиционированная append-only таблица) = семантически AC action/metadata/timestamp; партиционирование по created_at = partition_month (та же помесячная RANGE-нарезка, отдельная DATE-колонка избыточна). Побочный фикс ordering-бага V2.4.7: первый реальный migration:run всей цепочки (раньше проверялся только рестарт coopback — он НЕ мигрирует) вскрыл, что в dev применены лишь миграции до 2.4.1, а 2.4.2–2.4.11 висели pending; прогон падал на V2.4.7 access_rules — V2.4.0 создаёт плейсхолдер (role/action/subject), V2.4.7 CREATE IF NOT EXISTS=no-op + CREATE INDEX subject_type падает, реконсиляция V2.4.8 недостижима (идёт после). Фикс forward-only: guard сноса пустого плейсхолдера перенесён в начало V2.4.7 (V2.4.8 → идемпотентный no-op); правка ранее-закоммиченной миграции допустима — ни одна из 2.4.2+ не применена нигде. После фикса вся цепочка 2.4.2–2.4.11 прошла. Верификация на dev: user_agent добавлена, партиции 06/07/08+default, гранты coop_app_user=INSERT,SELECT (без UPDATE/DELETE), coop_audit_reader graceful-degrade. ESLint exit 0. Чистая DDL-миграция — отдельный unit-спек не заводится (как V2.4.10/V2.4.6).

  • 8.2 Структурированные audit fields. AuditService — поле userAgent первого класса (AuditRecord +userAgent?, record() пишет в колонку user_agent из V2.4.11); до 8.2 UA складывался обходным путём в context. DeviceTrackingService (3.8) переведён: в coopid.login.successful UA уходит в userAgent верхнего уровня, из context убран (остаются device_new+accept_language). docs/audit/event-schema.md — каноническая дока: колонки audit_events+семантика (дрейф имён event/context/created_at ↔ AC action/metadata/timestamp), конвенция explicit-null-with-reason (отсутствующее поле = null, причину отсутствия флагом в context типа {ip_unknown:'internal_call'} ставит вызывающий — сервис не домысливает), инвариант secret-blacklist, каталог реальных имён событий. Дрейф (прав код): колонка context не metadata (из 8.1, в доке contextmetadata); explicit-null-with-reason = caller-side-конвенция, не логика сервиса; остальные 23 вызова .record не тронуты (userAgent опционален, обратная совместимость; проводка UA в OIDC/key-rotation — 8.3/8.4). Тесты: новый audit.service.spec.ts 5 (INSERT включает user_agent отдельным параметром / userAgent отсутствует→null / context-ip-subject-actor дефолты / secret-blacklist throw вкл. вложенность + запись не дошла / assertContextHasNoSecrets пропускает безопасные ключи; DataSource замокан spy на getDataSource) + device-tracking.service.spec обновлены 3 ассерта. ESLint exit 0, coopback рестартнул чисто без DI/AuditService-ошибок.

  • 8.6 PiiErasureService — daily cron для excluded participants BLOCKED на решении владельца по модели данных. AC описывает scan postgres-таблицы participants (status='excluded' AND excluded_at <= now()-30d AND pii_erased=false), но такой таблицы нет: ПДн (ФИО/паспорт/адрес/телефон) живут в document-store генератора (mongo, коллекции individual/organization/entrepreneur по usernameIndividualRepositoryImplementationgeneratorPort), в coop_domain_db их нет; нет ни excluded_at, ни pii_erased, ни понятия «статус excluded с временной меткой». Реализация требует: (1) решить авторитетный сигнал «исключён + когда» (on-chain исключение из совета vs off-chain user.status — откуда 30-дн отсчёт); (2) новую модель отслеживания pii_erased; (3) erasure-рутину над document-store, не postgres-scan. Канон «архитектура согласуется ДО реализации» → выношу решение владельцу, модель не угадываю.

  • 8.3 Audit для OIDC operations BLOCKED Эпиком 5 (OIDC). AC требует писать audit_events на финализации OIDC-операций (login/token issue/refresh/revocation/consent/logout) + authentik native events через webhook. Этих точек в коде нет — весь Эпик 5 (5.1–5.7) в backlog. Аудировать нечего до реализации Эпика 5.

  • 8.4 Audit для key rotation BLOCKED Story 3.3. AC: audit_events.KeyRotated{trigger,old_pubkey,new_pubkey,initiator_id} на финализации ротации. Реальной ротации в коде нет: RECOVERY_FINALIZATION_PORT забинден на RecoveryFinalizationPlaceholder (throws 503) — все пути (recovery-confirm/force/offline) сходятся в него; 3.3 несёт боевую финализацию, у неё внешние зависимости + открытый вопрос владельцу (источник admin-токена authentik). manual_revoke ключ не ротирует (уже аудируется KeyRevokedManually 4.7), scheduled — фичи нет. Эмитить KeyRotated сейчас = аудит несуществующего события. Разблокируется с 3.3.

  • 8.7 Лог-санитайзер Winston + ESLint no-sensitive-in-log (защита в глубину NFR9, две независимые линии обороны). (1) Runtime-маскированиеsrc/config/log-redaction.ts: SENSITIVE_LOG_KEY_PATTERNS (надмножество audit-blacklist 8.2 — добавлены крипто wif/mnemonic/seed и транспортные authorization/cookie/api_key) + redactSensitive(value) рекурсивно маскирует значение секрет-ключа на любой глубине → '[REDACTED]' (в отличие от audit-sanitize, что удаляет ключ: в логах ключ безопасен, опасно значение, и логирование не должно ронять поток). WeakSet от циклов; Error/Date/Buffer/класс-инстансы не деконструируются (сохраняем stack для логгера). redactionFormat winston вставлен в format.combine после timestamp/enumerateError до printf — чистит meta каждого события (own-string-ключи кроме служебных level/message/timestamp/context/ms/splat); message-строку не трогает. (2) Статический запрет.eslintrc.json override no-restricted-syntax (no-sensitive-in-log) глобально src/** (искл. *.spec.ts + auth-v2/**) ловит прямой identifier/member-аргумент с секрет-именем у console.<log|info|warn|error|debug> / <любой>.<...|verbose>. Дрейф (прав код): голый *key* из AC сужен до privatekey/private_key (+wif/mnemonic/seed) — bare key даёт массу ложноположительных (cacheKey/idempotencyKey/publicKey); publicKey НЕ секрет. Ловушка single-key override (как 6.5): no-restricted-syntax уже занят AuthRoles-баном в auth-v2/**; поздний override клобберит → глобальное правило исключает auth-v2/**, а те же 4 sensitive-селектора продублированы в auth-v2 override (там оба набора). Логирование секрета внутри объекта статикой не ловится осознанно — это ловит runtime-маскирование. Прогон grep по всему src = 0 существующих нарушений; глобальный override ничего не ломает. Тесты: log-redaction.spec.ts 12 (плоско/вложенно/массив/publicKey-не-маскируется/Error-не-деконструирует/цикл/примитивы/не-мутирует/контейнер-credentials целиком) + no-sensitive-in-log.eslint.spec.ts 7 (ESLint Node API как 6.5: console.log(privateKey)/logger.info(token)/logger.error(user.privateKey)/console.log(this.secret)→error; publicKey/строка/объект-обёртка→чисто; работает и в auth-v2). ESLint exit 0 (на src/application+infrastructure только пре-существующие no-constant-condition, не мои), coopback рестартнул чисто (формат логгера грузится на старте без ошибок). (Позиция redactionFormat в combine скорректирована историей 8.8 — должна быть после splat(); см. ниже.)

  • 8.8 Тест чистоты логов + сканер утечек секретов (NFR9, 152-ФЗ) — 3-я линия обороны после маскирования и ESLint-запрета 8.7: доказательство по содержимому значения (ловит то, что key-based 8.7 пропускает — секрет под несекретным ключом, интерполяция в message). (1) src/config/log-purity.ts: SENSITIVE_TEST_PATTERNS (тест-фикстурные регэкспы test-password-*/secret-*/private-key-*/wif-*/mnemonic-*/seed-* — на production-логах не зажигаются), findSensitiveLeaks(output, exactValues?)SensitiveLeak[]{line(1-based),pattern,match} построчно по паттернам + точным значениям фикстур, assertNoSensitiveLeaks бросает Sensitive value leaked to logs at line N: <match> (pattern <p>) (формат из AC). (2) src/config/log-purity.spec.ts: захват боевого logger через winston.transports.Stream поверх node Writable (logger.add/remove в finally — настоящая цепочка форматов 8.7, без транзитивной winston-transport); сценарии: secret под секрет-ключами → [REDACTED] и assertNoSensitiveLeaks проходит; утечка под несекретным ключом (note:'wif-LEAK') → сканер ловит; helper-unit (номер строки / exact-fixture / чисто→[] / бросает «at line N»). Пойманный дефект 8.7 (ценность теста): первый сквозной прогон боевой winston-цепочки вскрыл, что logger.info('msg', {password}) выводил секрет в сыром виде — redactionFormat стоял до winston.format.splat(), а splat() при сообщении без %-токенов делает Object.assign(info, ...исходный meta) из info[SPLAT], повторно вмёрживая сырьё поверх маскировки. Фикс в src/config/logger.ts: redactionFormat() перенесён после splat() (перед printf) + комментарий-инвариант о порядке; теперь маскируются и logger.info('msg', meta), и %s-интерполяция. Unit-тесты 8.7 (тестировали redactSensitive+ESLint, но не цепочку winston) этого не ловили — ровно ради этого Story 8.8. Дрейф (прав код): захват через доп. winston-Stream-транспорт на боевом logger, а не перехват process.stdout.write — детерминированно и тестирует ту же цепочку; сканер вынесен в переиспользуемый log-purity.ts (8.10/CI-гейт могут прогнать assertNoSensitiveLeaks по общему test-run stdout). Самопроверка: jest log-purity+log-redaction --runInBand 19/19 зелёных (7 от 8.8 + 12 регресс 8.7); ESLint новых файлов exit 0; coopback затронут (порядок форматов logger.ts), рестарт чистый — «Nest application successfully started», без ошибок DI.

  • 8.5 Audit admin actions через @AuditAction interceptor. @AuditAction(category='admin') (SetMetadata AUDIT_ACTION). AuditActionInterceptor (implements NestInterceptor): читает метку с метода/класса (нет → next.handle() без аудита), извлекает user+args из GraphQL и HTTP (тот же приём, что AuthorizationGuard 6.4), event=coopid.<category>.<handlerName>, subjectId=args.target_id ?? args.id, на успехе (rxjs tap) audit.record(success), на ошибке (catchError) record(failure +_error) + rethrow, metadata=sanitizeArgs (рекурсивно выкидывает ключи-секреты на всех уровнях, выкинутые → _redacted[]). Провайдер в AuthV2Module, применяется точечно @UseInterceptors (не глобальный APP_INTERCEPTOR). Дрейф (прав код): interceptor точечный (канон 6.4/6.5); event=coopid.<category>.<handler> не голый handler (таксономия coopid.* из 8.2); санация = удаление ключа, не маска значения (secret-blacklist AuditService бросает на КЛЮЧ); failure-аудит best-effort в .catch (не подменяет исходную ошибку). Первые боевые потребители — admin-резолверы 6.6/6.7 (changeRoles/grantCapability/excludeParticipant, blocked) — как первые @CheckAbility легли в 6.8; на уже-аудирующие вручную endpoints (KeyRevokedManually 4.7) @AuditAction не вешаем (двойная запись). Тесты: audit-action.interceptor.spec.ts 6 unit (нет метки→пропуск / HTTP success event+subjectId target_id+фолбэк id+actor / GraphQL args из data / секреты в _redacted вкл. вложенность + проходит secret-blacklist / ошибка→failure+rethrow). ESLint exit 0, coopback рестартнул чисто без DI-ошибок.

  • 9.12 Структурированные JSON-логи + Sentry без секретов (issue 105-12, Эпик 9) — взята как автономный backend-fallback после исчерпания названного плана. (1) src/config/logger.ts: вынесена buildLogFormat(isProduction) — единая цепочка с ветвлением: productionwinston.format.json() с полем service='coopback' (+ request_id/metadata если переданы в meta), без colorize (ANSI в JSON недопустим); dev/test → прежний pretty-printf с colorize (поведение разработки НЕ меняется). redactionFormat (8.7) остаётся после splat() в обеих ветках (инвариант 8.8), сериализация строго после redaction → секреты не утекают и в JSON. (2) src/shared/utils/sentry-scrub-event.ts: scrubSensitiveDataFromSentryEvent дополнен redactSensitive (та же утилита 8.7) по event.extra и event.contexts — прикладные данные в Sentry (user_id/request_id/доменный контекст ошибки) маскируются по секрет-ключам на любой глубине (AC «stack trace без secrets из 8.7»); транспортные заголовки по-прежнему чистятся redactSensitiveHttpHeaders. Дрейф (прав код): JSON только в production (AC безусловен, но мотивирован prod-агрегацией «любым docker-logs-агрегатором»; в dev читаемость ERROR-логов критична для петли разработки) — стандартный prod-JSON/dev-pretty; service='coopback' константа (в config нет app-name); request_id/user_id поддержаны как поля, но не авто-инжектятся (сквозной ALS request-id — отдельная инфра, шире 9.12 и рискованна в живом стеке); user_id sanitization через redaction 8.7. stdout/stderr standard transport (Console+DailyRotateFile, без kafka/elastic) уже был. Тесты: logger-format.spec.ts 7 (prod: валидный JSON + service + маскировка password/vault.privateKey + нет ANSI; dev: не-JSON + нет "service" + маскировка token) + sentry-scrub-event.spec.ts 4 (extra на глубине / contexts / хедеры сохранены / пустое событие не падает). 28/28 зелёных с регрессом log-redaction+log-purity. ESLint exit 0 (warning non-null убран); coopback (env=development → dev-ветка) рестартнул чисто — «Nest application successfully started».

  • 9.13 SDK cross-runtime smoke-тесты (issue 105-12, Эпик 9, NFR26) — взята как автономный backend-fallback. Общий components/auth/test/cross-runtime/smoke.test.ts ходит только публичным API @coopenomics/auth: vault round-trip (Argon2id+AES-GCM через WebCrypto) → unlockWallet (fetch застаблен blob'ом как от контроллера), signDocumentverifyDocumentOffline, WalletLocked при запертом кошельке, типизированный AuthV2Error(not_implemented) для login/getAccessToken. Три таргета: pnpm test:node (vitest) + test:browser (vitest browser mode, playwright+chromium headless, vitest.config.browser.ts) + test:electron (electron main-process против собранного dist/index.cjs через electron-main.cjs + run-electron.mjs, xvfb-run на безголовом хосте). Оркестратор test:cross-runtime = build→node→browser→electron; алиас в корне монорепы. CI .github/workflows/sdk-cross-runtime.yaml (PR→dev по путям components/auth/**, xvfb + playwright chromium с --with-deps). Перф: KDF один раз в beforeAll (600s) — в chromium pure-JS Argon2id это минуты, per-test упирался в таймауты. Дрейф (осознанный, в spec): login/getAccessToken — скелет Story 1.2 (вход 1.7 живёт в controller+desktop), smoke фиксирует текущий контракт (модуль грузится, канал типизированных ошибок жив); «desktop-runtime»=electron — в платформе нет electron-приложения (desktop = Quasar PWA/браузер), electron-таргет оставлен по букве AC как проверка переносимости bundle (пользователь подтвердил оставить); «все три passing для merge» — branch-protection, не код пакета. Node 4/4 + browser 4/4 + electron 7/7 зелёные; ESLint 0; tsc 0.

🤖 Generated with Claude Code

## Подход 4 «Управление и авторизация» — Эпик 6 (CoopID, компонент 42) Stacked-PR поверх `feat/coopid-epic-4` (Эпик 4). После merge нижних веток база будет перетарджечена. **Источник AC:** project 13 `epics.md`, Epic 6 (issue 105-9). Спеки реализации — `_bmad-output/implementation-artifacts/spec-6-*.md`. Председатель и совет управляют пайщиками с защитой от ошибок: гибкие роли + capabilities, CASL guards (Layered Authorization Pattern, 4 слоя), критические действия требуют 2 подтверждений (chairman + ≥1 совет), force-recovery невозможен без согласия пайщика/решения собрания. **Переиспользование зачатка с `marketplace2`:** там заложен CASL-совместимый matrix-паттерн с пометкой «Phase 2 → CASL `defineAbility`». Платформенный фундамент ролей (`CoreRole=User|Member|Chairman`) поднят на уровень auth-v2 и развит в реальный `@casl/ability`. Marketplace-matrix остаётся в своей ветке и мигрирует в эту платформенную CASL (контракт в `authorization/README.md`) — обе ветки мерджатся в dev без конфликтов файлов (работа аддитивна). ### Истории Эпика 6 - [x] **6.1** CASL Ability-factory + Layer 1 (Static Ability). Зависимость `@casl/ability@^7` в `dependencies` (не dev — `prune --prod`). Платформенный `auth-v2/authorization/core-roles.ts` (`CoreRole=User|Member|Chairman` + `mapUserRoleToCoreRoles`, зеркало marketplace-зачатка). `AbilityFactory.createForParticipant({username, role})` → `AppAbility = MongoAbility<[CoopAction, CoopSubject]>` по статической матрице Layer 1: **User** — `read/update` свои `Certificate/Session` + `manage` свою `RecoveryStrategy` (condition `{owner}`); **Member** (наследует) — read-only `Participant/VerificationRule/CriticalAction/AuditEvent` + `confirm CriticalAction` (второй подписант 6.8); **Chairman** (наследует) — `manage VerificationRule/CoopSettings`, `update Participant` (роли 6.6), `create Capability` (6.7), `create CriticalAction` (инициатор). admin/неизвестная роль → пустая Ability. Сериализация `serialize/deserialize` через `ability.rules` для Redis-session (не JWT-claim — нужна инвалидация pub/sub 6.2). **Дрейф AC↔код (прав код):** роли `User/Member/Chairman` вместо `participant/council_member` — существующий канон контроллера, совместимо с marketplace2. Связывание Ability с session-стором — Story 6.4. 10 unit-тестов (все роли + ownership + round-trip). - [x] **6.2** CASL Layer 2 — `access_rules` matrix-driven + Redis pub/sub инвалидация. Таблица `access_rules` в coop_domain_db (миграция `V2.4.7`: `subject_type` role|participant, `subject_id`, `effect` allow|deny, `action`, `resource_type`, `conditions` jsonb, `expires_at` — TTL capabilities 6.7; индекс по принципалу). Доменный порт `access-rules.port.ts` (enum `AccessRuleEffect`/`AccessRulePrincipalKind`; `IAccessRulesRepository.findForPrincipal`+`insert`; `IAccessRulesInvalidationPublisher` + канал `coopid:access-rules:invalidate`). `PostgresAccessRulesRepository` (coop_domain_db lazy DataSource; `findForPrincipal` = правила core-ролей пайщика OR персональные, истёкшие отброшены). `AbilityFactory.createForParticipant(user, rules)` мерджит Layer 2 поверх Layer 1: **allow-первыми, deny-последними** (CASL берёт последнее матчащее правило → deny перекрывает статический allow); `conditions` пробрасываются в `can`/`cannot`. `createForParticipantWithRules(user)` читает репозиторий по core-ролям+username. `RedisAccessRulesInvalidationPublisher` (ioredis — только infrastructure, hexagonal-инвариант). **Дрейф AC↔код (прав код):** добавлены `effect` (allow/deny невыразим без него) и `expires_at` (forward-compat 6.7). Подписчик инвалидации (сброс кэша Ability активной сессии) — Story 6.4 (точка потребления Ability). 15 unit-тестов (Layer 1: 10 + Layer 2 merge: 4 + publisher: 1). - [x] **6.3** CASL Layer 3 — PolicyHandler registry с DB-доступом. `policy.types.ts` (`IPolicyHandler {name; evaluate(ctx):boolean|Promise}`, `PolicyEvaluationContext {user,action,subject,resource?}`). `@PolicyHandler(name)` (Injectable+SetMetadata). `@CheckAbility(action,subject,{policy?})` + `CHECK_ABILITY` metadata (введён здесь, потребляется guard'ом 6.4). `PolicyRegistry` (OnModuleInit, `DiscoveryService`) индексирует все `@PolicyHandler`-провайдеры по имени; `has/get/evaluate`; **fail-closed** (неизвестное имя → throw, не молчаливый allow); дубль имени → throw на старте. `SameCoopVotingPolicy` `@PolicyHandler('same-coop-voting')`: DB-lookup членства = существование participant-vault (`IVaultRepository.find`, без расшифровки — инвариант CoopID); чужой `coopname` (≠`config.coopname`) → deny (per-coop); пустой username → deny. `AuthorizationModule` импортит `DiscoveryModule`, регистрирует `PolicyRegistry`+`SameCoopVotingPolicy`, экспортит `PolicyRegistry`. **Фикс субстрата Layer 2 — миграция `V2.4.8`:** init `V2.4.0` создавал `access_rules` плейсхолдером (старая схема `role/action/subject`), `V2.4.7` (6.2) с `CREATE IF NOT EXISTS` = no-op на БД где `V2.4.0` раньше → старая схема → repo 6.2 упал бы на `column "subject_type" does not exist` (подтверждено на dev). Forward-миграция (append-only): нет `subject_type` → DROP пустого плейсхолдера + CREATE правильной; идемпотентно. **Дрейф AC↔код (прав код):** subject `CriticalAction` вместо `Decision` (`Decision` нет в `CoopSubject`; голосование по `CriticalAction`, 6.8). Тесты: 4 registry + 5 policy unit + **1 integration с реальной coop_domain_db** (`PostgresVaultRepository`, seed/cleanup, в контейнере); coopback рестартнул чисто — `PolicyRegistry` обнаружил `same-coop-voting` через `DiscoveryService` без DI-ошибок. - [x] **6.4** CASL Layer 4 + единый `AuthorizationGuard`. `authorization-denial.ts` (enum `AuthorizationDenialReason` для лога + публичный `authorization_denied` → 403; наружу обобщённый код, точная причина — только в server-log). `PolicyService.ensure(requirement, user, resource?)` (Layer 4) — единый вычислитель всех слоёв: нет user → 403; L1+2 свежая Ability + `can(action, asSubject(subject, resource))` для instance-level ownership; L3 политика (вызывается только после прохождения Ability). `AuthorizationGuard implements CanActivate` — читает `@CheckAbility` (метод+класс), нет требования → пропуск; `server-secret` → обход; извлекает user+resource из **GraphQL и HTTP REST** (общий middleware); делегирует `PolicyService.ensure`. **Дрейф (прав код):** L4 = `PolicyService.ensure`, guard делегирует туда же (нет дубля логики); Ability per-request без session-кэша (изменения `access_rules` применяются сразу; кэш+инвалидация publisher 6.2 — отдельная перф-оптимизация); единый `error_code`; guard точечный, не APP_GUARD. Тесты: 6 `policy.service` + 5 `guard` unit; coopback рестартнул чисто без DI-ошибок. - [x] **6.5** Миграция `@AuthRoles` → `@CheckAbility` + ESLint-запрет `no-authroles-in-authv2`. Новый `overrides`-блок в `.eslintrc.json` (scope `src/application/auth-v2/**`, кроме `*.spec.ts`) через `no-restricted-syntax`: `ImportSpecifier[imported.name='AuthRoles']` + `Decorator[expression.callee.name='AuthRoles']` → error; сообщение именует канон-правило и направляет на `@CheckAbility`. `no-restricted-syntax` выбран намеренно (а не `no-restricted-imports`) — тот ключ в override `application/**` занят wharfkit-баном; разные ключи мерджатся аддитивно. Тест `no-authroles-in-authv2.eslint.spec.ts` (ESLint Node API): `@AuthRoles` в auth-v2 → error, в legacy `auth/` → нет. **Дрейф (прав код):** auth-v2 — REST-контроллеры, `@AuthRoles` не используется → «резолверы используют только @CheckAbility» вакуумно; текущие эндпоинты самообслуживания (JWT+ownership) не роль-гейтятся, первые боевые `@CheckAbility` — в админ-6.6/6.7 и 6.8; кастомного `@coopenomics`-плагина нет (как и у wharfkit-бана). Регрессия: `eslint auth-v2/**` exit 0. - [ ] 6.6 Admin UI — назначение ролей пайщикам ⛔ **BLOCKED на решениях пользователя:** модель ролей (единое `user.role` vs новый `participants.roles[]`; Member=член совета вероятно on-chain soviet — канон/архитектура, согласуется ДО реализации), правка core-User/soviet вне auth-v2, Vue-страница (скриншоты пользователя). Первый боевой `@CheckAbility('update','Participant')` — сюда после решения модели. - [ ] 6.7 Admin UI — выдача точечных capabilities ⛔ **BLOCKED:** Vue admin-страница (скриншоты пользователя); бэкенд (`access_rules.expires_at` из 6.2 + cron-cleanup) автономен, UI — с пользователем. - [x] **6.8** Multi-party critical actions service. Миграция `V2.4.9` `pending_critical_actions` (action_type, actor_id, target_id, payload, status, confirmations `[{by,at}]`, expires_at, finalized_at). Порт (enum `CriticalActionType`/`CriticalActionStatus`, repo, `ICriticalActionNotifier` + канал `coopid:critical-action:pending`). `PostgresPendingCriticalActionsRepository`, `RedisCriticalActionNotifier` (publish события; фан-аут — notification-center downstream). `CriticalActionsService`: `initiate` (подпись инициатора + окно 24ч + notify), `confirm` (404/409 для not-found/not-pending/expired/self-confirm/duplicate; ≥2 подписи → finalized + audit `CriticalActionConfirmed` с обоими подписантами + `payload_hash`), `@Cron` daily `expireStale` → `CriticalActionExpired`. **Первый боевой `@CheckAbility`+`AuthorizationGuard`:** `POST /coop/critical-actions` (`create CriticalAction`, председатель) + `POST /:id/confirm` (`confirm CriticalAction`, член совета) поверх `HttpJwtAuthGuard`. Тесты: 8 unit; coopback рестартнул чисто, роуты смаплены, `@Cron` зарегистрирован. **Разблокирует Story 4.7.** - [x] **6.9** Force-recovery rules — запрет единоличного сброса доступа председателем. Порт `force-recovery-consent.port.ts` (`IForceRecoveryConsentStore` issue/consume single-use/markGranted/isGranted; `IForceRecoveryConsentNotifier` + канал `coopid:force-recovery:consent-requested`). `RedisForceRecoveryConsentStore` (запрос `coopid:fr:req:<token>` PX-TTL, `consumeRequest` атомарен Lua `GET→DEL` как recovery-токен; отметка `coopid:fr:granted:<target>:<initiator>` кратким TTL; `ioredis` только infra). `RedisForceRecoveryConsentNotifier` (publish события с токеном; доставка письма — notification-center downstream как 6.8). `ForceRecoveryService`: два независимых канала согласия — **(а)** пайщик подтверждает magic-link, **(б)** решение собрания (on-chain tx, `assemblyDecisionTxId` валидируется как 64-hex). enum `ForceRecoveryConsentVia` (наружу+audit), внутренний `ForceRecoveryDenialReason` только в лог (наружу единый `force_recovery_denied`). `authorize`: нет granted-отметки и нет валидного tx → `deny(no_consent)`/403; при стратегии `RecoveryStrategy.Council` → требует подтверждённый force_recovery critical action 6.8 иначе `deny(council_approval_missing)`; успех → audit `ForceRecoveryAuthorized` `triggered_by: chairman`. `requestConsent`/`grantConsent` (single-use токен, `consent/:token` **без auth-guard** — пайщик потерял доступ). Контроллер `/coop/force-recovery`: `request-consent`+`authorize` `@CheckAbility('create','CriticalAction')` поверх `HttpJwtAuthGuard`+`AuthorizationGuard`. **Дрейф (прав код):** on-chain верификация решения собрания — Эпик 3 (здесь гейт допуска); Council переиспользует кворум 6.8; финализация (ротация ключа) — recovery-flow Эпик 3 (placeholder 3.3). Тесты: 9 unit; coopback рестартнул чисто, все три роута смаплены. - [x] **6.10** Audit critical actions с обоими подписантами + `getAuditTrail`. Порт `IPendingCriticalActionsRepository` +`listByTarget(targetId)` (новые сверху; индекс `target_id` уже из `V2.4.9`). `PostgresPendingCriticalActionsRepository.listByTarget` (`SELECT WHERE target_id ORDER BY created_at DESC`). `CriticalActionsService`: экспорт-типы `CriticalActionAttribution` (`initiatorId`/`initiatedAt`/`confirmerIds`/`payloadHash`) + `CriticalActionAuditEntry`; приватный `attribution(action)` — единый источник полной атрибуции (инициатор=`actorId`, `initiatedAt`=timestamp первой подписи, `confirmerIds`=подтверждения `by!==actorId` со своими timestamps, `payloadHash`=sha256); `auditConfirmed` переписан на него — context чётко разделяет `initiator_id`+`initiated_at` и `confirmer_ids` (**только совет**; в 6.8 массив включал инициатора); `getAuditTrail(targetId)` = `listByTarget`→`CriticalActionAuditEntry[]`. **`GET /coop/critical-actions/audit-trail/:targetId`** `@CheckAbility('read','CriticalAction')` (Member/Chairman = контролирующий орган; отдельный path-сегмент во избежание коллизии с `:id/confirm`). **Дрейф (прав код):** `confirmer_ids`=только совет (AC разводит роли — инициатор в отдельное поле, полнота сохранена); trail из `pending_critical_actions` (источник истины по составу), не из `audit_events`; `payload_hash` вместо payload (не раскрываем чувствительное); REST-метод, не GraphQL (auth-v2 REST-контур, канон 6.5). Тесты: +3 к 8 = 11 unit (полная атрибуция / пустой trail / детерминизм `payload_hash` + усилена проверка финализации); coopback рестартнул чисто, `GET audit-trail` смаплен, без DI-ошибок. **Закрывает Эпик 6 backend** (остаются только UI-6.6/6.7, blocked). ### Дополнительно: перенесённая история Эпика 4, разблокированная Эпиком 6 Эти коммиты физически в ветке `feat/coopid-epic-6` (story была отложена sprint-планом до появления CASL 6.1–6.4 + multi-party 6.8 — «нет окна единоличного revoke»). При перетарджете base после merge нижних веток diff остаётся корректным. - [x] **4.7** Manual revoke endpoint (compromised-key MVP). Миграция `V2.4.10` `revoked_keys` (coop_domain_db: `target_id`, `reason`, `revoked_by`, `revoked_at`, `recovered_at` nullable; индекс `target_id`+`recovered_at`; `recovered_at IS NULL` = отзыв активен / pending recovery). Порт `key-revocation.port.ts` (`IKeyRevocationRepository` `record`/`findActive`/`markRecovered`). `PostgresKeyRevocationRepository` (lazy DS как pending-critical-actions). `KeyRevocationService.revoke`: (1) durable pending-state `repo.record` — **MVP-вариант AC «или с переходом на pending state»** вместо on-chain `updateauth` (реальная ротация — recovery-flow Эпика 3 placeholder 3.3, тот же раздел ответственности что force-recovery 6.9); (2) `sessions.revokeAll(target, ip)` гасит все сессии; (3) audit `KeyRevokedManually` (`reason`, `chairman_id`, `sessions_revoked`). `isPendingRecovery(target)` для downstream-гейта Эпик 3/7. **`POST /coop/keys/revoke`** `@CheckAbility('update','Participant')` поверх `HttpJwtAuthGuard`+`AuthorizationGuard`. **Дрейф (прав код):** MVP=pending-state не on-chain `updateauth` (AC прямо допускает); право=`update Participant` без нового `CoopAction 'revoke'` (добавление глагола в матрицу — канон-решение, согласуется ДО; председатель уже имеет `update Participant`, новых прав не выдаём); single-chairman action (AC: `chairman_id`, не список подписантов; compromised-key срочный — контроль через CASL+audit+обратимость recovery; multi-party 6.8 — для не-срочных критических); compromised-key registry с авто-verify при verify — Growth (FR65). Тесты: 4 unit; coopback рестартнул чисто, `POST /coop/keys/revoke` смаплен, без DI-ошибок. ### Дополнительно: автономные backend-истории Эпиков 8 (audit, issue 105-11) и 9 (наблюдаемость, issue 105-12) Истории без цепи/UI идут на этой же ветке. При перетарджете base остаются корректным diff'ом. Автономный backend названного плана (Подход 1: 1.7–1.11 → 2.3,2.5 → 9.1,9.8,9.9) **исчерпан**; 9.12 взята как fallback вне плана по согласию пользователя. Заблокированные истории Эпика 8 (8.3 — нужен Эпик 5/OIDC; 8.4 — нужна Story 3.3/решение владельца; 8.6 — модель данных ПДн) — ниже. - [x] **8.1** `audit_events` schema + INSERT-only triggers + monthly partitioning. Append-only `audit_events` (помесячные партиции `PARTITION BY RANGE` + DEFAULT, ROW-триггер UPDATE/DELETE + STATEMENT-триггер TRUNCATE с `RAISE EXCEPTION 'audit_events is append-only'`, `REVOKE UPDATE,DELETE,TRUNCATE`/`GRANT INSERT,SELECT`) **уже создан в `V2.4.0`** — боевая таблица, ~30 точек записи. 8.1 закрывает остаток AC миграцией **`V2.4.11`**: (1) колонка `user_agent text` (форензика, наполняет 8.2); (2) выделенная read-only роль `coop_audit_reader` (AC: INSERT приложению / SELECT читателю аудита; в `V2.4.0` разделение ролей отложено в прод-плейбук) — создание под `EXCEPTION WHEN insufficient_privilege` (в рестриктед prod без CREATEROLE роль заведёт плейбук, миграция не падает; `GRANT SELECT` условный `IF EXISTS`); (3) форвард-роллинг партиций на текущий+2 мес (идемпотентно); (4) re-assert append-only грантов. **Дрейф (прав код):** имена колонок `event/context/created_at/id bigint/subject_id text` сохранены (боевая партиционированная append-only таблица) = семантически AC `action/metadata/timestamp`; партиционирование по `created_at` = `partition_month` (та же помесячная RANGE-нарезка, отдельная DATE-колонка избыточна). **Побочный фикс ordering-бага `V2.4.7`:** первый реальный `migration:run` всей цепочки (раньше проверялся только рестарт coopback — он НЕ мигрирует) вскрыл, что в dev применены лишь миграции до `2.4.1`, а `2.4.2–2.4.11` висели pending; прогон падал на `V2.4.7` access_rules — `V2.4.0` создаёт плейсхолдер (`role/action/subject`), `V2.4.7` `CREATE IF NOT EXISTS`=no-op + `CREATE INDEX subject_type` падает, реконсиляция `V2.4.8` недостижима (идёт после). Фикс forward-only: guard сноса пустого плейсхолдера перенесён в начало `V2.4.7` (`V2.4.8` → идемпотентный no-op); правка ранее-закоммиченной миграции допустима — ни одна из `2.4.2+` не применена нигде. После фикса вся цепочка `2.4.2–2.4.11` прошла. Верификация на dev: `user_agent` добавлена, партиции `06/07/08+default`, гранты `coop_app_user`=`INSERT,SELECT` (без UPDATE/DELETE), `coop_audit_reader` graceful-degrade. ESLint exit 0. Чистая DDL-миграция — отдельный unit-спек не заводится (как `V2.4.10/V2.4.6`). - [x] **8.2** Структурированные audit fields. `AuditService` — поле `userAgent` первого класса (`AuditRecord` +`userAgent?`, `record()` пишет в колонку `user_agent` из `V2.4.11`); до 8.2 UA складывался обходным путём в `context`. `DeviceTrackingService` (3.8) переведён: в `coopid.login.successful` UA уходит в `userAgent` верхнего уровня, из `context` убран (остаются `device_new`+`accept_language`). **`docs/audit/event-schema.md`** — каноническая дока: колонки `audit_events`+семантика (дрейф имён `event/context/created_at` ↔ AC `action/metadata/timestamp`), конвенция **explicit-null-with-reason** (отсутствующее поле = `null`, причину отсутствия флагом в `context` типа `{ip_unknown:'internal_call'}` ставит вызывающий — сервис не домысливает), инвариант secret-blacklist, каталог реальных имён событий. **Дрейф (прав код):** колонка `context` не `metadata` (из 8.1, в доке `context`≈`metadata`); explicit-null-with-reason = caller-side-конвенция, не логика сервиса; остальные 23 вызова `.record` не тронуты (`userAgent` опционален, обратная совместимость; проводка UA в OIDC/key-rotation — 8.3/8.4). Тесты: новый `audit.service.spec.ts` 5 (INSERT включает `user_agent` отдельным параметром / `userAgent` отсутствует→null / context-ip-subject-actor дефолты / secret-blacklist throw вкл. вложенность + запись не дошла / `assertContextHasNoSecrets` пропускает безопасные ключи; DataSource замокан spy на `getDataSource`) + `device-tracking.service.spec` обновлены 3 ассерта. ESLint exit 0, coopback рестартнул чисто без DI/AuditService-ошибок. - [ ] 8.6 `PiiErasureService` — daily cron для excluded participants ⛔ **BLOCKED на решении владельца по модели данных.** AC описывает scan postgres-таблицы `participants` (`status='excluded' AND excluded_at <= now()-30d AND pii_erased=false`), но такой таблицы **нет**: ПДн (ФИО/паспорт/адрес/телефон) живут в document-store генератора (mongo, коллекции `individual`/`organization`/`entrepreneur` по `username` — `IndividualRepositoryImplementation`→`generatorPort`), в `coop_domain_db` их нет; нет ни `excluded_at`, ни `pii_erased`, ни понятия «статус excluded с временной меткой». Реализация требует: (1) решить авторитетный сигнал «исключён + когда» (on-chain исключение из совета vs off-chain `user.status` — откуда 30-дн отсчёт); (2) новую модель отслеживания `pii_erased`; (3) erasure-рутину над document-store, не postgres-scan. Канон «архитектура согласуется ДО реализации» → выношу решение владельцу, модель не угадываю. - [ ] 8.3 Audit для OIDC operations ⛔ **BLOCKED Эпиком 5 (OIDC).** AC требует писать `audit_events` на финализации OIDC-операций (login/token issue/refresh/revocation/consent/logout) + authentik native events через webhook. Этих точек в коде нет — весь Эпик 5 (5.1–5.7) в backlog. Аудировать нечего до реализации Эпика 5. - [ ] 8.4 Audit для key rotation ⛔ **BLOCKED Story 3.3.** AC: `audit_events.KeyRotated{trigger,old_pubkey,new_pubkey,initiator_id}` на финализации ротации. Реальной ротации в коде нет: `RECOVERY_FINALIZATION_PORT` забинден на `RecoveryFinalizationPlaceholder` (throws 503) — все пути (recovery-confirm/force/offline) сходятся в него; 3.3 несёт боевую финализацию, у неё внешние зависимости + открытый вопрос владельцу (источник admin-токена authentik). manual_revoke ключ не ротирует (уже аудируется `KeyRevokedManually` 4.7), scheduled — фичи нет. Эмитить `KeyRotated` сейчас = аудит несуществующего события. Разблокируется с 3.3. - [x] **8.7** Лог-санитайзер Winston + ESLint `no-sensitive-in-log` (защита в глубину NFR9, две независимые линии обороны). **(1) Runtime-маскирование** — `src/config/log-redaction.ts`: `SENSITIVE_LOG_KEY_PATTERNS` (надмножество audit-blacklist 8.2 — добавлены крипто `wif`/`mnemonic`/`seed` и транспортные `authorization`/`cookie`/`api_key`) + `redactSensitive(value)` рекурсивно **маскирует значение** секрет-ключа на любой глубине → `'[REDACTED]'` (в отличие от audit-sanitize, что **удаляет ключ**: в логах ключ безопасен, опасно значение, и логирование не должно ронять поток). `WeakSet` от циклов; `Error`/`Date`/`Buffer`/класс-инстансы не деконструируются (сохраняем stack для логгера). `redactionFormat` winston вставлен в `format.combine` после `timestamp`/`enumerateError` до `printf` — чистит `meta` каждого события (own-string-ключи кроме служебных `level/message/timestamp/context/ms/splat`); `message`-строку не трогает. **(2) Статический запрет** — `.eslintrc.json` override `no-restricted-syntax` (`no-sensitive-in-log`) глобально `src/**` (искл. `*.spec.ts` + `auth-v2/**`) ловит **прямой** identifier/member-аргумент с секрет-именем у `console.<log|info|warn|error|debug>` / `<любой>.<...|verbose>`. **Дрейф (прав код):** голый `*key*` из AC сужен до `privatekey`/`private_key` (+`wif`/`mnemonic`/`seed`) — bare `key` даёт массу ложноположительных (`cacheKey`/`idempotencyKey`/`publicKey`); `publicKey` НЕ секрет. **Ловушка single-key override (как 6.5):** `no-restricted-syntax` уже занят AuthRoles-баном в `auth-v2/**`; поздний override клобберит → глобальное правило исключает `auth-v2/**`, а те же 4 sensitive-селектора **продублированы в auth-v2 override** (там оба набора). Логирование секрета внутри объекта статикой не ловится осознанно — это ловит runtime-маскирование. Прогон grep по всему `src` = 0 существующих нарушений; глобальный override ничего не ломает. Тесты: `log-redaction.spec.ts` 12 (плоско/вложенно/массив/`publicKey`-не-маскируется/`Error`-не-деконструирует/цикл/примитивы/не-мутирует/контейнер-`credentials` целиком) + `no-sensitive-in-log.eslint.spec.ts` 7 (ESLint Node API как 6.5: `console.log(privateKey)`/`logger.info(token)`/`logger.error(user.privateKey)`/`console.log(this.secret)`→error; `publicKey`/строка/объект-обёртка→чисто; работает и в auth-v2). ESLint exit 0 (на `src/application`+`infrastructure` только пре-существующие `no-constant-condition`, не мои), coopback рестартнул чисто (формат логгера грузится на старте без ошибок). _(Позиция `redactionFormat` в `combine` скорректирована историей 8.8 — должна быть после `splat()`; см. ниже.)_ - [x] **8.8** Тест чистоты логов + сканер утечек секретов (NFR9, 152-ФЗ) — **3-я линия обороны** после маскирования и ESLint-запрета 8.7: доказательство по **содержимому значения** (ловит то, что key-based 8.7 пропускает — секрет под несекретным ключом, интерполяция в `message`). **(1)** `src/config/log-purity.ts`: `SENSITIVE_TEST_PATTERNS` (тест-фикстурные регэкспы `test-password-*`/`secret-*`/`private-key-*`/`wif-*`/`mnemonic-*`/`seed-*` — на production-логах не зажигаются), `findSensitiveLeaks(output, exactValues?)` → `SensitiveLeak[]{line(1-based),pattern,match}` построчно по паттернам + точным значениям фикстур, `assertNoSensitiveLeaks` бросает `Sensitive value leaked to logs at line N: <match> (pattern <p>)` (формат из AC). **(2)** `src/config/log-purity.spec.ts`: захват **боевого** `logger` через `winston.transports.Stream` поверх node `Writable` (`logger.add`/`remove` в `finally` — настоящая цепочка форматов 8.7, без транзитивной `winston-transport`); сценарии: secret под секрет-ключами → `[REDACTED]` и `assertNoSensitiveLeaks` проходит; утечка под **несекретным** ключом (`note:'wif-LEAK'`) → сканер ловит; helper-unit (номер строки / exact-fixture / чисто→`[]` / бросает «at line N»). **Пойманный дефект 8.7 (ценность теста):** первый сквозной прогон боевой winston-цепочки вскрыл, что `logger.info('msg', {password})` выводил секрет в сыром виде — `redactionFormat` стоял **до** `winston.format.splat()`, а `splat()` при сообщении без `%`-токенов делает `Object.assign(info, ...исходный meta)` из `info[SPLAT]`, повторно вмёрживая сырьё поверх маскировки. **Фикс** в `src/config/logger.ts`: `redactionFormat()` перенесён **после** `splat()` (перед `printf`) + комментарий-инвариант о порядке; теперь маскируются и `logger.info('msg', meta)`, и `%s`-интерполяция. Unit-тесты 8.7 (тестировали `redactSensitive`+ESLint, но не цепочку winston) этого не ловили — ровно ради этого Story 8.8. **Дрейф (прав код):** захват через доп. winston-Stream-транспорт на боевом `logger`, а не перехват `process.stdout.write` — детерминированно и тестирует ту же цепочку; сканер вынесен в переиспользуемый `log-purity.ts` (8.10/CI-гейт могут прогнать `assertNoSensitiveLeaks` по общему test-run stdout). Самопроверка: `jest log-purity+log-redaction --runInBand` **19/19 зелёных** (7 от 8.8 + 12 регресс 8.7); ESLint новых файлов exit 0; coopback затронут (порядок форматов `logger.ts`), рестарт чистый — «Nest application successfully started», без ошибок DI. - [x] **8.5** Audit admin actions через `@AuditAction` interceptor. `@AuditAction(category='admin')` (`SetMetadata AUDIT_ACTION`). `AuditActionInterceptor` (`implements NestInterceptor`): читает метку с метода/класса (нет → `next.handle()` без аудита), извлекает `user`+`args` из **GraphQL и HTTP** (тот же приём, что `AuthorizationGuard` 6.4), `event=coopid.<category>.<handlerName>`, `subjectId=args.target_id ?? args.id`, на успехе (`rxjs tap`) `audit.record(success)`, на ошибке (`catchError`) `record(failure +_error)` + rethrow, `metadata=sanitizeArgs` (рекурсивно **выкидывает** ключи-секреты на всех уровнях, выкинутые → `_redacted[]`). Провайдер в `AuthV2Module`, применяется точечно `@UseInterceptors` (**не** глобальный APP_INTERCEPTOR). **Дрейф (прав код):** interceptor точечный (канон 6.4/6.5); `event=coopid.<category>.<handler>` не голый handler (таксономия `coopid.*` из 8.2); санация = удаление ключа, не маска значения (secret-blacklist `AuditService` бросает на КЛЮЧ); failure-аудит best-effort в `.catch` (не подменяет исходную ошибку). Первые боевые потребители — admin-резолверы 6.6/6.7 (`changeRoles`/`grantCapability`/`excludeParticipant`, blocked) — как первые `@CheckAbility` легли в 6.8; на уже-аудирующие вручную endpoints (`KeyRevokedManually` 4.7) `@AuditAction` не вешаем (двойная запись). Тесты: `audit-action.interceptor.spec.ts` 6 unit (нет метки→пропуск / HTTP success event+subjectId target_id+фолбэк id+actor / GraphQL args из data / секреты в `_redacted` вкл. вложенность + проходит secret-blacklist / ошибка→failure+rethrow). ESLint exit 0, coopback рестартнул чисто без DI-ошибок. - [x] **9.12** Структурированные JSON-логи + Sentry без секретов (issue 105-12, Эпик 9) — взята как автономный backend-fallback после исчерпания названного плана. **(1)** `src/config/logger.ts`: вынесена `buildLogFormat(isProduction)` — единая цепочка с ветвлением: **production** → `winston.format.json()` с полем `service='coopback'` (+ `request_id`/metadata если переданы в meta), **без** `colorize` (ANSI в JSON недопустим); **dev/test** → прежний pretty-printf с `colorize` (поведение разработки НЕ меняется). `redactionFormat` (8.7) остаётся **после** `splat()` в обеих ветках (инвариант 8.8), сериализация строго после redaction → секреты не утекают и в JSON. **(2)** `src/shared/utils/sentry-scrub-event.ts`: `scrubSensitiveDataFromSentryEvent` дополнен `redactSensitive` (та же утилита 8.7) по `event.extra` и `event.contexts` — прикладные данные в Sentry (`user_id`/`request_id`/доменный контекст ошибки) маскируются по секрет-ключам на любой глубине (AC «stack trace без secrets из 8.7»); транспортные заголовки по-прежнему чистятся `redactSensitiveHttpHeaders`. **Дрейф (прав код):** JSON только в production (AC безусловен, но мотивирован prod-агрегацией «любым docker-logs-агрегатором»; в dev читаемость ERROR-логов критична для петли разработки) — стандартный prod-JSON/dev-pretty; `service='coopback'` константа (в config нет app-name); `request_id`/`user_id` поддержаны как поля, но **не авто-инжектятся** (сквозной ALS request-id — отдельная инфра, шире 9.12 и рискованна в живом стеке); user_id sanitization через redaction 8.7. `stdout/stderr` standard transport (Console+DailyRotateFile, без kafka/elastic) уже был. Тесты: `logger-format.spec.ts` 7 (prod: валидный JSON + `service` + маскировка `password`/`vault.privateKey` + нет ANSI; dev: не-JSON + нет `"service"` + маскировка `token`) + `sentry-scrub-event.spec.ts` 4 (`extra` на глубине / `contexts` / хедеры сохранены / пустое событие не падает). **28/28 зелёных** с регрессом `log-redaction`+`log-purity`. ESLint exit 0 (warning non-null убран); coopback (env=development → dev-ветка) рестартнул чисто — «Nest application successfully started». - [x] **9.13** SDK cross-runtime smoke-тесты (issue 105-12, Эпик 9, NFR26) — взята как автономный backend-fallback. Общий `components/auth/test/cross-runtime/smoke.test.ts` ходит **только публичным API** `@coopenomics/auth`: vault round-trip (Argon2id+AES-GCM через WebCrypto) → `unlockWallet` (fetch застаблен blob'ом как от контроллера), `signDocument`→`verifyDocumentOffline`, `WalletLocked` при запертом кошельке, типизированный `AuthV2Error(not_implemented)` для `login`/`getAccessToken`. Три таргета: `pnpm test:node` (vitest) + `test:browser` (vitest browser mode, playwright+chromium headless, `vitest.config.browser.ts`) + `test:electron` (electron main-process против собранного `dist/index.cjs` через `electron-main.cjs` + `run-electron.mjs`, `xvfb-run` на безголовом хосте). Оркестратор `test:cross-runtime` = build→node→browser→electron; алиас в корне монорепы. CI `.github/workflows/sdk-cross-runtime.yaml` (PR→dev по путям `components/auth/**`, xvfb + playwright chromium с `--with-deps`). Перф: KDF один раз в `beforeAll` (600s) — в chromium pure-JS Argon2id это минуты, per-test упирался в таймауты. **Дрейф (осознанный, в spec):** `login`/`getAccessToken` — скелет Story 1.2 (вход 1.7 живёт в controller+desktop), smoke фиксирует текущий контракт (модуль грузится, канал типизированных ошибок жив); «desktop-runtime»=electron — в платформе **нет** electron-приложения (desktop = Quasar PWA/браузер), electron-таргет оставлен по букве AC как проверка переносимости bundle (пользователь подтвердил оставить); «все три passing для merge» — branch-protection, не код пакета. **Node 4/4 + browser 4/4 + electron 7/7 зелёные; ESLint 0; tsc 0.** 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude added 16 commits 2026-06-13 05:51:41 +00:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 6.3 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

PolicyHandler-контракт (IPolicyHandler {name; evaluate(ctx)}), декораторы @PolicyHandler(name)
и @CheckAbility(action,subject,{policy?}), PolicyRegistry на DiscoveryService (fail-closed на
неизвестное имя, throw на дубль), политика-образец SameCoopVotingPolicy с DB-lookup членства
по participant-vault. Wiring через DiscoveryModule в AuthorizationModule.

Фикс субстрата Layer 2: forward-миграция V2.4.8 реконсилирует access_rules — init V2.4.0 создавал
плейсхолдер со старой схемой, V2.4.7 (6.2) был no-op → repo упал бы на column subject_type does
not exist. DROP пустого плейсхолдера + CREATE правильной схемы, идемпотентно, канон append-only.

Тесты: 4 registry + 5 policy unit + 1 integration с реальной coop_domain_db; coopback рестартнул
чисто, PolicyRegistry обнаружил same-coop-voting через DiscoveryService.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 6.4 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

PolicyService.ensure — общий вычислитель: Layer 1+2 (свежая Ability с access_rules,
instance-level ownership через asSubject), Layer 3 (именованная политика после Ability).
AuthorizationGuard читает @CheckAbility, обходит по server-secret, извлекает user и ресурс
из GraphQL и HTTP REST контекстов и делегирует ensure — guard и императивный путь делят
одну реализацию. Отказ — 403 с обобщённым authorization_denied, точная причина (enum) только
в server-log (security: не раскрываем слой/правило). Guard точечный, не APP_GUARD.

Тесты: 6 policy.service + 5 guard unit; coopback рестартнул чисто без DI-ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 6.5 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

Новый overrides-блок в .eslintrc.json (scope auth-v2/**) через no-restricted-syntax с двумя
селекторами: импорт символа AuthRoles и применение декоратора @AuthRoles(...) дают error.
no-restricted-syntax выбран намеренно — no-restricted-imports в соседнем override занят
wharfkit-баном, разные ключи правил мерджатся аддитивно. legacy auth/ сохраняет @AuthRoles
до Phase-3 cleanup. Тест через ESLint Node API проверяет срабатывание в auth-v2 и допуск в auth/.

Дрейф (прав код): auth-v2 на REST-контроллерах без @AuthRoles, миграция вакуумна; первые
боевые @CheckAbility придут в админ-эндпоинтах 6.6/6.7 и critical-actions 6.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 6.8 Эпика 6 (Подход 4 «Управление и авторизация», CoopID компонент 42).

Таблица pending_critical_actions (V2.4.9), порт + Postgres-репозиторий coop_domain_db,
Redis-нотификатор совета (publish события, фан-аут — notification-center). CriticalActionsService:
инициатор-председатель ставит действие в pending со своей подписью и окном 24ч, член совета
(отличный от инициатора, уникальный) подтверждает; на 2 подписях — финализация с аудитом
обоих подписантов и payload_hash; @Cron ежедневно отменяет истёкшие. Контроллер — первое боевое
применение @CheckAbility + AuthorizationGuard: create/confirm CriticalAction поверх JWT-guard.

Гейтинг прав на guard, кворум/окно/различимость подписантов в сервисе. Тесты: 8 unit; coopback
рестартнул чисто, роуты смаплены, @Cron зарегистрирован. Разблокирует Story 4.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 6.9: force-recovery гейтится двумя независимыми каналами согласия —
magic-link пайщика (Redis single-use токен, Lua GET→DEL) либо on-chain решение
общего собрания; при стратегии «решение совета» дополнительно требуется
подтверждённый critical action 6.8 (кворум 2 подписей). Отказ → 403 +
audit ForceRecoveryDenied, разрешение → audit triggered_by:chairman.
Порт + Redis-store/notifier (ioredis только в infrastructure), сервис-гейт,
контроллер /coop/force-recovery с @CheckAbility поверх AuthorizationGuard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 6.10: разделяю в audit инициатора (с timestamp инициации) и подтверждающих
совета (с timestamps), фиксирую payload_hash sha256 для невозможности подмены
содержимого действия в журнале. Добавляю repo.listByTarget и сервис getAuditTrail,
отдающий все критические действия пайщика с полной атрибуцией через эндпоинт
GET /coop/critical-actions/audit-trail/:targetId под @CheckAbility read CriticalAction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 4.7: председатель отзывает ключ через POST /coop/keys/revoke под CASL
update Participant; MVP фиксирует durable pending-state в revoked_keys (вместо
on-chain updateauth, как допускает AC), гасит все активные сессии пайщика и
пишет audit KeyRevokedManually с reason и chairman_id. Пайщик далее обязан
пройти recovery Эпика 3 для получения нового ключа; разблокирована Эпиком 6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 8.1: append-only audit_events с партициями и триггерами создан ещё в V2.4.0;
V2.4.11 добавляет колонку user_agent (форензика, наполняет 8.2), выделенную read-only
роль coop_audit_reader (graceful-degrade без CREATEROLE), форвард-роллинг партиций и
re-assert append-only грантов. Побочно: перенос guard сноса плейсхолдера в начало V2.4.7
чинит ordering-баг (column subject_type does not exist), из-за которого вся цепочка
миграций 2.4.2-2.4.11 не применялась в dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 8.2: AuditService получил первоклассное поле userAgent (пишется в колонку
user_agent из V2.4.11) вместо обходного хранения в context; DeviceTrackingService
переведён на него. Добавлена каноническая дока event-schema.md (колонки, конвенция
explicit-null-with-reason, secret-blacklist, каталог событий) рядом с кодом аудита,
т.к. components/controller/docs gitignored под генерённый сайт.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Story 8.5: декоратор @AuditAction помечает резолвер, AuditActionInterceptor
автоматически пишет audit_events (event=coopid.<category>.<handler>, subject_id из
target_id/id, result success/failure, metadata с выкинутыми секретами в _redacted),
извлекая user/args из GraphQL и HTTP контекста как AuthorizationGuard. Точечный
@UseInterceptors, не глобальный; первые потребители — admin-резолверы 6.6/6.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сканер src/config/log-purity.ts (SENSITIVE_TEST_PATTERNS, findSensitiveLeaks,
assertNoSensitiveLeaks) + интеграционный тест на боевом winston-логгере. Тест
вскрыл дефект 8.7: redactionFormat стоял до splat(), splat() повторно вмёрживал
сырой meta из info[SPLAT] поверх маскировки. Фикс: redactionFormat перенесён
после splat() (перед printf). 19/19 тестов зелёные (7 от 8.8 + 12 регресс 8.7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildLogFormat(isProduction): в production winston.format.json() с полем service
и redaction после splat() (инвариант 8.8), в dev — прежний pretty-printf без
изменений. scrubSensitiveDataFromSentryEvent дополнен redactSensitive по
event.extra/contexts — секреты в Sentry маскируются той же утилитой 8.7. Тесты:
logger-format 7 (обе ветки + маскировка) + sentry-scrub 4. 28/28 зелёных с регрессом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Smoke против публичного API @coopenomics/auth на трёх рантаймах: vault round-trip
(Argon2id+AES-GCM WebCrypto), signDocument→verifyDocumentOffline, WalletLocked,
типизированный not_implemented для login/getAccessToken (скелет Story 1.2). Node и
browser — vitest (общий smoke.test.ts), electron — main-process против собранного
dist (electron-main.cjs + run-electron.mjs c xvfb). Скрипты test:node/browser/
electron/cross-runtime + workflow sdk-cross-runtime.yaml. KDF один раз в beforeAll —
в chromium pure-JS Argon2id это минуты, per-test упирался в таймауты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-13 08:25:33 +00:00
RecoveryFinalizationPlaceholder (кидал 503) заменён на RecoveryFinalizationService.
Ротация active-ключа — существующим путём registrator::changekey (registrator владеет
аккаунтами пайщиков; подпись ключом кооператива из vault), без нового authentik-пути.
Порядок: новый vault-блоб → changekey → revokeAll сессий → audit KeyRotated
{trigger,old_pubkey,new_pubkey,initiator_id} (Story 8.4) → уведомление пайщику
SecurityEventKind.KeyRotated. vault сохраняется ДО on-chain переключения (новый
приватный ключ живёт только в блобе — иначе сбой залочил бы пайщика). Пароль authentik
в recovery НЕ трогается — это Эпик 5 (контроллер пока только читает authentik). Мультисиг
для self-recovery не нужен (подтверждён факторами пайщика); защита от единоличного захвата —
только force-recovery (6.9). Тесты: recovery-finalization 4 + регресс recovery-confirm 9 =
13/13. ESLint 0; coopback: Nest application successfully started, без DI-ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-13 09:27:18 +00:00
Сторона провайдера (IdP), потребители НЕ настраиваются (решение владельца:
«делаем только CoopID чтоб OIDC работал стандартно, потребителей накинем позже» —
это универсально).

- infra/coopid/authentik/blueprints/coopid-oidc-provider.yaml — декларативный
  blueprint: OAuth2/OpenID-провайдер + приложение `coopid`. client_type
  confidential, RS256 signing-key (5.1), PKCE S256 (5.2), per_provider issuer
  (5.4, per-coop через домен), sub_mode user_uuid, scope-mappings openid/email/
  profile + кастомный coop:verification_types (coopname+типы верификации через
  /userinfo и id_token).
- infra/coopid/caddy/Caddyfile — rewrite корневого /.well-known/openid-configuration
  и /.well-known/jwks.json на authentik app-эндпоинты: RP получают стандартный
  корневой discovery, Host сохраняется → issuer/endpoints строятся по домену коопа.
- infra/coopid/scripts/coopid-oidc-smoke.sh — smoke против поднятого стека:
  все required-поля discovery, PKCE S256, RS256 jwks, алиас jwks.json.

Live-валидация: стек поднят, blueprint применился (статус successful, провайдер+
приложение+scope в БД), smoke через caddy зелёный (issuer/authorize/token/userinfo/
jwks/end-session/introspect/revoke + RS256). Стек возвращён в выключенное состояние.

Отклонения (честно): issuer = https://<домен>/application/o/coopid/ (authentik
всегда включает путь, корневой iss из AC 5.4 ломал бы валидацию у RP; per-coop —
доменом). 5.2 hard-block implicit/ROPC: authentik рекламирует их в discovery
глобально (это возможности сервера, не per-provider), per-client убрать нельзя;
провайдер confidential+code, безопасный путь обеспечен PKCE.

За рамками (потребительская часть/follow-up): 5.3 transport participant_certificate
ES256K, 5.5 COOPOS-верификация в SDK, 5.6 Gitea-тест, 5.7 полный conformance-suite,
8.3 аудит OIDC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-13 10:05:13 +00:00
Все OIDC-операции выполняет authentik — контроллер узнаёт о них только через
native-события, доставляемые webhook'ом на /coop/internal/authentik-events
(механизм Story 1.5, shared-токен constant-time). Расширяем его на OIDC.

- infra/coopid/authentik/blueprints/coopid-oidc-audit.yaml — event-matcher +
  notification-rule + policy-binding на каждое действие (login/logout/
  authorize_application/login_failed/suspicious_request) + webhook body-mapping
  (action/user/client_ip/app/created) + transport на тот же endpoint. Поля
  сверены со /blueprints/schema.json образа 2026.2.
- authentik-events.controller.ts — mapAuthentikEvent расширен: login→OidcLoginSuccess,
  logout→OidcLogout, authorize_application→OidcTokenIssued (семантические Oidc*);
  прочие native-события → Authentik<Action> (login_failed→AuthentikLoginFailed,
  suspicious_request→AuthentikSuspiciousRequest, failure-result). ip=client_ip,
  context={authentik_action,app,authentik_created}. policy_execution (Story 1.5)
  без изменений. Контекст проходит secret-blacklist аудита.
- authentik-events.controller.spec.ts — 10 кейсов (weak-password, Oidc*, Authentik*,
  null, secret-blacklist).

Доставка идёт по членам destination_group: пустая группа НЕ доставляет (транспорт
не вызывается), поэтому выделенная группа coopid-audit с неактивным сервис-членом
coopid-audit-sink — надёжная доставка webhook'ом без спама реальных админов.

Live-валидация (стек поднят, потом потушен): blueprint применился (successful,
объекты+членство в БД); прямой webhook login→OidcLoginSuccess и login_failed→
AuthentikLoginFailed записались в audit_events; РЕАЛЬНЫЙ login_failed в authentik
прошёл всю цепочку (event→rule→webhook по docker-сети→контроллер→audit_events:
AuthentikLoginFailed, ip, user=akadmin). Unit 10/10, ESLint 0.

Отклонение (честно): OidcTokenRevoked/refresh не подключены — в authentik 2026.2 нет
надёжного native-action для отзыва/refresh OAuth-токена (enum eventmatcher не
содержит); endpoint'ы revoke/introspect работают, их аудит — отдельный механизм
при подключении потребителей. Отложено.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-13 10:05:54 +00:00
claude added 1 commit 2026-06-13 12:45:37 +00:00
Реализация решения владельца 2026-06-13 по #16. Только бэкенд — страницу
«Персонал» стола совета сейчас не делаем (явное указание), готовим всё нужное
для управления этими правами. Движок CASL не трогаем: добавлен один ИСТОЧНИК
правил. AbilityFactory.createForParticipantWithRules теперь собирает Ability из
coreRoles (+персональные гранты) ∪ активных наборов пайщика.

- migrations/V2.4.12 — capability_sets (реестр шаблонов set_key/title/builtin/
  coopname) + participant_capability_sets (назначение, UNIQUE username+set_key,
  revoked_at/expires_at). Правила набора — в СУЩЕСТВУЮЩЕЙ access_rules с новым
  subject_type='capability_set' (переиспользует allow/deny/conditions/TTL/Redis-
  инвалидацию). Seed: accountant (read AccountingDesk — стол бухгалтера уже есть)
  + cashier (read+confirm PaymentRegistry — реестр платежей, гранулярно не manage).
- capability-sets.port + access-rules.port (+AccessRulePrincipalKind.CapabilitySet,
  +findForCapabilitySets). PostgresCapabilitySetsRepository (lazy DS; assign=
  ON CONFLICT DO UPDATE идемпотентно оживляет отозванный; revoke=UPDATE RETURNING).
- CapabilitySetService (assign/revoke/listSets/listForParticipant + валидация
  набора + аудит CapabilitySetAssigned/Revoked + инвалидация по пайщику).
- CapabilitySetController coop/capability-sets под @CheckAbility('read'|'manage',
  'CapabilitySet') + HttpJwtAuthGuard+AuthorizationGuard. Chairman L1 +manage
  CapabilitySet. ability.types +CapabilitySet +AccountingDesk/PaymentRegistry.

Различие осей: назначаемые роли (accountant/cashier/auditor — выдаёт председатель)
≠ вычисляемые (оператор ПВЗ/председатель КУ — выводятся из контекста на своих
столах, в этот субстрат не входят).

Self-review: unit 27 зелёных (ability.factory +2 set-merge, capability-set.service
5, policy регресс); ESLint 0; SQL миграции + запросы репозитория + join
AbilityFactory проверены на реальном postgres:18 (идемпотентность seed, цикл
назначение→merge(cashier read+confirm)→revoke RETURNING→0 активных, кириллица).

Отложено честно: полный boot coopback (DI runtime) + migration:run-в-стеке —
CoopID-стек был выключен, поднимать полный backend на машине с 3 рабочими
coopback'ами непропорционально идемпотентной DDL; DI-проводка сверена чтением
(идентична рабочей ACCESS_RULES_REPOSITORY), SQL доказан на PG → следующий
подъём/CI. Имена grant-субъектов столов (AccountingDesk/PaymentRegistry) — на
согласование при разводке desktop-gating. Разблокирует модель 6.6/6.7, питает
Эпик 10 (Story 10.4: ключ = ещё один принципал того же субстрата).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-13 13:10:19 +00:00
Фронт-интеграция назначаемых наборов возможностей по канону desktop. auth-v2
(CoopID) endpoints зовём по REST (sendGET/sendPOST, как coop/certificate) —
codegen/Zeus не нужен; гейтинг прав на guard'е бэкенда (@CheckAbility manage
CapabilitySet).

Бэкенд:
- capability-set.service.listSets теперь обогащает каждый набор его грантами
  (action+resource из access_rules) — UI показывает «эта роль открывает …».
- AccessController GET /coop/access/me + service.getMyAccess: эффективный доступ
  пайщика (активные наборы + плоские allow-гранты из собранной Ability) — это
  ОСНОВАНИЕ гейтинга столов/страниц по выданным правам. Та же модель
  resource:action, что и grants marketplace2 (CoopID-side seam, при мердже
  сводятся, провайдер не дублируем).
- порт: AccessGrant / CapabilitySetWithGrants / ParticipantAccess.

Фронт (components/desktop):
- features/Personnel (api REST + model useCapabilitySets): каталог наборов,
  назначения пайщика, назначить/снять.
- shared/lib/access/useCoopAccess: singleton-композабл, GET /coop/access/me +
  can(action,resource)/hasSet — столы/страницы консультируются для видимости.
- pages/Cooperative/Personnel: страница «Персонал» (канон — q-table :grid,
  Base*-компоненты, токены --p-*): таблица пайщиков + диалог управления ролями
  (chips назначенных + селект добавления + показ что роль открывает).
- extensions/soviet/install.ts: маршрут personnel на Столе Совета,
  meta.roles=['chairman'].

Self-review: бэкенд unit 6/6 (listSets-гранты + getMyAccess добавлены), ESLint 0
бэкенд+фронт. Вёрстку визуально НЕ самопроверял — жду скриншот (канон петли).

Архитектурное (честно): полный механизм grants (getDesktop.grants + провайдер)
и стол бухгалтера живут на ветке marketplace2, не на dev/coopid — здесь видимость
столов по meta.roles. Поэтому «набор → автопоказ стола бухгалтера» не вшит (стола
тут нет); заложено ОСНОВАНИЕ (useCoopAccess.can), которым стол/страница гейтятся,
и которое сводится с grants marketplace2 при мердже. Страница «Персонал» и выдача
ролей полностью рабочие и проверяемы (назначить «Бухгалтер» пайщику → запись +
аудит + его /coop/access/me содержит read AccountingDesk).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-13 15:26:45 +00:00
Решение владельца: SDK — единственный типизированный фасад фронта; нового способа
взаимодействия с бэкендом на фронте появляться не должно, bearer-токен живёт только в SDK.
auth-v2 как параллельный REST-контракт, вызываемый напрямую с фронта, отвергнут.

- Бэкенд: AuthorizationResolver (capability-sets/access) + CertificateResolver поверх тех же
  сервисов; AuthorizationGuard/@CheckAbility уже GraphQL-aware. Удалены REST-контроллеры
  capability-set/access/certificate. DTO snake_case, резолвер маппит camelCase→snake_case.
- SDK: Queries.Authorization.*/Mutations.Authorization.*/Queries.Certificate.getMyCertificate
  + селекторы; codegen прогнан, Zeus-клиент закоммичен.
- Фронт: Personnel(api/model)+useCoopAccess+ProfilePage(api) на client.Query/Mutation, типы из
  SDK; прямого /coop/* REST на desktop не осталось.
- Проверки: SDL (новые типы/операции), SDK build+tsc+ESLint 0, unit 12/12, ESLint 0 везде.

Фазы 2/3 (остальной auth-v2 CRUD → GraphQL; SDK логин-фасад) — отдельными задачами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude added 1 commit 2026-06-14 06:22:20 +00:00
Корзина C Фазы 2: sessions/2fa/recovery-strategy/security-not-me →
AccountSecurityResolver (9 операций, GqlJwtAuthGuard), а
critical-actions/keys/force-recovery → CriticalActionsResolver (6 операций,
тот же CASL @CheckAbility+AuthorizationGuard). Движок Эпика 6 не тронут —
резолверы поверх тех же сервисов. 5 REST-контролёров сняты целиком, 2 урезаны
до magic-link :token (корзина D). SDK-домены AccountSecurity/CriticalActions +
codegen (SDL 62 резолвера) + Zeus закоммичен. Транспорт (IP/refresh-токен) —
request-meta декораторы, не GraphQL-переменные. Unit 24/24, ESLint 0.

Зачем: наружу на фронт смотрит только @coopenomics/sdk — нового способа
взаимодействия с бэкендом не появляется, bearer живёт только в SDK. OIDC
(.well-known), webhook (coop/internal) и login-контур (Фаза 3) остаются REST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ant added this to the CoopID project 2026-06-15 13:17:41 +00:00
Author
Owner

Схлопнут в единый PR #148 (coopID → dev). Ветка сохранена как резерв, ничего не потеряно.

Схлопнут в единый PR #148 (coopID → dev). Ветка сохранена как резерв, ничего не потеряно.
claude closed this pull request 2026-06-15 13:26:46 +00:00

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: C9S/mono#134