Перейти до вмісту

Автентифікація й доступ

Огляд для розробника. Модель прав (діловодства → ролі → дозволи) описана в Архітектурі, тут — маршрути й механіка.

AuthController (AuthModule), JWT Bearer з refresh-сесіями.

  • POST /register/kurin — адмін створює курінь разом з першим акаунтом Звʼязкового; POST /register — акаунт для людини, яка вже є в курені.
  • POST /login — вхід. Якщо в акаунта є другий фактор і пристрій ще не довірений, відповідь несе requiresMfa і короткоживучий mfaToken замість токенів.
  • POST /mfa/login-verify — другий крок: код з автентифікатора або резервний код разом з mfaToken. Успіх ставить cookie mfaTrust (Security:MfaTrustDays, 7 днів), і наступні входи з цього пристрою минають другий крок, поки не зміниться security stamp акаунта.
  • POST /refresh · POST /logout — оновлення й завершення сесії. Сесія — рядок у UserRefreshTokens; вхід додає рядок, вихід завершує один, зміна пароля чи MFA завершує всі. Вихід не чіпає mfaTrust. Рефреш наново читає діловодства з бази й поруч із токенами віддає access — { isAdmin, roles, permissions, kurinKey } (IAccessSnapshotFactory, те саме, що й при вході), тож нові права доходять без перезаходу.
  • POST /kurin-scope — вибір активного куреня; токен доступу несе ролі саме для нього. GET /kurin-scope/options — з яких куренів можна обирати (усі активні членства).
  • POST /check-access — серверна перевірка доступу до конкретного ресурсу, якою користується фронтенд, щоб ховати кнопки за тим самим рішенням, що й ендпоінт.

Онбординг: список очікування → запрошення → активація

Section titled “Онбординг: список очікування → запрошення → активація”

OnboardingController. Потік: заявка → модерація → запрошення → активація.

  • POST /waitlist — заявка (зараз лише від Звʼязкового куреня). Кожен адміністратор отримує лист і сповіщення в застосунку; у сайдбарі та на кнопці «Заявки» зʼявляється крапка, поки є що розглянути. GET /waitlist — список для адміна;
  • GET /krais — коди й назви країв для форми, без входу (лише це: кількість куренів бачить адмін у GET api/krai). Заявка несе kraiCode; без нього обовʼязковий вільний текст regionOrCountry, як надсилали старі форми. Невідомий край — 400 KraiNotFound;
  • POST /waitlist/{key}/approve — схвалити: створює курінь і акаунт, надсилає запрошення. Необовʼязкове тіло { "kraiCode": "pl" } замінює край із заявки; без жодного курінь відкривається в Krai:Default. Невідомий край — 400 KraiNotFound. Зайнятість числа перевіряється в тому краї, де курінь відкриється: зайняте — 409 KurinNumberTaken; POST /waitlist/{key}/reject — відхилити; POST /waitlist/{key}/resend-invitation — надіслати ще раз; POST /invitation/resend — те саме для будь-якого неактивованого акаунта;
  • GET /invitation/{token}/validate — перевірити токен;
  • POST /activate — задати пароль. Відповідь — LoginUserResponse з refresh-cookie: людина одразу в застосунку;
  • POST /password-reset/request · POST /password-reset/reset — відновлення пароля. Запит також надсилає нове запрошення, якщо акаунт ще не активований.

Листи йдуть через IEmailService — див. Пошта. Сутності: WaitlistEntry, Invitation.

TOTP. GET /mfa/setup (секрет і QR) → POST /mfa/enable (код) → POST /mfa/recovery-codes (резервні коди, за поточним паролем). GET /mfa/status каже, чи увімкнено і чи вимагається.

Примус для проводу: PrivilegedMfaEnforcementMiddleware + MfaEnforcementPolicy вимагають другий фактор для адміністратора і Звʼязкового на Production і Staging; Security:EnforcePrivilegedMFA керує цим на self-host. Ключ E2E:BypassPrivilegedMfa читається лише на тестових тирах. Гейт не ходить у базу: access-токен несе клейм amr (mfa / pwd, AccessTokenClaims), записаний при видачі — login, refresh, активація. Увімкнення MFA (mfa/enable) обриває всі сесії й одразу віддає нову (Tokens у відповіді + cookie), тож старий токен «лише пароль» не доживає до відмови; вимкнення теж обриває всі сесії, і токен без другого фактора живе щонайбільше Jwt:ExpiresInMinutes. Фронтенд: mfa-setup-dialog (обовʼязковий діалог після входу), account-settings.

  • [Authorize(Policy = …)] — грубі політики (RequireUser, RequireAdmin, RequireKurinManagement…).
  • [ResourceAuthorize(ResourceType, ResourceAction, "route:key")] — дозвіл на конкретний ресурс: RolePermissionMap перекладає ролі з діловодств у дозволи зі скоупом (KurinWide, OwnGroups, Own), а AccessContextResolver визначає, в якому курені людина стоїть і які діловодства тримає.
  • Активне закріплення впорядника за гуртком (MentorAssignment) трактується як діловодство впорядника в КВ.
  • Обидві перевірки покриті тестом AuthorizationBaselineMatrixTests: кожна дія контролера має бути в матриці, інакше тест падає.

