Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,13 @@ asset-only extensions.
### Roles

Roles form a ladder: `user` < `viewer` < `auditor` < `admin` < `superuser`.
A user's level is the highest role they hold in **any** workspace; any signed-in
user is at least `user`, and a Django superuser is `superuser`. An entry is
shown to users at or above the lowest role it lists. Entries are instance-wide:
there are no per-workspace manifests.
Entries are instance-wide, so the two top levels come from the instance, not
from a workspace: a Django superuser is `superuser`, and a staff user
(`is_staff`) is `admin`. Any signed-in user can create a workspace and become
its admin, so workspace roles count only up to `auditor`: a user's level is the
highest role they hold in any workspace, capped at `auditor`, and any signed-in
user is at least `user`. An entry is shown to users at or above the lowest role
it lists. There are no per-workspace manifests.

Filtering happens on the server. An entry a user may not see is absent from
the HTML, its assets are not loaded for that user, and its embed page returns
Expand Down
12 changes: 11 additions & 1 deletion infra/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,18 +175,28 @@ def load_extensions() -> tuple[Extension, ...]:


def user_level(user) -> int | None:
"""The user's place on ROLE_LADDER, or None when not signed in."""
"""The user's place on ROLE_LADDER, or None when not signed in.

Extensions are instance-wide, so ``admin`` and ``superuser`` come from the
instance flags ``is_staff`` and ``is_superuser``, not from a workspace:
any signed-in user can create a workspace and become its admin
(``accounts.services.create_workspace``). Workspace roles still count, up
to ``auditor``.
"""
if user is None or not user.is_authenticated:
return None
if user.is_superuser:
return _LEVEL["superuser"]
if user.is_staff:
return _LEVEL["admin"]
cached = getattr(user, "_studio_ext_level", None) # one query per request
if cached is not None:
return cached
from accounts.models import ProjectMembership

roles = set(ProjectMembership.objects.filter(user=user).values_list("role", flat=True))
level = max((_LEVEL[r] for r in roles if r in _LEVEL), default=_LEVEL["user"])
level = min(level, _LEVEL["auditor"])
user._studio_ext_level = level
return level

Expand Down
23 changes: 20 additions & 3 deletions infra/tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ def test_manifest_from_file(self):
class ExtensionPageTests(TestCase):
def setUp(self):
self.project = ProjectFactory()
self.admin = UserFactory()
MembershipFactory(user=self.admin, project=self.project, role="admin")
# Extension admin comes from is_staff; the viewer membership only gives an active workspace.
self.admin = UserFactory(is_staff=True)
MembershipFactory(user=self.admin, project=self.project, role="viewer")
self.viewer = UserFactory()
MembershipFactory(user=self.viewer, project=self.project, role="viewer")

Expand Down Expand Up @@ -164,11 +165,27 @@ def test_embed_page(self):
self.assertEqual(anon.status_code, 302)
self.assertIn("/login/", anon["Location"])

def test_workspace_roles_count_up_to_auditor(self):
ws_admin = UserFactory()
MembershipFactory(user=ws_admin, project=self.project, role="admin")
entries = MANIFEST + [{"slug": "review", "label": "Review", "embed": "/review/", "roles": ["auditor"]}]
with manifest(entries):
page = self._page(ws_admin, "/scenarios/")
self.assertIn('href="/x/review/"', page)
self.assertNotIn("/x/feedback/", page)
ctx = json.loads(re.search(
r'<script id="studio-page-context" type="application/json">(.*?)</script>', page, re.DOTALL,
).group(1))
self.assertEqual(ctx["role"], "auditor")
self.assertEqual(self._client(ws_admin).get("/ext/authz?role=auditor").status_code, 200)
self.assertEqual(self._client(ws_admin).get("/ext/authz?role=admin").status_code, 403)
self.assertEqual(self._client(ws_admin).get("/x/feedback/").status_code, 404)

def test_authz(self):
with manifest():
ok = self._client(self.admin).get("/ext/authz?role=admin")
self.assertEqual(ok.status_code, 200)
self.assertEqual(ok["X-Studio-Role"], "admin")
self.assertEqual(ok["X-Studio-Role"], "user") # chat.config.identity(): no workspace admin row
self.assertEqual(ok["X-Studio-Username"], self.admin.username)
self.assertIn("X-Studio-Email", ok)
self.assertEqual(self._client(self.viewer).get("/ext/authz").status_code, 403) # default role admin
Expand Down
47 changes: 47 additions & 0 deletions infra/tests/test_extensions_self_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Admin-only extensions vs. a self-created workspace (follow-up to issue #83)."""
import json

from django.test import Client, TestCase, override_settings

from accounts.services import create_workspace
from infra.tests.factories import UserFactory

ADMIN_ONLY = [{"slug": "feedback", "label": "Feedback", "section": "Tools", "embed": "/feedback/"}] # roles default ["admin"]


@override_settings(SIMPLEAUDIT_EXTENSIONS=json.dumps(ADMIN_ONLY))
class SelfCreatedWorkspaceTests(TestCase):
def _assert_no_admin_extension(self, client):
authz = client.get("/ext/authz?role=admin")
seen = {
"menu_entry_in_html": 'href="/x/feedback/"' in client.get("/").content.decode(),
"embed_status": client.get("/x/feedback/").status_code,
"authz_status": authz.status_code,
"authz_role_header": authz.get("X-Studio-Role"),
}
self.assertEqual(seen, {"menu_entry_in_html": False, "embed_status": 404,
"authz_status": 403, "authz_role_header": None})

def test_via_service(self):
user = UserFactory()
client = Client()
client.force_login(user)
self._assert_no_admin_extension(client) # baseline: passes
create_workspace(user=user, name="mine")
self._assert_no_admin_extension(client) # fails on ba55a23

def test_via_api(self):
user = UserFactory()
client = Client()
client.force_login(user)
self.assertEqual(client.post("/api/projects/create/", {"name": "mine"}).status_code, 201)
self._assert_no_admin_extension(client)

def test_via_self_registration(self):
client = Client()
resp = client.post("/api/auth/register/", {
"username": "stranger", "email": "stranger@example.org", "password": "x-Correct-Horse-9",
})
self.assertEqual(resp.status_code, 201) # signed in, no invitation
self.assertEqual(client.post("/api/projects/create/", {"name": "mine"}).status_code, 201)
self._assert_no_admin_extension(client)
Loading