Skip to content

Workspace admin is treated as a global privilege (Open WebUI admin role, ADMINS connection sharing) #77

Open

Description

@SushantGautam

Summary

Two privileges assume that "admin of any workspace" is a trustworthy, global role. But any signed-in user can create a workspace and become its admin (accounts/services.py create_workspace). So both privileges are open to every user.

Found during the system audit in #76; not changed there because both need a product decision.

1. Workspace admin becomes a global Open WebUI admin

chat/config.py identity() sends X-Studio-Role: admin for superusers and for anyone who is admin of at least one workspace.

  • An Open WebUI admin can see every user's chats, models and settings, and can run Python through Functions/Tools.
  • Any user can therefore: create a workspace → become its admin → become an Open WebUI admin → read other users' chats and run code on the server.
  • This is documented in docs/chat.md (role table) and covered by test_workspace_admin_is_chat_admin.
  • Studio's Knowledge/Tools admin embeds rely on Open WebUI admin rights.
  • System audit: security, data-integrity and correctness fixes #76 already keeps Studio's own secrets out of the embedded Open WebUI process. Even so, Open WebUI runs as the same OS user in embedded/single-docker mode.

2. "Workspaces where I'm admin" connection visibility reaches every workspace's admins

The ADMINS visibility level is labelled "Workspaces where I'm admin". In practice it shares the connection, and its API key, with the admins of every workspace:

  • model_registry/services.py visible_connections_for and visible_connection_ids_for;
  • chat/access_sync.py, which grants every workspace's admin group.

So any user who creates a workspace can see and use other tenants' admin-shared connections, spending the owner's key in chat, the chat API passthrough and connection pings. This is encoded in test_admins_visibility_admins_of_any_ws_see_it, so it may be intended, but the label promises something narrower.

Related: models from connections shared into a workspace appear in the experiment picker, but create_audit_run rejects them ("Model is unavailable in this project"). The intended sharing rule decides which side should change.

Options to brainstorm

  • Open WebUI role:
    • map only Studio superusers to Open WebUI admin;
    • keep workspace admins as Open WebUI user and give the admin embeds another path (scoped Open WebUI groups or permissions, or proxy the needed admin APIs through Studio).
  • ADMINS visibility:
    • limit it to workspaces where the connection's creator (or an admin of the owning workspace) is also admin;
    • or rename the level to say what it does.
  • Self-service workspaces:
  • Shared models at launch: once sharing is settled, either allow shared-connection models in create_audit_run or hide them from the picker.

Affected

  • chat/config.py, chat/access_sync.py, docs/chat.md
  • model_registry/services.py, audits/services.py
  • accounts/services.py

Activity

  1. avalyset commented on Nov 4, 2026

    @avalyset
    Contributor

    Some input on P2 and I3/I4 (deferred from #76), read against main at 243747b.

    Open WebUI role. Open WebUI's permissions docs say to "treat the Tools Access permission as root-equivalent access", because Tools and Functions run Python on the host. Scoped groups or permissions can therefore carry the Knowledge embed, but not the Tools embed: any route that lets a workspace admin manage Tools also gives them code execution. Tools would have to stay with superusers, whichever option is chosen for the rest.

    For Knowledge, part of this already exists. chat/access_sync.py:145 creates a "— Admins" group per workspace, and Open WebUI group permissions are additive on top of the global defaults, so Knowledge Access could be granted to those groups with the defaults left off. The sync currently grants only read access to models (_connection_grants, access_sync.py:102–125), so the embed would also need knowledge-base grants to the workspace groups.

    The role header is applied at sign-in (docs/chat.md, role mapping; Open WebUI writes the trusted role to the user row in its sign-in handler). Accounts that were already promoted keep admin in Open WebUI until their next sign-in, and any Tools or Functions they created remain. Changing the mapping alone does not cover them.

    ADMINS visibility. ModelConnection.created_by exists (model_registry/models.py:46), so "workspaces where the creator is admin" is one extra condition at model_registry/services.py:302 and :341, with the matching grant at access_sync.py:112–114. created_by is SET_NULL, so under that condition a deleted creator would leave the connection owner-only. shared_with (models.py:41) already covers explicit shares to specific workspaces. If the level is renamed instead, the label appears in two places: models.py:23 and connection_share_label at services.py:373.

  2. avalyset commented on Nov 6, 2026

    @avalyset
    Contributor

    The extensions from #83 (v0.5.46, ba55a23) use the same rule this issue describes, so a user who creates their own workspace gets admin level there too: user_level() takes the highest role in any workspace (infra/extensions.py:188-189), and create_workspace() makes the creator admin (accounts/services.py:177-200).

    With a manifest configured, that user sees admin-only menu entries (infra/context_processors.py:200), can open /x/<slug>/ (infra/extension_views.py:27), and gets 200 from /ext/authz?role=admin (infra/extension_views.py:57) with X-Studio-Role: admin (chat/config.py:203, :221). Self-registration is on by default (accounts/services.py:482), so this needs no invitation.

    Two possible directions:

    1. Take the extension admin/superuser levels from is_staff/is_superuser instead of workspace membership.
    2. Ignore workspaces the user created (Project.created_by, accounts/models.py:60) in user_level(). The creator can still add a second account as admin (accounts/views.py:299, add_member), so on its own this closes the one-account path but not the two-account one.

    Whatever is settled here for "admin of any workspace" may also cover the extension ladder, which is a separate copy of the rule.

    Reproduction: infra/tests/test_extensions_self_admin.py, 3 of 3 fail on ba55a23

    With infra/tests/test_extensions.py: 3 failed, 12 passed. The check before the workspace is created passes; every check after it fails with:

    - {'authz_role_header': 'admin', 'authz_status': 200, 'embed_status': 200, 'menu_entry_in_html': True}
    + {'authz_role_header': None,    'authz_status': 403, 'embed_status': 404, 'menu_entry_in_html': False}
    
    """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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions