* 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
176 lines
7 KiB
Python
176 lines
7 KiB
Python
"""GET /catalog — unified Browse / My Stack card grid (Task 8.2 of v49 plan).
|
|
|
|
The page replaces the old per-source-card layout with marketplace.html
|
|
parity: hero + tab strip + filter chips + search + card grid using the
|
|
shared `_stack_card.html` macro. Per-table drill-down moves into
|
|
/catalog/p/<slug> (Task 8.3).
|
|
|
|
These tests render the page with seeded users + grants and assert the
|
|
new structure (tabs, chips, cards, empty banner) without asserting
|
|
on legacy markup.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
def _auth(token: str) -> dict:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _make_pkg(slug: str = "sales-bundle", name: str = "Sales bundle"):
|
|
"""Create a data package and return its id."""
|
|
from src.db import get_system_db
|
|
from src.repositories.data_packages import DataPackagesRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
pkg_id = DataPackagesRepository(conn).create(
|
|
name=name,
|
|
slug=slug,
|
|
description=f"{name} desc",
|
|
icon="📦",
|
|
color="#fce7f3",
|
|
created_by="test",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
return pkg_id
|
|
|
|
|
|
def _grant(group_name: str, resource_type: str, resource_id: str,
|
|
requirement: str = "available", users: list[str] | None = None):
|
|
"""Add a resource_grants row for the named user-group.
|
|
|
|
Also ensure ``users`` (typically the test's analyst id) are members of
|
|
the group so the resolver picks up the grant — seeded_app puts only
|
|
admin1 in the Admin group; everybody else has zero memberships by
|
|
default in the test fixture.
|
|
"""
|
|
import uuid
|
|
from src.db import get_system_db
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [group_name]
|
|
).fetchone()
|
|
if not gid:
|
|
return
|
|
group_id = gid[0]
|
|
if users:
|
|
members = UserGroupMembersRepository(conn)
|
|
for u in users:
|
|
try:
|
|
members.add_member(u, group_id, source="test")
|
|
except Exception:
|
|
pass
|
|
conn.execute(
|
|
"INSERT INTO resource_grants(id, group_id, resource_type, resource_id, "
|
|
"requirement, assigned_at, assigned_by) "
|
|
"VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 'test')",
|
|
[str(uuid.uuid4()), group_id, resource_type, resource_id, requirement],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
class TestCatalogUnifiedPage:
|
|
def test_admin_sees_hero_and_tabs(self, seeded_app):
|
|
"""Hero + tab strip (Browse / My Stack) + filter chips + grid container
|
|
all render for admin (who sees every package via god-mode)."""
|
|
_make_pkg("admin-test-pkg-1", "Sales bundle")
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
resp = c.get("/catalog", headers=_auth(token))
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
# Hero + tab strip mirrors marketplace.html structure.
|
|
assert "Data Packages" in body
|
|
# Browse / My Stack tabs.
|
|
assert "Browse" in body
|
|
assert "My Stack" in body
|
|
# Filter chips.
|
|
assert "All" in body
|
|
assert "Required" in body
|
|
# Grid container present (rendered even if empty).
|
|
assert "stack-grid" in body or "stack-empty" in body
|
|
|
|
def test_analyst_with_required_grant_sees_package_card(self, seeded_app):
|
|
"""Required grant for the analyst's Everyone group surfaces the
|
|
package on Browse with the Required badge."""
|
|
pkg_id = _make_pkg("eng-bundle", "Engineering bundle")
|
|
_grant("Everyone", "data_package", pkg_id, requirement="required",
|
|
users=["analyst1"])
|
|
c = seeded_app["client"]
|
|
token = seeded_app["analyst_token"]
|
|
resp = c.get("/catalog", headers=_auth(token))
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
assert "Engineering bundle" in body
|
|
# Required state.
|
|
assert "is-required" in body
|
|
|
|
def test_analyst_no_grants_sees_empty_state_banner(self, seeded_app):
|
|
"""Without any data_package grant, the analyst lands on the empty
|
|
banner — no cards, explicit CTA."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["analyst_token"]
|
|
resp = c.get("/catalog", headers=_auth(token))
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
# Empty banner copy hints at the admin-grant path.
|
|
assert "ask your admin" in body.lower() or "No data packages" in body
|
|
|
|
def test_card_buttons_carry_data_action_attrs(self, seeded_app):
|
|
"""JS wiring for Add/Remove rides on data-action attributes."""
|
|
pkg_id = _make_pkg("avail-pkg", "Available pkg")
|
|
_grant("Everyone", "data_package", pkg_id, requirement="available",
|
|
users=["analyst1"])
|
|
c = seeded_app["client"]
|
|
token = seeded_app["analyst_token"]
|
|
resp = c.get("/catalog", headers=_auth(token))
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
# Available + not subscribed → Add button with data-action="add".
|
|
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})"
|
|
)
|