feat: streamline moderation decisions

This commit is contained in:
ik
2026-09-12 21:43:41 +07:00
parent 8e419c1832
commit ba4c42f8f6
4 changed files with 15 additions and 11 deletions
+1 -1
View File
@@ -246,7 +246,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью. В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Во время решения вся карточка блокируется; при ошибке введённая причина остаётся на месте, а после успеха интерфейс сообщает результат и переводит фокус к следующей записи. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
## Эксплуатация production ## Эксплуатация production
@@ -15,7 +15,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<label>Порядок<select name="order"><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label> <label>Порядок<select name="order"><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
<button data-action="primary" type="submit">Применить</button> <button data-action="primary" type="submit">Применить</button>
</form> </form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden> <nav data-pages aria-label="Страницы очереди" hidden>
<button data-action="secondary" type="button" data-previous>Предыдущая</button> <button data-action="secondary" type="button" data-previous>Предыдущая</button>
<span data-page-number aria-live="polite"></span> <span data-page-number aria-live="polite"></span>
@@ -27,6 +27,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const list = document.querySelector<HTMLElement>("[data-external-list]"); const list = document.querySelector<HTMLElement>("[data-external-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const pages = document.querySelector<HTMLElement>("[data-pages]"); const pages = document.querySelector<HTMLElement>("[data-pages]");
const previous = document.querySelector<HTMLButtonElement>("[data-previous]"); const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
const next = document.querySelector<HTMLButtonElement>("[data-next]"); const next = document.querySelector<HTMLButtonElement>("[data-next]");
@@ -41,7 +42,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } }; const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join(""); const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
@@ -75,7 +77,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } }); filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try { list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true); const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
if (button.hasAttribute("data-suggest")) { if (button.hasAttribute("data-suggest")) {
const suggestion = await json(`${base}/alias-suggestions`, {headers}); const suggestion = await json(`${base}/alias-suggestions`, {headers});
const fish = fishes.find(item => item.slug === suggestion.fish_slug); const fish = fishes.find(item => item.slug === suggestion.fish_slug);
@@ -84,9 +86,9 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (message) message.textContent = fish || water if (message) message.textContent = fish || water
? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.` ? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.`
: "Для этого источника подтверждённых соответствий пока нет."; : "Для этого источника подтверждённых соответствий пока нет.";
button.disabled = false; cardButtons.forEach(item => item.disabled = false);
return; return;
} }
if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } }); let message = "Наблюдение обновлено."; if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); message = "Соответствия сохранены."; } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); message = "Наблюдение опубликовано."; } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); message = "Наблюдение отклонено."; } await loadQueue(); succeed(message); list.querySelector<HTMLButtonElement>("button")?.focus(); } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
</script> </script>
</Layout> </Layout>
+6 -4
View File
@@ -8,13 +8,14 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form> <form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p> <p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
</section> </section>
<script> <script>
const root = document.querySelector<HTMLElement>(".moderation-app"); const root = document.querySelector<HTMLElement>(".moderation-app");
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const list = document.querySelector<HTMLElement>("[data-moderation-list]"); const list = document.querySelector<HTMLElement>("[data-moderation-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar"); const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = ""; let token = "";
@@ -23,7 +24,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } }; const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
async function loadQueue() { async function loadQueue() {
@@ -45,8 +47,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null; const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; } if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return; if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
button.disabled = true; const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
try { const deleting = button.hasAttribute("data-delete"); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
}); });
</script> </script>
</Layout> </Layout>
+1 -1
View File
@@ -64,7 +64,7 @@
- [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage. - [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage.
- [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений. - [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений.
- [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку от новых или старых; параметры работают до пагинации. Далее: приоритет риска, понятное подтверждение результата, восстановление черновика причины, переход к следующей карточке и доступные горячие клавиши. - [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку от новых или старых; параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Далее: приоритет риска и доступные горячие клавиши.
- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных. - [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных.
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором. - [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.