From 833795afeb7f6d215458e2db2ac53ec9a26f93e9 Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Fri, 6 Nov 2026 21:20:18 +0100 Subject: [PATCH] Extensions: take admin and superuser from is_staff/is_superuser, cap workspace roles at auditor user_level() gave an extension user the highest role they held in any workspace. Any signed-in user can create a workspace and become its admin, so every user could reach admin-only extensions: the menu entry, /x// and /ext/authz?role=admin. Extensions are instance-wide, so the admin level now comes from is_staff and the superuser level from is_superuser. Workspace roles still count, up to auditor. The existing extension tests gave their admin a workspace admin row; that user is now a staff user with a viewer row for the active workspace. test_extensions_self_admin.py covers the self-created workspace via the service, the API and self-registration. --- docs/extensions.md | 11 ++++-- infra/extensions.py | 12 +++++- infra/tests/test_extensions.py | 23 +++++++++-- infra/tests/test_extensions_self_admin.py | 47 +++++++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 infra/tests/test_extensions_self_admin.py diff --git a/docs/extensions.md b/docs/extensions.md index 93ac23bc..cca488d3 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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 diff --git a/infra/extensions.py b/infra/extensions.py index b22e1da1..4348644f 100644 --- a/infra/extensions.py +++ b/infra/extensions.py @@ -175,11 +175,20 @@ 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 @@ -187,6 +196,7 @@ def user_level(user) -> int | None: 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 diff --git a/infra/tests/test_extensions.py b/infra/tests/test_extensions.py index 07246568..ad59492b 100644 --- a/infra/tests/test_extensions.py +++ b/infra/tests/test_extensions.py @@ -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") @@ -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'', 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 diff --git a/infra/tests/test_extensions_self_admin.py b/infra/tests/test_extensions_self_admin.py new file mode 100644 index 00000000..0a94618a --- /dev/null +++ b/infra/tests/test_extensions_self_admin.py @@ -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)