Задачи 032-033: ролевая модель с привязкой к организации, исправления по ревью
Пентест (docs/reviews/2026-09-27-pentest.md) и план 031 (Swagger, TLS) — план, не реализован.
032 Роль superadmin (без организации) и привязка admin/viewer к одной организации:
users.organization_id + CHECK, audit_log.organization_id (миграции 0010-0012);
require_org/scope_org во всех чтениях и записях, журнал и «Обзор» в границах
организации; пользователи, организации, типы устройств, настройки журнала — только superadmin.
033 Исправление находок ревью 032 (docs/reviews/2026-09-27-changes-032-review.md,
docs/reviews/2026-09-27-codebase-review.md):
- FK audit_log.organization_id ON DELETE SET NULL (миграция 0013) — удаление организаций;
- проверка организации в предпросмотре подсети;
- инвариант «роль — организация» по итоговому состоянию (повышение снимает организацию,
понижение требует её), 422/404 вместо обезличенных 409;
- одинаковый 404 для чужих и несуществующих объектов (VRF, устройство, parent_id, оператор);
- отказы удаления в журнале организации, счётчики типов в пределах организации;
- UI: живое поле «Организация» в диалоге пользователя, бейдж superadmin; род в текстах 404.
README актуализирован под ролевую модель.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5210ba3333
commit
744a025960
28 files changed
+1283
-164
No files matched your search
+50
-25
@@ -87,8 +87,9 @@ const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =
|
||||
const searchBox = (value, placeholder, width) =>
|
||||
`<label class="search" style="width:${width}px"><span class="ico">${I.search()}</span><input type="search" id="q" data-input="q" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" value="${esc(value)}"></label>`;
|
||||
const currentOrg = () => orgs.find((o) => o.id === store.orgId);
|
||||
const isAdmin = () => user?.role === "admin";
|
||||
const ROLE_RU = { admin: "Администратор", viewer: "Просмотр" };
|
||||
const isAdmin = () => user?.role !== "viewer"; // изменение 032: admin И superadmin
|
||||
const isSuperadmin = () => user?.role === "superadmin"; // изменение 032
|
||||
const ROLE_RU = { superadmin: "Суперадминистратор", admin: "Администратор", viewer: "Просмотр" }; // изменение 032
|
||||
|
||||
/* ------------------------------------------------ выбор строк и групповые операции (только admin) */
|
||||
const selSet = () => (S.sel ||= new Set());
|
||||
@@ -119,14 +120,18 @@ function filterBtn(id, label, items, value, width = 180) {
|
||||
}
|
||||
function orgSwitcher() {
|
||||
const o = currentOrg();
|
||||
return `<span class="rel" style="flex:none"><button type="button" class="org-btn" data-action="menu" data-menu="org">${I.org()}<span class="lbl">Организация:</span><span class="val">${esc(o?.name ?? "—")}</span>${I.chevD(14)}</button>${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}</span><div class="vsep"></div>`;
|
||||
// изменение 032: интерактивный переключатель для superadmin, статичная подпись для других
|
||||
if (isSuperadmin()) {
|
||||
return `<span class="rel" style="flex:none"><button type="button" class="org-btn" data-action="menu" data-menu="org">${I.org()}<span class="lbl">Организация:</span><span class="val">${esc(o?.name ?? "—")}</span>${I.chevD(14)}</button>${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}</span><div class="vsep"></div>`;
|
||||
}
|
||||
return `<span style="flex:none;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px">${I.org()}<span>Организация: <b style="font-weight:600;color:var(--text)">${esc(o?.name ?? "—")}</b></span></span><div class="vsep"></div>`;
|
||||
}
|
||||
const crumbsOrg = () => `<div class="crumbs"><a href="#/orgs" style="color:var(--muted)">Организации</a>${I.chevR(14)}<span class="cur">${esc(currentOrg()?.name ?? "")}</span></div>`;
|
||||
const header = (title, sub, actions = "") => `<div class="head"><div><h1>${esc(title)}</h1><div class="sub">${sub}</div></div><div class="actions">${actions}</div></div>`;
|
||||
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "admin"]];
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "superadmin"]]; // изменение 032: "superadmin"
|
||||
function shell(active, content) {
|
||||
const nav = NAV.filter(([, , role]) => role !== "admin" || isAdmin()); // раздел «Пользователи» виден только администратору
|
||||
const nav = NAV.filter(([, , role]) => role !== "superadmin" || isSuperadmin()); // изменение 032: раздел «Пользователи» виден только суперадминистратору
|
||||
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
|
||||
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
|
||||
<div class="grow"></div><span class="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}</span>${btn("Сменить пароль", "pw-change", { cls: "ghost" })}${btn("Выйти", "logout", { cls: "ghost" })}</div>
|
||||
@@ -218,12 +223,17 @@ screens.orgs = async () => {
|
||||
const all = q ? (await api("/organizations", { params: { limit: 1 } })).total : total;
|
||||
setSelectable(items.map((o) => o.id));
|
||||
const cols = selCols("minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px");
|
||||
const rows = items.map((o, i) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
|
||||
const rows = items.map((o, i) => {
|
||||
const items2 = [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }]; // изменение 032
|
||||
if (isSuperadmin()) items2.push({ label: "Удалить", value: "del:" + o.id, cls: "danger" }); // изменение 032: кнопка удаления только для superadmin
|
||||
return `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
|
||||
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, items2, "row-pop")}</span></div>`; // изменение 032
|
||||
}).join("");
|
||||
S.rows = items;
|
||||
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
|
||||
const addBtn = isSuperadmin() ? btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }) : ""; // изменение 032: кнопка только для superadmin
|
||||
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, addBtn)}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
|
||||
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
|
||||
@@ -306,8 +316,9 @@ screens.devices = async () => {
|
||||
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="right-link"><a href="#" data-action="types">${I.gear()}Управление типами</a></div>
|
||||
const addBtn = btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }); // изменение 033: устройство создаёт любой admin своей организации
|
||||
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, addBtn)}
|
||||
<div class="right-link"><a href="#" data-action="types">${I.gear()}Типы устройств</a></div>
|
||||
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
|
||||
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
|
||||
@@ -341,18 +352,23 @@ async function typesDialog() {
|
||||
? `<input class="input" id="edit-name" value="${esc(t.name)}" style="height:32px">`
|
||||
: `<span style="font-weight:600;${t.is_default ? "color:var(--faint)" : ""}">${esc(t.name)}</span>`;
|
||||
const locked = t.devices_count > 0;
|
||||
const acts = t.is_default
|
||||
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
|
||||
: editing
|
||||
? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}`
|
||||
: `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`;
|
||||
let acts;
|
||||
if (!isSuperadmin()) { // изменение 032: управление только для superadmin
|
||||
acts = t.is_default ? `<span class="muted" style="font-size:12px">по умолчанию</span>` : "";
|
||||
} else {
|
||||
acts = t.is_default
|
||||
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
|
||||
: editing
|
||||
? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}`
|
||||
: `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`;
|
||||
}
|
||||
return `<div class="tr" style="--cols:${cols};--h:48px">${name}<span class="muted">${t.devices_count}</span><span style="display:flex;justify-content:flex-end;align-items:center;gap:4px">${acts}</span></div>`;
|
||||
}).join("");
|
||||
openDialog({
|
||||
title: "Управление типами устройств", width: 640,
|
||||
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.",
|
||||
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.", // изменение 032
|
||||
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Устройств</span><span></span></div>${rows}</div>
|
||||
<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>`,
|
||||
${isSuperadmin() ? `<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>` : ""}`, // изменение 032: кнопка только для superadmin
|
||||
foot: `<div class="dialog-foot">${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
|
||||
});
|
||||
}
|
||||
@@ -678,11 +694,11 @@ async function doClear() {
|
||||
|
||||
// ---- users
|
||||
screens.users = async () => {
|
||||
if (!isAdmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только администратору</div></div>`);
|
||||
if (!isSuperadmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только суперадминистратору</div></div>`); // изменение 032
|
||||
const q = S.q || "";
|
||||
const { items, total } = await api("/users", { params: { q, limit: 500 } });
|
||||
S.rows = items;
|
||||
const cols = selCols("minmax(200px,1.5fr) 190px 190px minmax(140px,1fr) 44px");
|
||||
const cols = selCols("minmax(200px,1.5fr) 190px minmax(180px,1fr) 190px minmax(140px,1fr) 44px"); // изменение 032: добавлен столбец для организации
|
||||
setSelectable(items.filter((u) => u.id !== user?.id).map((u) => u.id)); // свою запись выбрать нельзя
|
||||
const rows = items.map((u, n) => {
|
||||
const self = u.id === user?.id;
|
||||
@@ -691,39 +707,46 @@ screens.users = async () => {
|
||||
{ label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" },
|
||||
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
|
||||
];
|
||||
const orgName = orgs.find((o) => o.id === u.organization_id)?.name || "—"; // изменение 032
|
||||
return `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(u.id)}" data-row="1" data-action="user-edit" data-id="${u.id}" style="--cols:${cols}">${selCell(u.id, self)}
|
||||
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
|
||||
<span>${badge(u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
|
||||
<span>${badge(u.role === "superadmin" ? "purple" : u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
|
||||
<span class="muted" style="font-size:13px">${u.role === "superadmin" ? "—" : esc(orgName)}</span>
|
||||
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
|
||||
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: администратор — запись, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
|
||||
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: суперадминистратор — полные права, администратор — запись в организации, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск по логину", 260)}</div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Статус</span><span></span><span></span></div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Организация</span><span>Статус</span><span></span><span></span></div>
|
||||
${rows || '<div class="empty">Пользователей нет</div>'}<div class="foot">Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}</div></div>`);
|
||||
};
|
||||
function userDialog(u) {
|
||||
const edit = !!u;
|
||||
const self = edit && u.id === user?.id;
|
||||
const roleInit = u?.role ?? "viewer"; // изменение 033: начальная видимость поля организации — по роли в форме
|
||||
S.dialog = async (v) => {
|
||||
if (edit) {
|
||||
const body = { is_active: v.is_active === true };
|
||||
if (v.role) body.role = v.role;
|
||||
if (v.role !== "superadmin" && v.organization_id) body.organization_id = Number(v.organization_id); // изменение 033: одинаково с созданием — сервер сам очистит организацию при повышении
|
||||
if (v.password) body.password = v.password;
|
||||
await api(`/users/${u.id}`, { method: "PATCH", body });
|
||||
} else {
|
||||
await api("/users", { method: "POST", body: { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false } });
|
||||
const body = { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false }; // изменение 032
|
||||
if (v.role !== "superadmin" && v.organization_id) body.organization_id = Number(v.organization_id); // изменение 032: проверка перед присвоением
|
||||
await api("/users", { method: "POST", body });
|
||||
}
|
||||
toast(edit ? "Пользователь сохранён" : "Пользователь добавлен");
|
||||
await draw();
|
||||
};
|
||||
openDialog({
|
||||
title: edit ? `Пользователь ${u.username}` : "Новый пользователь",
|
||||
note: "«Администратор» может изменять данные, «Просмотр» — только читать их. Логин после создания не меняется.",
|
||||
note: "«Суперадминистратор» — полные права, «Администратор» — запись в организации, «Просмотр» — только чтение. Логин после создания не меняется.", // изменение 032
|
||||
body: formBody(`${edit ? `<label class="field"><span>Логин</span><input class="input" value="${esc(u.username)}" disabled></label>` : fInput("username", "Логин", { ph: "ivanov", hint: "латиница, цифры, . _ -" })}
|
||||
${self ? "" : fInput("password", edit ? "Новый пароль" : "Пароль", { type: "password", optional: edit, ph: edit ? "не менять" : "", hint: "минимум 8 символов" })}
|
||||
${self ? "" : fSelect("role", "Роль", [{ value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], u?.role ?? "viewer")}
|
||||
${self ? "" : fSelect("role", "Роль", [{ value: "superadmin", label: "Суперадминистратор" }, { value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], roleInit)}
|
||||
${self ? "" : `<div id="org-field-wrap" ${roleInit === "superadmin" ? "hidden" : ""}>${fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), u?.organization_id ?? "")}</div>`}
|
||||
<label class="check"><input type="checkbox" name="is_active" ${edit ? (u.is_active ? "checked" : "") : "checked"} ${self ? "disabled" : ""}><span>Доступ разрешён<small>Отключённый пользователь не входит в UI; его текущий токен перестаёт действовать сразу</small></span></label>
|
||||
${self ? `<div class="info">Свою учётную запись нельзя понизить, отключить или удалить — пароль меняется кнопкой «Сменить пароль» в шапке.</div>` : ""}`),
|
||||
foot: dlgFoot(edit ? "Сохранить" : "Добавить пользователя"),
|
||||
@@ -926,6 +949,8 @@ const actions = {
|
||||
closeDropdowns();
|
||||
box.querySelector(".select").focus();
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
// изменение 033: живое переключение поля «Организация» при смене роли в диалоге пользователя
|
||||
if (S.dialog && input.name === "role") $("#org-field-wrap")?.toggleAttribute("hidden", d.value === "superadmin");
|
||||
},
|
||||
sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); },
|
||||
"sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); },
|
||||
|
||||
@@ -100,6 +100,7 @@ svg{flex:none}
|
||||
.badge.blue{background:#eaf0ff;color:#1e449e}
|
||||
.badge.red{background:#fbe6e6;color:#a32020}
|
||||
.badge.amber{background:#fdf0d5;color:#7c4a00}
|
||||
.badge.purple{background:#efe8fb;color:#5b2a9e}
|
||||
.more{margin-left:4px;display:inline-flex;align-items:center;height:18px;padding:0 5px;border-radius:4px;background:#eaf0ff;color:#1e449e;font:500 11px/1 var(--sans)}
|
||||
|
||||
/* overview */
|
||||
|
||||
Reference in new issue
Block a user