[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
pull from: feat/coopid-epic-6
merge into: C9S:feat/coopid-epic-4
C9S:main
C9S:dev
C9S:testnet
C9S:marketplace2
C9S:fix/598-51-membership-fee-and-type-cleanup
C9S:feat/capital-canon
C9S:feat/598-supplier-registry
C9S:feature/capital-cpp-private-data-templates
C9S:fix/soviet-install-validation
C9S:feat/ku-selforg
C9S:feat/membership-exit
C9S:feat/marketplace-admin-orders
C9S:fix/capital-share-onproject-event
C9S:ci/independent-env-deploy
C9S:fix/registration-pending-display
C9S:coopID
C9S:feat/expense-chassis-mvp
C9S:fix/zip-utf8-flag
C9S:feat/coopid-auth-ui
C9S:feat/coopid-7-signing-bridge
C9S:feat/coopid-12-recovery-account-from-confirm
C9S:feat/coopid-epic-9-metrics
C9S:feat/coopid-epic-6-access-rules-cleanup
C9S:feat/coopid-epic-12
C9S:feat/coopid-epic-11
C9S:feat/coopid-epic-7
C9S:feat/coopid-epic-9
C9S:feat/marketplace-process-registry
C9S:feat/marketplace-notifications-map
C9S:feat/marketplace-ku-economy
C9S:savanna
C9S:feat/apps-catalog-finish
C9S:feat/epic-13-billing-package-powerup
C9S:feat/coopid-epic-4
C9S:feat/coopid-epic-3
C9S:feat/blagorost-on-chassis
C9S:feat/coopid-epic-1
C9S:feat/marketplace-coop-stock
C9S:feat/marketplace-live-catalog
C9S:feat/coop-invest-blagorost
C9S:feat/ff-release-flow
C9S:fix/marketplace-offer-instance-not-template
C9S:fix/extension-config-lost-update
C9S:feature/C28-28-email-relay
C9S:feat/E16-onsite-sign-gate
C9S:feat/pwa-version-watch
C9S:fix/participants-registry-status-badges
C9S:feat/E16-coop-categories
C9S:fix/sanitize-blockchain-error-toast
C9S:fix/wallet-withdraw-no-phantom-payment
C9S:fix/agenda-program-offer-links
C9S:feat/notification-center-dc-v3
C9S:fix/chatcoop-livekit-room-name
C9S:feat/delete-inactive-participant
C9S:feat/registration-ledger2-suspense
C9S:feat/registration-refund
C9S:feat/registration-payment-dedup
C9S:perf/factory-parallel-fetch
C9S:feat/E16-sklad-rework
C9S:fix/decision-createproject-tsc
C9S:perf/factory-weasyprint-warm-pool
C9S:feat/E16-korzina-zakaz-agregat
C9S:fix/agenda-pending-new-question
C9S:feat/soviet-negative-consensus
C9S:feat/registration-payment-decline
C9S:feat/coop-standards-audit
C9S:parser2
C9S:feat/mkt-return-chairman-cosign
C9S:feat/registration-fsm
C9S:feat/pwa-reliable-update
C9S:parser2-block-time-and-version
C9S:fix/gorozhane-phantom-cleanup
C9S:fix/remove-bank-kpp
C9S:feat/E9-3b-sub-subscribe
C9S:feat/E9-9-approve-moderation
C9S:feat/E10-5-on-chain-watcher
C9S:feat/E10-4-install-pipeline
C9S:feat/E9-3b-ui-publish-package
C9S:feat/E1-tspp-docs-real-text
C9S:feat/E10-3b-dynamic-supergraph
C9S:apps-catalog
C9S:feat/E9-9-3-b-publish-mutation
C9S:feat/sig-v2-export
C9S:feat/E10-1-federation-subgraph
C9S:parser2-epic-4-forks
C9S:feat/decision-authorize-backend
C9S:feat/blagorost-program-expenses-ui
C9S:parser2-epic-6-canonical-storage
C9S:wip/pre-e16-desktop-docs
C9S:feat/E15-min-volume-per-ku
C9S:feat/blagorost-E2-E5-program-expenses
C9S:feat/epic-14-billing-notifications
C9S:blagorost
C9S:feat/E14-explicit-shipment
C9S:feat/marketplace-collective-supply
C9S:feat/blagorost-E1-contract
C9S:feat/C28-21-pg-signed-documents
C9S:provider-billing
C9S:fix/desktop-format-asset-errors
C9S:feat/marketplace-docs
C9S:feat/epic-12-billing-subscriptions
C9S:fix/min-soviet-members-3
C9S:parser2-epic-3-transport
C9S:feat/marketplace2-drop-blocked
C9S:worktree-design-wave1
C9S:feat/notif-mobile-canon
C9S:fix/vue-tsc-desktop
C9S:worktree-marketplace-docs-impl
C9S:feat/chatcoop-hide-matrix-room-id
C9S:feat/epic-8-coop-registry-provider
C9S:parser2-epic-2-idempotency
C9S:feat/chatcoop-secretary-rooms
C9S:feat/remove-l3-blocked
C9S:ci-trial-typecheck-2026-05-23
C9S:fix/controller-revert-transpileonly
C9S:chore/mongo-standalone-local
C9S:chore/ci-unify-release-workflows
C9S:feat/controller-swc-builder
C9S:feat/chatcoop-transcription-memo
C9S:fix/mono-base-ca-certificates
C9S:fix/wallet-withdraw-gateway-timing
C9S:feat/ledger2-burn-blocked-withdraw
C9S:fix/boot-tests-wallet-signagree-v2
C9S:chore/trustees-ownership
C9S:feat/capital-w-cap-gen-cooperative
C9S:chore/re-review-E5-apl-fail-fast
C9S:chore/re-review-E4-fail-fast-tx-hash
C9S:chore/re-review-E3-fix-withdraw-stub
C9S:chore/review-E3-vitrina-fixes
C9S:chore/review-E10-design-fixes
C9S:chore/review-E8-spisanie-fixes
C9S:chore/review-E7-vozvrat-fixes
C9S:chore/review-E6-vydacha-fixes
C9S:chore/review-E5-postavka-fixes
C9S:chore/review-E4-order-fixes
C9S:feat/epic0-onboarding-harness
C9S:chore/marketplace2-sync-dev-and-controller-di
C9S:feat/E11-stol-zakazov-frontend-and-tests
C9S:feat/E11-stol-zakazov-final
C9S:feat/capital-artifacts-access
C9S:fix/floor-asset-display
C9S:feat/epic-9-warehouse-reporting
C9S:feat/598-11-epic-8-writeoff
C9S:feat/E7-garantiynyy-vozvrat
C9S:design
C9S:fix/capital-revert-394-generation-convert-refs
C9S:fix/is-unioned-zod-string-parse
C9S:fix/capital-rename-generation-convert-to-money-invest
C9S:feat/E6-vydacha-payshchiku
C9S:worktree-remove-1081-1082-rename-1080
C9S:feat/init-by-server-from-provider
C9S:feat/E5-postavka-priemka
C9S:feat/E11-techdebt-L12-split-payout
C9S:feat/E4-zakaz
C9S:gh-pages
C9S:feat/E3-vitrina-canon
C9S:feat/E11-S1-ledger2-marketplace-ops
C9S:file-storage
C9S:feat/1-10-marketplace-registration-offer-status
C9S:feat/1-9-marketplace-board-acceptance
C9S:feat/1-8-marketplace-access-matrix
C9S:feat/1-7-marketplace-template-registry
C9S:feat/1-6-marketplace-role-guard
C9S:feat/1-5-marketplace-member-wallet
C9S:feat/1-4-marketplace-onboarding-gate
C9S:feat/1-3-marketplace-access-by-membership
C9S:feat/1-1-install-from-catalog
C9S:ci/lock-tags-only
C9S:onboarding
C9S:reports
C9S:ledger3
C9S:graph-standarts
C9S:feat/ledger-double-entry
C9S:feat/docs-enrichment
C9S:feat/documentation
C9S:feat/marketplace-orders
C9S:feat/interops-extension-system
C9S:cursor/development-environment-setup-2882
C9S:feat/unified-test-pipeline
C9S:fix/security-vulnerabilities
C9S:audio-stream
C9S:project-authorization-on-create
C9S:refactoring
C9S:capital
C9S:element
C9S:ledger
C9S:marketplace
C9S:notifications2
C9S:notifications
C9S:document2
C9S:TSK-900
C9S:TSK-878
C9S:dacom-dark-sun/sprint-4
C9S:uniconstructor/sprint-5
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/coopid-epic-6"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Подход 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 → CASLdefineAbility». Платформенный фундамент ролей (CoreRole=User|Member|Chairman) поднят на уровень auth-v2 и развит в реальный@casl/ability. Marketplace-matrix остаётся в своей ветке и мигрирует в эту платформенную CASL (контракт вauthorization/README.md) — обе ветки мерджатся в dev без конфликтов файлов (работа аддитивна).Истории Эпика 6
@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-onlyParticipant/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).access_rulesmatrix-driven + Redis pub/sub инвалидация. Таблицаaccess_rulesв coop_domain_db (миграцияV2.4.7:subject_typerole|participant,subject_id,effectallow|deny,action,resource_type,conditionsjsonb,expires_at— TTL capabilities 6.7; индекс по принципалу). Доменный портaccess-rules.port.ts(enumAccessRuleEffect/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).policy.types.ts(IPolicyHandler {name; evaluate(ctx):boolean|Promise},PolicyEvaluationContext {user,action,subject,resource?}).@PolicyHandler(name)(Injectable+SetMetadata).@CheckAbility(action,subject,{policy?})+CHECK_ABILITYmetadata (введён здесь, потребляется 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: initV2.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↔код (прав код): subjectCriticalActionвместо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-ошибок.AuthorizationGuard.authorization-denial.ts(enumAuthorizationDenialReasonдля лога + публичный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. Тесты: 6policy.service+ 5guardunit; coopback рестартнул чисто без DI-ошибок.@AuthRoles→@CheckAbility+ ESLint-запретno-authroles-in-authv2. Новыйoverrides-блок в.eslintrc.json(scopesrc/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) — тот ключ в overrideapplication/**занят wharfkit-баном; разные ключи мерджатся аддитивно. Тестno-authroles-in-authv2.eslint.spec.ts(ESLint Node API):@AuthRolesв auth-v2 → error, в legacyauth/→ нет. Дрейф (прав код): auth-v2 — REST-контроллеры,@AuthRolesне используется → «резолверы используют только @CheckAbility» вакуумно; текущие эндпоинты самообслуживания (JWT+ownership) не роль-гейтятся, первые боевые@CheckAbility— в админ-6.6/6.7 и 6.8; кастомного@coopenomics-плагина нет (как и у wharfkit-бана). Регрессия:eslint auth-v2/**exit 0.user.rolevs новыйparticipants.roles[]; Member=член совета вероятно on-chain soviet — канон/архитектура, согласуется ДО реализации), правка core-User/soviet вне auth-v2, Vue-страница (скриншоты пользователя). Первый боевой@CheckAbility('update','Participant')— сюда после решения модели.access_rules.expires_atиз 6.2 + cron-cleanup) автономен, UI — с пользователем.V2.4.9pending_critical_actions(action_type, actor_id, target_id, payload, status, confirmations[{by,at}], expires_at, finalized_at). Порт (enumCriticalActionType/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 + auditCriticalActionConfirmedс обоими подписантами +payload_hash),@CrondailyexpireStale→CriticalActionExpired. Первый боевой@CheckAbility+AuthorizationGuard:POST /coop/critical-actions(create CriticalAction, председатель) +POST /:id/confirm(confirm CriticalAction, член совета) поверхHttpJwtAuthGuard. Тесты: 8 unit; coopback рестартнул чисто, роуты смаплены,@Cronзарегистрирован. Разблокирует Story 4.7.force-recovery-consent.port.ts(IForceRecoveryConsentStoreissue/consume single-use/markGranted/isGranted;IForceRecoveryConsentNotifier+ каналcoopid:force-recovery:consent-requested).RedisForceRecoveryConsentStore(запросcoopid:fr:req:<token>PX-TTL,consumeRequestатомарен LuaGET→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). enumForceRecoveryConsentVia(наружу+audit), внутреннийForceRecoveryDenialReasonтолько в лог (наружу единыйforce_recovery_denied).authorize: нет granted-отметки и нет валидного tx →deny(no_consent)/403; при стратегииRecoveryStrategy.Council→ требует подтверждённый force_recovery critical action 6.8 иначеdeny(council_approval_missing); успех → auditForceRecoveryAuthorizedtriggered_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 рестартнул чисто, все три роута смаплены.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 остаётся корректным.V2.4.10revoked_keys(coop_domain_db:target_id,reason,revoked_by,revoked_at,recovered_atnullable; индексtarget_id+recovered_at;recovered_at IS NULL= отзыв активен / pending recovery). Портkey-revocation.port.ts(IKeyRevocationRepositoryrecord/findActive/markRecovered).PostgresKeyRevocationRepository(lazy DS как pending-critical-actions).KeyRevocationService.revoke: (1) durable pending-staterepo.record— MVP-вариант AC «или с переходом на pending state» вместо on-chainupdateauth(реальная ротация — recovery-flow Эпика 3 placeholder 3.3, тот же раздел ответственности что force-recovery 6.9); (2)sessions.revokeAll(target, ip)гасит все сессии; (3) auditKeyRevokedManually(reason,chairman_id,sessions_revoked).isPendingRecovery(target)для downstream-гейта Эпик 3/7.POST /coop/keys/revoke@CheckAbility('update','Participant')поверхHttpJwtAuthGuard+AuthorizationGuard. Дрейф (прав код): MVP=pending-state не on-chainupdateauth(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_eventsschema + INSERT-only triggers + monthly partitioning. Append-onlyaudit_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 таблица) = семантически ACaction/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.7access_rules —V2.4.0создаёт плейсхолдер (role/action/subject),V2.4.7CREATE 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_readergraceful-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.successfulUA уходит вuserAgentверхнего уровня, изcontextубран (остаютсяdevice_new+accept_language).docs/audit/event-schema.md— каноническая дока: колонкиaudit_events+семантика (дрейф имёнevent/context/created_at↔ ACaction/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.ts5 (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-chainuser.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 ключ не ротирует (уже аудируетсяKeyRevokedManually4.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 для логгера).redactionFormatwinston вставлен вformat.combineпослеtimestamp/enumerateErrorдоprintf— чиститmetaкаждого события (own-string-ключи кроме служебныхlevel/message/timestamp/context/ms/splat);message-строку не трогает. (2) Статический запрет —.eslintrc.jsonoverrideno-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) — barekeyдаёт массу ложноположительных (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.ts12 (плоско/вложенно/массив/publicKey-не-маскируется/Error-не-деконструирует/цикл/примитивы/не-мутирует/контейнер-credentialsцеликом) +no-sensitive-in-log.eslint.spec.ts7 (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поверх nodeWritable(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 --runInBand19/19 зелёных (7 от 8.8 + 12 регресс 8.7); ESLint новых файлов exit 0; coopback затронут (порядок форматовlogger.ts), рестарт чистый — «Nest application successfully started», без ошибок DI.8.5 Audit admin actions через
@AuditActioninterceptor.@AuditAction(category='admin')(SetMetadata AUDIT_ACTION).AuditActionInterceptor(implements NestInterceptor): читает метку с метода/класса (нет →next.handle()без аудита), извлекаетuser+argsиз GraphQL и HTTP (тот же приём, чтоAuthorizationGuard6.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-blacklistAuditServiceбросает на КЛЮЧ); failure-аудит best-effort в.catch(не подменяет исходную ошибку). Первые боевые потребители — admin-резолверы 6.6/6.7 (changeRoles/grantCapability/excludeParticipant, blocked) — как первые@CheckAbilityлегли в 6.8; на уже-аудирующие вручную endpoints (KeyRevokedManually4.7)@AuditActionне вешаем (двойная запись). Тесты:audit-action.interceptor.spec.ts6 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)— единая цепочка с ветвлением: 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/stderrstandard transport (Console+DailyRotateFile, без kafka/elastic) уже был. Тесты:logger-format.spec.ts7 (prod: валидный JSON +service+ маскировкаpassword/vault.privateKey+ нет ANSI; dev: не-JSON + нет"service"+ маскировкаtoken) +sentry-scrub-event.spec.ts4 (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'ом как от контроллера),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
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>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>Все 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>Схлопнут в единый PR #148 (coopID → dev). Ветка сохранена как резерв, ничего не потеряно.
Pull request closed