* fix(rbac): stack-gate analyst table access via data_packages exclusively
Previously analysts could see a table in ``agnes catalog`` /
``/api/sync/manifest`` either by:
1. being in a group with ``resource_grants(group, 'table', id)``, or
2. being in a group with ``resource_grants(group, 'data_package', …)``
for a package containing the table.
Path 1 leaked: admins who minted a per-table grant without ever
wrapping the table in a data_package still shipped the table to
analysts — directly contradicting the unified-stack mental model
("the stack is the unit of access"). User report:
"i když to admin nedal do data package tak to by default uživatelé
dostali to by se nemělo stát".
New policy: analyst visibility is strictly stack-gated. A table is
visible iff at least one data_package containing it is in the
analyst's stack (required ∪ subscribed). Admin god-mode and the three
internal data-source tables (agnes_sessions / _telemetry / _audit
with row-level RBAC) keep their existing carve-outs.
Touched surfaces:
* ``src/rbac.can_access_table`` + ``get_accessible_tables`` —
routed through ``StackResolver.stack(user, DATA_PACKAGE)`` +
``data_package_tables`` join instead of ``resource_grants(table)``.
* ``app/api/sync._build_direct_tables_section`` — always returns
``[]`` (key kept for older CLI destructuring); per-table grants
no longer manifest.
* Standardised 403 detail across ``/api/data/*``, ``/api/query``,
``/api/v2/sample``, ``/api/v2/scan``, ``/api/v2/schema``:
``Table 'X' is not in your stack. Ask an admin to add it to a
Data Package you have access to (Required or in your stack),
then run `agnes pull` to refresh.`` Single source of truth lives
in ``src.rbac.table_not_in_stack_message`` so the wording stays
consistent across CLI surfaces.
UX side: ``/catalog/t/<id>`` (table detail page) dropped the four
editorial sections (Sample questions, What's inside, Things to know,
Pairs well with) per user feedback — the page's job is now
"what is this table, where do I find it" (hero + parent packages).
Tests:
* ``tests/conftest.grant_table_via_package`` / ``revoke_table_via_package``
— shared helpers that wrap a table in an auto-named data_package +
grant the package required to a custom group. Replaces the legacy
per-test ``_grant_table_to_analyst`` table-grant pattern.
* All 17 previously-failing legacy tests (test_access_control,
test_journey_rbac, test_audit_gap_*, test_rbac, …) migrated to use
the new helper; logic stays the same.
* ``tests/fixtures/analyst_bootstrap._grant_table_access`` updated
to wrap via data_package so the ``test_pat`` fixture's "two table
grants" semantics still ship parquets through ``agnes init``.
* New ``tests/test_table_not_in_stack_message.py`` locks in the
standardised 403 detail across the data + check-access endpoints.
5204 tests passing (added 1).
* fix(catalog): first-demo UX feedback — required-first grouping + longer card description
Two minor polish items from the 2026-05-19 stakeholder demo:
1. Required packages cluster at the top of the Browse grid instead of
being interleaved by ``created_at``. Sort key
``(requirement != 'required', name)`` runs before the adapter
call in both /catalog (data_packages) and /corporate-memory
(memory_domains) so the required block is visible without
scrolling. Regression test pins the order via
``data-id="…"`` position in rendered HTML.
2. ``.stack-card__desc`` line clamp bumped 2 → 4 lines. Two-line clamp
trailed almost every admin-authored description off in "…" before
the second clause, forcing a click-through to read it. The detail
page (/catalog/p/<slug>) keeps the unclamped body for longer
content.
* release: 0.55.3 — stack-gated analyst RBAC (BREAKING) + first-demo UX polish + #345 A/B/C/D + #347 UI consistency
219 lines
9.2 KiB
HTML
219 lines
9.2 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ table.name }} — Table detail{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<style>
|
|
.td-back {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
color: var(--text-secondary, #5f6368); text-decoration: none;
|
|
font-size: 13px; margin-bottom: 12px;
|
|
}
|
|
.td-back:hover { color: var(--primary, #0073D1); }
|
|
|
|
/* ── Hero ─────────────────────────────────────────────────── */
|
|
.td-hero {
|
|
display: flex; align-items: flex-start; gap: 16px;
|
|
padding: 20px 24px; background: var(--surface, #fff);
|
|
border: 1px solid var(--border); border-radius: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.td-hero__glyph {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 56px; height: 56px; border-radius: 12px; flex-shrink: 0;
|
|
font-size: 22px; font-weight: 700; color: #fff;
|
|
background: var(--td-glyph-bg, var(--primary, #0073D1));
|
|
}
|
|
.td-hero__body { flex: 1; min-width: 0; }
|
|
.td-hero__title {
|
|
margin: 0 0 6px;
|
|
font-size: 22px; font-weight: 700;
|
|
letter-spacing: -0.2px;
|
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
}
|
|
.td-hero__id {
|
|
font-family: var(--font-mono); font-size: 12px;
|
|
color: var(--text-secondary);
|
|
padding: 2px 6px; background: var(--border-light, #f3f4f6);
|
|
border-radius: 4px;
|
|
}
|
|
.td-hero__desc {
|
|
margin: 6px 0 0;
|
|
color: var(--text-primary); font-size: 14px; line-height: 1.5;
|
|
}
|
|
.td-hero__meta {
|
|
display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap;
|
|
margin-top: 12px;
|
|
font-size: 12px; color: var(--text-secondary);
|
|
}
|
|
.td-hero__meta strong {
|
|
color: var(--text-primary); font-weight: 600;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.td-pill {
|
|
display: inline-block; padding: 2px 8px;
|
|
border-radius: 999px; font-size: 11px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.04em;
|
|
line-height: 1.4;
|
|
}
|
|
.td-pill--mode-local { background: #d1fae5; color: #065f46; }
|
|
.td-pill--mode-remote { background: #fef3c7; color: #92400e; }
|
|
.td-pill--mode-materialized{ background: #e0f2fe; color: #075985; }
|
|
.td-pill--mode-internal { background: #f3f4f6; color: #4b5563; }
|
|
.td-pill--source { background: var(--primary-light, #e0f2fe); color: var(--primary, #0073D1); }
|
|
|
|
/* ── Sections ─────────────────────────────────────────────── */
|
|
.td-section {
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border); border-radius: 12px;
|
|
padding: 18px 20px; margin-bottom: 14px;
|
|
}
|
|
.td-section__head {
|
|
display: flex; align-items: baseline; justify-content: space-between;
|
|
gap: 12px; margin-bottom: 10px;
|
|
}
|
|
.td-section__title {
|
|
margin: 0; font-size: 13px; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
color: var(--text-secondary);
|
|
}
|
|
.td-section__edit-btn {
|
|
border: 1px solid var(--border); background: transparent;
|
|
padding: 3px 10px; border-radius: 6px; font-size: 12px;
|
|
cursor: pointer; color: var(--text-secondary);
|
|
}
|
|
.td-section__edit-btn:hover { background: var(--border-light, #f3f4f6); color: var(--text-primary); }
|
|
|
|
.td-empty {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 8px 0;
|
|
color: var(--text-secondary); font-size: 13px; font-style: italic;
|
|
}
|
|
.td-empty__cta {
|
|
margin-left: auto; font-style: normal;
|
|
}
|
|
|
|
.td-list { margin: 0; padding-left: 20px; }
|
|
.td-list li { margin: 4px 0; line-height: 1.5; font-size: 14px; }
|
|
|
|
.td-columns { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.td-columns th, .td-columns td {
|
|
text-align: left; padding: 6px 10px;
|
|
border-bottom: 1px solid var(--border-light, #f0f0f0);
|
|
}
|
|
.td-columns th {
|
|
color: var(--text-secondary); font-weight: 600;
|
|
text-transform: uppercase; font-size: 11px;
|
|
letter-spacing: 0.4px; background: var(--border-light, #f9fafb);
|
|
}
|
|
.td-columns code { font-family: var(--font-mono); font-size: 12px; color: var(--primary, #0073D1); }
|
|
.td-columns td.td-null { color: var(--text-secondary); }
|
|
|
|
.td-pairs { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.td-pairs a {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
padding: 6px 12px; background: var(--border-light, #f3f4f6);
|
|
border-radius: 6px; text-decoration: none;
|
|
color: var(--text-primary); font-size: 13px;
|
|
}
|
|
.td-pairs a:hover { background: var(--primary-light, #e0f2fe); color: var(--primary, #0073D1); }
|
|
|
|
.td-note {
|
|
white-space: pre-wrap; line-height: 1.5; font-size: 14px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* ── Admin inline edit ─────────────────────────────────────── */
|
|
.td-edit { display: none; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
|
|
.td-edit.is-open { display: block; }
|
|
.td-edit textarea, .td-edit input[type="text"] {
|
|
width: 100%; padding: 8px 10px;
|
|
border: 1px solid var(--border); border-radius: 6px;
|
|
font-family: inherit; font-size: 13px; line-height: 1.5;
|
|
background: var(--surface); color: var(--text-primary);
|
|
}
|
|
.td-edit textarea { resize: vertical; min-height: 100px; font-family: inherit; }
|
|
.td-edit__hint { color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
|
|
.td-edit__row { display: flex; gap: 8px; margin-top: 10px; justify-content: flex-end; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{# Back link — prefer the first parent package, fall back to /catalog. #}
|
|
<a class="td-back"
|
|
href="{% if parent_packages %}/catalog/p/{{ parent_packages[0].slug }}{% else %}/catalog{% endif %}">
|
|
← Back to {% if parent_packages %}{{ parent_packages[0].name }}{% else %}catalog{% endif %}
|
|
</a>
|
|
|
|
{# ── Hero card ─────────────────────────────────────────────── #}
|
|
{% set _qmode = (table.query_mode or 'local')|lower %}
|
|
{% set _glyph_bg = table.color or '#0073D1' %}
|
|
{% set _initials =
|
|
(table.name or table.id or '?').split()|map('first')|join('')|upper
|
|
%}
|
|
<div class="td-hero">
|
|
<div class="td-hero__glyph" style="--td-glyph-bg: {{ _glyph_bg }};">
|
|
{% if table.icon %}{{ table.icon }}{% else %}{{ _initials[:2] }}{% endif %}
|
|
</div>
|
|
<div class="td-hero__body">
|
|
<h1 class="td-hero__title">
|
|
{{ table.name }}
|
|
<span class="td-hero__id">{{ table.id }}</span>
|
|
</h1>
|
|
|
|
{% if table.description %}
|
|
<p class="td-hero__desc">{{ table.description }}</p>
|
|
{% endif %}
|
|
|
|
<div class="td-hero__meta">
|
|
<span class="td-pill td-pill--mode-{{ _qmode }}" title="query mode">{{ _qmode }}</span>
|
|
{# Skip the source-type pill when it's redundant with the mode
|
|
pill — internal tables have source_type == query_mode ==
|
|
'internal' and rendering two identical pills is visual noise. #}
|
|
{% if table.source_type and table.source_type|lower != _qmode %}
|
|
<span class="td-pill td-pill--source" title="source type">{{ table.source_type }}</span>
|
|
{% endif %}
|
|
{# Qualified-name code chip — only useful for sources that round-trip
|
|
to an upstream system (Keboola bucket.table, BigQuery dataset.table).
|
|
Internal tables have no meaningful upstream qualified name. #}
|
|
{% if table.bucket and table.source_type|lower != 'internal' %}
|
|
· <code style="font-family:var(--font-mono); font-size:12px;">{{ table.bucket }}{% if table.source_table %}.{{ table.source_table }}{% endif %}</code>
|
|
{% endif %}
|
|
{% if rows_display %}
|
|
· <span><strong>{{ rows_display }}</strong> rows</span>
|
|
{% endif %}
|
|
{% if size_display %}
|
|
· <span><strong>{{ size_display }}</strong></span>
|
|
{% endif %}
|
|
{% if last_sync_display %}
|
|
· Last sync <strong>{{ last_sync_display }}</strong>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if parent_packages %}
|
|
<div class="td-hero__meta">
|
|
In package{{ '' if parent_packages|length == 1 else 's' }}:
|
|
{% for p in parent_packages %}
|
|
<a href="/catalog/p/{{ p.slug }}"
|
|
style="color:var(--primary, #0073D1); text-decoration:none; font-weight:600;">{{ p.name }}</a>{% if not loop.last %}, {% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# The original layout had four editorial sections — Sample questions,
|
|
What's inside (columns), Things to know, Pairs well with — that the
|
|
admin could fill in. Per user feedback they read as noise on a page
|
|
whose job is "what is this table and where do I find it"; the
|
|
structural answer is the hero (name + description + parent
|
|
packages). Dropping the four sections keeps the page focused. The
|
|
PATCH endpoint + columns query still exist for tools that read
|
|
them programmatically; this only changes the user-facing render. #}
|
|
|
|
{# Inline-edit / sync-trigger / docs-PATCH JS was removed alongside the
|
|
four editorial sections it drove (sample_questions, columns,
|
|
things_to_know, pairs_well_with). The endpoints (`POST
|
|
/api/sync/trigger`, `PATCH /api/admin/registry/{id}/docs`) still
|
|
exist for direct API callers + admin tooling. #}
|
|
{% endblock %}
|