openedx-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of openedx-mcp might be problematic. Click here for more details.
- openedx_mcp/__init__.py +2 -0
- openedx_mcp/admin.py +67 -0
- openedx_mcp/api/__init__.py +0 -0
- openedx_mcp/api/mcp/__init__.py +0 -0
- openedx_mcp/api/mcp/auth.py +95 -0
- openedx_mcp/api/mcp/authoring_views.py +105 -0
- openedx_mcp/api/mcp/base.py +24 -0
- openedx_mcp/api/mcp/cms_urls.py +23 -0
- openedx_mcp/api/mcp/guards.py +196 -0
- openedx_mcp/api/mcp/ops_views.py +100 -0
- openedx_mcp/api/mcp/tool_views.py +113 -0
- openedx_mcp/api/mcp/urls.py +57 -0
- openedx_mcp/api/mcp/views.py +69 -0
- openedx_mcp/apps.py +76 -0
- openedx_mcp/migrations/0001_initial.py +53 -0
- openedx_mcp/migrations/__init__.py +0 -0
- openedx_mcp/models.py +130 -0
- openedx_mcp/native/__init__.py +8 -0
- openedx_mcp/native/analytics.py +66 -0
- openedx_mcp/native/authoring.py +131 -0
- openedx_mcp/native/certificates.py +85 -0
- openedx_mcp/native/enrollment.py +85 -0
- openedx_mcp/native/reports.py +63 -0
- openedx_mcp/native/retirement.py +54 -0
- openedx_mcp/native/roles.py +87 -0
- openedx_mcp/native/users.py +87 -0
- openedx_mcp/settings/__init__.py +0 -0
- openedx_mcp/settings/common.py +7 -0
- openedx_mcp/settings/development.py +2 -0
- openedx_mcp/settings/production.py +5 -0
- openedx_mcp-0.1.0.dist-info/METADATA +37 -0
- openedx_mcp-0.1.0.dist-info/RECORD +36 -0
- openedx_mcp-0.1.0.dist-info/WHEEL +5 -0
- openedx_mcp-0.1.0.dist-info/entry_points.txt +5 -0
- openedx_mcp-0.1.0.dist-info/licenses/LICENSE +662 -0
- openedx_mcp-0.1.0.dist-info/top_level.txt +1 -0
openedx_mcp/__init__.py
ADDED
openedx_mcp/admin.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django admin for MCP keys — this IS the "console" for key creation.
|
|
3
|
+
|
|
4
|
+
No custom MFE, no GraphQL. An is_staff user goes to /admin/openedx_mcp/mcpkey/,
|
|
5
|
+
clicks Add, picks the acting user + scopes, and saves. The raw key is generated
|
|
6
|
+
server-side and surfaced ONCE via a success message (it is never stored in the
|
|
7
|
+
clear, so it cannot be shown again). Thereafter the row shows only a prefix.
|
|
8
|
+
"""
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
|
|
11
|
+
from django.conf import settings
|
|
12
|
+
from django.contrib import admin, messages
|
|
13
|
+
from django.utils import timezone
|
|
14
|
+
|
|
15
|
+
from .models import MCPAuditLog, MCPKey, generate_raw_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@admin.register(MCPKey)
|
|
19
|
+
class MCPKeyAdmin(admin.ModelAdmin):
|
|
20
|
+
list_display = ("name", "user", "key_prefix", "scopes", "is_revoked", "expires_at",
|
|
21
|
+
"last_used_at", "created_at")
|
|
22
|
+
list_filter = ("is_revoked", "created_at")
|
|
23
|
+
search_fields = ("name", "user__username", "user__email", "key_prefix")
|
|
24
|
+
readonly_fields = ("key_prefix", "key_hash", "last_used_at", "created_at")
|
|
25
|
+
actions = ["revoke_keys"]
|
|
26
|
+
|
|
27
|
+
def get_fields(self, request, obj=None):
|
|
28
|
+
if obj is None: # add form
|
|
29
|
+
return ("user", "name", "scopes", "expires_at")
|
|
30
|
+
return ("user", "name", "scopes", "is_revoked", "expires_at",
|
|
31
|
+
"key_prefix", "last_used_at", "created_at")
|
|
32
|
+
|
|
33
|
+
def save_model(self, request, obj, form, change):
|
|
34
|
+
if not change: # creating: mint the secret now, show it once
|
|
35
|
+
raw = generate_raw_key()
|
|
36
|
+
obj.set_raw_key(raw)
|
|
37
|
+
# Auto-set expiry from the configured default TTL when left blank.
|
|
38
|
+
if obj.expires_at is None:
|
|
39
|
+
ttl_days = getattr(settings, "OPENEDX_MCP_DEFAULT_KEY_TTL_DAYS", None)
|
|
40
|
+
if ttl_days:
|
|
41
|
+
obj.expires_at = timezone.now() + timedelta(days=ttl_days)
|
|
42
|
+
super().save_model(request, obj, form, change)
|
|
43
|
+
messages.warning(
|
|
44
|
+
request,
|
|
45
|
+
"MCP key created. Copy it now — it will not be shown again:\n%s" % raw,
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
super().save_model(request, obj, form, change)
|
|
49
|
+
|
|
50
|
+
@admin.action(description="Revoke selected keys")
|
|
51
|
+
def revoke_keys(self, request, queryset):
|
|
52
|
+
n = queryset.update(is_revoked=True)
|
|
53
|
+
self.message_user(request, f"Revoked {n} key(s).", messages.SUCCESS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@admin.register(MCPAuditLog)
|
|
57
|
+
class MCPAuditLogAdmin(admin.ModelAdmin):
|
|
58
|
+
list_display = ("created_at", "tool", "outcome", "user", "affected_count")
|
|
59
|
+
list_filter = ("outcome", "tool", "created_at")
|
|
60
|
+
search_fields = ("tool", "user__username", "error")
|
|
61
|
+
readonly_fields = [f.name for f in MCPAuditLog._meta.fields]
|
|
62
|
+
|
|
63
|
+
def has_add_permission(self, request):
|
|
64
|
+
return False # append-only, written by the facade
|
|
65
|
+
|
|
66
|
+
def has_change_permission(self, request, obj=None):
|
|
67
|
+
return False
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication + authorization for the MCP facade.
|
|
3
|
+
|
|
4
|
+
Two accepted credentials, in priority order:
|
|
5
|
+
|
|
6
|
+
1. X-MCP-Key — an openedx_mcp.MCPKey bearer token (the primary path; keys are
|
|
7
|
+
minted from the Django admin). Resolves to the key's acting user.
|
|
8
|
+
2. The host platform's own JwtAuthentication / SessionAuthentication — so the
|
|
9
|
+
same endpoints can also be driven directly with an Open edX JWT that already
|
|
10
|
+
carries is_staff/is_superuser (oauth_dispatch encodes both claims). This is
|
|
11
|
+
what the key-management endpoints themselves use.
|
|
12
|
+
|
|
13
|
+
Whichever path authenticates, the authorization rule is identical and re-checked
|
|
14
|
+
LIVE on every request: the acting user must be is_staff or is_superuser right now.
|
|
15
|
+
A key does not cache privilege — demote the user and every key they hold dies on
|
|
16
|
+
the next call. `scopes` on a key only narrow, never widen.
|
|
17
|
+
"""
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from django.contrib.auth import get_user_model
|
|
21
|
+
from django.utils import timezone
|
|
22
|
+
from rest_framework import authentication, exceptions, permissions
|
|
23
|
+
|
|
24
|
+
from ...models import MCPKey, hash_key
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
User = get_user_model()
|
|
28
|
+
|
|
29
|
+
MCP_KEY_HEADER = "X-MCP-Key"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MCPKeyAuthentication(authentication.BaseAuthentication):
|
|
33
|
+
"""Authenticate by X-MCP-Key. Returns None (not an error) when absent, so it
|
|
34
|
+
composes with JWT/Session authenticators on the same view."""
|
|
35
|
+
|
|
36
|
+
def authenticate(self, request):
|
|
37
|
+
raw = request.headers.get(MCP_KEY_HEADER)
|
|
38
|
+
if not raw:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
key = MCPKey.objects.filter(key_hash=hash_key(raw)).select_related("user").first()
|
|
42
|
+
if key is None:
|
|
43
|
+
raise exceptions.AuthenticationFailed("Invalid MCP key.")
|
|
44
|
+
if key.is_revoked:
|
|
45
|
+
raise exceptions.AuthenticationFailed("This MCP key has been revoked.")
|
|
46
|
+
if key.is_expired:
|
|
47
|
+
raise exceptions.AuthenticationFailed("This MCP key has expired.")
|
|
48
|
+
|
|
49
|
+
# Best-effort last-used telemetry; never blocks the request.
|
|
50
|
+
MCPKey.objects.filter(pk=key.pk).update(last_used_at=timezone.now())
|
|
51
|
+
return (key.user, key)
|
|
52
|
+
|
|
53
|
+
def authenticate_header(self, request):
|
|
54
|
+
return MCP_KEY_HEADER
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_key(request):
|
|
58
|
+
"""The MCPKey backing this request, or None when authed by JWT/session."""
|
|
59
|
+
auth = getattr(request, "auth", None)
|
|
60
|
+
return auth if isinstance(auth, MCPKey) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def granted_scopes(request):
|
|
64
|
+
"""Scopes available on this request. A JWT/session admin (no key) has all
|
|
65
|
+
scopes; a key is limited to its own granted set."""
|
|
66
|
+
key = get_key(request)
|
|
67
|
+
if key is None:
|
|
68
|
+
from ...models import ALL_SCOPES
|
|
69
|
+
return set(ALL_SCOPES)
|
|
70
|
+
return key.granted_scopes()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IsStaffOrSuperuser(permissions.BasePermission):
|
|
74
|
+
"""Live authority check — the acting user must currently be staff/superuser.
|
|
75
|
+
This is the single gate; it runs on every request regardless of credential."""
|
|
76
|
+
|
|
77
|
+
message = "This endpoint requires a staff or superuser account."
|
|
78
|
+
|
|
79
|
+
def has_permission(self, request, view):
|
|
80
|
+
user = getattr(request, "user", None)
|
|
81
|
+
return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def require_scopes(request, *needed):
|
|
85
|
+
"""Raise PermissionDenied unless the request's credential carries every scope.
|
|
86
|
+
Superuser bypasses scope narrowing entirely."""
|
|
87
|
+
if request.user.is_superuser:
|
|
88
|
+
return
|
|
89
|
+
have = granted_scopes(request)
|
|
90
|
+
missing = set(needed) - have
|
|
91
|
+
if missing:
|
|
92
|
+
raise exceptions.PermissionDenied(
|
|
93
|
+
{"error": "Missing scope(s).", "required": sorted(needed),
|
|
94
|
+
"missing": sorted(missing)}
|
|
95
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""CMS course-authoring tool endpoints. Loaded only in the Studio process
|
|
2
|
+
(/api/mcp/cms/). Reads need read; writes need write:courses; delete adds
|
|
3
|
+
destructive. Draft edits apply immediately; delete/publish use confirm handshake."""
|
|
4
|
+
from rest_framework.response import Response
|
|
5
|
+
|
|
6
|
+
from ...models import Scope
|
|
7
|
+
from ...native import authoring as nau
|
|
8
|
+
from .base import MCPView
|
|
9
|
+
from .guards import audited_write
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CourseOutlineView(MCPView):
|
|
13
|
+
def get(self, request, course_id):
|
|
14
|
+
outline = nau.read_outline(request.user, course_id)
|
|
15
|
+
if outline is None:
|
|
16
|
+
return Response({"error": "No such course."}, status=404)
|
|
17
|
+
return Response(outline)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CreateCourseView(MCPView):
|
|
21
|
+
@audited_write("create_course", scope=Scope.WRITE_COURSES, require_confirm=True)
|
|
22
|
+
def post(self, request, confirmed=True):
|
|
23
|
+
d = request.data
|
|
24
|
+
if not confirmed:
|
|
25
|
+
return Response({"would_create": {"org": d["org"], "number": d["number"],
|
|
26
|
+
"run": d["run"], "name": d["display_name"]}})
|
|
27
|
+
return Response(nau.create_course(request.user, d["org"], d["number"], d["run"],
|
|
28
|
+
d["display_name"], d.get("extra_fields"))), 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CloneCourseView(MCPView):
|
|
32
|
+
@audited_write("clone_course", scope=Scope.WRITE_COURSES, require_confirm=True)
|
|
33
|
+
def post(self, request, confirmed=True):
|
|
34
|
+
d = request.data
|
|
35
|
+
if not confirmed:
|
|
36
|
+
return Response({"would_clone": d["source_course_id"],
|
|
37
|
+
"into": f"{d['org']}+{d['number']}+{d['run']}"})
|
|
38
|
+
return Response(nau.clone_course(request.user, d["source_course_id"], d["org"],
|
|
39
|
+
d["number"], d["run"], d.get("display_name"))), 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DeleteCourseView(MCPView):
|
|
43
|
+
@audited_write("delete_course", scope=Scope.DESTRUCTIVE, require_confirm=True)
|
|
44
|
+
def post(self, request, confirmed=True):
|
|
45
|
+
d = request.data
|
|
46
|
+
if not confirmed:
|
|
47
|
+
return Response({"would_delete": d["course_id"]})
|
|
48
|
+
return Response(nau.delete_course(request.user, d["course_id"],
|
|
49
|
+
keep_instructors=d.get("keep_instructors", False))), 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CreateBlockView(MCPView):
|
|
53
|
+
@audited_write("create_xblock", scope=Scope.WRITE_COURSES, require_confirm=False)
|
|
54
|
+
def post(self, request, confirmed=True):
|
|
55
|
+
d = request.data
|
|
56
|
+
return Response(nau.create_block(request.user, d["parent_locator"], d["category"],
|
|
57
|
+
d.get("display_name"), d.get("boilerplate"))), 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CreateBlockTreeView(MCPView):
|
|
61
|
+
"""Create a whole subtree (sections→subsections→units→components) in one call."""
|
|
62
|
+
@audited_write("create_xblock", scope=Scope.WRITE_COURSES, require_confirm=False)
|
|
63
|
+
def post(self, request, confirmed=True):
|
|
64
|
+
d = request.data
|
|
65
|
+
result = nau.create_block_tree(request.user, d["parent_locator"], d["nodes"])
|
|
66
|
+
# affected = count of blocks created across the tree
|
|
67
|
+
def _count(nodes):
|
|
68
|
+
return sum(1 + _count(n.get("children", [])) for n in nodes)
|
|
69
|
+
return Response(result), _count(result.get("created", []))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UpdateBlockView(MCPView):
|
|
73
|
+
@audited_write("update_xblock", scope=Scope.WRITE_COURSES, require_confirm=False)
|
|
74
|
+
def post(self, request, confirmed=True):
|
|
75
|
+
d = request.data
|
|
76
|
+
return Response(nau.update_block(request.user, d["locator"], data=d.get("data"),
|
|
77
|
+
metadata=d.get("metadata"), fields=d.get("fields"),
|
|
78
|
+
publish=d.get("publish"))), 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PublishBlockView(MCPView):
|
|
82
|
+
@audited_write("publish_xblock", scope=Scope.WRITE_COURSES, require_confirm=True)
|
|
83
|
+
def post(self, request, confirmed=True):
|
|
84
|
+
d = request.data
|
|
85
|
+
if not confirmed:
|
|
86
|
+
return Response({"would_publish": d["locator"]})
|
|
87
|
+
return Response(nau.publish_block(request.user, d["locator"])), 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DeleteBlockView(MCPView):
|
|
91
|
+
@audited_write("delete_xblock", scope=Scope.WRITE_COURSES, require_confirm=True)
|
|
92
|
+
def post(self, request, confirmed=True):
|
|
93
|
+
d = request.data
|
|
94
|
+
if not confirmed:
|
|
95
|
+
return Response({"would_delete": d["locator"]})
|
|
96
|
+
return Response(nau.delete_block(request.user, d["locator"])), 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class UpdateCourseSettingsView(MCPView):
|
|
100
|
+
@audited_write("update_course_settings", scope=Scope.WRITE_COURSES, require_confirm=False)
|
|
101
|
+
def post(self, request, confirmed=True):
|
|
102
|
+
d = request.data
|
|
103
|
+
return Response(nau.update_course_settings(
|
|
104
|
+
request.user, d["course_id"], details=d.get("details"),
|
|
105
|
+
grading=d.get("grading"), advanced=d.get("advanced"))), 1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared DRF base for every MCP facade view.
|
|
3
|
+
|
|
4
|
+
Authentication accepts, in order: the MCP key, an Open edX JWT bearer (carries
|
|
5
|
+
is_staff/is_superuser), or a session. Authorization is the single live
|
|
6
|
+
IsStaffOrSuperuser gate. These platform auth classes only import inside a running
|
|
7
|
+
LMS/CMS, which is the only place these URL configs are loaded.
|
|
8
|
+
"""
|
|
9
|
+
from rest_framework.views import APIView
|
|
10
|
+
|
|
11
|
+
from .auth import IsStaffOrSuperuser, MCPKeyAuthentication
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _platform_auth_classes():
|
|
15
|
+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
|
16
|
+
from rest_framework.authentication import SessionAuthentication
|
|
17
|
+
return [MCPKeyAuthentication, JwtAuthentication, SessionAuthentication]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MCPView(APIView):
|
|
21
|
+
permission_classes = [IsStaffOrSuperuser]
|
|
22
|
+
|
|
23
|
+
def get_authenticators(self):
|
|
24
|
+
return [cls() for cls in _platform_auth_classes()]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""CMS (Studio) URL config — mounted at ^api/mcp/cms/ by MCPCmsConfig.
|
|
2
|
+
Only the authoring tools live here; they need the modulestore (CMS-writable)."""
|
|
3
|
+
from django.urls import path
|
|
4
|
+
|
|
5
|
+
from . import authoring_views as av
|
|
6
|
+
|
|
7
|
+
app_name = "openedx_mcp_cms"
|
|
8
|
+
|
|
9
|
+
COURSE_ID = "<path:course_id>"
|
|
10
|
+
|
|
11
|
+
urlpatterns = [
|
|
12
|
+
path(f"courses/{COURSE_ID}/outline/", av.CourseOutlineView.as_view(), name="outline"),
|
|
13
|
+
path("courses/create/", av.CreateCourseView.as_view(), name="create-course"),
|
|
14
|
+
path("courses/clone/", av.CloneCourseView.as_view(), name="clone-course"),
|
|
15
|
+
path("courses/delete/", av.DeleteCourseView.as_view(), name="delete-course"),
|
|
16
|
+
path("courses/settings/", av.UpdateCourseSettingsView.as_view(), name="course-settings"),
|
|
17
|
+
|
|
18
|
+
path("blocks/create/", av.CreateBlockView.as_view(), name="create-block"),
|
|
19
|
+
path("blocks/create-tree/", av.CreateBlockTreeView.as_view(), name="create-block-tree"),
|
|
20
|
+
path("blocks/update/", av.UpdateBlockView.as_view(), name="update-block"),
|
|
21
|
+
path("blocks/publish/", av.PublishBlockView.as_view(), name="publish-block"),
|
|
22
|
+
path("blocks/delete/", av.DeleteBlockView.as_view(), name="delete-block"),
|
|
23
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safety rails for state-changing MCP tools.
|
|
3
|
+
|
|
4
|
+
An MCP key is driven by an autonomous agent, not a human clicking a button. The
|
|
5
|
+
failure mode designed against is a *looping* agent — a retry storm that mass-
|
|
6
|
+
enrols or deletes. Four rails, applied by the `audited_write` decorator:
|
|
7
|
+
|
|
8
|
+
1. Live authority re-check (the IsStaffOrSuperuser permission already does this
|
|
9
|
+
per request; audited_write assumes it ran).
|
|
10
|
+
2. Rate limit — per (key, tool) fixed window. Turns a runaway loop into ~N calls.
|
|
11
|
+
3. Confirm token — a write with no token performs a DRY RUN and returns a preview
|
|
12
|
+
plus a single-use token bound to a fingerprint of the exact payload. Re-send
|
|
13
|
+
with the token to apply. Changing the payload invalidates the token.
|
|
14
|
+
4. Append-only audit — intent recorded before the write; the write is refused if
|
|
15
|
+
the record cannot be persisted.
|
|
16
|
+
|
|
17
|
+
The confirm-token store and rate-limit counter use the ambient Django cache. In
|
|
18
|
+
production that must be a shared backend (Redis/memcached); under LocMemCache
|
|
19
|
+
(dev) both are per-process.
|
|
20
|
+
"""
|
|
21
|
+
import functools
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from django.core.cache import cache
|
|
29
|
+
from rest_framework import status
|
|
30
|
+
from rest_framework.response import Response
|
|
31
|
+
|
|
32
|
+
from ...models import MCPAuditLog
|
|
33
|
+
from .auth import get_key, require_scopes
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
CONFIRM_TOKEN_TTL_SECONDS = 300
|
|
38
|
+
_CONFIRM_PREFIX = "mcp:confirm:"
|
|
39
|
+
_RL_PREFIX = "mcp:rl:"
|
|
40
|
+
|
|
41
|
+
# Per-(key, tool) fixed-window limits: (max_calls, window_seconds). Tuned to the
|
|
42
|
+
# blast radius of one call — mass tools get the tightest budget.
|
|
43
|
+
RATE_LIMITS = {
|
|
44
|
+
"enroll_user": (60, 60),
|
|
45
|
+
"unenroll_user": (60, 60),
|
|
46
|
+
"bulk_enroll": (5, 300),
|
|
47
|
+
"create_user": (20, 300),
|
|
48
|
+
"set_role": (30, 60),
|
|
49
|
+
"deactivate_user": (10, 300),
|
|
50
|
+
"create_course": (10, 300),
|
|
51
|
+
"clone_course": (5, 600),
|
|
52
|
+
"delete_course": (5, 600),
|
|
53
|
+
"create_xblock": (200, 60),
|
|
54
|
+
"update_xblock": (200, 60),
|
|
55
|
+
"publish_xblock": (100, 60),
|
|
56
|
+
"delete_xblock": (60, 60),
|
|
57
|
+
"update_course_settings": (60, 60),
|
|
58
|
+
}
|
|
59
|
+
DEFAULT_RATE_LIMIT = (30, 60)
|
|
60
|
+
|
|
61
|
+
_SENSITIVE = {"confirm_token", "password", "key", "secret", "token"}
|
|
62
|
+
_MAX_LIST_SAMPLE = 20
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fingerprint(tool, user_id, payload):
|
|
66
|
+
blob = json.dumps({"tool": tool, "user": str(user_id), "payload": payload},
|
|
67
|
+
sort_keys=True, default=str)
|
|
68
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def issue_confirm_token(tool, user_id, payload):
|
|
72
|
+
token = secrets.token_urlsafe(24)
|
|
73
|
+
cache.set(f"{_CONFIRM_PREFIX}{token}", _fingerprint(tool, user_id, payload),
|
|
74
|
+
CONFIRM_TOKEN_TTL_SECONDS)
|
|
75
|
+
return token
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def consume_confirm_token(token, tool, user_id, payload):
|
|
79
|
+
if not token:
|
|
80
|
+
return False
|
|
81
|
+
ck = f"{_CONFIRM_PREFIX}{token}"
|
|
82
|
+
stored = cache.get(ck)
|
|
83
|
+
if not stored or stored != _fingerprint(tool, user_id, payload):
|
|
84
|
+
return False
|
|
85
|
+
cache.delete(ck) # single use
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_rate_limit(tool, key_id):
|
|
90
|
+
"""Fixed-window counter per (key, tool). Fails OPEN on cache errors."""
|
|
91
|
+
limit, window = RATE_LIMITS.get(tool, DEFAULT_RATE_LIMIT)
|
|
92
|
+
try:
|
|
93
|
+
window_start = int(time.time()) // window
|
|
94
|
+
ck = f"{_RL_PREFIX}{key_id}:{tool}:{window_start}"
|
|
95
|
+
cache.add(ck, 0, window)
|
|
96
|
+
try:
|
|
97
|
+
count = cache.incr(ck)
|
|
98
|
+
except ValueError:
|
|
99
|
+
cache.set(ck, 1, window)
|
|
100
|
+
count = 1
|
|
101
|
+
return (count <= limit), window
|
|
102
|
+
except Exception as exc: # noqa: BLE001 — fail open, audit is the real guard
|
|
103
|
+
log.warning("rate-limit check failed tool=%s key=%s: %s", tool, key_id, exc)
|
|
104
|
+
return True, 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _redact(payload):
|
|
108
|
+
out = {}
|
|
109
|
+
for k, v in (payload or {}).items():
|
|
110
|
+
if k.lower() in _SENSITIVE:
|
|
111
|
+
out[k] = "[redacted]"
|
|
112
|
+
elif isinstance(v, list):
|
|
113
|
+
out[k] = {"count": len(v), "sample": [str(x)[:200] for x in v[:_MAX_LIST_SAMPLE]],
|
|
114
|
+
"truncated": len(v) > _MAX_LIST_SAMPLE}
|
|
115
|
+
elif isinstance(v, dict):
|
|
116
|
+
out[k] = _redact(v)
|
|
117
|
+
elif isinstance(v, str):
|
|
118
|
+
out[k] = v[:500]
|
|
119
|
+
else:
|
|
120
|
+
out[k] = v
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _unpack(result):
|
|
125
|
+
if isinstance(result, tuple):
|
|
126
|
+
return result[0], result[1]
|
|
127
|
+
return result, 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def audited_write(tool, scope="", require_confirm=True):
|
|
131
|
+
"""Wrap a DRF view method with the write rails.
|
|
132
|
+
|
|
133
|
+
The wrapped method is called as `method(self, request, *args, confirmed=bool,
|
|
134
|
+
**kwargs)` and must return a Response or `(Response, affected_count)`. When
|
|
135
|
+
`confirmed` is False it MUST NOT write — return a preview of what *would*
|
|
136
|
+
happen. With require_confirm=False the method is always called confirmed=True.
|
|
137
|
+
"""
|
|
138
|
+
def decorator(view_method):
|
|
139
|
+
@functools.wraps(view_method)
|
|
140
|
+
def wrapper(self, request, *args, **kwargs):
|
|
141
|
+
if scope:
|
|
142
|
+
require_scopes(request, scope) # raises PermissionDenied on miss
|
|
143
|
+
|
|
144
|
+
key = get_key(request)
|
|
145
|
+
key_id = key.pk if key else None
|
|
146
|
+
user_id = request.user.id
|
|
147
|
+
payload = request.data if isinstance(request.data, dict) else {}
|
|
148
|
+
plan = {k: v for k, v in payload.items() if k != "confirm_token"}
|
|
149
|
+
summary = _redact(plan)
|
|
150
|
+
|
|
151
|
+
def _audit(outcome, affected=0, error=""):
|
|
152
|
+
MCPAuditLog.objects.create(
|
|
153
|
+
key=key, user=request.user, tool=tool, scope=scope,
|
|
154
|
+
outcome=outcome, request_summary=summary,
|
|
155
|
+
affected_count=affected, error=error[:2000],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
allowed, window = check_rate_limit(tool, key_id or user_id)
|
|
159
|
+
if not allowed:
|
|
160
|
+
_audit("denied", error="rate limited")
|
|
161
|
+
limit, _ = RATE_LIMITS.get(tool, DEFAULT_RATE_LIMIT)
|
|
162
|
+
return Response(
|
|
163
|
+
{"error": "Rate limit exceeded for this tool.",
|
|
164
|
+
"limit": limit, "window_seconds": window},
|
|
165
|
+
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if require_confirm:
|
|
169
|
+
confirmed = consume_confirm_token(payload.get("confirm_token"),
|
|
170
|
+
tool, user_id, plan)
|
|
171
|
+
if not confirmed:
|
|
172
|
+
_audit("dry_run")
|
|
173
|
+
resp, _ = _unpack(view_method(self, request, *args, confirmed=False, **kwargs))
|
|
174
|
+
if resp.status_code < 400 and isinstance(resp.data, dict):
|
|
175
|
+
resp.data["dry_run"] = True
|
|
176
|
+
resp.data["confirm_token"] = issue_confirm_token(tool, user_id, plan)
|
|
177
|
+
resp.data["confirm_expires_in_seconds"] = CONFIRM_TOKEN_TTL_SECONDS
|
|
178
|
+
resp.data["next_step"] = (
|
|
179
|
+
"Re-send the identical request with this confirm_token to apply. "
|
|
180
|
+
"Single-use, valid only for this exact payload."
|
|
181
|
+
)
|
|
182
|
+
return resp
|
|
183
|
+
|
|
184
|
+
_audit("attempt")
|
|
185
|
+
try:
|
|
186
|
+
result = view_method(self, request, *args, confirmed=True, **kwargs)
|
|
187
|
+
except Exception as exc: # noqa: BLE001 — record then re-raise
|
|
188
|
+
_audit("error", error=str(exc))
|
|
189
|
+
raise
|
|
190
|
+
resp, affected = _unpack(result)
|
|
191
|
+
_audit("success" if resp.status_code < 400 else "error",
|
|
192
|
+
affected=affected,
|
|
193
|
+
error="" if resp.status_code < 400 else str(getattr(resp, "data", ""))[:500])
|
|
194
|
+
return resp
|
|
195
|
+
return wrapper
|
|
196
|
+
return decorator
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""LMS ops endpoints — certificates, async reports (grade export), retirement.
|
|
2
|
+
Reads require staff/superuser; writes add scope + rails via audited_write."""
|
|
3
|
+
from rest_framework.response import Response
|
|
4
|
+
|
|
5
|
+
from ...models import Scope
|
|
6
|
+
from ...native import certificates as nc
|
|
7
|
+
from ...native import reports as nrep
|
|
8
|
+
from ...native import retirement as nret
|
|
9
|
+
from .base import MCPView
|
|
10
|
+
from .guards import audited_write
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# --- certificates ---
|
|
14
|
+
|
|
15
|
+
class UserCertificateView(MCPView):
|
|
16
|
+
def get(self, request, username, course_id):
|
|
17
|
+
return Response(nc.get_user_certificate(username, course_id))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserCertificatesView(MCPView):
|
|
21
|
+
def get(self, request, username):
|
|
22
|
+
return Response(nc.list_user_certificates(username))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CourseCertificatesView(MCPView):
|
|
26
|
+
def get(self, request, course_id):
|
|
27
|
+
return Response(nc.list_course_certificates(
|
|
28
|
+
course_id, limit=int(request.query_params.get("limit", 200)),
|
|
29
|
+
offset=int(request.query_params.get("offset", 0))))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GenerateCertificateView(MCPView):
|
|
33
|
+
@audited_write("generate_certificate", scope=Scope.WRITE_USERS, require_confirm=False)
|
|
34
|
+
def post(self, request, confirmed=True):
|
|
35
|
+
d = request.data
|
|
36
|
+
# Batch when no username given (whole course / a student_set).
|
|
37
|
+
if not d.get("username"):
|
|
38
|
+
return Response(nc.generate_course_certificates(
|
|
39
|
+
request, d["course_id"], student_set=d.get("student_set"))), 1
|
|
40
|
+
return Response(nc.generate_user_certificate(d["username"], d["course_id"])), 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RegenerateCertificatesView(MCPView):
|
|
44
|
+
@audited_write("generate_certificate", scope=Scope.WRITE_USERS, require_confirm=True)
|
|
45
|
+
def post(self, request, confirmed=True):
|
|
46
|
+
d = request.data
|
|
47
|
+
if not confirmed:
|
|
48
|
+
return Response({"would_regenerate": d["course_id"], "statuses": d["statuses"]})
|
|
49
|
+
return Response(nc.regenerate_course_certificates(
|
|
50
|
+
request, d["course_id"], d["statuses"])), 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InvalidateCertificateView(MCPView):
|
|
54
|
+
@audited_write("invalidate_certificate", scope=Scope.DESTRUCTIVE, require_confirm=True)
|
|
55
|
+
def post(self, request, confirmed=True):
|
|
56
|
+
d = request.data
|
|
57
|
+
if not confirmed:
|
|
58
|
+
return Response({"would_invalidate": d["username"], "course_id": d["course_id"]})
|
|
59
|
+
return Response(nc.invalidate_certificate(
|
|
60
|
+
request, d["username"], d["course_id"], notes=d.get("notes", ""))), 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- async reports (grade export) ---
|
|
64
|
+
|
|
65
|
+
class SubmitReportView(MCPView):
|
|
66
|
+
@audited_write("submit_report", scope=Scope.READ, require_confirm=False)
|
|
67
|
+
def post(self, request, confirmed=True):
|
|
68
|
+
d = request.data
|
|
69
|
+
return Response(nrep.submit_report(
|
|
70
|
+
request, d["course_id"], kind=d.get("kind", "grades"),
|
|
71
|
+
features=d.get("features"))), 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ReportTasksView(MCPView):
|
|
75
|
+
def get(self, request, course_id):
|
|
76
|
+
return Response(nrep.list_tasks(course_id))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ReportDownloadsView(MCPView):
|
|
80
|
+
def get(self, request, course_id):
|
|
81
|
+
return Response(nrep.list_report_downloads(
|
|
82
|
+
course_id, config_name=request.query_params.get("config", "GRADES_DOWNLOAD")))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- account retirement ---
|
|
86
|
+
|
|
87
|
+
class RetirementStatusView(MCPView):
|
|
88
|
+
def get(self, request, username):
|
|
89
|
+
return Response(nret.retirement_status(username))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RequestRetirementView(MCPView):
|
|
93
|
+
@audited_write("request_retirement", scope=Scope.DESTRUCTIVE, require_confirm=True)
|
|
94
|
+
def post(self, request, confirmed=True):
|
|
95
|
+
d = request.data
|
|
96
|
+
full = d.get("full", True)
|
|
97
|
+
if not confirmed:
|
|
98
|
+
return Response({"would_retire": d["username"],
|
|
99
|
+
"mode": "full (irreversible, clears PII)" if full else "queue-only"})
|
|
100
|
+
return Response(nret.request_retirement(d["username"], full=full)), 1
|