Частину прав курінь налаштовує сам («Права й видимість»): хто бачить особисті дані, хто дає перестороги кожного ступеня й підтверджує відзначення кожного рівня, хто видаляє підтверджені відзначення, підписує й знімає підписи проб, підтверджує вмілості, кому призначає події й задачі і хто читає журнал дій. Ці можливості описані в одній таблиці — KurinCapabilities; курінь зберігає лише відхилення від її значень за замовчуванням у Kurin.AccessPolicy.

  • Дозволи людини = статична частина RolePermissionMap + гранти політики її куреня (IPermissionResolver, у межах запиту — ICurrentPermissions). Політика кешується на хвилину й скидається при збереженні, тож сервер застосовує її з наступного ж запиту.
  • Рівні перевіряє обробник (KurinCapabilities.GiveWarning(level), ConfirmAward(level)), решту — IResourceAccessService.CheckCapabilityAsync.
  • GET api/member/{key}/abilities — що викликач може зробити з цією людиною (ступені пересторог, рівні відзначень, видалення, підпис і зняття підпису, вмілості, журнал). Клієнти беруть кнопки звідси, а не з назв ролей.
  • Звʼязковий і адміністратор закріплені на повний доступ; сервер відхиляє політику, що це міняє (400 AccessPolicyInvalid). Так само відхиляється значення, якого рядок не пропонує (CapabilityDescriptor.Reaches): AssignKurin буває лише «—» або «Увесь курінь».
  • Події й задачі. Кому призначати — чотири рядки на одну пару AgendaItem:Create: AssignKurin, AssignGroups, AssignMembers, AssignProvid (провід гуртка — за його гуртком, провід куреня й КВ — лише за «Увесь курінь»). IAgendaAccess.AuthorizeTargetAsync перевіряє кожну ціль через CheckCapabilityAsync у створенні й при додаванні цілі в редагуванні; дерево GET api/agenda/{kurinKey}/assign-targets будує ті самі рішення з GetAssignReachAsync і позначає кожну ціль (canTarget і в людей). Грубої політики за ролями тут нема (RequireUser): хто не має жодного з чотирьох — 403 AGENDA_AUTHOR_FORBIDDEN, а клієнтський AgendaItem:Create:* приходить із політики, тож кнопки «Нова подія/задача» зникають самі.

Токен доступу несе ролі на момент видачі (iat). Коли людині призначають чи знімають офіс або впорядництво, змінюють членство, роль адміністратора чи політику куреня, сервер позначає це в IAccessChangeSignal, і AccessStaleHeaderMiddleware додає до відповіді на старий токен X-Access-Stale: 1. Веб і телефон на цей заголовок тихо роблять рефреш і застосовують access — сайдбар і кнопки оновлюються без виходу. Позначки живуть у пам’яті процесу; на кількох інстансах запасні шляхи — рефреш після 403 ResourceAccessDenied (не частіше ніж раз на 30 с) і закінчення токена (Jwt:ExpiresInMinutes).

