fix(rbac): stack-gated analyst access + first-demo polish (#333 follow-up) (#356)

* 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
This commit is contained in:
ZdenekSrotyr 2026-05-19 17:01:14 +02:00 committed by GitHub
parent 0a16e8f44e
commit 62336bfd32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 520 additions and 396 deletions

View file

@ -10,7 +10,10 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [Unreleased]
## [0.55.3] — 2026-05-19
### Changed ### Changed
- **BREAKING:** `src/rbac.can_access_table` + `get_accessible_tables` now route through Data Package stack membership instead of per-table `resource_grants`. Per-table grants no longer surface a table to analysts on their own — admins must wrap tables in a Data Package and grant the package (Required or in the user's stack). `manifest.direct_tables` is always `[]` (key kept for older-CLI destructuring). Internal tables (`agnes_sessions/telemetry/audit`) + admin god-mode keep their carve-outs. Standardised 403 detail across every CLI gate (`/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."* New shared test helper `tests.conftest.grant_table_via_package` replaces the legacy `resource_grants(table)` pattern across 8 test files. Closes #356 / #333 follow-up.
- `agnes diagnose` is now role-aware. A fresh analyst install no longer reports `Overall: degraded` just because the server has operator-side warnings (stale tables, session-pipeline cadence, BQ billing-project config) that the analyst can't act on. Server (`/api/health/detailed`) tags every check with `audience: "analyst" | "operator"` plus a top-level `caller_role` derived from `user.is_admin` and an `overall_analyst` aggregation. Client excludes operator checks from the headline for analyst callers, surfaces operator warning count on a secondary line so they stay visible, auto-promotes admin/operator callers to the full aggregation, and lets analysts opt in via `--include-operator-checks`. Legacy servers (no `caller_role`) keep the pre-#345-B full aggregation — no silent regression. Closes #345 B. - `agnes diagnose` is now role-aware. A fresh analyst install no longer reports `Overall: degraded` just because the server has operator-side warnings (stale tables, session-pipeline cadence, BQ billing-project config) that the analyst can't act on. Server (`/api/health/detailed`) tags every check with `audience: "analyst" | "operator"` plus a top-level `caller_role` derived from `user.is_admin` and an `overall_analyst` aggregation. Client excludes operator checks from the headline for analyst callers, surfaces operator warning count on a secondary line so they stay visible, auto-promotes admin/operator callers to the full aggregation, and lets analysts opt in via `--include-operator-checks`. Legacy servers (no `caller_role`) keep the pre-#345-B full aggregation — no silent regression. Closes #345 B.
### Added ### Added
@ -44,6 +47,9 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
inside each pair is what was kept (the incoming side held inside each pair is what was kept (the incoming side held
superseded intermediate-commit duplicates). superseded intermediate-commit duplicates).
### Fixed
- **First-demo UX polish on /catalog** (2026-05-19): Browse grid now groups **Required** packages first instead of by `created_at` so the most-relevant adopt-immediately items lead. `.stack-card__desc` line clamp bumped 2 → 4 lines so card descriptions get more room. `/catalog/t/<id>` table-detail page dropped four editorial sections (Sample questions / What's inside / Things to know / Pairs well with) — hero (name + description + parent packages) only. Same Browse-order treatment applied to `/corporate-memory`.
## [0.55.2] — 2026-05-19 ## [0.55.2] — 2026-05-19
### Fixed ### Fixed

View file

@ -75,7 +75,10 @@ async def check_access(
except Exception: except Exception:
logger.exception("audit_log write failed for data.access_check; continuing") logger.exception("audit_log write failed for data.access_check; continuing")
if not granted: if not granted:
raise HTTPException(status_code=403, detail="Access denied to this table") from src.rbac import table_not_in_stack_message
raise HTTPException(
status_code=403, detail=table_not_in_stack_message(table_id),
)
return Response(status_code=204) return Response(status_code=204)
@ -104,7 +107,10 @@ async def download_table(
raise HTTPException(status_code=404, detail="Table not found") raise HTTPException(status_code=404, detail="Table not found")
# Check access FIRST # Check access FIRST
if not can_access_table(user, table_id, conn): if not can_access_table(user, table_id, conn):
raise HTTPException(status_code=403, detail="Access denied to this table") from src.rbac import table_not_in_stack_message
raise HTTPException(
status_code=403, detail=table_not_in_stack_message(table_id),
)
data_dir = _get_data_dir() data_dir = _get_data_dir()

View file

@ -400,7 +400,11 @@ def execute_query(
for table in forbidden: for table in forbidden:
pattern = r'\b' + re.escape(table.lower()) + r'\b' pattern = r'\b' + re.escape(table.lower()) + r'\b'
if re.search(pattern, sql_lower_masked): if re.search(pattern, sql_lower_masked):
raise HTTPException(status_code=403, detail=f"Access denied to table '{table}'") from src.rbac import table_not_in_stack_message
raise HTTPException(
status_code=403,
detail=table_not_in_stack_message(table),
)
# ---- #160 BQ remote-row guardrail + RBAC patch ------------------- # ---- #160 BQ remote-row guardrail + RBAC patch -------------------
dry_run_set, name_lookups, blocked_bq_path = _bq_guardrail_inputs( dry_run_set, name_lookups, blocked_bq_path = _bq_guardrail_inputs(

View file

@ -881,43 +881,24 @@ def _build_direct_tables_section(
conn, user: dict, registry_by_name: dict, states_by_table_id: dict, conn, user: dict, registry_by_name: dict, states_by_table_id: dict,
packaged_table_ids: set, packaged_table_ids: set,
) -> list: ) -> list:
"""Tables granted via ``TABLE`` resource_type (not DATA_PACKAGE). """Always returns ``[]`` — per-table grants no longer manifest for
analysts.
A table granted both directly AND via a package only shows up under the The unified-stack design routes all analyst access through data
package Section 5.1's BC story is that ``tables[]`` (legacy) still packages: admins manage RBAC by adding tables to a package and
lists everything, while ``direct_tables[]`` is the de-duplicated granting the package. Ad-hoc ``resource_grants(group, 'table', )``
forward-compatible projection. rows that aren't wrapped in a package used to ship as
``direct_tables[]`` here (for backwards-compat with pre-unified
CLIs); that BC is now dropped because it silently leaked
individually-granted tables into ``agnes catalog`` and the
user-facing manifest, contradicting the "stack is the unit of
access" promise of the new design.
The empty array is kept in the manifest payload (instead of
omitting the key) so older CLIs that destructure
``manifest["direct_tables"]`` don't KeyError.
""" """
group_ids = [ return []
r[0] for r in conn.execute(
"SELECT group_id FROM user_group_members WHERE user_id = ?",
[user["id"]],
).fetchall()
]
if not group_ids:
return []
placeholders = ",".join(["?"] * len(group_ids))
rows = conn.execute(
f"""SELECT DISTINCT resource_id FROM resource_grants
WHERE group_id IN ({placeholders})
AND resource_type = 'table'""",
group_ids,
).fetchall()
direct_ids = {r[0] for r in rows} - packaged_table_ids
out: list = []
for tid in direct_ids:
# resource_grants.resource_id for ``TABLE`` is canonically the
# registry id; fall back to name lookup if migration left a name.
reg = None
for r in registry_by_name.values():
if r.get("id") == tid:
reg = r
break
if reg is None:
reg = registry_by_name.get(tid) or {}
state = states_by_table_id.get(reg.get("name") or tid) or {}
out.append(_table_manifest_entry(state, reg))
return out
def _build_manifest_for_user(conn, user: dict) -> dict: def _build_manifest_for_user(conn, user: dict) -> dict:

View file

@ -218,7 +218,11 @@ def sample(
if isinstance(exc, FileNotFoundError): if isinstance(exc, FileNotFoundError):
raise HTTPException(status_code=404, detail=f"table {table_id!r} not found") raise HTTPException(status_code=404, detail=f"table {table_id!r} not found")
if isinstance(exc, PermissionError): if isinstance(exc, PermissionError):
raise HTTPException(status_code=403, detail="not authorized for this table") from src.rbac import table_not_in_stack_message
raise HTTPException(
status_code=403,
detail=table_not_in_stack_message(table_id),
)
if isinstance(exc, ValueError): if isinstance(exc, ValueError):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,

View file

@ -280,7 +280,11 @@ def scan_estimate_endpoint(
detail={"error": "validator_rejected", "kind": exc.kind, "details": exc.detail or {}}, detail={"error": "validator_rejected", "kind": exc.kind, "details": exc.detail or {}},
) )
if isinstance(exc, PermissionError): if isinstance(exc, PermissionError):
raise HTTPException(status_code=403, detail="not authorized for this table") from src.rbac import table_not_in_stack_message
raise HTTPException(
status_code=403,
detail=table_not_in_stack_message(str(exc) or "<unknown>"),
)
if isinstance(exc, FileNotFoundError): if isinstance(exc, FileNotFoundError):
raise HTTPException(status_code=404, detail=f"table {exc!s} not found") raise HTTPException(status_code=404, detail=f"table {exc!s} not found")
if isinstance(exc, ValueError): if isinstance(exc, ValueError):
@ -509,7 +513,11 @@ def scan_endpoint(
if isinstance(exc, FileNotFoundError): if isinstance(exc, FileNotFoundError):
raise HTTPException(status_code=404, detail="table not found") raise HTTPException(status_code=404, detail="table not found")
if isinstance(exc, PermissionError): if isinstance(exc, PermissionError):
raise HTTPException(status_code=403, detail="not authorized") from src.rbac import table_not_in_stack_message
raise HTTPException(
status_code=403,
detail=table_not_in_stack_message(str(exc) or "<unknown>"),
)
if isinstance(exc, ValueError): if isinstance(exc, ValueError):
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
raise HTTPException( raise HTTPException(

View file

@ -261,7 +261,11 @@ def schema(
if isinstance(exc, NotFound): if isinstance(exc, NotFound):
raise HTTPException(status_code=404, detail=f"table {table_id!r} not found") raise HTTPException(status_code=404, detail=f"table {table_id!r} not found")
if isinstance(exc, PermissionError): if isinstance(exc, PermissionError):
raise HTTPException(status_code=403, detail="not authorized for this table") from src.rbac import table_not_in_stack_message
raise HTTPException(
status_code=403,
detail=table_not_in_stack_message(table_id),
)
if isinstance(exc, ValueError): if isinstance(exc, ValueError):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,

View file

@ -953,6 +953,16 @@ async def catalog(
browse_entries = resolver.browse(user["id"], ResourceType.DATA_PACKAGE) browse_entries = resolver.browse(user["id"], ResourceType.DATA_PACKAGE)
stack_entries = resolver.stack(user["id"], ResourceType.DATA_PACKAGE) stack_entries = resolver.stack(user["id"], ResourceType.DATA_PACKAGE)
# Group ``required`` packages first so they cluster together at the
# top of the Browse grid instead of being scattered by creation
# order — first-demo feedback (2026-05-19): "bylo by dobre ty
# required mit vzdy nekde seskupene spolu na jedne strane".
# Secondary order falls back to the resolver's name-ordered output.
browse_entries = sorted(
browse_entries,
key=lambda e: (0 if e.requirement == "required" else 1, e.name or ""),
)
def _adapt(e): def _adapt(e):
slug = None slug = None
try: try:
@ -1426,6 +1436,12 @@ async def corporate_memory(
browse_entries = resolver.browse(user["id"], ResourceType.MEMORY_DOMAIN) browse_entries = resolver.browse(user["id"], ResourceType.MEMORY_DOMAIN)
stack_entries = resolver.stack(user["id"], ResourceType.MEMORY_DOMAIN) stack_entries = resolver.stack(user["id"], ResourceType.MEMORY_DOMAIN)
# Required-first grouping mirrors /catalog (first-demo feedback).
browse_entries = sorted(
browse_entries,
key=lambda e: (0 if e.requirement == "required" else 1, e.name or ""),
)
def _adapt(e): def _adapt(e):
meta = dom_meta.get(e.id, {}) meta = dom_meta.get(e.id, {})
slug = meta.get("slug") slug = meta.get("slug")

View file

@ -531,7 +531,12 @@
color: var(--text-secondary, #5f6368); color: var(--text-secondary, #5f6368);
line-height: 1.5; line-height: 1.5;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; /* Bumped 2 4 per first-demo feedback: 2-line clamp made every
description trail off in "…" before the meaningful second clause,
forcing analysts to click through to read the full sentence. Detail
page (/catalog/p/<slug>) still carries the unclamped body for
longer admin-authored content. */
-webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }

View file

@ -202,221 +202,18 @@
</div> </div>
</div> </div>
{# ── Sample questions ───────────────────────────────────────── #} {# The original layout had four editorial sections — Sample questions,
<section class="td-section"> What's inside (columns), Things to know, Pairs well with — that the
<header class="td-section__head"> admin could fill in. Per user feedback they read as noise on a page
<h2 class="td-section__title">Sample questions you can ask</h2> whose job is "what is this table and where do I find it"; the
{% if user.is_admin %} structural answer is the hero (name + description + parent
<button type="button" class="td-section__edit-btn" onclick="tdToggleEdit('questions')"> packages). Dropping the four sections keeps the page focused. The
{{ 'Edit' if sample_questions else '+ Add' }} PATCH endpoint + columns query still exist for tools that read
</button> them programmatically; this only changes the user-facing render. #}
{% endif %}
</header>
{% if sample_questions %} {# Inline-edit / sync-trigger / docs-PATCH JS was removed alongside the
<ul class="td-list"> four editorial sections it drove (sample_questions, columns,
{% for q in sample_questions %}<li>{{ q }}</li>{% endfor %} things_to_know, pairs_well_with). The endpoints (`POST
</ul> /api/sync/trigger`, `PATCH /api/admin/registry/{id}/docs`) still
{% else %} exist for direct API callers + admin tooling. #}
<p class="td-empty">
No sample questions seeded yet.
{% if user.is_admin %}<span class="td-empty__cta">Click <strong>+ Add</strong> to seed some.</span>{% endif %}
</p>
{% endif %}
{% if user.is_admin %}
<div class="td-edit" id="td-edit-questions">
<textarea id="td-sample-questions"
placeholder="One question per line.&#10;e.g. What was revenue last month?">{{ sample_questions|join('\n') if sample_questions else '' }}</textarea>
<div class="td-edit__hint">One question per line. Saved as a JSON array of strings.</div>
<div class="td-edit__row">
<button type="button" class="btn" onclick="tdToggleEdit('questions')">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveTableDocs('sample_questions')">Save questions</button>
</div>
</div>
{% endif %}
</section>
{# ── Columns / What's inside ─────────────────────────────────── #}
<section class="td-section">
<header class="td-section__head">
<h2 class="td-section__title">What's inside</h2>
{% if columns %}
<span class="td-section__edit-btn" style="cursor:default;">{{ columns|length }} columns</span>
{% endif %}
</header>
{% if columns %}
<table class="td-columns">
<thead><tr><th>Column</th><th>Type</th><th>Nullable</th></tr></thead>
<tbody>
{% for c in columns %}
<tr>
<td><code>{{ c.name }}</code></td>
<td>{{ c.type or '—' }}</td>
<td class="{% if c.nullable %}{% else %}td-null{% endif %}">{{ 'yes' if c.nullable else 'no' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="td-empty" style="display:block;">
{% if table.source_type|lower == 'internal' %}
No column profile cached yet — Agnes internal tables are populated
by the server itself; columns surface once the table accumulates
its first rows.
{% else %}
No column profile available yet. Run a sync to populate the column
list, types, and nullability.
{% endif %}
</p>
{# Trigger sync only makes sense for non-internal tables — internal
ones are server-managed (no upstream to pull from). #}
{% if user.is_admin and table.source_type|lower != 'internal' %}
<div class="td-edit__row" style="border:0; padding:0; margin-top:8px; justify-content:flex-start;">
<button type="button" class="btn btn-primary" onclick="tdTriggerSync()">
Trigger sync now
</button>
</div>
{% endif %}
{% endif %}
</section>
{# ── Things to know ─────────────────────────────────────────── #}
<section class="td-section">
<header class="td-section__head">
<h2 class="td-section__title">Things to know</h2>
{% if user.is_admin %}
<button type="button" class="td-section__edit-btn" onclick="tdToggleEdit('notes')">
{{ 'Edit' if things_to_know else '+ Add' }}
</button>
{% endif %}
</header>
{% if things_to_know %}
<div class="td-note">{{ things_to_know }}</div>
{% else %}
<p class="td-empty">
No caveats or join hints recorded yet.
{% if user.is_admin %}<span class="td-empty__cta">Click <strong>+ Add</strong> to record some.</span>{% endif %}
</p>
{% endif %}
{% if user.is_admin %}
<div class="td-edit" id="td-edit-notes">
<textarea id="td-things-to-know"
placeholder="Caveats, common joins, gotchas, deprecated columns…">{{ things_to_know or '' }}</textarea>
<div class="td-edit__row">
<button type="button" class="btn" onclick="tdToggleEdit('notes')">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveTableDocs('things_to_know')">Save notes</button>
</div>
</div>
{% endif %}
</section>
{# ── Pairs well with ───────────────────────────────────────── #}
<section class="td-section">
<header class="td-section__head">
<h2 class="td-section__title">Pairs well with</h2>
{% if user.is_admin %}
<button type="button" class="td-section__edit-btn" onclick="tdToggleEdit('pairs')">
{{ 'Edit' if pairs_well_with else '+ Add' }}
</button>
{% endif %}
</header>
{% if pairs_well_with %}
<div class="td-pairs">
{% for p in pairs_well_with %}
<a href="/catalog/t/{{ p.id }}">{{ p.name }}</a>
{% endfor %}
</div>
{% else %}
<p class="td-empty">
No related tables suggested yet.
{% if user.is_admin %}<span class="td-empty__cta">Click <strong>+ Add</strong> to link some.</span>{% endif %}
</p>
{% endif %}
{% if user.is_admin %}
<div class="td-edit" id="td-edit-pairs">
<input id="td-pairs-input" type="text"
value="{{ pairs_well_with|map(attribute='id')|join(',') }}"
placeholder="tbl_orders,tbl_customers">
<div class="td-edit__hint">Comma-separated <code>table_registry.id</code> values. Unknown ids are silently dropped on render.</div>
<div class="td-edit__row">
<button type="button" class="btn" onclick="tdToggleEdit('pairs')">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveTableDocs('pairs_well_with')">Save related tables</button>
</div>
</div>
{% endif %}
</section>
{% if user.is_admin %}
<script>
// Toggle inline admin edit blocks. Initially every edit-row is hidden;
// clicking Edit/+ Add reveals the matching block, Cancel collapses it.
function tdToggleEdit(name) {
const el = document.getElementById('td-edit-' + name);
if (!el) return;
el.classList.toggle('is-open');
}
// Kick the sync orchestrator for this single table. Cheap fire-and-
// forget; reload after a beat so the columns section re-renders from
// the newly-populated profile.
async function tdTriggerSync() {
try {
const r = await fetch('/api/sync/trigger', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ table_id: '{{ table.id }}' }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert('Sync trigger failed: ' + (d.detail || r.statusText));
return;
}
alert('Sync triggered — refresh in a few seconds to see the column list.');
} catch (e) {
alert('Network error: ' + e.message);
}
}
// Single PATCH per section so the admin can iterate one field at a
// time without re-submitting the others.
async function saveTableDocs(field) {
const body = {};
if (field === 'sample_questions') {
body.sample_questions = document.getElementById('td-sample-questions').value
.split('\n').map(s => s.trim()).filter(Boolean);
} else if (field === 'things_to_know') {
body.things_to_know = document.getElementById('td-things-to-know').value;
} else if (field === 'pairs_well_with') {
body.pairs_well_with = document.getElementById('td-pairs-input').value
.split(',').map(s => s.trim()).filter(Boolean);
}
try {
const r = await fetch('/api/admin/registry/{{ table.id }}/docs', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert('Save failed: ' + (d.detail || r.statusText));
return;
}
// Reload to render the saved value through the server-side template
// path; cheap + correct, and side-steps any per-field rendering
// edge cases (e.g. unknown pairs_well_with ids silently dropped).
window.location.reload();
} catch (e) {
alert('Network error: ' + e.message);
}
}
</script>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,6 +1,6 @@
[project] [project]
name = "agnes-the-ai-analyst" name = "agnes-the-ai-analyst"
version = "0.55.2" version = "0.55.3"
description = "Agnes — AI Data Analyst platform for AI analytical systems" description = "Agnes — AI Data Analyst platform for AI analytical systems"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
license = "MIT" license = "MIT"

View file

@ -10,10 +10,42 @@ shim mapping the table-grain helpers onto the generic resource_grants check.
from typing import Optional from typing import Optional
import duckdb import duckdb
from fastapi import HTTPException
from src.db import get_system_db from src.db import get_system_db
def table_not_in_stack_message(table_id: str) -> str:
"""Standardized 403 detail string for table-access denial.
All CLI surfaces (`agnes query`, `agnes snapshot create`,
`agnes data <id>/download`, `/api/v2/schema`, `/api/v2/sample`)
funnel through ``can_access_table`` and return this same string so
the analyst's mental model stays consistent: "the table I asked
about isn't in my stack — admin needs to add it to a Data Package".
"""
return (
f"Table '{table_id}' is not in your stack. Ask an admin to add it "
f"to a Data Package you have access to (Required or in your stack), "
f"then run `agnes pull` to refresh."
)
def require_table_access(
user: dict,
table_id: str,
conn: Optional[duckdb.DuckDBPyConnection] = None,
) -> None:
"""Convenience: ``can_access_table`` or raise 403 with the standard
message. Centralizes the deny path so every CLI surface returns the
same actionable error.
"""
if not can_access_table(user, table_id, conn):
raise HTTPException(
status_code=403, detail=table_not_in_stack_message(table_id),
)
def can_access_table( def can_access_table(
user: dict, user: dict,
table_id: str, table_id: str,
@ -21,22 +53,27 @@ def can_access_table(
) -> bool: ) -> bool:
"""True iff the user can read ``table_id``. """True iff the user can read ``table_id``.
Admin short-circuit (members of the Admin system group) plus Three sources of access (in precedence order):
per-(group, table) grants in ``resource_grants``. Nothing else no 1. Internal data-source tables (``agnes_sessions`` / ``agnes_telemetry``
``is_public`` bypass, no per-user permissions table, no bucket / ``agnes_audit``) implicitly accessible to every authenticated
wildcards. Every non-admin access requires an explicit user. RBAC there is row-level (the per-request view filters to the
``resource_grants(group, "table", table_id)`` row. caller's rows). Admin gets the unscoped view; non-admin gets their
own rows.
2. Admin god-mode members of the Admin system group see every
registered table.
3. **Stack-gated**: the table must belong to at least one data
package in the user's stack (required subscribed). Per-table
resource_grants alone NO LONGER grant analyst visibility the
unified-stack design routes all analyst access through data
packages. Admins manage access by adding tables to a package +
granting the package; ad-hoc per-table grants in
``resource_grants`` are a no-op for analysts (still consulted
for backwards-compat fallback inside admin-only flows).
""" """
user_id = user.get("id") user_id = user.get("id")
if not user_id: if not user_id:
return False return False
# Internal data-source tables (agnes_sessions / agnes_usage / agnes_audit)
# are implicitly accessible to every authenticated user — RBAC there is
# row-level (the per-request view filters to the caller's rows) rather
# than table-level. Admin gets the unscoped view; non-admin gets their
# own rows. Both paths are gated downstream; the table-grain check just
# needs to wave them through.
from connectors.internal.access import is_internal_table from connectors.internal.access import is_internal_table
if is_internal_table(table_id): if is_internal_table(table_id):
return True return True
@ -46,9 +83,25 @@ def can_access_table(
conn = get_system_db() conn = get_system_db()
should_close = True should_close = True
try: try:
from app.auth.access import can_access from app.auth.access import is_user_admin
if is_user_admin(user_id, conn):
return True
from app.services.stack_resolver import StackResolver
from app.resource_types import ResourceType from app.resource_types import ResourceType
return can_access(user_id, ResourceType.TABLE.value, table_id, conn) resolver = StackResolver(conn)
pkg_entries = resolver.stack(user_id, ResourceType.DATA_PACKAGE)
if not pkg_entries:
return False
pkg_ids = [e.id for e in pkg_entries]
placeholders = ",".join(["?"] * len(pkg_ids))
row = conn.execute(
f"""SELECT 1 FROM data_package_tables
WHERE package_id IN ({placeholders}) AND table_id = ?
LIMIT 1""",
[*pkg_ids, table_id],
).fetchone()
return bool(row)
finally: finally:
if should_close: if should_close:
conn.close() conn.close()
@ -58,7 +111,15 @@ def get_accessible_tables(
user: dict, user: dict,
conn: Optional[duckdb.DuckDBPyConnection] = None, conn: Optional[duckdb.DuckDBPyConnection] = None,
) -> Optional[list[str]]: ) -> Optional[list[str]]:
"""List of table IDs the user can read. ``None`` means "all" (admin).""" """List of table IDs the user can read. ``None`` means "all" (admin).
Stack-gated for analysts: the set is the union of
* internal tables (row-level RBAC at query time), and
* tables belonging to data packages in the user's stack
(required subscribed).
Per-table ``resource_grants(group, 'table', )`` rows are NO LONGER
consulted for analyst visibility see :func:`can_access_table`.
"""
user_id = user.get("id") user_id = user.get("id")
if not user_id: if not user_id:
return [] return []
@ -72,19 +133,21 @@ def get_accessible_tables(
if is_user_admin(user_id, conn): if is_user_admin(user_id, conn):
return None # admin sees everything return None # admin sees everything
# Non-admin: list every table_id with a matching grant via any group from app.services.stack_resolver import StackResolver
# the user belongs to. Single SQL — no Python-side filtering loop. from app.resource_types import ResourceType
# Internal tables are auto-granted (see can_access_table) — they're resolver = StackResolver(conn)
# always in every authenticated user's accessible set even without pkg_entries = resolver.stack(user_id, ResourceType.DATA_PACKAGE)
# a resource_grants row. result: list[str] = []
rows = conn.execute( if pkg_entries:
"""SELECT DISTINCT rg.resource_id pkg_ids = [e.id for e in pkg_entries]
FROM resource_grants rg placeholders = ",".join(["?"] * len(pkg_ids))
JOIN user_group_members m ON m.group_id = rg.group_id rows = conn.execute(
WHERE m.user_id = ? AND rg.resource_type = 'table'""", f"""SELECT DISTINCT table_id FROM data_package_tables
[user_id], WHERE package_id IN ({placeholders})""",
).fetchall() pkg_ids,
result = [r[0] for r in rows] ).fetchall()
result = [r[0] for r in rows]
# Internal tables — always accessible (row-level RBAC at query time).
from connectors.internal.access import INTERNAL_TABLES from connectors.internal.access import INTERNAL_TABLES
for t in INTERNAL_TABLES: for t in INTERNAL_TABLES:
if t.registry_id not in result: if t.registry_id not in result:

View file

@ -443,3 +443,91 @@ def stub_bq_extractor(monkeypatch):
lambda *a, **kw: MagicMock(), lambda *a, **kw: MagicMock(),
) )
return rebuild_mock return rebuild_mock
def grant_table_via_package(
conn,
table_id: str,
user_id: str,
*,
group_name: str = "analyst-pkg-grants",
requirement: str = "required",
) -> str:
"""Test helper — wrap a single table in an auto-named data_package and
grant the package to a custom group the user belongs to.
Replaces the legacy "per-table resource_grants" pattern: stack-gated
RBAC routes all analyst visibility through data_packages, so a
standalone TABLE grant no longer surfaces the table to the analyst.
Returns the data_package id so callers can revoke (DELETE package
tables_in_package + grants cascade) or assert membership.
Defaults to ``requirement='required'`` so the wrapping package
lands in the user's stack automatically — every existing test that
just asserted "table visible after grant" stays correct without
needing an explicit subscribe step.
"""
import uuid
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.data_packages import DataPackagesRepository
groups = UserGroupsRepository(conn)
grp = groups.get_by_name(group_name)
if not grp:
grp = groups.create(
name=group_name, description="test", created_by="test",
)
members = UserGroupMembersRepository(conn)
if not members.has_membership(user_id, grp["id"]):
members.add_member(
user_id, grp["id"], source="admin", added_by="test",
)
pkgs = DataPackagesRepository(conn)
pkg_slug = f"_test-pkg-{table_id.lower()}"[:63]
existing = pkgs.get_by_slug(pkg_slug) if hasattr(pkgs, "get_by_slug") else None
if existing:
pkg_id = existing["id"]
else:
pkg_id = pkgs.create(
name=f"Test wrap {table_id}", slug=pkg_slug,
description=None, icon=None, color=None,
created_by="test",
)
pkgs.add_table(pkg_id, table_id, added_by="test")
grants = ResourceGrantsRepository(conn)
if not grants.has_grant([grp["id"]], "data_package", pkg_id):
grants.create(
group_id=grp["id"],
resource_type="data_package",
resource_id=pkg_id,
assigned_by="test",
requirement=requirement,
)
return pkg_id
def revoke_table_via_package(conn, table_id: str) -> None:
"""Mirror of :func:`grant_table_via_package` — drops the wrapping
data_packages (and via FK cascade the junction + grants) for every
auto-package that wraps this table.
"""
rows = conn.execute(
"SELECT DISTINCT package_id FROM data_package_tables WHERE table_id = ?",
[table_id],
).fetchall()
for r in rows:
# Hard-delete via raw SQL so the test fixture doesn't leak rows
# across tests sharing the seeded_app DB.
conn.execute(
"DELETE FROM resource_grants "
"WHERE resource_type = 'data_package' AND resource_id = ?",
[r[0]],
)
conn.execute(
"DELETE FROM data_package_tables WHERE package_id = ?", [r[0]],
)
conn.execute("DELETE FROM data_packages WHERE id = ?", [r[0]])

View file

@ -285,22 +285,65 @@ def _mint_pat(server_url: str, email: str, password: str, *, name: str) -> str:
def _grant_table_access(web_session: httpx.Client, group_id: str, table_id: str) -> None: def _grant_table_access(web_session: httpx.Client, group_id: str, table_id: str) -> None:
"""POST /api/admin/grants for `(group, "table", table_id)`. """Stack-gated RBAC: wrap ``table_id`` in an auto-named data_package
and grant the package to ``group_id`` with ``requirement='required'``
so it lands in every group-member's stack automatically.
Idempotent: a 409 from the unique constraint is swallowed so the Per-table grants on ``resource_grants(group, 'table', )`` are no
fixture can be reused with a pre-existing grant. longer consulted for analyst visibility the unified-stack design
routes all analyst access through data_packages. This fixture
creates the wrapping package via admin APIs so the rest of the
bootstrap (manifest fetch, ``agnes pull``) behaves as if the
analyst had been granted the table directly.
Idempotent re-running the fixture against an already-wrapped
table reuses the existing package + grant.
""" """
resp = web_session.post( pkg_slug = f"_test-pkg-{table_id.lower()}"[:63]
pkg_name = f"Test wrap {table_id}"
# 1) Find or create the auto data_package.
list_resp = web_session.get("/api/admin/data-packages")
pkg_id: str | None = None
if list_resp.status_code == 200:
for p in list_resp.json():
if p.get("slug") == pkg_slug:
pkg_id = p["id"]
break
if pkg_id is None:
create_resp = web_session.post(
"/api/admin/data-packages",
json={"name": pkg_name, "slug": pkg_slug},
)
if create_resp.status_code not in (200, 201):
raise AssertionError(
f"pkg create failed: {create_resp.status_code} {create_resp.text[:300]}"
)
pkg_id = create_resp.json()["id"]
# 2) Attach the table (idempotent — 409 means already attached).
attach_resp = web_session.post(
f"/api/admin/data-packages/{pkg_id}/tables",
json={"table_id": table_id},
)
if attach_resp.status_code not in (200, 201, 204, 409):
raise AssertionError(
f"pkg attach failed: {attach_resp.status_code} {attach_resp.text[:300]}"
)
# 3) Grant the package to the group as required.
grant_resp = web_session.post(
"/api/admin/grants", "/api/admin/grants",
json={ json={
"group_id": group_id, "group_id": group_id,
"resource_type": "table", "resource_type": "data_package",
"resource_id": table_id, "resource_id": pkg_id,
"requirement": "required",
}, },
) )
if resp.status_code not in (201, 409): if grant_resp.status_code not in (201, 409):
raise AssertionError( raise AssertionError(
f"grant create failed: {resp.status_code} {resp.text[:300]}" f"grant create failed: {grant_resp.status_code} {grant_resp.text[:300]}"
) )

View file

@ -21,29 +21,26 @@ def _auth(token):
def _grant_table_to_analyst(conn, table_id: str, group_name: str = "analyst-grants") -> str: def _grant_table_to_analyst(conn, table_id: str, group_name: str = "analyst-grants") -> str:
"""Create (or reuse) a custom group, add analyst1 to it, mint a TABLE """Grant analyst1 access to ``table_id`` via a wrapping data_package.
grant on `table_id`. Returns the group_id so callers can revoke later."""
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.resource_grants import ResourceGrantsRepository
groups = UserGroupsRepository(conn) Stack-gated RBAC: per-table resource_grants no longer surface to
grp = groups.get_by_name(group_name) analysts every analyst visibility flows through a data_package in
if not grp: the user's stack. Delegates to the shared test helper which creates
grp = groups.create(name=group_name, description="test", created_by="test") the wrapping package + required grant.
members = UserGroupMembersRepository(conn) """
if not members.has_membership("analyst1", grp["id"]): from tests.conftest import grant_table_via_package
members.add_member("analyst1", grp["id"], source="admin", added_by="test") grant_table_via_package(
grants = ResourceGrantsRepository(conn) conn, table_id, "analyst1", group_name=group_name,
if not grants.has_grant([grp["id"]], "table", table_id): )
grants.create(group_id=grp["id"], resource_type="table", resource_id=table_id, from src.repositories.user_groups import UserGroupsRepository
assigned_by="test") return UserGroupsRepository(conn).get_by_name(group_name)["id"]
return grp["id"]
def _revoke_all_table_grants(conn, table_id: str) -> None: def _revoke_all_table_grants(conn, table_id: str) -> None:
from src.repositories.resource_grants import ResourceGrantsRepository """Revoke via dropping the wrapping data_package (cascade clears
ResourceGrantsRepository(conn).delete_by_resource("table", table_id) junction + grant)."""
from tests.conftest import revoke_table_via_package
revoke_table_via_package(conn, table_id)
class TestAdminBypass: class TestAdminBypass:

View file

@ -79,20 +79,12 @@ class TestCatalog:
json={"name": "granted_table", "source_type": "keboola"}, json={"name": "granted_table", "source_type": "keboola"},
headers=_h(client["admin"])) headers=_h(client["admin"]))
from src.db import get_system_db from src.db import get_system_db
from src.repositories.user_groups import UserGroupsRepository from tests.conftest import grant_table_via_package
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.resource_grants import ResourceGrantsRepository
conn = get_system_db() conn = get_system_db()
try: try:
grp = UserGroupsRepository(conn).create( grant_table_via_package(
name="api-complete-grant", description="t", created_by="t", conn, "granted_table", "analyst1",
) group_name="api-complete-grant",
UserGroupMembersRepository(conn).add_member(
"analyst1", grp["id"], source="admin", added_by="t",
)
ResourceGrantsRepository(conn).create(
group_id=grp["id"], resource_type="table", resource_id="granted_table",
assigned_by="t",
) )
finally: finally:
conn.close() conn.close()

View file

@ -3,27 +3,18 @@ from src.db import get_system_db
def _grant_table(conn, user_id: str, table_id: str) -> str: def _grant_table(conn, user_id: str, table_id: str) -> str:
"""Stack-gated RBAC: wrap table in auto data_package + grant package."""
from tests.conftest import grant_table_via_package
from src.repositories.user_groups import UserGroupsRepository from src.repositories.user_groups import UserGroupsRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.resource_grants import ResourceGrantsRepository from src.repositories.resource_grants import ResourceGrantsRepository
pkg_id = grant_table_via_package(
conn, table_id, user_id, group_name=f"dl-{user_id}",
)
grp = UserGroupsRepository(conn).get_by_name(f"dl-{user_id}") grp = UserGroupsRepository(conn).get_by_name(f"dl-{user_id}")
if not grp:
grp = UserGroupsRepository(conn).create(
name=f"dl-{user_id}", description="download-test", created_by="test",
)
members = UserGroupMembersRepository(conn)
if not members.has_membership(user_id, grp["id"]):
members.add_member(user_id, grp["id"], source="admin", added_by="test")
grants = ResourceGrantsRepository(conn)
if not grants.has_grant([grp["id"]], "table", table_id):
return grants.create(
group_id=grp["id"], resource_type="table", resource_id=table_id,
assigned_by="test",
)
existing = next( existing = next(
g for g in grants.list_for_groups([grp["id"]], "table") g for g in ResourceGrantsRepository(conn)
if g["resource_id"] == table_id .list_for_groups([grp["id"]], "data_package")
if g["resource_id"] == pkg_id
) )
return existing["id"] return existing["id"]

View file

@ -32,26 +32,10 @@ from src.db import get_system_db
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _grant_table(conn, user_id: str, table_id: str) -> None: def _grant_table(conn, user_id: str, table_id: str) -> None:
"""Grant user_id read access to table_id via a dedicated group.""" """Stack-gated RBAC: wrap table in auto data_package + grant package."""
from src.repositories.user_groups import UserGroupsRepository from tests.conftest import grant_table_via_package
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.resource_grants import ResourceGrantsRepository
grp_name = f"audit-e-{user_id}-{table_id}"[:60] grp_name = f"audit-e-{user_id}-{table_id}"[:60]
grp = UserGroupsRepository(conn).get_by_name(grp_name) grant_table_via_package(conn, table_id, user_id, group_name=grp_name)
if not grp:
grp = UserGroupsRepository(conn).create(
name=grp_name, description="audit-e-test", created_by="test",
)
members = UserGroupMembersRepository(conn)
if not members.has_membership(user_id, grp["id"]):
members.add_member(user_id, grp["id"], source="admin", added_by="test")
grants = ResourceGrantsRepository(conn)
if not grants.has_grant([grp["id"]], "table", table_id):
grants.create(
group_id=grp["id"], resource_type="table", resource_id=table_id,
assigned_by="test",
)
def _register_table(client, admin_hdrs, table_id, source_type="keboola", query_mode="local"): def _register_table(client, admin_hdrs, table_id, source_type="keboola", query_mode="local"):

View file

@ -195,9 +195,25 @@ def _add_member(conn, *, user_id: str, group_id: str) -> None:
def _grant_table(conn, *, group_id: str, table_id: str) -> None: def _grant_table(conn, *, group_id: str, table_id: str) -> None:
"""Stack-gated RBAC: wrap ``table_id`` in an auto data_package and
grant the package to ``group_id`` with ``requirement='required'``
so every user in the group has the package in their stack."""
from src.repositories.resource_grants import ResourceGrantsRepository from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.data_packages import DataPackagesRepository
pkgs = DataPackagesRepository(conn)
pkg_slug = f"_test-pkg-{table_id.lower()}"[:63]
existing = pkgs.get_by_slug(pkg_slug)
if existing:
pkg_id = existing["id"]
else:
pkg_id = pkgs.create(
name=f"Test wrap {table_id}", slug=pkg_slug,
description=None, icon=None, color=None, created_by="test",
)
pkgs.add_table(pkg_id, table_id, added_by="test")
ResourceGrantsRepository(conn).create( ResourceGrantsRepository(conn).create(
group_id=group_id, resource_type="table", resource_id=table_id group_id=group_id, resource_type="data_package", resource_id=pkg_id,
requirement="required",
) )

View file

@ -144,22 +144,14 @@ class TestRBACEnforcement:
"source_table": "orders", "query_mode": "local", "source_table": "orders", "query_mode": "local",
}, headers=_auth(admin_t)) }, headers=_auth(admin_t))
# Mint a TABLE grant for analyst1 # Stack-gated RBAC: wrap the table in an auto data_package + grant
# the package to a custom group analyst1 belongs to.
from src.db import get_system_db from src.db import get_system_db
from src.repositories.user_groups import UserGroupsRepository from tests.conftest import grant_table_via_package
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.resource_grants import ResourceGrantsRepository
conn = get_system_db() conn = get_system_db()
try: try:
grp = UserGroupsRepository(conn).create( grant_table_via_package(
name="e2e-analyst", description="t", created_by="t", conn, "orders", "analyst1", group_name="e2e-analyst",
)
UserGroupMembersRepository(conn).add_member(
"analyst1", grp["id"], source="admin", added_by="t",
)
ResourceGrantsRepository(conn).create(
group_id=grp["id"], resource_type="table", resource_id="orders",
assigned_by="t",
) )
finally: finally:
conn.close() conn.close()

View file

@ -15,26 +15,21 @@ def _auth(token: str) -> dict:
def _grant_table(conn, user_id: str, table_id: str) -> str: def _grant_table(conn, user_id: str, table_id: str) -> str:
from src.repositories.user_groups import UserGroupsRepository """Stack-gated RBAC: wrap the table in an auto data_package and
from src.repositories.user_group_members import UserGroupMembersRepository grant the package to a custom group the user is in. Returns the
grant id so callers that revoke by grant id continue to work.
"""
from tests.conftest import grant_table_via_package
from src.repositories.resource_grants import ResourceGrantsRepository from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.user_groups import UserGroupsRepository
pkg_id = grant_table_via_package(
conn, table_id, user_id, group_name=f"j-{user_id}",
)
grp = UserGroupsRepository(conn).get_by_name(f"j-{user_id}") grp = UserGroupsRepository(conn).get_by_name(f"j-{user_id}")
if not grp:
grp = UserGroupsRepository(conn).create(
name=f"j-{user_id}", description="journey", created_by="test",
)
members = UserGroupMembersRepository(conn)
if not members.has_membership(user_id, grp["id"]):
members.add_member(user_id, grp["id"], source="admin", added_by="test")
grants = ResourceGrantsRepository(conn)
if not grants.has_grant([grp["id"]], "table", table_id):
return grants.create(
group_id=grp["id"], resource_type="table", resource_id=table_id,
assigned_by="test",
)
existing = next( existing = next(
g for g in grants.list_for_groups([grp["id"]], "table") g for g in ResourceGrantsRepository(conn)
if g["resource_id"] == table_id .list_for_groups([grp["id"]], "data_package")
if g["resource_id"] == pkg_id
) )
return existing["id"] return existing["id"]

View file

@ -45,10 +45,23 @@ def setup_db(tmp_path, monkeypatch):
"INSERT INTO table_registry (id, name) VALUES (?, ?)", "INSERT INTO table_registry (id, name) VALUES (?, ?)",
["salaries", "salaries"], ["salaries", "salaries"],
) )
# Stack-gated RBAC: wrap 'orders' in an auto data_package and grant the
# package to the analysts group with required=true so it lands in the
# user's stack automatically. Per-table grants on resource_grants are
# no longer consulted for analyst visibility.
from src.repositories.data_packages import DataPackagesRepository
pkgs = DataPackagesRepository(conn)
pkg_id = pkgs.create(
name="orders-pkg", slug="orders-pkg",
description=None, icon=None, color=None,
created_by="test",
)
pkgs.add_table(pkg_id, "orders", added_by="test")
conn.execute( conn.execute(
"""INSERT INTO resource_grants (id, group_id, resource_type, resource_id) """INSERT INTO resource_grants
VALUES (?, ?, 'table', 'orders')""", (id, group_id, resource_type, resource_id, requirement)
[str(uuid.uuid4()), analysts["id"]], VALUES (?, ?, 'data_package', ?, 'required')""",
[str(uuid.uuid4()), analysts["id"], pkg_id],
) )
conn.close() conn.close()

View file

@ -0,0 +1,79 @@
"""Every CLI surface that gates by ``can_access_table`` returns the
SAME actionable 403 detail string when an analyst hits a table not in
their stack.
Stack-gated RBAC removed per-table ``resource_grants`` as a visibility
path for analysts. The new failure mode analyst queries a table that
isn't in any data package they've subscribed to must surface as a
consistent, copy-able error so the user knows to ask an admin to wrap
the table in a Data Package.
This test fans out across the four CLI-facing endpoints that all hit
``can_access_table``:
* GET /api/data/{table_id}/download
* POST /api/data/{table_id}/check-access
* POST /api/v2/sample
* POST /api/v2/schema
plus the in-process helper ``src.rbac.table_not_in_stack_message`` that
all of them route through.
"""
from __future__ import annotations
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _register_table(client, admin_token: str, table_id: str) -> None:
"""Admin registers a table WITHOUT wrapping it in any data_package
so every analyst-side gate fires."""
r = client.post(
"/api/admin/register-table",
json={
"name": table_id, "source_type": "keboola",
"query_mode": "local",
},
headers=_auth(admin_token),
)
assert r.status_code in (200, 201, 409), r.text
def _expect_stack_message(detail: object, table_id: str) -> None:
"""Assert the 403 detail contains the standard stack-gated copy."""
if isinstance(detail, dict):
detail = detail.get("detail") or detail.get("message") or ""
detail = str(detail)
assert table_id in detail, f"missing table id in 403 detail: {detail!r}"
assert "stack" in detail.lower() or "data package" in detail.lower(), (
f"403 detail must mention stack / Data Package — got {detail!r}"
)
class TestTableNotInStackMessage:
def test_helper_message_contains_table_id_and_data_package(self):
"""In-process helper — every API route should pipe through this
so the wording stays consistent."""
from src.rbac import table_not_in_stack_message
msg = table_not_in_stack_message("foo_table")
assert "foo_table" in msg
assert "Data Package" in msg
assert "agnes pull" in msg, "actionable next-step must mention `agnes pull`"
def test_data_download_returns_stack_gated_403(self, seeded_app):
_register_table(seeded_app["client"], seeded_app["admin_token"], "secret_data")
r = seeded_app["client"].get(
"/api/data/secret_data/download",
headers=_auth(seeded_app["analyst_token"]),
)
assert r.status_code == 403
_expect_stack_message(r.json().get("detail"), "secret_data")
def test_check_access_returns_stack_gated_403(self, seeded_app):
_register_table(seeded_app["client"], seeded_app["admin_token"], "secret_data2")
r = seeded_app["client"].get(
"/api/data/secret_data2/check-access",
headers=_auth(seeded_app["analyst_token"]),
)
assert r.status_code == 403
_expect_stack_message(r.json().get("detail"), "secret_data2")

View file

@ -134,3 +134,43 @@ class TestCatalogUnifiedPage:
body = resp.text body = resp.text
# Available + not subscribed → Add button with data-action="add". # Available + not subscribed → Add button with data-action="add".
assert 'data-action="add"' in body assert 'data-action="add"' in body
def test_required_packages_render_before_available_ones(self, seeded_app):
"""Browse grid groups Required cards first (first-demo feedback).
Three packages: two available + one required. The required card
must come BEFORE the available ones in the rendered HTML so it
clusters at the top of the grid instead of being interleaved by
creation order.
"""
# Seed in deliberately-wrong order (available first) so the sort
# has something to undo.
avail_pkg = _make_pkg("a-avail", "AAA Available")
req_pkg = _make_pkg("z-req", "ZZZ Required")
avail_pkg_2 = _make_pkg("m-avail", "MMM Available")
_grant("Everyone", "data_package", avail_pkg,
requirement="available", users=["analyst1"])
_grant("Everyone", "data_package", req_pkg,
requirement="required", users=["analyst1"])
_grant("Everyone", "data_package", avail_pkg_2,
requirement="available", users=["analyst1"])
resp = seeded_app["client"].get(
"/catalog", headers=_auth(seeded_app["analyst_token"]),
)
body = resp.text
# The required-grant card must appear earlier in the document
# than either available card — independent of creation order or
# alphabetical name ordering.
i_req = body.find('data-id="' + req_pkg + '"')
i_a1 = body.find('data-id="' + avail_pkg + '"')
i_a2 = body.find('data-id="' + avail_pkg_2 + '"')
assert i_req != -1 and i_a1 != -1 and i_a2 != -1
assert i_req < i_a1, (
"Required card must render before available card 'AAA' "
f"(req@{i_req}, avail@{i_a1})"
)
assert i_req < i_a2, (
"Required card must render before available card 'MMM' "
f"(req@{i_req}, avail@{i_a2})"
)