agnes-the-ai-analyst/app/web/templates/admin_groups.html
ZdenekSrotyr e9d7af3cce feat(rbac+marketplace): RBAC v13 + Claude Code marketplace + #81/#83/#44 hardening
This squashes 13 commits from ma/staging plus a small docstring translation
into a single coherent unit. Three workstreams.

== RBAC v13 redesign ==
- Drops core.viewer/analyst/km_admin/admin hierarchy and the
  internal_roles / group_mappings / user_role_grants / plugin_access tables.
- Replaced by user_group_members + resource_grants. Atomic v12→v13 backfill
  wrapped in BEGIN/COMMIT; ROLLBACK leaves schema_version at 12 for retry.
- Two authorization primitives in app.auth.access:
    require_admin                        — Admin-group god-mode
    require_resource_access(rt, "{path}") — entity-scoped grants
  Single DB lookup per request; no session cache; no implies BFS.
- /admin/access UI (single page) replaces /admin/role-mapping +
  /admin/plugin-access. CLI `da admin group/grant *` replaces
  `da admin role/mapping/grant-role/revoke-role/effective-roles`.
- ResourceType.TABLE listing-only — admins can record table grants,
  runtime enforcement still flows through legacy dataset_permissions
  (migration plan in docs/TODO-rbac-data-enforcement.md).