Маршрут Хто
GET / PUT api/kurin/{key}/access-policy — права й видимість куреня Kurin:Update на цей курінь
GET api/member/{key}/abilities — що викликач може зробити з людиною Member:Read на цю людину
GET api/kurin/{key}/journal?area&memberKey&actorUserKey&fromUtc&toUtc&skip&take — журнал дій KurinJournal:Read; охоплення (весь курінь чи свої гуртки) — з політики
Маршрут Хто
api/krai/* — краї (додати, перейменувати), профіль краю, його ревізії й відновлення давнішої (KraiController) RequireAdmin на весь контролер
GET api/krai/{code}/dues/concessions?year=&number= — скільки кожна пільга краю зняла з вкладки його куренів за квартал (без кварталу — поточний за часом краю) RequireAdmin (той самий контролер)
PUT api/kurin/{key}/krai — перевести курінь до іншого краю (число має бути вільне там) RequireAdmin
POST api/group/{groupKey}/dues/members/{membershipKey}/concessions — надати людині пільгу краю; PUT / DELETE api/group/{groupKey}/dues/concessions/{grantKey} — змінити чи прибрати надану GroupDues:Update на цей гурток
GET / PUT api/kurin/{key}/card-fields — які поля картки курінь приховує Kurin:Update на цей курінь
GET api/kurin/{key}/profile — профіль краю, за яким живе курінь, з урахуванням його прихованих полів Kurin:Read на цей курінь
GET api/kurin/{key}/catalog/badges — вмілості, які пропонує курінь Kurin:Read на цей курінь

Пошта й телефон, дата народження (з роком і окремо день із місяцем), адреса й школа, контакти батьків і поля краю (CardExtras) — шість груп з MemberDataFields. Хто бачить кожну, вирішує політика куреня (See*): нікому, свій гурток (гуртки, які людина веде, і її власний) чи увесь курінь.

  • Маска одна — MemberFieldMask з IMemberFieldAccess.ForViewerAsync. Її накладають картка (GetMemberByKeyQuery), списки (GetMembersQuery, звідти й вивантаження реєстру), досьє, верифікація профілю й відповідь на збереження. Маскування в SQL немає.
  • Приховане приходить порожнім, а відповідь несе hiddenData (назви груп) і birthday (MM-dd) — клієнт не рендерить полів прихованих груп ні в картці, ні у формі (порожнє неприховане лишається «Не вказано»).
  • Своє людина бачить завжди. Публічний код (PublicId) — лише вона сама: провід, що знав би код, міг би записати її в інший курінь, не спитавши. Статус акаунта (AccountStatus) — тим, хто може редагувати цю людину (Member:Update).
  • Запис: група, прихована від того, хто редагує, вважається ненадісланою — збережене значення лишається, обовʼязковість не перевіряється (HiddenDataOnWrite).
  • PDF-звіт куреня не маскується: його бачать лише ті, хто керує всім куренем, а вони закріплені на повний доступ (тест це тримає).
  • authGuard — вимагає автентифікації;
  • kurinAccessGuard('kurin' | 'panel') — активний курінь у скоупі, або адмін-панель;
  • capabilityGuard('admin' | 'kurinManagement' | 'groupLeadership') — можливість за роллю, ті самі поняття, що й політики на бекенді;
  • leadershipAccessGuard — сторінка складу проводу: курінний бачить провід куреня, гуртковий — свого гуртка, КВ не редагується тут узагалі (PermissionService.canSetupLeadership);
  • EntityGuard — доступ до конкретної сутності через check-access, fail-closed: помилка сервера означає «заборонено»;
  • setupGuard — майстер первинного налаштування;
  • publicAuthRedirectGuard — вже автентифікованих з публічних сторінок веде додому.

Принцип: guard-и ховають, бекенд вирішує. Жодна перевірка на фронті не є останньою.

Маршрути точкування (api/kurin/{key}/score/*) пускає будь-кого з куреня, а хто що може всередині, вирішує обробник через ScoreAccess — для кожної людини й гуртка окремо.

Маршрут Хто
PUT / DELETE api/kurin/{key}/score/events/{itemKey}/{occurrence}/not-held — «Сходин не було» і зняти це той, хто може відмітити присутність бодай одного юнака куреня (GroupScore:Create на гурток або на неприписаних); PUT відмовляє 409 OccurrenceHasAttendance, поки хтось відмічений
GET api/me/duties — справи проводу (items, hiddenCount) RequireUser; кожна справа — лише там, де офіси людини дають на неї право
POST api/me/duties/dismissed { key, fingerprint } — сховати справу для себе; DELETE api/me/duties/dismissed?key= — повернути одну чи (без key) усі RequireUser; стосується лише рядків самого викликача

KurinAuditEvent — хто, що й коли змінив у курені. Обробник пише запис через IKurinAudit.RecordAsync перед власним SaveChangesAsync, тож запис і зміна комітяться разом. Дія — рядок з AuditActions, розділ — AuditArea; зміни — AuditChanges (лише змінене, особисті поля як «змінено» без значень). Історія на картці проби й вмілості читається з того ж журналу (IKurinJournalReader). Строк — 365 днів, див. зберігання.

Локальні дев-інструменти

Section titled “Локальні дев-інструменти”

На Development, E2E, Tailscale є DevToolsController (api/dev/impersonate, api/dev/impersonate/member, api/dev/return) і перемикач ролей на правому краю екрана: адмін заходить за діловодство у курені або як людина з відкритої картки і повертається квитком. Квиток живе 12 годин; прострочений фронт викидає й позичає роль на поточній сесії, замість того щоб падати на кожному перемиканні. На інших тирах контролер знімається з моделі (DevOnlyControllerFeatureProvider).