* feat(rbac): drop dataset_permissions + access_requests + users.role + is_public; v19 migration
BREAKING. Sjednocení datové RBAC vrstvy do per-group resource_grants modelu.
Před PR byla legacy data RBAC vrstva (dataset_permissions + is_public bypass)
de-facto neaktivní — is_public neměl API/UI/CLI surface, default true znamenal
že can_access_table vždycky bypassl. Dnes každý non-admin přístup vyžaduje
explicitní resource_grants(group, "table", id) řádek.
Schema v18 → v19 (src/db.py:_v18_to_v19_finalize):
- DROP TABLE dataset_permissions, access_requests
- DROP COLUMN users.role (NULL artifact since v13)
- DROP COLUMN table_registry.is_public
- Drops přes table-rebuild idiom (rename → create new → INSERT … SELECT
→ drop old) kvůli DuckDB ALTER DROP COLUMN limitacím na tabulkách
s historic FK constraints. INSERT picks intersection sloupců, takže
test fixtures s minimal pre-v19 schemou migrate cleanly.
Runtime:
- src/rbac.py:can_access_table → deleguje na app.auth.access.can_access
- DatasetPermissionRepository, AccessRequestRepository smazány
- AGNES_ENABLE_TABLE_GRANTS env-gate v app/resource_types.py odstraněn
(TABLE je unconditionally enabled)
API drop:
- app/api/permissions.py, app/api/access_requests.py celé soubory
- /admin/permissions web route + admin_permissions.html
- "Request Access" modal v catalog.html + locked-row UI
- ~10 if user.get("role") != "admin" checků nahrazeno (admin shortcut
je uvnitř can_access_table)
- /api/settings: drop permissions field z GET; PUT /api/settings/dataset
gate přepnut na can_access(user_id, "table", dataset, conn)
Auth:
- app/auth/jwt.py:create_access_token: drop role parametr (claim zmizí
z nově vydávaných JWT; staré tokeny zůstávají valid, claim ignored)
- app/api/users.py: drop role z CreateUserRequest / UpdateUserRequest
(admin promotion = explicit add to Admin group via memberships API)
- src/repositories/users.py: drop role z create() / update()
CLI:
- da admin set-role smazán → hard-fail s replacement command
- da admin add-user --role flag pryč
- da auth import-token --role flag pryč
- da auth whoami: drop "Role:" výpis
- cli/config.py:save_token: role parametr now optional, no longer written
(back-compat se starými token.json soubory zachována — pole se ignoruje)
Tests:
- DELETE: test_permissions.py, test_permissions_api.py, test_access_requests_api.py
- REWRITE: test_access_control.py (resource_grants flow), test_rbac.py
(can_access_table over resource_grants), test_journey_rbac.py
(drop access-request flow), test_resource_types.py (drop env-gate
tests, drop is_public from helpers), test_v2_*.py (drop role-based
user dicts in favor of id-based + Admin group membership),
test_settings_api.py (no permissions field, can_access gate)
- TRIVIAL: ~30 souborů — drop role="admin" arg z UserRepository.create
a 3rd positional role z create_access_token
- NEW: test_v18_to_v19 migration test (test_db.py),
test_can_access_table_no_implicit_public (test_rbac.py),
test_admin_set_role_returns_hardfail (test_cli_admin.py)
- OpenAPI snapshot regenerated
Docs:
- CHANGELOG: BREAKING entry pod [Unreleased]
- CLAUDE.md: schema v18 → v19
- docs/architecture.md: schema table + RBAC sekce přepsána
- docs/auth-google-oauth.md: admin promotion přes da admin break-glass
- cli/skills/security.md: kompletně přepsáno na group-based model
- docs/TODO-rbac-data-enforcement.md: smazáno (TODO splněn)
Test results: 2363 passed, 19 failed. Zbývající failures jsou pre-existing
Windows-specific issues (fcntl, charset) nesouvisející s tímto PR —
ověřeno git stash pop.
Plan: ~/.claude/plans/floofy-coalescing-parnas.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): cut 0.27.0
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
699 lines
27 KiB
Python
699 lines
27 KiB
Python
"""Tests for /api/admin/groups origin + mapped_email surface.
|
|
|
|
Covers the admin-UI rule: when AGNES_GROUP_ADMIN_EMAIL /
|
|
AGNES_GROUP_EVERYONE_EMAIL map a Workspace group onto the seeded Admin /
|
|
Everyone system row, the row carries:
|
|
|
|
- ``origin = 'google_sync'`` (the seed badge is suppressed —
|
|
Workspace is the authoritative source for membership)
|
|
- ``mapped_email`` = the Workspace group email
|
|
|
|
so the list / detail templates can render `Admin / admins@workspace.test`
|
|
with a green `google_sync` chip instead of `Admin / Admin` with the
|
|
yellow system chip. Without the env mapping, the same row stays a plain
|
|
`'system'` with no mapped_email.
|
|
"""
|
|
|
|
import tempfile
|
|
import uuid
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_db(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
monkeypatch.setenv("DATA_DIR", tmp)
|
|
monkeypatch.setenv("TESTING", "1")
|
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
|
|
from src.db import close_system_db
|
|
close_system_db()
|
|
yield tmp
|
|
close_system_db()
|
|
|
|
|
|
def _seed_admin():
|
|
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.users import UserRepository
|
|
from app.auth.jwt import create_access_token
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
uid = str(uuid.uuid4())
|
|
UserRepository(conn).create(id=uid, email="admin@test", name="Admin")
|
|
admin_gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
|
|
).fetchone()[0]
|
|
UserGroupMembersRepository(conn).add_member(uid, admin_gid, source="system_seed")
|
|
return uid, create_access_token(user_id=uid, email="admin@test")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _groups_by_name(client: TestClient, token: str) -> dict:
|
|
"""Fetch /api/admin/groups, return {name: row} for assertion brevity."""
|
|
resp = client.get("/api/admin/groups", headers={"Authorization": f"Bearer {token}"})
|
|
assert resp.status_code == 200, resp.text
|
|
return {g["name"]: g for g in resp.json()}
|
|
|
|
|
|
def test_admin_row_origin_is_google_sync_when_env_mapped(fresh_db, monkeypatch):
|
|
"""When AGNES_GROUP_ADMIN_EMAIL is set, the seeded Admin row reports
|
|
origin='google_sync' — the system badge is suppressed because
|
|
Workspace is the authoritative source of membership for this row."""
|
|
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
groups = _groups_by_name(client, token)
|
|
admin = groups["Admin"]
|
|
|
|
assert admin["origin"] == "google_sync"
|
|
assert admin["mapped_email"] == "admins@workspace.test"
|
|
assert admin["is_google_managed"] is True
|
|
|
|
|
|
def test_everyone_row_origin_is_google_sync_when_env_mapped(fresh_db, monkeypatch):
|
|
monkeypatch.setenv("AGNES_GROUP_EVERYONE_EMAIL", "everyone@workspace.test")
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
groups = _groups_by_name(client, token)
|
|
everyone = groups["Everyone"]
|
|
|
|
assert everyone["origin"] == "google_sync"
|
|
assert everyone["mapped_email"] == "everyone@workspace.test"
|
|
assert everyone["is_google_managed"] is True
|
|
|
|
|
|
def test_admin_row_is_plain_system_without_env_mapping(fresh_db):
|
|
"""Without AGNES_GROUP_ADMIN_EMAIL set, the seeded Admin row is just a
|
|
regular system row — system chip, no mapped_email."""
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
groups = _groups_by_name(client, token)
|
|
admin = groups["Admin"]
|
|
|
|
assert admin["origin"] == "system"
|
|
assert admin["mapped_email"] is None
|
|
assert admin["is_google_managed"] is False
|
|
|
|
|
|
def test_user_created_google_sync_group_origin(fresh_db):
|
|
"""A Workspace-derived group whose `name` is the email itself reports
|
|
origin='google_sync' and has null mapped_email — the email is already
|
|
the canonical name."""
|
|
from app.main import app
|
|
from src.db import get_system_db
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
UserGroupsRepository(conn).create(
|
|
name="finance@workspace.test",
|
|
created_by="system:google-sync",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
groups = _groups_by_name(client, token)
|
|
g = groups["finance@workspace.test"]
|
|
|
|
assert g["origin"] == "google_sync"
|
|
assert g["mapped_email"] is None
|
|
assert g["is_google_managed"] is True
|
|
|
|
|
|
def test_admin_created_custom_group_origin(fresh_db):
|
|
"""Admin-created groups report origin='custom' — the value is named
|
|
after the *origin* of the row, not the creator's role, so the chip
|
|
doesn't visually clash with the seeded `Admin` system group."""
|
|
from app.main import app
|
|
from src.db import get_system_db
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
UserGroupsRepository(conn).create(name="data-team", created_by="admin@test")
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
groups = _groups_by_name(client, token)
|
|
g = groups["data-team"]
|
|
|
|
assert g["origin"] == "custom"
|
|
assert g["mapped_email"] is None
|
|
assert g["is_google_managed"] is False
|
|
|
|
|
|
# ── UI ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_admin_groups_template_uses_mapped_email_in_subtitle(fresh_db):
|
|
"""List view JS must consult `g.mapped_email` for the subtitle so
|
|
mapped Admin/Everyone show the Workspace email under the canonical
|
|
name instead of `Admin / Admin`."""
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get(
|
|
"/admin/groups",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
assert "g.mapped_email" in body
|
|
|
|
|
|
def test_access_overview_returns_origin_and_mapped_email(fresh_db, monkeypatch):
|
|
"""`/api/admin/access-overview` powers the /admin/access sidebar; the
|
|
groups payload must carry the same origin / mapped_email / is_google_managed
|
|
fields the dedicated /api/admin/groups endpoint exposes, so the sidebar
|
|
can render the identical pill + subtitle treatment."""
|
|
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
|
|
resp = client.get(
|
|
"/api/admin/access-overview",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
by_name = {g["name"]: g for g in data["groups"]}
|
|
|
|
admin = by_name["Admin"]
|
|
assert admin["origin"] == "google_sync"
|
|
assert admin["mapped_email"] == "admins@workspace.test"
|
|
assert admin["is_google_managed"] is True
|
|
|
|
everyone = by_name["Everyone"]
|
|
assert everyone["origin"] == "system"
|
|
assert everyone["mapped_email"] is None
|
|
assert everyone["is_google_managed"] is False
|
|
|
|
|
|
def test_admin_access_template_renders_origin_pill_and_mapped_email(fresh_db, monkeypatch):
|
|
"""The /admin/access page JS must read `origin` / `mapped_email` from
|
|
each group so the sidebar gets the same pill + subtitle as
|
|
/admin/groups. Pin the JS contract so a renderer regression that
|
|
drops the consult on these fields fails CI."""
|
|
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get(
|
|
"/admin/access",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
# JS reads these fields per group when rendering the sidebar.
|
|
assert "g.origin" in body
|
|
assert "g.mapped_email" in body
|
|
assert "g.is_google_managed" in body
|
|
# Origin chip CSS classes (multi-color) must be present so the pill renders.
|
|
assert ".origin-google_sync" in body
|
|
assert ".origin-system" in body
|
|
assert ".origin-custom" in body
|
|
|
|
|
|
def test_user_groups_payload_carries_origin(fresh_db, monkeypatch):
|
|
"""`/api/users` returns each membership chip's origin so the user-list
|
|
page can color the pill (yellow / gray / green / purple) without a
|
|
second fetch."""
|
|
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
|
|
from app.main import app
|
|
from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP, get_system_db
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
from src.repositories.users import UserRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
# Target user belongs to: Admin (mapped → google_sync), Everyone
|
|
# (system, unmapped), data-team (custom), eng@workspace.test (google_sync).
|
|
ug_repo = UserGroupsRepository(conn)
|
|
custom_g = ug_repo.create(name="data-team", created_by="admin@test")
|
|
gsync_g = ug_repo.create(name="eng@workspace.test", created_by="system:google-sync")
|
|
admin_gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
|
|
).fetchone()[0]
|
|
everyone_gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_EVERYONE_GROUP]
|
|
).fetchone()[0]
|
|
target_uid = str(uuid.uuid4())
|
|
UserRepository(conn).create(
|
|
id=target_uid, email="t@test", name="T",
|
|
)
|
|
members = UserGroupMembersRepository(conn)
|
|
members.add_member(target_uid, admin_gid, source="google_sync")
|
|
members.add_member(target_uid, everyone_gid, source="admin")
|
|
members.add_member(target_uid, custom_g["id"], source="admin")
|
|
members.add_member(target_uid, gsync_g["id"], source="google_sync")
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get("/api/users", headers={"Authorization": f"Bearer {token}"})
|
|
assert resp.status_code == 200
|
|
target = next(u for u in resp.json() if u["id"] == target_uid)
|
|
by_name = {g["name"]: g for g in target["groups"]}
|
|
|
|
# Admin row is env-mapped → origin='google_sync' (matches /api/admin/groups).
|
|
assert by_name["Admin"]["origin"] == "google_sync"
|
|
# Everyone has no env mapping → stays 'system'.
|
|
assert by_name["Everyone"]["origin"] == "system"
|
|
# Custom + google-sync user-created groups carry their respective tags.
|
|
assert by_name["data-team"]["origin"] == "custom"
|
|
assert by_name["eng@workspace.test"]["origin"] == "google_sync"
|
|
|
|
|
|
def test_user_memberships_payload_carries_origin(fresh_db, monkeypatch):
|
|
"""`/api/admin/users/{id}/memberships` must carry `origin` so the
|
|
user detail page can color-code the membership chips identically to
|
|
the user list."""
|
|
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
|
|
from app.main import app
|
|
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
from src.repositories.users import UserRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
ug_repo = UserGroupsRepository(conn)
|
|
custom_g = ug_repo.create(name="data-team", created_by="admin@test")
|
|
gsync_g = ug_repo.create(name="legal@workspace.test", created_by="system:google-sync")
|
|
admin_gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
|
|
).fetchone()[0]
|
|
target_uid = str(uuid.uuid4())
|
|
UserRepository(conn).create(
|
|
id=target_uid, email="t@test", name="T",
|
|
)
|
|
members = UserGroupMembersRepository(conn)
|
|
members.add_member(target_uid, admin_gid, source="google_sync")
|
|
members.add_member(target_uid, custom_g["id"], source="admin")
|
|
members.add_member(target_uid, gsync_g["id"], source="google_sync")
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get(
|
|
f"/api/admin/users/{target_uid}/memberships",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
by_name = {m["group_name"]: m for m in resp.json()}
|
|
|
|
# env-mapped Admin → google_sync (matches /api/admin/groups behavior)
|
|
assert by_name["Admin"]["origin"] == "google_sync"
|
|
assert by_name["data-team"]["origin"] == "custom"
|
|
assert by_name["legal@workspace.test"]["origin"] == "google_sync"
|
|
|
|
|
|
def test_add_user_to_group_response_carries_origin(fresh_db):
|
|
"""POST /api/admin/users/{id}/memberships must compute `origin` the
|
|
same way GET does. Without this, any caller relying on the POST
|
|
response (or rendering the chip optimistically before the GET
|
|
re-fetch) sees `'custom'` even when adding to the seeded Admin /
|
|
Everyone system rows.
|
|
"""
|
|
from app.main import app
|
|
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
from src.repositories.users import UserRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
admin_gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
|
|
).fetchone()[0]
|
|
custom_g = UserGroupsRepository(conn).create(
|
|
name="data-team", created_by="admin@test",
|
|
)
|
|
target_uid = str(uuid.uuid4())
|
|
UserRepository(conn).create(
|
|
id=target_uid, email="t@test", name="T",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
# System group (no env mapping) → origin must be 'system', not the
|
|
# default 'custom'.
|
|
resp = client.post(
|
|
f"/api/admin/users/{target_uid}/memberships",
|
|
headers=headers,
|
|
json={"group_id": admin_gid},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
assert resp.json()["origin"] == "system"
|
|
|
|
# Custom admin-created group → origin stays 'custom'.
|
|
resp = client.post(
|
|
f"/api/admin/users/{target_uid}/memberships",
|
|
headers=headers,
|
|
json={"group_id": custom_g["id"]},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
assert resp.json()["origin"] == "custom"
|
|
|
|
|
|
def test_effective_access_lists_explicit_grants_for_admin_user(fresh_db):
|
|
"""`/api/admin/users/{id}/effective-access` no longer short-circuits
|
|
for admins — they get the same per-resource breakdown as everyone
|
|
else, so an operator auditing a target user can see precisely which
|
|
grants the Admin group carries via which group, instead of a flat
|
|
"Full access" pill that hides the wiring. Authorization at runtime
|
|
still gives Admin god-mode regardless of this list."""
|
|
from app.main import app
|
|
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
|
|
from src.repositories.resource_grants import ResourceGrantsRepository
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
from src.repositories.users import UserRepository
|
|
|
|
# Set up: a target admin user belongs to the Admin group AND a
|
|
# custom "data-team" group. The Admin group has no explicit grants;
|
|
# data-team has one. The endpoint should list the data-team grant.
|
|
conn = get_system_db()
|
|
try:
|
|
admin_gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
|
|
).fetchone()[0]
|
|
custom_g = UserGroupsRepository(conn).create(
|
|
name="data-team", created_by="admin@test",
|
|
)
|
|
target_uid = str(uuid.uuid4())
|
|
UserRepository(conn).create(
|
|
id=target_uid, email="t-admin@test", name="T",
|
|
)
|
|
members = UserGroupMembersRepository(conn)
|
|
members.add_member(target_uid, admin_gid, source="admin")
|
|
members.add_member(target_uid, custom_g["id"], source="admin")
|
|
ResourceGrantsRepository(conn).create(
|
|
group_id=custom_g["id"],
|
|
resource_type="plugin",
|
|
resource_id="agnes/foo",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get(
|
|
f"/api/admin/users/{target_uid}/effective-access",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
# is_admin still reflects reality; the UI just doesn't short-circuit on it.
|
|
assert data["is_admin"] is True
|
|
# The actual grant list is no longer empty — `data-team` carries one.
|
|
rids = [it["resource_id"] for it in data["items"]]
|
|
assert "agnes/foo" in rids
|
|
|
|
|
|
def test_profile_template_renders_color_coded_membership_chips(fresh_db, monkeypatch):
|
|
"""The /profile page must render group memberships with the same
|
|
chip vocabulary used on the user list / detail pages: a colored
|
|
.group-chip with class derived from name (Admin / Everyone) first
|
|
and origin (google_sync / custom) second. google_sync chip text is
|
|
shortened via the prefix-strip logic and the raw email sits on the
|
|
chip's title attribute for hover reveal."""
|
|
monkeypatch.setenv("AGNES_GOOGLE_GROUP_PREFIX", "grp_acme_")
|
|
from app.main import app
|
|
from src.db import get_system_db
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
|
|
admin_uid, token = _seed_admin()
|
|
conn = get_system_db()
|
|
try:
|
|
# Add the seeded admin to a Workspace-derived group so the
|
|
# rendered profile page actually has a green chip we can grep
|
|
# for. The chip text should be "Legal" (prefix stripped,
|
|
# capitalized); the title attribute should keep the raw email.
|
|
ug_repo = UserGroupsRepository(conn)
|
|
gsync = ug_repo.create(
|
|
name="grp_acme_legal@workspace.test",
|
|
created_by="system:google-sync",
|
|
)
|
|
UserGroupMembersRepository(conn).add_member(
|
|
admin_uid, gsync["id"], source="google_sync",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
resp = client.get(
|
|
"/profile",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
# Chip CSS classes from the shared vocabulary.
|
|
assert ".group-chip.is-admin" in body
|
|
assert ".group-chip.is-google_sync" in body
|
|
assert ".group-chip.is-custom" in body
|
|
# Admin row gets the canonical-name chip class (server-side rendered).
|
|
assert 'class="group-chip is-admin"' in body
|
|
# Workspace-derived group's chip text is the shortened display name;
|
|
# the raw email lives in the title attribute for hover reveal.
|
|
assert ">Legal<" in body
|
|
assert 'title="grp_acme_legal@workspace.test"' in body
|
|
|
|
|
|
def test_my_effective_access_lists_explicit_grants_for_admin_user(fresh_db):
|
|
"""`/api/me/effective-access` (the /profile page surface) mirrors
|
|
/api/admin/users/{id}/effective-access — admins see their explicit
|
|
grant breakdown rather than a flat "Full access" short-circuit. Same
|
|
rationale as the admin-side endpoint: audit the grant graph, not the
|
|
runtime god-mode."""
|
|
from app.main import app
|
|
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
|
|
from src.repositories.resource_grants import ResourceGrantsRepository
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
|
|
# _seed_admin already adds the admin to the Admin group; we layer on
|
|
# a custom group with one grant so the response actually has items.
|
|
admin_uid, token = _seed_admin()
|
|
conn = get_system_db()
|
|
try:
|
|
custom_g = UserGroupsRepository(conn).create(
|
|
name="data-team", created_by="admin@test",
|
|
)
|
|
UserGroupMembersRepository(conn).add_member(
|
|
admin_uid, custom_g["id"], source="admin",
|
|
)
|
|
ResourceGrantsRepository(conn).create(
|
|
group_id=custom_g["id"],
|
|
resource_type="plugin",
|
|
resource_id="agnes/foo",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
resp = client.get(
|
|
"/api/me/effective-access",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert data["is_admin"] is True
|
|
rids = [it["resource_id"] for it in data["items"]]
|
|
assert "agnes/foo" in rids
|
|
|
|
|
|
def test_profile_template_drops_full_access_pill(fresh_db):
|
|
"""The /profile page no longer renders the gold "Full access via
|
|
Admin" empty-state for admin users — it should fall through to the
|
|
grant list (or the generic "no resource access" message). Pinning
|
|
the absence of the old branch."""
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get(
|
|
"/profile",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
assert "Full access via Admin" not in body
|
|
assert "You can read and write everything in Agnes regardless" not in body
|
|
|
|
|
|
def test_admin_user_detail_template_drops_full_access_pill(fresh_db):
|
|
"""The `ea-admin-pill` short-circuit branch is gone — a regression
|
|
that re-adds a special-case render for admins would slip through if
|
|
we don't pin its absence."""
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
target_uid = _create_user("v3@test")
|
|
resp = client.get(
|
|
f"/admin/users/{target_uid}",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
# No longer references the "Full access via the Admin group" branch.
|
|
assert "Full access via the Admin group" not in body
|
|
assert "ea-admin-pill" not in body
|
|
|
|
|
|
def test_user_detail_dropdown_hides_google_managed_groups(fresh_db):
|
|
"""The "Add to group" dropdown on /admin/users/{id} must skip any
|
|
row with `is_google_managed=true` — membership for those groups is
|
|
owned by Workspace and the API 409s on POST anyway. Pin the JS
|
|
contract so a regression that drops the filter (and floods the
|
|
picker with un-grantable options) surfaces in CI."""
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
target_uid = _create_user("victim2@test")
|
|
resp = client.get(
|
|
f"/admin/users/{target_uid}",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
# The filter sits inside the picker-population loop.
|
|
assert "g.is_google_managed" in body
|
|
|
|
|
|
def test_admin_user_detail_template_uses_color_coded_chips(fresh_db):
|
|
"""Detail page must declare the same chip CSS classes + reference
|
|
`m.origin` and `deriveDisplayName` in the membership renderer so a
|
|
regression that drops the rebuild surfaces in CI."""
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
target_uid = _create_user("victim@test")
|
|
resp = client.get(
|
|
f"/admin/users/{target_uid}",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
# Color classes match the user list's chip vocabulary.
|
|
assert ".group-chip.is-admin" in body
|
|
assert ".group-chip.is-everyone" in body
|
|
assert ".group-chip.is-google_sync" in body
|
|
assert ".group-chip.is-custom" in body
|
|
# JS reads m.origin to pick the chip class.
|
|
assert "m.origin" in body
|
|
# google_sync chip text runs through deriveDisplayName.
|
|
assert "deriveDisplayName" in body
|
|
|
|
|
|
def _create_user(email: str) -> str:
|
|
"""Inline helper for the membership UI test — not reused above."""
|
|
import uuid as _uuid
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
uid = str(_uuid.uuid4())
|
|
UserRepository(conn).create(id=uid, email=email, name="V")
|
|
return uid
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_admin_users_template_renders_color_coded_chips(fresh_db):
|
|
"""Pin the JS contract: the user list assigns chip classes based on
|
|
name (Admin / Everyone) first and falls back to `is-${origin}` so
|
|
google_sync chips go green and custom chips go purple. A renderer
|
|
regression that drops the consult on g.origin would surface here.
|
|
Also pin the deriveDisplayName shortening for google-sync chips —
|
|
they must show "Legal" rather than the raw Workspace email so the
|
|
membership cell stays readable."""
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get(
|
|
"/admin/users",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
# The four chip-color classes that style the pills.
|
|
assert ".group-chip.is-admin" in body
|
|
assert ".group-chip.is-everyone" in body
|
|
assert ".group-chip.is-google_sync" in body
|
|
assert ".group-chip.is-custom" in body
|
|
# JS reads g.origin to pick the class for non-Admin / non-Everyone rows.
|
|
assert "g.origin" in body
|
|
# google_sync chips run their name through deriveDisplayName so the
|
|
# cell shows "Legal" rather than the full Workspace email; the raw
|
|
# email goes into the chip's `title` (hover reveal).
|
|
assert "deriveDisplayName" in body
|
|
|
|
|
|
def test_admin_group_detail_template_uses_mapped_email_subtitle(fresh_db, monkeypatch):
|
|
"""Detail page Jinja must render `mapped_email` as the subtitle when
|
|
the row is the env-mapped Admin/Everyone, instead of the canonical
|
|
name (which would yield `Admin / Admin`)."""
|
|
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
|
|
from app.main import app
|
|
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
admin_gid = conn.execute(
|
|
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
|
|
).fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
client = TestClient(app)
|
|
_, token = _seed_admin()
|
|
resp = client.get(
|
|
f"/admin/groups/{admin_gid}",
|
|
headers={"Accept": "text/html"},
|
|
cookies={"access_token": token},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
# The mapped Workspace email shows up as the gd-title-email subtitle.
|
|
assert "admins@workspace.test" in body
|
|
# The data attribute the JS reads to skip the deriveDisplayName rewrite.
|
|
assert 'data-mapped-email="admins@workspace.test"' in body
|