== Claude Code marketplace ==
- Aggregated /marketplace.zip + /marketplace.git/* (PAT-gated,
  RBAC-filtered, content-addressed cache via dulwich).
- Admin god-mode dropped on the marketplace surface — admins curate
  their own view via grants like everyone else.
- Bare-repo cache materializes per RBAC-filtered ETag; stale entries
  not pruned in this iteration (disclaimed in git_backend.py docstring).

== #81 #83 #44 security/ops hardening ==
- #81 Group A — orchestrator ATTACH allow-listing (extension/url/alias).
- #81 Group B — Keboola extractor 3-state exit codes:
    0 success / 1 total fail / 2 PARTIAL fail
  Sync API logs PARTIAL FAILURE alert on exit 2. Operators with binary
  alerting must teach it the new partial signal.
- #81 Group C — schema v10 view_ownership; rejects silent overwrite
  of a prior connector's view name on collision.
- #81 Group D — extractor-side identifier validation.
- #83 — Jira webhook fail-closed when JIRA_WEBHOOK_SECRET unset
  + path-traversal fix.
- #44 — entire /api/scripts/* surface is admin-only (planted-script +
  sandbox-bypass risk closed).

== Web UI polish + deploy fix ==
- /admin/access: live grant-count badges (no stale snapshot revert),
  shared-header CSS link added to /catalog and /admin/{tables,permissions},
  per-resource-type colored stripes.
- docker-compose.host-mount.yml: bind,rbind so dual-disk hosts don't
  silently shadow sub-mounts and write state to the wrong disk.

== OSS vendor-neutralization (waves 1+2) ==
- scripts/grpn/ → scripts/ops/. Customer-specific identifiers
  (project IDs, internal hostnames, dev/prod VM IPs, brand names)
  replaced with placeholders across code, docs, Terraform, Caddyfile,
  OAuth probe, and planning docs. Downstream infra repos that copied
  scripts/grpn/agnes-tls-rotate.sh or agnes-auto-upgrade.sh must
  update the path.

== Translation ==
- src/repositories/user_groups.py::ensure_system docstring translated
  from Czech to English for codebase consistency.

Co-authored-by: Mina Rustamyan <mina@keboola.com>
2026-04-28 14:25:04 +02:00

442 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Groups — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.container:has(.gp-page) { max-width: none; padding: 24px 16px; }
.gp-page { max-width: 1400px; margin: 0 auto; padding: 0; }
.gp-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 12px; margin-bottom: 18px; flex-wrap: wrap;
}
.gp-title { margin: 0; font-size: 22px; font-weight: 600; }
.gp-sub { margin: 0; font-size: 13px; color: var(--text-secondary, #6b7280); }
.gp-search {
padding: 8px 12px 8px 32px; min-width: 280px;
border: 1px solid var(--border, #e5e7eb); border-radius: 8px;
font-size: 13px;
background: #fff url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>") no-repeat 10px center;
}
.gp-table-wrap {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
overflow: hidden;
}
.gp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.gp-table thead th {
text-align: left; padding: 12px 16px;
background: var(--border-light, #f9fafb);
border-bottom: 1px solid var(--border, #e5e7eb);
font-weight: 600; color: var(--text-secondary, #6b7280);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
}
.gp-table tbody td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light, #f3f4f6);
vertical-align: middle;
}
.gp-table tbody tr:last-child td { border-bottom: none; }
.gp-table tbody tr { cursor: pointer; }
.gp-table tbody tr:hover { background: var(--border-light, #fafafa); }
.gp-name {
font-weight: 500; color: var(--text-primary, #111827);
text-decoration: none;
}
.gp-name:hover { color: var(--primary, #4338ca); text-decoration: underline; }
.gp-desc {
color: var(--text-secondary, #6b7280); font-size: 12px;
max-width: 380px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.count-cell { text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary, #4b5563); font-weight: 500; }
.date-cell { color: var(--text-secondary, #6b7280); font-size: 12px; white-space: nowrap; }
.origin-chip {
display: inline-block;
padding: 3px 10px; border-radius: 999px;
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px;
}
.origin-system { background: #fef3c7; color: #92400e; }
.origin-admin { background: #ede9fe; color: #6d28d9; }
.origin-google_sync { background: #dcfce7; color: #166534; }
.gp-actions { display: flex; gap: 6px; justify-content: flex-end; }
.icon-btn {
padding: 5px 10px; border-radius: 6px; font-size: 12px;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer; color: var(--text-primary, #111827);
text-decoration: none;
}
.icon-btn:hover { background: var(--border-light, #f9fafb); }
.icon-btn.danger { color: #b91c1c; border-color: #fecaca; }
.icon-btn.danger:hover { background: #fef2f2; }
.icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.gp-loading, .gp-empty {
padding: 40px 16px; text-align: center;
color: var(--text-secondary, #6b7280); font-size: 13px;
}
.gp-empty .big { font-size: 15px; font-weight: 600; color: var(--text-primary, #111827); margin-bottom: 4px; }
.gp-btn {
padding: 8px 14px; border-radius: 8px;
font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer;
}
.gp-btn:hover { background: var(--border-light, #f9fafb); }
.gp-btn.primary {
background: var(--primary, #6366f1); color: #fff;
border-color: var(--primary, #6366f1);
}
.gp-btn.primary:hover { filter: brightness(1.05); }
/* Modal — same vocabulary as the rest of the admin pages */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
display: none; align-items: center; justify-content: center; z-index: 1000;
padding: 16px;
}
.modal-backdrop.is-open { display: flex; }
.modal-card {
background: var(--surface, #fff); border-radius: 12px;
padding: 24px; width: 100%; max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
max-height: 90vh; overflow-y: auto;
}
.modal-card h3 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
.modal-card p.sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
.modal-card label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary, #6b7280); margin: 12px 0 6px; }
.modal-card input[type="text"], .modal-card textarea {
width: 100%; padding: 9px 12px; border: 1px solid var(--border, #e5e7eb);
border-radius: 8px; font-size: 13px; box-sizing: border-box;
background: var(--surface, #fff); color: var(--text-primary, #111827);
font-family: inherit;
}
.modal-card textarea { min-height: 60px; resize: vertical; }
/* Suggested groups from Google Workspace (admin's own membership) */
.suggest-block { display: none; margin: 0 0 4px; }
.suggest-block.is-visible { display: block; }
.suggest-label {
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--text-secondary, #6b7280); margin: 4px 0 8px;
}
.suggest-help { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; }
.suggest-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.suggest-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;
font-size: 12px; cursor: pointer; font-family: inherit;
transition: background 0.12s, border-color 0.12s, transform 0.05s;
}
.suggest-chip:hover { background: #dcfce7; border-color: #86efac; }
.suggest-chip:active { transform: translateY(1px); }
.suggest-chip .src-tag {
font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.4px;
background: rgba(22,101,52,0.12); color: #166534;
padding: 1px 6px; border-radius: 3px; font-weight: 600;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.modal-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer;
}
.modal-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.toast-stack {
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
}
.toast {
background: #111827; color: #fff; padding: 10px 16px;
border-radius: 8px; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
opacity: 0; transform: translateY(8px); transition: opacity 0.2s, transform 0.2s;
pointer-events: auto; max-width: 380px;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { background: #047857; }
.toast.error { background: #b91c1c; }
</style>
<div class="gp-page">
<div class="gp-toolbar">
<div>
<h2 class="gp-title">Groups</h2>
<p class="gp-sub">Named buckets that hold members and resource grants.
Manage who has access to what on <a href="/admin/access">Resource access</a>.</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<input id="search" type="search" class="gp-search" placeholder="Filter by name or description…" autocomplete="off">
<button class="gp-btn primary" id="open-create-btn">+ New group</button>
</div>
</div>
<div class="gp-table-wrap">
<table class="gp-table" id="groups-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Origin</th>
<th class="count-cell">Members</th>
<th class="count-cell">Resources</th>
<th>Created</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody id="groups-tbody"></tbody>
</table>
<div id="groups-loading" class="gp-loading">Loading groups…</div>
<div id="groups-empty" class="gp-empty" style="display:none;">
<div class="big">No groups yet</div>
<div>Click <strong>+ New group</strong> to create one. <code>Admin</code> and <code>Everyone</code> are seeded automatically.</div>
</div>
</div>
</div>
<!-- Create / edit modal -->
<div class="modal-backdrop" id="group-modal" role="dialog" aria-modal="true">
<div class="modal-card">
<h3 id="group-modal-title">New group</h3>
<p class="sub">Pick a name that identifies a logical audience (e.g. <code>data-team</code>, <code>engineers</code>).</p>
<div class="suggest-block" id="suggest-block">
<div class="suggest-label">
Suggested from your Google account
<span class="suggest-help">Click a chip to use the name.</span>
</div>
<div class="suggest-chips" id="suggest-chips"></div>
</div>
<label for="group-name">Name</label>
<input id="group-name" type="text" autocomplete="off" placeholder="data-team">
<label for="group-desc">Description (optional)</label>
<textarea id="group-desc" autocomplete="off"></textarea>
<div class="modal-actions">
<button class="modal-btn" data-close-modal="group-modal">Cancel</button>
<button class="modal-btn primary" id="group-save-btn">Save</button>
</div>
</div>
</div>
<!-- Confirm delete modal -->
<div class="modal-backdrop" id="confirm-modal" role="dialog" aria-modal="true">
<div class="modal-card">
<h3 id="confirm-title">Delete group?</h3>
<p class="sub" id="confirm-text"></p>
<div class="modal-actions">
<button class="modal-btn" data-close-modal="confirm-modal">Cancel</button>
<button class="modal-btn danger" id="confirm-ok-btn">Delete</button>
</div>
</div>
</div>
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<script>
const API = "/api/admin/groups";
function esc(s) { const d = document.createElement("div"); d.textContent = s == null ? "" : String(s); return d.innerHTML; }
function fmtDate(s) { return s ? String(s).slice(0, 16).replace("T", " ") : "-"; }
function toast(msg, kind = "") {
const el = document.createElement("div");
el.className = "toast " + kind;
el.textContent = msg;
document.getElementById("toast-stack").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3500);
}
function openModal(id) { document.getElementById(id).classList.add("is-open"); }
function closeModal(id) { document.getElementById(id).classList.remove("is-open"); }
document.querySelectorAll("[data-close-modal]").forEach(el =>
el.addEventListener("click", () => closeModal(el.dataset.closeModal)));
document.querySelectorAll(".modal-backdrop").forEach(el => {
el.addEventListener("click", e => { if (e.target === el) el.classList.remove("is-open"); });
});
document.addEventListener("keydown", e => {
if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.is-open").forEach(m => m.classList.remove("is-open"));
});
let allGroups = [];
let editingId = null;
async function loadGroups() {
const r = await fetch(API, { credentials: "include" });
document.getElementById("groups-loading").style.display = "none";
if (!r.ok) {
toast("Failed to load: HTTP " + r.status, "error");
return;
}
allGroups = await r.json();
render();
}
function render() {
const tbody = document.getElementById("groups-tbody");
const empty = document.getElementById("groups-empty");
const filter = document.getElementById("search").value.trim().toLowerCase();
const filtered = !filter ? allGroups : allGroups.filter(g =>
(g.name || "").toLowerCase().includes(filter) ||
(g.description || "").toLowerCase().includes(filter)
);
if (filtered.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
if (allGroups.length > 0) {
empty.querySelector(".big").textContent = "No matching groups";
empty.querySelector("div:last-child").textContent = "Try a different search.";
}
return;
}
empty.style.display = "none";
tbody.innerHTML = "";
for (const g of filtered) {
const tr = document.createElement("tr");
tr.dataset.id = g.id;
tr.style.cursor = "pointer";
const origin = g.origin || "admin";
const actions = g.is_system
? `<span style="color:#9ca3af;font-size:11px">read-only</span>`
: `<button class="icon-btn" data-action="edit">Edit</button>
<button class="icon-btn danger" data-action="delete">Delete</button>`;
tr.innerHTML = `
<td><a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(g.name)}</a></td>
<td><span class="gp-desc">${esc(g.description || "")}</span></td>
<td><span class="origin-chip origin-${esc(origin)}">${esc(origin.replace("_"," "))}</span></td>
<td class="count-cell">${g.member_count || 0}</td>
<td class="count-cell">
${g.grant_count || 0}
${g.grant_count > 0 ? `<a class="icon-btn" style="margin-left:6px" href="/admin/access?group=${encodeURIComponent(g.id)}" data-action="grants">→</a>` : ""}
</td>
<td class="date-cell">${fmtDate(g.created_at)}</td>
<td><div class="gp-actions">${actions}</div></td>
`;
// Row click → detail (unless action button)
tr.addEventListener("click", e => {
if (e.target.closest("[data-action]")) return;
window.location.href = `/admin/groups/${encodeURIComponent(g.id)}`;
});
const editBtn = tr.querySelector('[data-action="edit"]');
if (editBtn) editBtn.addEventListener("click", e => { e.stopPropagation(); openEdit(g); });
const delBtn = tr.querySelector('[data-action="delete"]');
if (delBtn) delBtn.addEventListener("click", e => { e.stopPropagation(); openDelete(g); });
tbody.appendChild(tr);
}
}
document.getElementById("search").addEventListener("input", render);
async function loadGroupSuggestions() {
const block = document.getElementById("suggest-block");
const wrap = document.getElementById("suggest-chips");
block.classList.remove("is-visible");
wrap.innerHTML = "";
try {
const r = await fetch("/api/admin/group-suggestions", { credentials: "include" });
if (!r.ok) return;
const items = await r.json();
if (!Array.isArray(items) || items.length === 0) return;
for (const it of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "suggest-chip";
btn.title = `Use "${it.name}" as the group name`;
btn.innerHTML = `${esc(it.name)} <span class="src-tag">Google</span>`;
btn.addEventListener("click", () => {
const input = document.getElementById("group-name");
input.value = it.name;
input.focus();
});
wrap.appendChild(btn);
}
block.classList.add("is-visible");
} catch (_) { /* fail-soft — suggestions are a hint, not required */ }
}
document.getElementById("open-create-btn").addEventListener("click", () => {
editingId = null;
document.getElementById("group-modal-title").textContent = "New group";
document.getElementById("group-name").value = "";
document.getElementById("group-desc").value = "";
openModal("group-modal");
setTimeout(() => document.getElementById("group-name").focus(), 50);
loadGroupSuggestions();
});
function openEdit(g) {
editingId = g.id;
document.getElementById("group-modal-title").textContent = "Edit group";
document.getElementById("group-name").value = g.name;
document.getElementById("group-desc").value = g.description || "";
// Suggestions are only useful when creating; hide on edit.
document.getElementById("suggest-block").classList.remove("is-visible");
document.getElementById("suggest-chips").innerHTML = "";
openModal("group-modal");
}
document.getElementById("group-save-btn").addEventListener("click", async () => {
const name = document.getElementById("group-name").value.trim();
const description = document.getElementById("group-desc").value.trim();
if (!name) { toast("Name is required", "error"); return; }
try {
if (editingId) {
const r = await fetch(`${API}/${encodeURIComponent(editingId)}`, {
method: "PATCH", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, description: description || null }),
});
if (!r.ok) throw new Error((await r.json()).detail || r.status);
toast("Group updated", "success");
} else {
const r = await fetch(API, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, description: description || null }),
});
if (!r.ok) throw new Error((await r.json()).detail || r.status);
toast("Group created", "success");
}
closeModal("group-modal");
loadGroups();
} catch (e) {
toast("Save failed: " + e.message, "error");
}
});
function openDelete(g) {
document.getElementById("confirm-title").textContent = `Delete group "${g.name}"?`;
document.getElementById("confirm-text").textContent =
`Removes the group, its ${g.member_count || 0} member${g.member_count === 1 ? "" : "s"} and ${g.grant_count || 0} grant${g.grant_count === 1 ? "" : "s"}. Cannot be undone.`;
const ok = document.getElementById("confirm-ok-btn");
ok.onclick = async () => {
try {
const r = await fetch(`${API}/${encodeURIComponent(g.id)}`, {
method: "DELETE", credentials: "include",
});
if (!r.ok) throw new Error(r.status);
toast("Group deleted", "success");
closeModal("confirm-modal");
loadGroups();
} catch (e) {
toast("Delete failed: " + e.message, "error");
}
};
openModal("confirm-modal");
}
loadGroups();
</script>
{% endblock %}