e2x-hub-rbac provides the management and authorization layer for LMS-like applications running on JupyterHub within the e2x ecosystem.
It connects JupyterHub's users and groups with the concepts an LMS needs to manage courses, terms, and participants. Rather than implementing a complete LMS itself, the package provides the common infrastructure for managing who can do what, and where.
A typical setup looks roughly like this:
┌─────────────────────────┐
│ JupyterHub │
│ │
│ Users + Groups │
└────────────┬────────────┘
│
│
┌────────────▼────────────┐
│ e2x-hub-rbac │
│ │
│ Roles & Permissions │
│ Scope Resolution │
│ Membership Management │
└────────────┬────────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌────────▼───────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ LMS / Course │ │ Assessment │ │ Other e2x │
│ Management │ │ Services │ │ Services │
└────────────────┘ └─────────────┘ └─────────────┘
The idea is to use JupyterHub as the user and group management infrastructure, while e2x-hub-rbac adds the LMS-specific concepts needed by applications built on top of it.
For example, an LMS-like application may need to answer questions such as:
- Is this user an LMS administrator?
- Can this user create a course?
- Is this user the owner of course
math101? - Is this user an instructor for
math101during the2024wsterm? - Can this teaching assistant add students to that term?
- Can this observer view the members of a term?
e2x-hub-rbac provides a common way to represent and enforce these relationships.
The package has two closely related responsibilities:
- Authorization — Translate JupyterHub group memberships into roles and determine whether a role grants a requested permission.
- Membership management — Provide an API for managing those roles by adding and removing users from the corresponding JupyterHub groups.
The package deliberately does not try to be a complete LMS. Course data, terms, assessments, content, and other domain-specific functionality remain the responsibility of consuming applications.
Instead, e2x-hub-rbac provides the shared management layer that applications can build upon.
The authorization model is based on three hierarchical scopes:
- LMS — Global permissions across the LMS.
- Course — Permissions for a specific course and all of its terms.
- Term — Permissions for a specific course and term.
Users receive roles through JupyterHub group memberships. For example:
lms.lms-admin
lms.course-creator
lms.course.math101.course-owner
lms.course.math101.term.2024ws.instructor
lms.course.math101.term.2024ws.student
These groups are interpreted as role assignments:
LMS
└── LMS_ADMIN
LMS
└── COURSE_CREATOR
Course: math101
└── COURSE_OWNER
Course: math101
└── Term: 2024ws
└── INSTRUCTOR
Course: math101
└── Term: 2024ws
└── STUDENT
Consuming applications then define which permissions each role provides.
For example, an application might define:
class ViewProfile(PermissionProtocol):
code = "view_profile"
required_scope = Scope.TERMand decide that INSTRUCTOR, TEACHING_ASSISTANT, and STUDENT can exercise that permission within their respective terms.
This separation is intentional:
e2x-hub-rbacdefines the roles and authorization machinery.- The consuming application defines its permissions and what each role is allowed to do.
- JupyterHub stores the users and group memberships.
An LMS-like application running on JupyterHub typically needs both JupyterHub's infrastructure and LMS-specific authorization concepts.
Without a common layer, every application would have to implement its own:
- Role definitions
- Course and term scoping
- JupyterHub group-name parsing
- Permission checks
- Membership management
- Authorization decorators
- JupyterHub API integration
e2x-hub-rbac centralizes these concerns so that multiple e2x services can use the same roles, group conventions, and authorization model.
In short:
JupyterHub provides the users and groups;
e2x-hub-rbacturns those groups into an LMS-oriented management and authorization model.
The remainder of this document describes the roles, group naming convention, permission resolution, and APIs provided by the package.
Roles are fixed and ship with this package.
| Role | Scope | Group name format |
|---|---|---|
LMS_ADMIN |
LMS | lms.lms-admin |
COURSE_CREATOR |
LMS | lms.course-creator |
COURSE_OWNER |
Course | lms.course.{course_id}.course-owner |
INSTRUCTOR |
Term | lms.course.{course_id}.term.{term_id}.instructor |
TEACHING_ASSISTANT |
Term | lms.course.{course_id}.term.{term_id}.teaching_assistant |
OBSERVER |
Term | lms.course.{course_id}.term.{term_id}.observer |
STUDENT |
Term | lms.course.{course_id}.term.{term_id}.student |
JupyterHub group memberships are automatically parsed into role assignments based on a structured naming convention. Each group name encodes the role scope and identifiers.
Group names follow these patterns:
- LMS-level roles:
lms.<role_name> - Course-level roles:
lms.course.<course_id>.<role_name> - Term-level roles:
lms.course.<course_id>.term.<term_id>.<role_name>
lms.lms-admin # LMS admin (global access)
lms.course-creator # Can create courses (global)
lms.course.math101.course-owner # Owner of course math101
lms.course.math101.term.2024ws.instructor # Instructor for math101 in 2024ws
lms.course.math101..2024ws.teaching_assistant # TA for math101 in 2024ws
lms.course.cs101.term.2024ss.student # Student in cs101 for 2024ss
lms.course.physics201.term.2025ws.observer # Observer in physics201 for 2025ws
- Group names are case-sensitive and use dot (
.) as the separator. - Only group names matching the expected formats are parsed; others are silently ignored.
- The role name must exactly match one of the predefined roles at the correct scope.
- Course IDs and term IDs can contain any characters except dots.
These group names will be ignored during parsing:
admin # Missing scope prefix
lms.invalid_role # Unknown role name
lms.course.math101 # Missing role name
lms.course.math101.math101.term.instructor # Missing term_id
lms.course.math101.student # Wrong scope for student role
- Lms roles apply globally to any resource.
- Course roles apply to their course and all terms within it.
- Term roles apply only to their specific course + term combination.
pip install e2x-hub-rbacgit clone https://github.com/Digiklausur/e2x-hub-rbac.git
cd e2x-hub-rbac
pip install -e .This package provides two main components:
- Permission System: Check if users have specific permissions based on their role assignments
- Membership API: Manage user memberships in courses and terms (add/remove users from roles)
PermissionProtocol is a structural protocol — implement it with class-level code and required_scope attributes.
from e2x_hub_rbac.auth import Scope, PermissionProtocol, Role, RolePermissions
class Permission(PermissionProtocol):
code = "view_profile"
required_scope = Scope.TERM
ROLE_PERMISSIONS: RolePermissions = {
Role.LMS_ADMIN: frozenset({Permission}),
Role.COURSE_CREATOR: frozenset(),
Role.COURSE_OWNER: frozenset({Permission}),
Role.INSTRUCTOR: frozenset({Permission}),
Role.TEACHING_ASSISTANT: frozenset({Permission}),
Role.OBSERVER: frozenset({Permission}),
Role.STUDENT: frozenset({Permission}),
}Note:
ROLE_PERMISSIONSmust include an entry for everyRolevalue, because the checker looks up each of the user's assigned roles in this mapping.
from e2x_hub_rbac.auth import UserLike, PermissionChecker
from dataclasses import dataclass
@dataclass
class User:
"""Example user representation."""
username: str
groups: list[str]
alice = User(username="alice", groups=["lms.course.math101.term.2024ws.student"])
checker = PermissionChecker(alice, ROLE_PERMISSIONS)
checker.has_permission(Permission, course_id="math101", term_id="2024ws") # True
checker.has_permission(Permission, course_id="cs101", term_id="2024ws") # FalseExtend BaseAPI and annotate methods with @require_permission. The decorator resolves user, course_id, and term_id from the method arguments by name.
from e2x_hub_rbac.api import BaseAPI
from e2x_hub_rbac.auth import UserLike, require_permission
class MyAPI(BaseAPI):
def __init__(self):
super().__init__(role_permissions=ROLE_PERMISSIONS)
@require_permission(Permission)
def get_profile(self, user, course_id, term_id):
return {"profile": "data"}
api.get_profile(alice, "math101", "2024ws") # succeeds
api.get_profile(alice, "cs101", "2024ws") # raises APIPermissionError (403)APIPermissionError is RFC 9457-compliant and carries status_code = 403.
The MembershipAPI provides methods to add, remove, and list users in various roles. It requires a backend implementation of the GroupBackend protocol.
The package includes a HubAPI backend for JupyterHub:
from e2x_hub_rbac.backend import HubAPI
from e2x_hub_rbac.api import MembershipAPI
# Initialize the JupyterHub backend
hub_backend = HubAPI(
api_token="your-jupyterhub-api-token",
api_url="https://your-hub.example.com/hub/api"
)
# Create the membership API
membership_api = MembershipAPI(
group_backend=hub_backend,
add_users_to_hub=True # Automatically create users if they don't exist
)Manage lms administrators and course creators:
from e2x_hub_rbac.auth import UserLike
# Admin user who can manage memberships
admin = User(username="admin", groups=["lms.lms-admin"])
# Add/remove lms admins
await membership_api.add_lms_admins(admin, ["user1", "user2"])
await membership_api.remove_lms_admins(admin, ["user1"])
admins = await membership_api.list_lms_admins(admin)
# Add/remove course creators
await membership_api.add_course_creators(admin, ["instructor1"])
await membership_api.remove_course_creators(admin, ["instructor1"])
creators = await membership_api.list_course_creators(admin)Manage course owners:
# Add/remove course owners
await membership_api.add_course_owners(admin, "math101", ["prof_smith"])
await membership_api.remove_course_owners(admin, "math101", ["prof_smith"])
owners = await membership_api.list_course_owners(admin, "math101")Manage instructors, teaching assistants, observers, and students:
course_id = "math101"
term_id = "2024ws"
# Instructors
await membership_api.add_instructors(admin, course_id, term_id, ["instructor1"])
await membership_api.remove_instructors(admin, course_id, term_id, ["instructor1"])
instructors = await membership_api.list_instructors(admin, course_id, term_id)
# Teaching Assistants
await membership_api.add_teaching_assistants(admin, course_id, term_id, ["ta1", "ta2"])
await membership_api.remove_teaching_assistants(admin, course_id, term_id, ["ta1"])
tas = await membership_api.list_teaching_assistants(admin, course_id, term_id)
# Observers
await membership_api.add_observers(admin, course_id, term_id, ["observer1"])
await membership_api.remove_observers(admin, course_id, term_id, ["observer1"])
observers = await membership_api.list_observers(admin, course_id, term_id)
# Students
await membership_api.add_students(admin, course_id, term_id, ["alice", "bob"])
await membership_api.remove_students(admin, course_id, term_id, ["alice"])
students = await membership_api.list_students(admin, course_id, term_id)All membership operations are protected by permissions. Different roles can perform different operations:
| Operation | Required Permission | Who Can Do It |
|---|---|---|
| Manage LMS admins | LMS-scoped | LMS admins only |
| Manage course creators | LMS-scoped | LMS admins only |
| Manage course owners | Course-scoped | LMS admins, course owners |
| Manage instructors | Term-scoped | LMS admins, course owners, instructors |
| Manage TAs | Term-scoped | LMS admins, course owners, instructors |
| Manage observers | Term-scoped | LMS admins, course owners, instructors |
| Manage students | Term-scoped | LMS admins, course owners, instructors, TAs |
| List term members | Term-scoped | LMS admins, course owners, instructors, TAs, observers |
Example of permission checking:
# Course owner can manage their course
course_owner = User(username="prof", groups=["lms.course.math101.course-owner"])
await membership_api.add_students(course_owner, "math101", "2024ws", ["student1"]) # ✓ Succeeds
# But cannot manage a different course
await membership_api.add_students(course_owner, "cs101", "2024ws", ["student1"]) # ✗ Raises APIPermissionError
# Teaching assistant can add students
ta = User(username="ta", groups=["lms.course.math101.term.2024ws.teaching-assistant"])
await membership_api.add_students(ta, "math101", "2024ws", ["student2"]) # ✓ Succeeds
# But cannot remove instructors
await membership_api.remove_instructors(ta, "math101", "2024ws", ["instructor1"]) # ✗ Raises APIPermissionErrorYou can implement your own backend by implementing the GroupBackend protocol:
from e2x_hub_rbac.backend.protocol import GroupBackend
class CustomBackend(GroupBackend):
async def ensure_group_exists(self, group_name: str, create_if_missing: bool) -> None:
# Your implementation
...
async def ensure_users_exist(self, usernames: list[str], create_if_missing: bool) -> None:
# Your implementation
...
async def add_users_to_group(self, group_name: str, usernames: list[str]) -> None:
# Your implementation
...
async def remove_users_from_group(self, group_name: str, usernames: list[str]) -> None:
# Your implementation
...
async def get_group_members(self, group_name: str) -> list[str]:
# Your implementation
...git clone https://github.com/Digiklausur/e2x-hub-rbac.git
cd e2x-hub-rbac
pip install -e ".[dev]"
pre-commit installpip install -e ".[test]"
pytestThe test suite includes:
- Permission checker tests
- Decorator tests
- MembershipAPI tests (requires
pytest-asyncio) - RBAC tests
This project is licensed under the MIT License - see the LICENSE file for details.
Contributions are welcome! Please open an issue or submit a pull request.
For questions or support, please contact Tim Metzler.