mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
215 lines
11 KiB
Python
215 lines
11 KiB
Python
"""Per-member tool access control + local-run toggle (the pre-release ACL).
|
|
|
|
Proves: `tool_access` gates the proxy call AND both run tiers; `local_run_enabled` gates the local tier;
|
|
NULL access = all tools; the owner is never restricted; the access PATCH validates tool names and collapses
|
|
'all checked' to NULL; and an invite carries its access onto the accepted membership.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlmodel import select
|
|
|
|
from conftest import make_upstream
|
|
|
|
from treg import crypto
|
|
from treg.api import app
|
|
from treg.infra.db import reset_db, session_maker
|
|
from treg.models import Membership, Org, User
|
|
|
|
|
|
def _h(t: str) -> dict:
|
|
return {"X-Treg-Token": t}
|
|
|
|
|
|
async def _mint(email: str, org_id: int, role: str) -> tuple[str, int]:
|
|
token = crypto.new_token()
|
|
async with session_maker() as s:
|
|
u = (await s.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
|
if u is None:
|
|
u = User(email=email)
|
|
s.add(u)
|
|
await s.flush()
|
|
s.add(Membership(user_id=u.id, org_id=org_id, role=role, token_hash=crypto.hash_token(token)))
|
|
await s.commit()
|
|
uid = u.id
|
|
return token, uid
|
|
|
|
|
|
@pytest.fixture
|
|
async def env():
|
|
await reset_db()
|
|
app.state.http = AsyncClient(transport=ASGITransport(app=make_upstream()), base_url="http://upstream")
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://registry") as c:
|
|
async with session_maker() as s:
|
|
org = Org(name="Team", slug="team")
|
|
s.add(org)
|
|
await s.commit()
|
|
await s.refresh(org)
|
|
org_id = org.id
|
|
owner, _ = await _mint("owner@x.dev", org_id, "owner")
|
|
member, member_uid = await _mint("m@x.dev", org_id, "member")
|
|
# a callable HTTP tool (alpha) + a runnable CLI tool (beta)
|
|
sid = (await c.post("/secrets", headers=_h(owner), json={"name": "a-key", "value": "v"})).json()["id"]
|
|
await c.post("/tools", headers=_h(owner), json={"name": "alpha", "base_url": "http://upstream", "secret_id": sid})
|
|
await c.post("/skills", headers=_h(owner), json={
|
|
"name": "beta", "recipe": "# beta",
|
|
"secrets": [{"local_name": "k", "kind": "env", "value": "s3cr"}],
|
|
"tools": [{"name": "beta", "base_url": "http://upstream",
|
|
"cli": {"bin": "sh", "inject": [{"secret": "k", "via": "env", "name": "K"}], "enabled": True}}]})
|
|
yield SimpleNamespace(c=c, org_id=org_id, owner=owner, member=member, member_uid=member_uid)
|
|
await app.state.http.aclose()
|
|
|
|
|
|
async def _set_access(env, tool_access, local=True):
|
|
r = await env.c.patch(f"/orgs/{env.org_id}/members/{env.member_uid}/access",
|
|
headers=_h(env.owner), json={"tool_access": tool_access, "local_run_enabled": local})
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
async def test_tool_access_gates_call_and_run(env):
|
|
await _set_access(env, ["alpha"]) # member may use ONLY alpha
|
|
assert (await env.c.get("/call/alpha", headers=_h(env.member))).status_code != 403 # allowed
|
|
assert (await env.c.get("/call/beta", headers=_h(env.member))).status_code == 403 # denied tool
|
|
# both run tiers on the denied tool are blocked too
|
|
assert (await env.c.post("/run", headers=_h(env.member), json={"tool": "beta"})).status_code == 403
|
|
g = await env.c.post("/tools/beta/grant", headers=_h(env.member), json={"argv": []})
|
|
assert g.status_code == 403 and "access" in g.json()["detail"]
|
|
|
|
|
|
async def test_local_run_toggle_blocks_only_local(env):
|
|
await _set_access(env, None, local=False) # all tools, but no LOCAL runs
|
|
g = await env.c.post("/tools/beta/grant", headers=_h(env.member), json={"argv": []})
|
|
assert g.status_code == 403 and "local execution is disabled" in g.json()["detail"]
|
|
# re-enabling passes the local-tier gate (a later 403 would be the shared-key proof, not this)
|
|
await _set_access(env, None, local=True)
|
|
g2 = await env.c.post("/tools/beta/grant", headers=_h(env.member), json={"argv": []})
|
|
assert "local execution is disabled" not in g2.text
|
|
|
|
|
|
async def test_set_access_validates_and_collapses(env):
|
|
bad = await env.c.patch(f"/orgs/{env.org_id}/members/{env.member_uid}/access",
|
|
headers=_h(env.owner), json={"tool_access": ["nope"], "local_run_enabled": True})
|
|
assert bad.status_code == 422 and "nope" in bad.json()["detail"]
|
|
# selecting EVERY tool collapses to 'all' (NULL) so new tools auto-apply
|
|
out = await _set_access(env, ["alpha", "beta"])
|
|
assert out["tool_access"] is None
|
|
|
|
|
|
async def test_owner_cannot_be_restricted(env):
|
|
owner_uid = (await env.c.get(f"/orgs/{env.org_id}/members", headers=_h(env.owner))).json()
|
|
oid = next(m["user_id"] for m in owner_uid if m["role"] == "owner")
|
|
r = await env.c.patch(f"/orgs/{env.org_id}/members/{oid}/access",
|
|
headers=_h(env.owner), json={"tool_access": ["alpha"], "local_run_enabled": True})
|
|
assert r.status_code == 403
|
|
|
|
|
|
async def test_list_members_returns_access(env):
|
|
await _set_access(env, ["alpha"], local=False)
|
|
members = (await env.c.get(f"/orgs/{env.org_id}/members", headers=_h(env.owner))).json()
|
|
me = next(m for m in members if m["user_id"] == env.member_uid)
|
|
assert me["tool_access"] == ["alpha"] and me["local_run_enabled"] is False
|
|
|
|
|
|
async def test_invite_carries_access_onto_membership(env):
|
|
inv = await env.c.post(f"/orgs/{env.org_id}/invites", headers=_h(env.owner),
|
|
json={"email": "new@x.dev", "role": "member",
|
|
"tool_access": ["alpha"], "local_run_enabled": False})
|
|
assert inv.status_code == 200, inv.text
|
|
acc = await env.c.post("/invites/accept", json={"code": inv.json()["code"], "email": "new@x.dev"})
|
|
assert acc.status_code == 200, acc.text
|
|
async with session_maker() as s:
|
|
u = (await s.execute(select(User).where(User.email == "new@x.dev"))).scalar_one()
|
|
m = (await s.execute(select(Membership).where(Membership.user_id == u.id))).scalar_one()
|
|
assert m.tool_access == ["alpha"] and m.local_run_enabled is False
|
|
|
|
|
|
# ---- heavy confirming pass: adversarial edge cases ----------------------------------------
|
|
async def test_viewer_is_also_tool_gated(env):
|
|
# a viewer can call (not run); the tool ACL still applies to their proxy calls
|
|
viewer, vuid = await _mint("v@x.dev", env.org_id, "viewer")
|
|
await env.c.patch(f"/orgs/{env.org_id}/members/{vuid}/access", headers=_h(env.owner),
|
|
json={"tool_access": ["alpha"], "local_run_enabled": True})
|
|
assert (await env.c.get("/call/alpha", headers=_h(viewer))).status_code != 403
|
|
assert (await env.c.get("/call/beta", headers=_h(viewer))).status_code == 403
|
|
|
|
|
|
async def test_admin_can_be_restricted_but_owner_cannot(env):
|
|
admin, auid = await _mint("a@x.dev", env.org_id, "admin")
|
|
await env.c.patch(f"/orgs/{env.org_id}/members/{auid}/access", headers=_h(env.owner),
|
|
json={"tool_access": ["alpha"], "local_run_enabled": True})
|
|
assert (await env.c.get("/call/beta", headers=_h(admin))).status_code == 403 # admins ARE gated
|
|
assert (await env.c.get("/call/beta", headers=_h(env.owner))).status_code != 403 # owner never is
|
|
|
|
|
|
async def test_member_cannot_set_access(env):
|
|
# granting access is admin+ — a plain member can't widen their own access
|
|
r = await env.c.patch(f"/orgs/{env.org_id}/members/{env.member_uid}/access", headers=_h(env.member),
|
|
json={"tool_access": None, "local_run_enabled": True})
|
|
assert r.status_code == 403
|
|
|
|
|
|
async def test_empty_list_blocks_every_tool(env):
|
|
await _set_access(env, []) # no tools → effectively CLI + proxy disabled for this member
|
|
assert (await env.c.get("/call/alpha", headers=_h(env.member))).status_code == 403
|
|
assert (await env.c.get("/call/beta", headers=_h(env.member))).status_code == 403
|
|
assert (await env.c.post("/run", headers=_h(env.member), json={"tool": "alpha"})).status_code == 403
|
|
|
|
|
|
async def test_tool_access_hides_listings_and_keys(env):
|
|
"""The ACL isn't just a call gate — a restricted member's dashboard listings must not reveal
|
|
the tools and credentials they can't use (/tools, /secrets, /health, /tools/by-name)."""
|
|
await _set_access(env, ["alpha"])
|
|
m = _h(env.member)
|
|
assert {t["name"] for t in (await env.c.get("/tools", headers=m)).json()} == {"alpha"}
|
|
assert {s["name"] for s in (await env.c.get("/secrets", headers=m)).json()} == {"a-key"}
|
|
assert {h["name"] for h in (await env.c.get("/health", headers=m)).json()} == {"a-key"}
|
|
assert (await env.c.get("/tools/by-name/alpha", headers=m)).status_code == 200
|
|
assert (await env.c.get("/tools/by-name/beta", headers=m)).status_code == 403 # names the fix, not a fake 404
|
|
# the owner and an unrestricted member still see everything
|
|
assert {t["name"] for t in (await env.c.get("/tools", headers=_h(env.owner))).json()} == {"alpha", "beta"}
|
|
await _set_access(env, None)
|
|
assert {t["name"] for t in (await env.c.get("/tools", headers=m)).json()} == {"alpha", "beta"}
|
|
assert {s["name"] for s in (await env.c.get("/secrets", headers=m)).json()} >= {"a-key"}
|
|
|
|
|
|
async def test_tool_access_gates_skill_visibility(env):
|
|
"""The access list also gates which SKILLS a restricted member can see: granted by the bundle's
|
|
own name (recipe-only skills) or via any of its tools. /bundles + both bundle getters comply."""
|
|
await env.c.post("/skills", headers=_h(env.owner),
|
|
json={"name": "gamma", "recipe": "# gamma", "secrets": [], "tools": []})
|
|
m = _h(env.member)
|
|
|
|
await _set_access(env, ["alpha"]) # a bare endpoint grant — no skills granted at all
|
|
assert {b["name"] for b in (await env.c.get("/bundles", headers=m)).json()} == set()
|
|
assert (await env.c.get("/bundles/by-name/beta", headers=m)).status_code == 403
|
|
|
|
await _set_access(env, ["gamma"]) # recipe-only skill granted by its own name
|
|
assert {b["name"] for b in (await env.c.get("/bundles", headers=m)).json()} == {"gamma"}
|
|
ok = await env.c.get("/bundles/by-name/gamma", headers=m)
|
|
assert ok.status_code == 200
|
|
assert (await env.c.get(f"/bundles/{ok.json()['id']}", headers=m)).status_code == 200 # skill install route
|
|
|
|
await _set_access(env, ["beta"]) # an integration skill granted via its tool's name
|
|
assert {b["name"] for b in (await env.c.get("/bundles", headers=m)).json()} == {"beta"}
|
|
|
|
# the owner is never restricted
|
|
assert {b["name"] for b in (await env.c.get("/bundles", headers=_h(env.owner))).json()} == {"beta", "gamma"}
|
|
|
|
|
|
async def test_restricted_member_gets_no_marketplace_calls(env):
|
|
"""Governance for direct catalog calls: a member scoped to a tool LIST can never satisfy the
|
|
virtual tool's name (the endpoint id), so the marketplace path is closed to them — while an
|
|
unrestricted member walks the ladder normally (here to tier 3, no credential)."""
|
|
await env.c.post("/secrets", headers=_h(env.owner), json={"name": "tikhub", "value": "MK"})
|
|
await _set_access(env, ["alpha"])
|
|
r = await env.c.get("/call/tikhub.tiktok.video.comments?aweme_id=7", headers=_h(env.member))
|
|
assert r.status_code == 403, r.text
|
|
await _set_access(env, None) # unrestricted again → the org credential serves it
|
|
r = await env.c.get("/call/tikhub.tiktok.video.comments?aweme_id=7", headers=_h(env.member))
|
|
assert r.status_code == 200, r.text
|