agnes-the-ai-analyst/app/web/templates/profile.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

344 lines
9.9 KiB
HTML

{% extends "base.html" %}
{% block title %}Profile — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
/* /profile — read-only account view with Google Workspace group list.
Matches the card/hero vocabulary used on /tokens. */
body > .container { max-width: 960px; }
.profile-page {
max-width: 960px;
margin: 0 auto;
padding: 28px 8px 48px;
box-sizing: border-box;
font-family: var(--font-primary, 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif);
}
@media (max-width: 720px) {
.profile-page { padding: 20px 0 32px; }
}
.profile-hero {
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 14px;
padding: 28px 32px 24px;
margin-bottom: 20px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.2);
color: #fff;
}
.profile-hero .hero-eyebrow {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 8px;
}
.profile-hero .profile-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 6px;
color: #fff;
}
.profile-hero .profile-subtitle {
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin: 0;
line-height: 1.5;
}
.section-card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 16px;
}
.section-card h3 {
margin: 0 0 14px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-secondary, #6b7280);
}
.account-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 10px 18px;
font-size: 14px;
}
.account-grid .k {
color: var(--text-secondary, #6b7280);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.4px;
font-weight: 600;
align-self: center;
}
.account-grid .v {
color: var(--text-primary, #1A253C);
font-weight: 500;
word-break: break-word;
}
.role-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
text-transform: capitalize;
letter-spacing: 0.2px;
background: rgba(0, 115, 209, 0.10);
color: #0073D1;
border: 1px solid rgba(0, 115, 209, 0.25);
}
.groups-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.group-row {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
border: 1px solid var(--border, #e5e7eb);
border-radius: 10px;
background: var(--background, #f9fafb);
}
.group-row .group-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1A253C);
min-width: 0;
flex: 0 1 auto;
}
.group-row .group-id {
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 12px;
color: var(--text-secondary, #6b7280);
word-break: break-all;
flex: 1 1 auto;
}
.empty-state {
padding: 20px 0 4px;
color: var(--text-secondary, #6b7280);
font-size: 14px;
line-height: 1.55;
}
.empty-state .empty-title {
font-weight: 600;
color: var(--text-primary, #1A253C);
margin-bottom: 4px;
}
.tokens-link-row {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border-light, #f3f4f6);
font-size: 13.5px;
color: var(--text-secondary, #6b7280);
}
.tokens-link-row a {
color: #0073D1;
text-decoration: none;
font-weight: 600;
}
.tokens-link-row a:hover { text-decoration: underline; }
/* Effective roles section: chip cloud + grouped breakdown */
.role-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0 0 4px;
}
.role-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 12px;
font-weight: 600;
background: rgba(0, 115, 209, 0.10);
color: #0073D1;
border: 1px solid rgba(0, 115, 209, 0.25);
}
.role-chip.is-core { background: rgba(16, 185, 129, 0.10); color: #047857; border-color: rgba(16, 185, 129, 0.25); }
.grant-list, .group-roles-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.grant-row, .group-roles-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border: 1px solid var(--border, #e5e7eb);
border-radius: 10px;
background: var(--background, #f9fafb);
flex-wrap: wrap;
}
.grant-row .grant-key, .group-roles-row .group-roles-name {
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1A253C);
}
.group-roles-row .group-roles-name {
font-family: var(--font-primary, 'Inter', system-ui, sans-serif);
}
.grant-row .grant-source {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(107, 114, 128, 0.12);
color: var(--text-secondary, #6b7280);
font-weight: 600;
}
.grant-row .grant-source.source-direct { background: rgba(0, 115, 209, 0.12); color: #0073D1; }
.grant-row .grant-source.source-auto { background: rgba(245, 158, 11, 0.12); color: #b45309; }
.grant-row .grant-meta {
font-size: 12px;
color: var(--text-secondary, #6b7280);
margin-left: auto;
}
.group-roles-row .group-roles-arrow {
color: var(--text-secondary, #9ca3af);
font-weight: 400;
}
.group-roles-row .role-chips { margin: 0; }
.effective-help {
margin-top: 12px;
font-size: 12.5px;
color: var(--text-secondary, #6b7280);
line-height: 1.55;
}
</style>
<div class="profile-page">
<section class="profile-hero" aria-labelledby="profile-title">
<div class="hero-eyebrow">Your account</div>
<h2 class="profile-title" id="profile-title">Profile</h2>
<p class="profile-subtitle">Account details and your group memberships.</p>
</section>
<section class="section-card" aria-label="Account details">
<h3>Account</h3>
<div class="account-grid">
<span class="k">Email</span>
<span class="v">{{ user.email or "—" }}</span>
<span class="k">Name</span>
<span class="v">{{ user.name or "—" }}</span>
<span class="k">Status</span>
<span class="v">
{% if is_admin %}
<span class="role-pill is-core">Admin</span>
{% else %}
User
{% endif %}
</span>
</div>
<div class="tokens-link-row">
Manage personal access tokens at <a href="/tokens">/tokens</a>.
</div>
</section>
<section class="section-card" aria-label="Group memberships">
<h3>Group memberships</h3>
{% if memberships and memberships | length > 0 %}
<ul class="groups-list" role="list">
{% for m in memberships %}
<li class="group-row" role="listitem">
<span class="group-name">
{{ m.name }}
{% if m.is_system %}<span class="role-chip is-core" style="margin-left:6px;">system</span>{% endif %}
</span>
<span class="group-id">via {{ m.source }}{% if m.added_at %} · added {{ m.added_at|string|truncate(16, true, '') }}{% endif %}</span>
</li>
{% endfor %}
</ul>
{% else %}
<div class="empty-state">
<div class="empty-title">No group memberships</div>
<div>You're not a member of any groups yet. Ask an admin to add you, or wait for the next Google Workspace sync if you sign in via Google.</div>
</div>
{% endif %}
</section>
<section class="section-card" aria-label="Effective access">
<h3>Effective access</h3>
<div id="effective-access-host">
<div class="empty-state">
<div class="empty-title">Loading…</div>
</div>
</div>
</section>
</div>
<script>
(function () {
const host = document.getElementById("effective-access-host");
fetch("/api/me/effective-access", { credentials: "include" })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) {
host.innerHTML = `<div class="empty-state">
<div class="empty-title">No data</div>
<div>Effective-access readout unavailable. Admins can view it at /admin/users/{{ user.id }}.</div>
</div>`;
return;
}
if (data.is_admin) {
host.innerHTML = `<div class="empty-state" style="background:linear-gradient(135deg,#fef3c7,#fde68a);border:1px solid #f59e0b;color:#78350f;">
<div class="empty-title" style="color:#422006;">🔑 Full access via Admin</div>
<div>You can read and write everything in Agnes regardless of explicit grants.</div>
</div>`;
return;
}
if (!data.items || data.items.length === 0) {
host.innerHTML = `<div class="empty-state">
<div class="empty-title">No resource access yet</div>
<div>Resources are granted to groups; ask an admin to add you to one with the access you need.</div>
</div>`;
return;
}
let html = `<ul class="grant-list" role="list">`;
for (const it of data.items) {
const via = it.via_groups.map(g => `<span class="grant-source source-direct">${g.group_name}</span>`).join(" ");
html += `<li class="grant-row">
<span class="grant-key">${escapeHtml(it.resource_type)} · ${escapeHtml(it.resource_id)}</span>
${via}
</li>`;
}
html += `</ul>`;
host.innerHTML = html;
})
.catch(() => {
host.innerHTML = `<div class="empty-state"><div class="empty-title">Failed to load</div></div>`;
});
function escapeHtml(s) { const d = document.createElement("div"); d.textContent = s == null ? "" : String(s); return d.innerHTML; }
})();
</script>
{% endblock %}