Skip to content

feat: standardize the registration, sign-in and password reset APIs - #39331

Draft
Abdul-Muqadim-Arbisoft wants to merge 82 commits into
openedx:masterfrom
edly-io:feat/api-adr-user-v2-phase-e
Draft

Abdul-Muqadim-Arbisoft wants to merge 82 commits into
openedx:masterfrom
edly-io:feat/api-adr-user-v2-phase-e

Conversation

@Abdul-Muqadim-Arbisoft

@Abdul-Muqadim-Arbisoft Abdul-Muqadim-Arbisoft commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of [API] User, #39058, under the API standardization umbrella #38137 (FC-0118). This PR applies the platform's REST API decisions and OEP-69 to the last group of the user API: registration, sign-in, password reset and registration validation. The decisions are
0025 ·
0026 ·
0027 ·
0028 ·
0029 ·
0030 ·
0031 ·
0032 ·
0033 ·
0034 ·
0035 ·
0036 ·
0037 ·
0038,
plus OEP-66 for scoping.

Stacking and merge order: #39297, then #39314, then #39330, then this PR. This branch is built on #39330
(feat/api-adr-user-v2-phase-d1 @ ed83beab3d), which is built on #39314 (feat/api-adr-user-v2-phase-c @
cab976a910); #39297 is a sibling in the same series and merges first. Those three carry the version 2 package
(openedx/core/djangoapps/user_api/v2/), its fallback route, and the superseded-address machinery in
openedx/core/djangoapps/user_api/schema.py, which the LMS and Studio share and this PR extends. This PR's diff is
the 23 commits on top of feat/api-adr-user-v2-phase-d1 @ ed83beab3d, head be57050bad. Every "base" below
means that commit. For the sibling Phase D2, see "Merging with Phase D2".

This is a URL-conformance and documentation change only. Nothing changes in any body, status code,
authentication, permission, CSRF, cookie or rate-limit behaviour at any address, old or new. No file under
openedx/core/djangoapps/user_authn/ is edited.

Why new addresses, and why in version 2

The nine operations are reached today at addresses the rest of the user API no longer uses:

address what does not conform (ADR 0038)
/api/user/{v1,v2}/account/registration/ singular collection account, and a second collection nested under it with no identifier between them (rules 2, 8, 9)
/api/user/{api_version}/account/login_session/ the same, and one route answering two versions through an {api_version} path parameter (rules 6, 7)
/api/user/v1/validation/registration no trailing slash (rule 6)
/api/user/v1/account/password_reset/token/validate/ singular collection, nesting without an identifier, and a verb (rules 2, 8, 10)

Registration and sign-in already have a published version 2 contract: /api/user/v2/account/registration/
and the v2 arm of the sign-in route, which the authentication front end calls today. One version cannot carry
two contracts for the same operation, so the conforming addresses keep exactly the version 2 contract. They are
added to the existing version 2 package, which has not been released yet. A rename alone is not a contract
change and needs no version bump (ADR 0037; the migration paragraph of ADR 0038). Password reset and
registration validation exist only in version 1. They join version 2 with their contract unchanged.

Change table

The diff is 23 commits. The first three carry the change. Nineteen came out of review and each corrects a
published description, a comment or a test of these three. The last declares, in every served document, the CSRF
scheme the sign-ins name. None of them changes how any operation answers.

commit endpoint(s) legacy behaviour kept new behaviour
feat: serve registration, sign-in and password reset under the user API's version 2 GET,POST /api/user/v2/registrations/ · POST /api/user/v2/registration_validations/ · GET,POST /api/user/v2/login_sessions/ · GET /api/user/v2/password_resets/ · POST /api/user/v2/password_reset_token_validations/ every legacy route, URL name, view and operationId is untouched five conforming addresses. Each is served by a subclass of the view serving the older address, which adds only permission_classes = (AllowAny,) and its published description. Bodies and codes are documented
feat: mark the older registration, sign-in and password reset addresses superseded the nine legacy operations (six published keys) they still resolve to the same views, with the same names and operationIds deprecated: true in the LMS and Studio documents. Eight of them now publish the bodies and codes their replacement publishes
docs: describe the order this version's mount is matched in as it now is none none stale comments in user_api/v2/urls.py corrected; no route changes
19 review commits (fix: / test: / docs:) the five new operations and eight superseded ones unchanged published shapes: a nullable label and hint, the 406 and 415 refusals, the unparsable-body 400, the three ways of registering, the token check's 403 causes, and the members the new sign-in's 403 always carries. The narrow LMS document leaves out the templated sign-in address. Tests of signals, events and third-party sign-in during form reads, of shared limits in every direction, and of the members each published body promises, held to the bodies really sent. Corrected comments and docstrings, UnknownAddressView's among them
fix: describe the CSRF token in every document whose sign-in names it the sign-ins naming csrf: the older one in both services' /api-docs/ (as on master), the new one in both LMS documents each operation's security is unchanged every served document declares components.securitySchemes.csrf, from one definition both services append; Studio appends only that scheme

The new addresses

operation URL name (user_v2:) served by replaces
GET /api/user/v2/registrations/ registration_list authn_views.RegistrationView(register.RegistrationView) GET /api/user/v1/account/registration/, GET /api/user/v2/account/registration/
POST /api/user/v2/registrations/ registration_list same POST of both
POST /api/user/v2/registration_validations/ registration_validation_list authn_views.RegistrationValidationView(...) POST /api/user/v1/validation/registration
GET /api/user/v2/login_sessions/ login_session_list authn_views.LoginSessionView(login.LoginSessionView); the route binds api_version="v2" GET /api/user/{api_version}/account/login_session/ (v1 and v2)
POST /api/user/v2/login_sessions/ login_session_list same POST of the same key (v1 and v2)
GET /api/user/v2/password_resets/ password_reset_list authn_views.PasswordResetView(...) GET /api/user/v1/account/password_reset/
POST /api/user/v2/password_reset_token_validations/ password_reset_token_validation_list authn_views.PasswordResetTokenValidation(...) POST /api/user/v1/account/password_reset/token/validate/

The token stays in the request body, because a secret is never put in an address.

Why a subclass and not a second route to the same view. Every behaviour is inherited through the parent's
decorated methods: ensure_csrf_cookie, csrf_exempt, csrf_protect, the ratelimit decorators,
sensitive_post_parameters, the third-party sign-in flow and the cookies. So there is still one code path. A
class of its own is needed for the documentation only. The sign-in handler carries its own extend_schema
describing the version 1 request (email), and drf-spectacular publishes a method's own annotation in place of
anything else. A second route to the same view would therefore document a body that version 2 refuses. All five
addresses use the same shape.

The rate-limit counters stay shared between the old and new addresses. django-ratelimit groups its counters by
the decorated method and the view class's name, so each subclass keeps its parent's name. Spend the limit at one
address and the other refuses you. This is tested in both directions for registration (both older addresses and
/create_account against the new one), the token check, registration validation, and the sign-in limit per
client (both versions of the older address and /login_ajax against the new one). The sign-in limit per account
is tested in both directions between /api/user/v2/account/login_session/ and the new address.

ADRs applied

  • 0025 serializers — not applied; deferred to a later version: the five classes add no handler of their own (a method written on a subclass would start the rate limits counting afresh), so every body comes from the frozen authentication views, and serializers there mean new handler code; every exchange is documented in the schema instead.
  • 0026 permissions — permission_classes = (AllowAny,) declared on all five views; every legacy check is inherited unchanged (Authorization table).
  • 0027 schema / docs — every new operation documented with each code it is measured to answer, and the members each answer promises held to the bodies really sent; eight superseded operations gain the same bodies; zero removed paths, operationIds or components, base → 8eb071be02, in all five documents; each served document declares every credential its operations name; recorded gaps in the matrix.
  • 0028 viewsets — APIView subclasses: collections that are only posted to or described; registering and signing in answer with a session and a redirect, not a resource read back here.
  • 0029 errors — not applied; deferred to a later version of the package: clients read the legacy error members (error_code, context.failure_count, field-keyed user_message lists, value); /api/user/v2/ therefore answers in two error shapes until then (Error-format decision).
  • 0030 GET idempotence — the form GETs write only the caller's session entry and send no other signal or event, identically on both mounts; the one documented exception is the session engine (matrix).
  • 0031 merged endpoints — considered, not applied: registration and its validation, the reset form and the token check, sign-in and registration are different resources, and a merge would put differently rate-limited handlers behind one route.
  • 0032 pagination — n/a: no operation returns a list.
  • 0033 filtering — n/a: no list endpoint, and no filter or sort parameter on any of the nine operations.
  • 0034 authentication — inherited: four views keep their commented authentication_classes = [], the token check keeps the platform default; nothing redeclared.
  • 0035 MFE config — n/a: no endpoint here serves site or front-end configuration; each form description describes one form and is not shared-cached.
  • 0036 nested JSON — n/a: every body is flat or one level deep (a form description is a list of field objects).
  • 0037 versioning — extends the existing, unreleased version 2 package; legacy views untouched; superseded operations marked only by deprecated: true; one recorded deviation: no docstring marker.
  • 0038 URL structure — plural nouns, a closing slash, the version as a literal, no nesting without an identifier, POST to a noun; every legacy URL name kept.
  • OEP-66 queryset scoping — n/a as a mechanism: no list endpoint and no queryset; the three layers are stated in the matrix.
  • OEP-69 conventions — URL names in snake_case, version-free, <basename>_list like the version's router names.

Compliance matrix

Tests are in openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py unless another file is named.
"Studio" means cms/lib/tests/test_spectacular.py. Gate lines come from the LMS run at 8eb071be02 against
base ed83beab3d, unless a row names the run against master, made at the same tree (Gate report below says how
8eb071be02 and head differ).

Decision Status How it is met, or the concrete reason it is excluded Evidence (test / gate)
0025 serializers not applied (later version) The five classes add no method of their own. A request runs the authentication app's own method with its decorators, and a method written on the subclass would start the rate limits counting afresh (see "Why a subclass"). The bodies are built in that frozen code. Registration and sign-in build theirs by hand, and the authentication front end parses them (error_code, context.failure_count, field-keyed user_message lists). The token check and registration validation answer with a DRF Response of a dict. Bringing serializers in means handler code of their own. For registration and sign-in it also means different bytes and error members. The ADR's version clause applies here: these responses cannot move to serializers without new handler code, so they ride the package's next version. No serializer is written; every exchange is documented in the schema instead (0027). ViewShapeTests::test_each_method_hands_the_request_to_the_one_it_extends; PublishedShapeTests::* (5, each published body validated against real responses)
0026 permissions met permission_classes = (AllowAny,) is declared on all five views. The token check identifies callers with the platform's JWT and session authentication. On it, the platform's JWT middleware adds NotJwtRestrictedApplication at runtime, as it does on the older token check, so a JWT issued to a restricted application is refused with 403 at both. Every legacy check stays inline and is inherited: the rate limits, CSRF, the third-party sign-in switches, the token's owner check and the deployment's filter steps. None moves into a permission class, because each answers with a status or body that clients read. ViewShapeTests::test_each_view_declares_only_who_may_call_it, ::test_the_extended_views_are_left_as_they_were; TokenValidationParityTests::test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused; deny paths per address under Authorization
0027 schema / docs met, with recorded gaps Each new operation publishes a summary, a description, its request (the media types the handler reads, and required where an empty body is refused) and every code the handler is measured to answer with, including 406 for an unusable Accept and 415 for an unreadable body. The registration request names the three ways of registering (password, or social_auth_provider, or provider with access_token) as anyOf alternatives. The 400 of registration and sign-in is oneOf the field-keyed body and DRF's {detail} for a body that cannot be parsed. Eight superseded operations publish the same objects. Every real body the suite receives at these operations is also held to the members its operation and code publish as always present: they must equal the members the body carries, less those a table in the test module lists as carried in some cases only, each with its case, and each listed member is shown carried by one real body and left out of another. That check found one wrong shape, now corrected: the new sign-in's 403 (a provider account linked to no account here) always carries value and error_code, but was published with the 400 refusals' shape, which promises success alone. It now promises all three; the schema gate cannot see a required list, so the evidence is the one-leaf document diff (Backward compatibility). Recorded gaps: (1) no documented 4xx references the shared ErrorResponse component, because these views do not produce it (0029). (2) The older posted sign-in keeps the description on its own frozen handler: the version 1 request (email), and neither 406, 415 nor the {detail} form of its 400, although it answers all three. Fixing that needs an edit to the frozen view, so that one address is left out of /lms-api/schema/; it is still marked in both service-wide documents. (3) Closed: the sign-in operations name a csrf security requirement (the older sign-in in both services' /api-docs/, as on master; the new sign-in in both LMS documents), and until this change only the docs.docs_settings document declared that scheme. Both services now append it from one definition, CREDENTIAL_SCHEMES in openedx/core/djangoapps/user_api/schema_components.py (apiKey, header X-CSRFToken), so every served document declares every credential its operations name. Studio's /authoring-api/schema/, built from the same settings, carries the scheme although none of its operations names it. The docs.docs_settings document still declares its own csrf, unchanged. (4) The registration request is stricter than the server in one case. During a third-party sign-in, a body with neither password nor social_auth_provider is accepted (measured 200), and no alternative admits it. The form lists that member whenever a known provider's sign-in is under way. The two third-party ways apply only where sign-in through other providers is on, which the shape's description says; elsewhere such a body is refused with the published 400. (5) Studio's service-wide document publishes the form and success bodies of the four registration operations, as every document does. With stock Studio settings, the form GET and a valid registration are answered with 500 instead, because REGISTRATION_FIELD_ORDER and ENABLE_AUTHN_REGISTER_HIBP_POLICY are LMS settings. (6) A field a site adds whose initial value or choice values are not text is emitted as it is (for example true), while the shape publishes text or null. (7) Some optional members are published that no answer from the platform's own code carries: redirect in the new sign-in's refusals, and on its 403 the optional members of the 400 refusals' shape, which it shares. ConformingDocumentTests::* (9), SupersededDocumentTests::* (7), PublishedShapeTests::* (5), RefusedRepresentationTests::* (3), RegistrationRequestShapeTests::* (3), ExtensionFormShapeTests::test_a_field_the_site_adds_is_described_as_published, SignInWithAnExposedPasswordTests::* (2), test_the_members_published_as_always_present_are_held_to_the_ones_each_body_carries (twelve mutations, first comment); Studio SupersededUserOperationTest::test_each_superseded_operation_publishes_what_it_reads_and_what_it_answers_with, ::test_a_registration_names_the_fields_the_stock_form_requires, ::test_each_registration_sign_in_and_reset_operation_publishes_its_refusals_of_a_media_type; schema gate RESULT: PASS (0 BREAKING, 12 WARN, 15 INFO); lms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::* (2), cms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::* (3) (six mutations, each red)
0028 viewsets met APIView subclasses. Each address is a collection that is only ever posted to or described. Registering and signing in answer with a session and where to go next rather than with the account, which is read at accounts/{username}/ like any other. The reset form, the token check and the validation describe or check without creating a resource. A ViewSet conversion would also start the rate limits counting afresh. ViewShapeTests::test_each_method_hands_the_request_to_the_one_it_extends; ConformingAddressTests::test_each_address_is_answered_by_a_view_extending_the_one_serving_the_older_address; RegistrationParityTests::test_the_account_registered_is_read_where_every_account_is
0029 errors not applied (later version) Clients are coupled to the legacy error bodies, and the ADR lets such APIs version (see Error-format decision). The new addresses answer with exactly those bodies and document them. the parity tests compare every refusal body on both mounts (Backward compatibility)
0030 GET idempotence met, one documented exception The registration and sign-in form GETs write only the caller's session entry, which holds third-party sign-in state. With third-party sign-in off they write nothing, and the reset form never writes. No Django signal and no openedx-event is sent from any GET or HEAD of the three forms, in five configurations: third-party sign-in on and off, and three sign-ins through another provider under way. Both mounts behave identically. Exception (decision 4, legacy behaviour inherited unchanged): under the db or cached_db session engine, that session write is a Session model save, which sends Django's pre_save and post_save for Session. The test suite runs the db engine, and the signal test excludes exactly those two signals, for the Session sender only. Under the default cache engine no model is saved. FormReadWithThirdPartyAuthTests, FormReadWithoutThirdPartyAuthTests, FormReadDuringThirdPartySignInTests, …WithoutProfileFillingTests, …ThroughAnUnknownProviderTests (13 tests: test_a_read_writes_only_the_session_and_the_same_way_at_both_addresses, test_a_read_sends_no_signal_or_event_at_either_address, test_each_form_is_built_for_the_sign_in_under_way); spy self-tests test_each_way_of_sending_is_recorded_even_with_events_turned_off, test_a_write_is_told_by_its_first_keyword_whatever_follows_it
0031 merged endpoints considered, not applied Registration and its validation, the reset form and the token check, and sign-in and registration are different resources, not an action-named family. A merge would put differently rate-limited handlers behind one route. none: nothing is merged, so there is nothing to test
0032 pagination not applicable No operation returns a list. none
0033 filtering not applicable No list endpoint, and no filter or sort parameter on any of the nine operations. none
0034 authentication met (inherited, unchanged) Four views keep authentication_classes = [], commented at the parent's declaration: they are anonymous entry points, and a session the caller holds is deliberately not consulted. The token check keeps the platform default, and a session whose account was made inactive is answered as no session at both addresses. Nothing is redeclared in new code. The new sign-in publishes the older sign-in's csrf requirement, and every served document now declares that scheme (apiKey, header X-CSRFToken), appended by both services from one definition; no authentication or CSRF behaviour changes. ViewShapeTests::test_how_a_caller_is_identified_is_the_extended_views; RegistrationParityTests::test_credentials_in_a_header_are_not_read, LoginParityTests::test_credentials_in_a_header_are_not_read; TokenValidationParityTests::test_credentials_that_cannot_be_read_are_refused, ::test_a_session_whose_account_was_made_inactive_is_answered_as_no_session; ConformingDocumentTests::test_signing_in_publishes_the_security_the_older_sign_in_publishes; lms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::* (2), cms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::* (3)
0035 MFE config not applicable No endpoint here serves site or front-end configuration. Each form description describes one form, sets the CSRF cookie and can carry third-party sign-in state, so it is not shared-cached. none
0036 nested JSON not applicable Every body is flat or one level deep. A form description is a list of field objects whose length is set by site configuration. none
0037 versioning met (one recorded deviation) This extends the existing, unreleased version 2 package rather than adding a version. There is no contract change anywhere, and the superseded operations are marked only by deprecated: true. Deviation: the superseded views carry no docstring marker, contrary to the ADR's docstring clause. The ground is the FC-0118 review direction given on #39100 and #39102: the PR that introduces a replacement adds no deprecation note in code. #39128 is already accepted, so OEP-21's one-line Deprecated <link> marker would be allowed; if a reviewer asks for a marker, that one line is what gets added. versions gate RESULT: PASS (against base and against master, both at 8eb071be02); SupersededDocumentTests::test_each_operation_is_marked_superseded_in_both_documents; Studio SupersededUserOperationTest::test_every_operation_of_those_addresses_is_marked_superseded, which reads Studio's own document, is green at base and head, and goes red when one key is dropped from the tuple (mutations, first comment)
0038 URL structure met registrations, registration_validations, login_sessions, password_resets and password_reset_token_validations are plural nouns with a closing slash and the version as a literal, with no nesting without an identifier, a POST to a noun, and the token in the body. Every legacy URL name is kept; one route serves one address. ConformingAddressTests::* (7); test_urls.py::RouteShapeTests::test_every_address_is_named_and_ends_in_a_slash; urls gate RESULT: PASS (0 FAIL, 1 WARN). The WARN is the verb heuristic matching "login" in login_sessions, which names the session a POST creates
OEP-66 scoping not applicable as a mechanism No list endpoint and no queryset. Layer 1: AllowAny. Layer 2: the one per-caller rule, under which a signed-in caller is told a token is valid only for its own account, is a per-request answer and is inherited. Layer 3: there is no filter, sort or shaping parameter. TokenValidationParityTests::test_a_signed_in_caller_is_told_only_about_its_own_account
OEP-69 conventions met URL names are snake_case and version-free, <basename>_list as the version's router names them. test_urls.py::UrlLiteralTests::test_names_are_snake_case_and_carry_no_version, ::test_reverse_literals; ConformingAddressTests::test_each_name_reverses_to_its_address

Backward compatibility

Trees: base = feat/api-adr-user-v2-phase-d1 @ ed83beab3d (#39330), head = be57050bad, master =
d178206575. Each number names the pair it compares. Full outputs: first comment. The documents,
gates, test ids and wide runs were made at 8eb071be02, which differs from head in one statement of Studio's
settings only (Gate report). URL names were dumped at 7325fef195; no later commit touches a route.

  • Every legacy address, URL name, view and operationId is unchanged. URL snapshot, base → 7325fef195: LMS
    3059 → 3064 routes, 2537 → 2542 names, 0 removed, 5 added under user_v2; Studio 2193 routes and 1790 names,
    unchanged. Master → 7325fef195: LMS 2513 → 2542 names, 0 removed. The URL gate at 8eb071be02 counts the same
    3059 → 3064 routes. user_authn/urls_common.py and every user_authn view module are absent from the diff.
  • Behaviour parity, tested on both mounts (test_authn_addresses.py, 138 tests at 8eb071be02). The tests compare
    status, Content-Type, the set of cookies set, and the body: byte-equal for the three forms, and member for
    member for the POSTs (per-request values such as a generated username are compared by type). Covered: success;
    every refusal (empty, duplicate, country, version 2's confirm_email, wrong password, inactive account,
    missing field, a deployment's own filter steps, the two switches that close registration or require another
    provider, an unlinked third-party account, an unparsable body); CSRF on both sides; malformed credentials and
    a restricted-application JWT; rate limits with a real cache, shared both ways; HEAD, OPTIONS and refused
    methods.
  • The one intended difference is the OPTIONS description, because each class describes itself. It is
    declared in the parity harness as OPTIONS_DIFFERENCES and asserted to occur.
  • Sign-in at the new address answers as version 2. email_or_username is read, and email alone is refused,
    as at the v2 arm of the old route. Registration at the new address answers as version 2. A configured
    confirm_email is offered and required, as at /api/user/v2/account/registration/.
  • Query counts are equal on both mounts for every operation (test_*_takes_as_many_queries_at_either_address,
    green at 8eb071be02); the absolute numbers in the first comment are from 0156789248, on the earlier base
    24b8a7bde5.
  • Legacy suites are green and unedited. Old-tests gate at 8eb071be02: LMS 820 passed, 4 skipped
    (user_authn/, lms/tests.py, the third-party sign-in specs, profile_images/, test_apidocs.py); Studio 177
    passed, 384 skipped. No file of those suites is in the diff.
  • Test ids, base → 8eb071be02, none removed: LMS (user_api/, profile_images/, lms/lib/tests/,
    lms/tests.py, test_apidocs.py) 2125 → 2268; user_authn/ 556 → 556; Studio cms/lib/tests/ 35 → 43. Each
    was measured in three steps, base → 7325fef195 → 47925143f3 → 8eb071be02, and no step removes an id.
  • Schema, base → 8eb071be02: zero removed paths, operationIds and components names of any kind in all five
    documents (LMS /lms-api/schema/, LMS and Studio /api-docs/, Studio /authoring-api/schema/, and the
    docs.docs_settings document). The schema gate flags two lines in each service-wide document, declared below.
    Two later changes are invisible to schema_diff.py, which reads each pair as PASS (0 BREAKING, 0 WARN, 0 INFO)
    in every document, so the leaf diffs are the evidence. From 7325fef195 to 47925143f3, one leaf changes, in the
    three LMS documents only: the new sign-in's 403 required goes from success to success, value,
    error_code (0027 row); the tool does not compare a response's required list. From 47925143f3 to
    8eb071be02, each of the four served documents gains components.securitySchemes.csrf (4 leaves, nothing
    else) and the docs.docs_settings document is byte-identical; the tool does not look at securitySchemes.
  • Schema, master → 8eb071be02: schema_diff.py reports exactly the two declared lines per service-wide
    document beyond master → base, and nothing else; it cannot see the added csrf scheme. No path, operationId or
    component is removed against master in any of the five documents, at base or at 8eb071be02. The one schema
    component Studio's /authoring-api/schema/ holds beyond master's comes from feat: standardize the account retirement pipeline as /api/user/v2/ #39330 (first comment).
  • Code that replaces the older view's handler at runtime reaches only the older addresses if made after this
    version's URLconf loads (measured; first comment).

Declared schema-gate lines (documentation correction, not a contract change)

LMS /api-docs/, Studio /api-docs/ and the docs.docs_settings document, each (base -> head and master -> head):
  BREAKING POST /api/user/v1/account/registration/
           request body newly required
  BREAKING POST /api/user/v2/account/registration/
           request body newly required

These two operations published no request body at all until now. They now publish the registration body and
mark it required, because the handler refuses a request without one. An empty registration is answered 400,
naming every required field, at both addresses and at the replacement
(RegistrationParityTests::test_an_empty_registration_names_every_field_it_needs). The handler is unchanged, and
the document now says what it always did. The gate cannot tell a corrected description from a tightened
contract.

In-place edits

user_api/v2/ is the current version package. It has not been released, and it arrives with this stack, so its
new modules and routes extend the target version. The versions gate at 8eb071be02 against base gives
RESULT: PASS: 3 files added inside user_api/v2/, 8 allowed (six in user_api/v2/ and the two settings
modules), and 5 other files outside any version directory. All 16 follow, with their category.

file category what changed, and the evidence
user_api/v2/authn_views.py, authn_schemas.py, tests/test_authn_addresses.py (new) current-version addition the five views, their published shapes, and their tests
user_api/v2/urls.py current-version addition and URL conformance five routes with URL names. Comments were corrected to the real match order (the authentication app's routes are matched before this mount) and to the real reason these are collections; no existing route changed. Evidence: urls gate, test_url_mount.py, test_urls.py
user_api/v2/views.py docstring only UnknownAddressView says that most views of this version answer with the JSON envelope and that the five addresses here answer with the bodies of the views they extend. Before, it claimed one body for every failure of the version
openedx/core/djangoapps/user_api/schema.py deprecation marking, and a documentation correction of eight superseded operations. Not an opt-in addition six keys appended to SUPERSEDED_USER_PATHS, which the existing hook openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths marks in both services' documents. Eight entries were added to superseded_bodies(), with one lazy import of authn_schemas; they change what eight frozen operations publish for every reader of the service-wide documents, and two of those changes are the declared lines above. Evidence: five documents generated at base, 7325fef195, 8eb071be02 and master
lms/lib/spectacular.py scope of the LMS's own document _is_published_user_path admits a superseded address only when it does not take its version as a parameter (_VERSION_PARAMETER), so the templated sign-in key stays out of /lms-api/schema/ (0027 gap 2) and is still marked in both service-wide documents. At base no superseded key had {api_version}, so every earlier entry is admitted as before. Evidence: SupersededDocumentTests::test_the_narrow_document_leaves_out_the_address_that_takes_its_version_as_a_parameter, lms/lib/tests/test_spectacular.py::LmsApiFilterTest::test_keeps_the_authentication_addresses_this_version_supersedes_and_none_beside_them
openedx/core/djangoapps/user_api/schema_components.py documentation correction of every served document gains CREDENTIAL_SCHEMES, holding the csrf scheme (apiKey, header X-CSRFToken); the module docstring is corrected to match, and the module still imports nothing. Evidence: ServedCredentialSchemesTest in both services
lms/envs/common.py, cms/envs/common.py documentation correction; one append each, to the schema settings only APPEND_COMPONENTS gains securitySchemes: the LMS appends dict(CREDENTIAL_SCHEMES), and Studio {'csrf': CREDENTIAL_SCHEMES['csrf']}, the only credential its operations name. Studio imports nothing from lms. Evidence: the leaf diff above
lms/lib/tests/test_spectacular.py, cms/lib/tests/test_spectacular.py, user_api/v2/tests/test_schema.py, test_urls.py, test_url_mount.py, test_views.py tests of the stack, extended every changed existing assertion is listed below; cms/lib/tests/test_spectacular.py only gains tests and table entries, and both lib/tests/test_spectacular.py gain ServedCredentialSchemesTest, which requests each served document

No hook or schema class was added or reordered, and the settings modules change only as above. The superseded
views are not in the diff: they carry no deprecation notice.

Each existing assertion this PR changes against base (seven tests in three files, plus tables extended and two
docstrings) is listed in the first comment.

Merging with Phase D2 (#39334, a sibling branch, also built on #39330)

Whichever of this PR and Phase D2 merges second meets these. Beyond them, the only conflicts are entries both
branches add to the same tables (SUPERSEDED_USER_PATHS and the test tables beside it, in five files); keep both
sides' entries:

  • openedx/core/djangoapps/user_api/schema_components.py: both add CREDENTIAL_SCHEMES. Join the entries and keep
    all three: jwt and bearer from Phase D2 (the credentials its mobile clients send) and csrf from this PR.
    Correct the comment above the mapping so it no longer counts the entries. It should say that the schema class
    describes the session cookie and nothing else.
  • lms/envs/common.py: both add the same 'securitySchemes': dict(CREDENTIAL_SCHEMES), line, which merges cleanly.
  • Studio keeps 'securitySchemes': {'csrf': CREDENTIAL_SCHEMES['csrf']}. It describes only the token its own sign-in
    names, not credentials no Studio operation uses.
  • Phase D2's Studio test test_no_operation_here_names_a_credential_only_the_other_service_describes goes red after
    the merge, because Studio's sign-in now names csrf, which joins the mapping. Resolve it by tightening, not by
    exempting.
    Delete the named & set(CREDENTIAL_SCHEMES) == set() assertion, tighten
    named - described <= {"csrf"} to named - described == set(), and drop the docstring's last sentence about the
    one named exception. The tightened form is strictly stronger, and it holds no hard-coded name.

The conflicts above are table appends, and a union of them can parse and pass every test while a test silently
stops running. Before trusting a resolution: parse each resolved file and check that every expected test is a method
inside the class it belongs to (not merely present in the file); check that no def test_... sits at module level;
check that no dict or set literal holds a duplicated key; and compare the collected test ids against both parents
under LC_ALL=C, not their counts.

Error-format decision

Unchanged; versioned later. The legacy bodies are what clients parse today. The authentication front end reads
error_code, context.failure_count and the field-keyed user_message lists, and the legacy sign-in page
reads error_code and value, among other members. So the new addresses answer with exactly those bodies and
document them as they are. Three consequences, stated so no one has to discover them:

  1. /api/user/v2/ answers in two error shapes. Most views of feat: standardize the user accounts, profile image, verification and name-change API #39297, feat: standardize the user preferences and email opt-in API #39314 and feat: standardize the account retirement pipeline as /api/user/v2/ #39330 answer with the
    standardized envelope. So does the version's fallback route, and the fallback is what answers each new address
    sent without its closing slash. The five new addresses answer with the authentication app's bodies, and with
    DRF's {detail} for the refusals DRF itself produces (406, 415, 405, the unparsable body). A version 2 client
    of both families needs both parsers. This is pinned by
    RegistrationParityTests::test_the_methods_it_does_not_serve_are_refused_alike and by the fallback envelope
    tests in test_urls.py.
  2. "A later version" means the package's next version, /api/user/v3/. This API versions per package, so
    the standardized envelope (and JSON in place of Django's HTML page for a missing CSRF token) reaches these
    five resources with that version, not separately.
  3. Recorded for that version, unchanged here: Django's HTML 403 for a missing CSRF token; the HTML 403s
    behind two non-default switches; and a 200 carrying an error body. The 200 comes back when a
    deployment's own filter step refuses a registration without giving a status (the StudentRegistrationRequested
    filter in register.py). The published 200 shape does not describe that body. It is reachable at the new
    registration address exactly as at the older ones, and stock settings configure no such step. The suite sends
    no such request, so the checks of published members against real bodies (0027 row) do not cover it.

Deprecations

The nine operations below are superseded by the addresses above. They are marked deprecated: true in the
published schemas: the LMS's own document, and the service-wide documents of the LMS and of Studio, which both
serve them. The superseded views are unchanged: there is no response header and no deprecation note in code.

The replacements are served by the LMS only. Studio mounts no version 2 URLconf. Its document marks the nine
operations because Studio serves them, but a client following a deprecation to a replacement address on the
Studio host gets a 404 (SupersededUserOperationTest::test_no_address_replacing_a_superseded_one_is_published_here).

superseded replacement what a client changes
GET /api/user/v1/account/registration/ GET /api/user/v2/registrations/ the address; a configured confirm_email field is now listed (version 2)
POST /api/user/v1/account/registration/ POST /api/user/v2/registrations/ the address; a configured required confirm_email is enforced (version 2)
GET /api/user/v2/account/registration/ GET /api/user/v2/registrations/ the address only
POST /api/user/v2/account/registration/ POST /api/user/v2/registrations/ the address only
GET /api/user/v1/account/password_reset/ GET /api/user/v2/password_resets/ the address only
POST /api/user/v1/account/password_reset/token/validate/ POST /api/user/v2/password_reset_token_validations/ the address only
POST /api/user/v1/validation/registration POST /api/user/v2/registration_validations/ the address, which now has a closing slash
GET /api/user/{api_version}/account/login_session/ (v1, v2) GET /api/user/v2/login_sessions/ the address only (same bytes)
POST /api/user/{api_version}/account/login_session/ (v1, v2) POST /api/user/v2/login_sessions/ for a v2 client, the address only. A v1 client also sends email_or_username in place of email, which is the existing difference between the two versions

The unversioned twins are not marked: /user_api/v1/account/registration/,
/user_api/v2/account/registration/, /user_api/{api_version}/account/login_session/,
/user_api/v1/account/password_reset/ and /user_api/v1/account/password_reset/token/validate/. Neither is
/create_account. The /user_api/v1/ twins of every address superseded earlier in this series are not marked
either, and deprecating the twins is one decision for all of them at once.

Removal prerequisites. The DEPR entry carries these too.

  1. The stock LMS still renders its own sign-in page while ENABLE_AUTHN_MICROFRONTEND is off, which is its
    default. That page posts to the form description's submit_url, which names the v1 sign-in address with
    the v1 field email. Before the v1 arm can be removed, the form description has to name
    POST /api/user/v2/login_sessions/ and email_or_username.
  2. The registration form's submit_url names /api/user/v2/account/registration/ for every non-v1 request,
    including those at the new address. Before that address can be removed, it has to name
    POST /api/user/v2/registrations/.
  3. Callers to move first: the Android app posts POST /api/user/v1/account/registration/. The authentication
    front end posts the v2 registration and sign-in addresses and the v1 validation address.

This is tracked on the FC-0118 umbrella DEPR ticket, #39128, which removes them one named release after the
release this ships in. This area's entry is posted there. Of the umbrella's markers, only
the schema flag is applied: there is no Warning header and no separate docs page.

Authorization

No authorization changes. Every legacy check stays where it is and is reached unchanged through inheritance.
None moves into a permission class, because each answers with a status or body that clients read.

address authentication (inherited) checks (all inherited, all tested on both mounts) deny-path tests
registrations none ([], commented at the parent) ratelimit → 403 forbidden-request, counter shared with both older addresses and /create_account (refused registrations count too); required third-party sign-in → 403 HTML; account creation closed → 403 HTML; a filter step; duplicate → 409; unknown country → 400; CSRF exempt RegistrationLimitTests::* (4); RegistrationParityTests::test_a_site_that_requires_another_provider_refuses_a_password, ::test_a_site_that_creates_no_accounts_here_refuses_the_registration, ::test_a_step_the_deployment_adds_refuses_it_at_both, ::test_credentials_in_a_header_are_not_read
password_resets none none PasswordResetFormParityTests::test_the_methods_it_does_not_serve_are_refused_alike
password_reset_token_validations platform default (JWT, session) anonymous throttle → 429, shared; session without CSRF → 403; JWT issued to a restricted application → 403 (platform middleware, at both); malformed JWT → 401; another account's token → is_valid: false; a session of an inactive account is answered as no session TokenValidationLimitTests::* (3); TokenValidationParityTests::test_a_session_caller_sending_no_csrf_token_is_refused, ::test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused, ::test_credentials_that_cannot_be_read_are_refused, ::test_a_signed_in_caller_is_told_only_about_its_own_account, ::test_a_session_whose_account_was_made_inactive_is_answered_as_no_session
registration_validations none ratelimit(block=True) → 403, shared RegistrationValidationLimitTests::* (2)
login_sessions none csrf_protect → 403 HTML; the limit per client → 400, shared with both versions and /login_ajax, tested each way; the limit per account → 400, shared with /api/user/v2/account/login_session/, tested each way; required third-party sign-in → 403 HTML; unlinked third-party account → 403 JSON; a filter step; wrong password or inactive account → 400 LoginParityTests::test_a_sign_in_without_a_csrf_token_is_refused; LoginLimitTests::* (5); LoginParityTests::test_a_site_that_requires_another_provider_refuses_a_password, ::test_a_provider_account_linked_to_no_account_here_is_refused, ::test_a_wrong_password_is_refused, ::test_an_inactive_account_is_refused

The area has no role check, and none is added.

Gate report

run_gates.sh, MongoDB attached, --allow on all 16 files of the diff, each tree clean. None SKIPPED. All three
runs are at 8eb071be02. Head be57050bad is the same 23 commits with one statement of the last one changed:
Studio's append in cms/envs/common.py, from dict(CREDENTIAL_SCHEMES) to {'csrf': CREDENTIAL_SCHEMES['csrf']},
with the comment above it extended and one sentence added to the commit message. Both build the same mapping
today: CREDENTIAL_SCHEMES holds only csrf. At head, cms/lib/tests/test_spectacular.py gives 43
passed, ruff passes, and the repository CI lint below passes every step.

gate LMS against base ed83beab3d LMS against master d178206575 Studio against base ed83beab3d
versions RESULT: PASS RESULT: PASS RESULT: PASS
hygiene RESULT: PASS (0 FAIL, 0 WARN), 16 files RESULT: FAIL (1 FAIL, 5 WARN): all on the stack's lines (H3 at v2/tests/test_views.py:1500; H3/H7 WARNs on earlier classes of the stack) RESULT: PASS (0 FAIL, 0 WARN), 16 files
schema RESULT: PASS (0 BREAKING, 12 WARN, 15 INFO) RESULT: PASS (0 BREAKING, 88 WARN, 83 INFO) check-present RESULT: FAIL — fewer than 1 paths: Studio serves no /api/user/v2/ path, by design
urls RESULT: PASS (0 FAIL, 1 WARN) RESULT: FAIL (2 FAIL, 3 WARN): R6/R8 on the stack's fallback route api/user/v2/{_}., already failing master → base RESULT: PASS (0 FAIL, 0 WARN)
old-tests 820 passed, 4 skipped 820 passed, 4 skipped 177 passed, 384 skipped
tests 1530 passed 1530 passed 43 passed

Every RESULT, WARN, BREAKING and FAIL line equals the one at 47925143f3; only the tests counts move (LMS 1528
→ 1530 in both runs, Studio 40 → 43): the five new tests. The first comment explains the 12 WARNs.

Repository CI lint (the steps of quality-checks, pylint-checks for the groups holding these files,
lint-imports, ci-static-analysis, verify-dunder-init and commitlint, each run on a snapshot of one
commit): base ed83beab3d, 8eb071be02 and head be57050bad each pass every step, and head has no new
finding
; its outputs equal 8eb071be02's apart from timing lines and line order. lint-imports keeps 4 contracts,
0 broken. commitlint passes all 23 commits.

Operational note

Traffic at the five new addresses is reported under the transaction names of
openedx.core.djangoapps.user_api.v2.authn_views.*, not the user_authn.views.* names of the older addresses.
Dashboards and alerts on sign-in or registration failure rates that key on the old names see only the older
addresses' share as callers move.

Follow-ups

  • Next version of these resources (/api/user/v3/): serializers and the standardized error envelope (0025,
    0029), JSON in place of Django's HTML CSRF page, the 200-with-error-body case above, and a sign-in form
    description naming the version 2 fields.
  • Before removal: the three prerequisites above.
  • No SDK change. None of the new operations is tagged for the generated client (the earlier phases tag only the
    account family), and adding the tag later is an addition, never a removal.
  • docs/lms-openapi.yaml is not regenerated here. It carries none of the version 2 work of this stack.

The user directory, the stored-preference listings and the time-zone
reference list were the least conforming endpoints of the User API: the
only gate was the deprecated X-Edx-Api-Key header, which ignores
request.user entirely, so any holder of the key could read every user's
email address and full preference mapping. The listings were addressed by
database id, two of them put the parent key in the path instead of the
query string, the time-zone list returned an unbounded array, the stored
preference row embedded its owner's entire record (one extra query per
row), and the forum-role listing created the role it was asked about on
every GET.

Each of those is a contract change, so they land on a new version and
/api/user/v1/ is untouched and keeps serving them.

What callers get instead:

  GET /api/user/v2/users/                              (also ?preference_key=,
                                                        ?forum_role=&course_key=)
  GET /api/user/v2/users/{username}/
  GET /api/user/v2/user_preferences/
  GET /api/user/v2/user_preferences/{username},{key}/
  GET /api/user/v2/time_zones/

Authenticated staff callers rather than an API key, the standard error
envelope, the standard pagination envelope on all three lists, filter
sets and a deterministic ordering, and the ?view= and ?fields= response
presets. The forum-role filter no longer creates a role, and an
unparsable course key is a validation error rather than a 500.

Being refused is part of the contract, so the refusals are explicit. A
deactivated staff account is turned away on both paths rather than only
one: the session authenticator already rejected it, but IsAdminUser reads
only is_staff and the JWT authenticator does not read is_active, so such
an account could read the whole directory until its token expired. These
addresses require an active user.

An unrecognised query parameter is a 400 rather than silence. A client
porting from a superseded address by changing the version segment alone
would otherwise pass a filter this version does not define, have it
ignored, and receive every row believing the page was filtered. The
allowlist is derived from the paginator, the ordering backend, the filter
set and the query serializer, so it cannot drift away from the filters it
describes.

Shared building blocks are used rather than rewritten:
StandardizedErrorMixin, DefaultPagination, IterablePaginationMixin,
ScopedQuerysetMixin with FullScopePolicy, MinimalViewMixin and project.
The two local classes each supply one predicate the library does not,
and each docstring says which.

The tests pin behaviour rather than structure: every allowed and every
refused caller with the error type asserted, value-level parity with each
superseded address, query counts that stay constant across 1, 5 and 25
rows where the superseded listing pays per row, SQL captures proving no
GET writes, and the URL literals each name reverses to.
The LMS drf-spectacular pre-processing hook is an allowlist and admitted
only /api/enrollment/vN/, so everything under /api/user/ generated,
routed and tested while being invisible in the published document. The
new v2 addresses would have shipped undocumented and the schema gate
would have reported the prefix missing with no clue as to why.

The filter now admits exactly what this change owns: the v2 addresses,
matched by their view package rather than by path prefix, plus the seven
legacy paths they supersede, so each superseded address appears beside
its replacement. It is deliberately not a /api/user/vN/ prefix. Admitting
the whole prefix pulled thirty-eight further v1 operations into the
document -- undocumented request bodies and internal docstrings published
as client-facing text -- none of which this change owns or documents.

SCHEMA_PATH_PREFIX becomes a two-branch pattern, stripped only to derive
tags and operationIds. Every existing enrollment operationId was compared
before and after and is unchanged, and so is every operationId, path and
component of the service-wide /api-docs document: a generated client
derives a module per operationId and a model per component, so a rename
there deletes a module as surely as a removal does. The title follows the
contents, and the three comments that named the old title follow it.

Deprecation is marked where it belongs, outside the frozen modules: the
existing post-processing hook gains a tuple of the seven exact legacy
paths this change supersedes. Exact paths, not a prefix, so an address
whose replacement nobody has written yet can never be marked -- the hook
test includes neighbouring user addresses, the unversioned twins and
another area's paths and asserts they are left alone.

POSTPROCESSING_HOOKS is unchanged: setting it replaces drf-spectacular's
defaults, so the enum hook stays restated and stays first.
…api/user/v2/

The account surface was the largest non-conforming part of the User API and the
one most callers depend on: frontend-platform's account fetch, the account and
profile MFEs, and both mobile apps all read it. Ten of its operations answered
in shapes nothing documented, refused malformed input with server faults, and
addressed one account's records through a permission that read a username from
somewhere other than the address.

Every change here is a contract change, so they land on a new version:
/api/user/v1/ is untouched and keeps serving every one of its callers.

What this version answers:

  GET    /api/user/v2/accounts/                     (?username=, ?email=,
                                                     ?lms_user_id=, ?view=,
                                                     ?fields=, ?ordering=)
  GET    /api/user/v2/accounts/me/
  GET    /api/user/v2/accounts/{username}/
  PATCH  /api/user/v2/accounts/{username}/
  POST   /api/user/v2/accounts/{username}/deactivation/
  POST   /api/user/v2/email_searches/
  POST   /api/user/v2/name_changes/
  POST   /api/user/v2/name_changes/{username}/confirmations/
  GET    /api/user/v2/accounts/{username}/verification_status/
  GET    /api/user/v2/accounts/{username}/verifications/

A refusal is part of the contract, so the refusals are explicit. The request
body is measured against a serializer before the account service reads it, so a
wrongly typed property is a validation envelope rather than a server fault; a
failure inside the service is a server fault rather than a validation envelope.
A permission reads the username from the address and from nowhere else, and
compares it exactly, because the handler behind it selects the record by that
same value. An account that is no longer active is refused on both halves of the
default authentication pair rather than only one.

Lists answer in the standard envelope, bounded and ordered by username; a filter
that matches nothing answers an empty page rather than reporting the address as
missing, and a status that has never been established answers "none" rather than
reporting the account as missing. An email search is bounded at a hundred
addresses, which bounds its answer.

The schema describes what the code does: the superseded addresses publish the
media types they actually read, every operation publishes the codes it really
answers, and the response components carry the properties the bodies really
hold, with presets modelled rather than implied.

The tests pin behaviour rather than structure. Every allowed and every refused
caller is named with the error type asserted; value-level parity is checked
against each superseded address; query counts are measured on the earlier
endpoint first and asserted not to grow with the collection; SQL captures prove
no read writes, including writes deferred to commit; and each guard was checked
by breaking what it covers and watching the right tests fail.
…rofile_image/

The profile-image endpoints are addressed under the User API but served by a
view that belongs to the profile_images app, so the conforming version lives in
that app's own new version package and is mounted into the user version two
URLconf exactly as the earlier view is imported today. The app's existing
modules are untouched, and the image validation they own is called rather than
copied.

  POST   /api/user/v2/accounts/{username}/profile_image/
  DELETE /api/user/v2/accounts/{username}/profile_image/

An upload is read before anything is stored, so a truncated file, metadata that
cannot be parsed, or a header declaring more pixels than the site will ever draw
are answered as the caller's error rather than as a server fault, while a
failure to store a well-formed image stays a server fault. The media types the
address reads are published, and the body is published as required.

A caller may set and clear the image on the account the address names and on no
other, staff included, since clearing an image is a change to an account rather
than a moderation tool. The permission reads the username from the address and
compares it exactly, because that is the value the stored names are built from,
so the account a caller is admitted for is always the account written to.

The tests drive real uploads: each refusal with its error type, parity against
the earlier address for the bodies both answer, and a check that a caller
holding another spelling of the owner's name is refused and stores nothing.
This package is shared with the CMS, and the CI shard that runs it under
cms.envs.test could not import this module at all: it read
settings.ACCOUNT_VISIBILITY_CONFIGURATION, an LMS setting, in the module body.
Collection failed, which failed the shard, which cancelled the rest of the
matrix.

Every test here that reads these sets is already @skip_unless_lms, but a module
body runs before any skip can apply, so the setting is now read through the same
guard user_api/accounts/tests/test_views.py uses for it, and the field sets fall
back to empty when it is absent.

Under cms.envs.test this module now collects, and the failures left in these two
packages are the same 44 as on master, in the same tests -- an artefact of
running two of the shard's sixty-nine paths rather than anything in this branch.
Under lms.envs.test nothing changes.
Six operations move to this version's conventions: a learner's preference
document and its individual members, addressed under the account they belong
to, and the email opt-in as a collection of its own.

  GET, PATCH          /api/user/v2/accounts/{username}/preferences/
  GET, PUT, DELETE    /api/user/v2/accounts/{username}/preferences/{key}/
  POST                /api/user/v2/email_opt_ins/

A preference name is the whole final segment, punctuation included, so a name
carrying a slash has no address of its own; it is read with the whole document
and removed by sending a null for it there.

The superseded /api/user/v1/ addresses keep answering exactly as they did and
are marked deprecated in the published LMS schema. The unversioned twin
/user_api/v1/preferences/email_opt_in/ is the same view at a second address: it
keeps resolving and carries no schema mark, which is how the other unversioned
twins are treated.
…uth class

Four views declare their own authentication_classes so that a caller who has
not yet confirmed their email address can still reach their own account, its
picture, its preferences and its email choice: the session class the platform
configures refuses such a caller, so that half is replaced.

For the token half they named DefaultJwtAuthentication. That class exists only
to be named in DEFAULT_AUTHENTICATION_CLASSES -- its own module says it is only
meant for that -- and the using_default_auth_classes attribute it records exists
so an operator can find the endpoints that are on the defaults. A view that
declares its own classes is not one of those, so recording the attribute filed
a false signal and polluted the query the attribute serves. Nothing on master
declares the class: two files name it, both definitional, against the library
class used throughout the rest of the platform.

The four views now name JwtAuthentication, the class the platform's own pair is
built on. Two of these addresses, the account and its picture, were added
earlier on this branch and are corrected in place here.

The guards assert the attribute is absent, each alongside a control request to
a sibling address that declares no classes and must still record it, so a pin
cannot pass merely because a patch target was mistyped.
An audit across the three phases on this branch found four defects, each
introduced by an earlier phase and each visible to a caller or to a generated
client. They are corrected together here, as the JWT class was, rather than left
to differ between sibling addresses of one version.

The profile image published a username with no pattern, the only one of the
eleven username path parameters in this version without it, so a generated
client validated the name before the request on nine addresses of the resource
and not on the tenth. The parameter helpers and the username pattern move from
views.py into view_utils.py, which that module already imports from, and the
parameter is built with the helper. The helper being private to one module was
the cause, and a further module would have hit it again.

The stored-preference resource pinned one operation id inline, which applies to
every document, so the service-wide document published that operation beside a
collection read carrying a different prefix, and a client generated from it
derived the two from different modules. The pin moves into the per-document
table beside the entries that already do this, so the resource's operations
share their leading tokens in each document. One id changes, in the service-wide
document only, to a name that document has never published.

The username filter accepted a single exact name on two collections and a
comma-separated list on a third, so a caller who learned the list form on the
account collection was answered a 200 with an empty page rather than told the
filter does not take a list. All three now accept the list, through one shared
selector, matching the member address of that resource, which already reads a
comma as a separator.

The email search was the one collection in this version reading accounts
directly without declaring a scoping seam, while the two collections over the
same table declare one. It now declares the same policy through a queryset the
shared mixin can wrap. No caller gains or loses access and no row changes: the
policy is a pass-through today, and the seam is what was missing.
Three operations describe the body they read in place rather than through a
serializer. A shape given in place carries no field the generator can read
requiredness from, so the document published no mark at all and a generated
client made the body an argument the caller may omit, while each handler
answers a request carrying no body with a 400.

The addresses are named explicitly and each is driven in the tests with its
form named and no body behind it, so the mark the document carries is held to
what the handler answers. The write of one stored preference is left unmarked
on the same evidence: it takes a request with no body.
…er them

Both documents already published these four addresses with a 200 and no body
before their bodies were described. No handler answers that: each answers 204.
The corrected code is now pinned in both documents and each handler is driven
on its success path in the same test, so the published code and the answered
one can only move together.

Replaces the pin that covered two of the four in one document.
…here too

Studio mounts the user API at the same addresses the LMS does and publishes
its own unauthenticated document at /api-docs/, built from its own settings,
so none of the marking applied in the LMS reached it: all twenty-five of those
operations were published there as current while the LMS published them as
superseded. A client generated from Studio's document therefore offered a
caller an address the LMS document steers them off.

The list of superseded addresses and the hook that reads it are shared rather
than restated, so the two services cannot drift apart. Measured on the two
generated documents: Studio marks 25 of 25, as the LMS does, and nothing else
in Studio's document changes -- no path, operation, operationId or component
is added or lost. The narrow Authoring API document Studio also serves carries
none of these addresses, so the hook cannot reach it.
…ettings

The documentation settings replaced the appended components wholesale rather
than adding the security schemes to them, so any component name the service
settings append was dropped from the settings the published reference is
generated from. Nothing showed it: the name simply stops being in the
reference, and in every client built from it, while every count stays where it
was.

They now add to what they inherit. The test loads those settings in a process
of its own -- loading them here would turn every feature flag on and add
Studio's applications to the settings this process already runs under -- and
checks that a component the service settings append arrives alongside the
security schemes.
Asking for a page of nothing, of fewer than nothing, or of something that is
not a whole number of rows was answered 200 with the default page of ten, on
every collection of this version that pages its rows. The framework drops a
size it cannot honour and falls back without telling the caller, so the page
reads as the one that was asked for; the links on it carry the value back, so
a client that follows them keeps sending a number nothing reads.

The shared paginator now reads the value itself and refuses one no page can
have, the way this version already refuses a query parameter it does not
read, a sort field it does not offer and a boolean naming neither side. A
number above what a page carries is still answered with a full page, which is
what it has always meant, and a value carrying nothing still leaves the size
alone.
The standard error body carries one line for a person to read beside the list
of what each parameter or property was refused for. Where the refusal named
parameters, that line was produced by printing the list itself, so a caller
read back the brackets, the quoting and the class names that write a data
structure out instead of a sentence. Everything that puts the line in front
of a person showed it: the message of the exception a generated client
raises, a line in an integration log, the notification a page puts up.

The line is now written from the same values as a sentence, naming each
parameter and what it was refused for; a refusal that names nothing in
particular is spelled on its own. The list itself is untouched, and a refusal
that never had a list -- an address that matches nothing, a caller that may
not read, a method the address does not serve -- keeps the line it had.
…ved it to

A member address of the account collection was answered by matching the name it
carried against the name each record that came back is stored under, by a
reduced form of that name failing that, and anything matching neither was
dropped. The database settles which stored records a name resolves to, and the
name it resolves to need not be written the way the address wrote it: where it
resolved to a record neither comparison matched, the list came back empty, the
member handler indexed the first element of it, and an authenticated caller was
answered 500. The superseded address answers 200 with that record, so the
replacement was worse than the address it stands in for, and a client or MFE
that pads or transliterates a name turned a working read into a server fault.

A member address is now answered from the records the lookup resolved it to:
the one stored under exactly the name given when there is one, and otherwise
the single record that came back. An address the lookup resolves to several
records, none of them stored under the name given, names no one of them, so it
is refused with 400 rather than answered with whichever came back first. A
lookup that comes back with nothing is reported as an absent address rather
than as a fault.

The page of a list keeps the order its names were asked in, which is what the
matching is for; a record it resolved to under a name none of them is written
as is now carried at the end of the page rather than left out of it in silence.

Tests: a member address the lookup resolves to a record stored differently, one
it resolves to nothing, one it resolves to several records with and without an
exact match among them, and a page carrying a record resolved under another
name. Each fails against the previous code, the first three with the 500 this
corrects.
The account collection selects by the email address of an owner with a lookup
that takes one record and refuses to answer at all where there is more than
one. Nothing in the user model keeps one address to one account, so a staff
caller looking up an address two accounts hold was answered 500, with nothing
in the answer to say why. The superseded address fails the same way, so this
was carried forward rather than introduced.

The selector now names every account holding the address, and the page carries
them all. A collection is what this address answers, and a page of the accounts
holding an address is already what the search over addresses beside it answers
with, so the page is the answer here whether the address is held once or
several times. An address no account holds still leaves the page empty.

Tests: the selector asked for an address two accounts hold, and the collection
read by a staff caller for the same address, asserting both accounts in the
page. Both fail against the previous code, the second with the 500 this
corrects.
…source

The accounts are one collection however many addresses reach into them, and
six operations that read or change a row of it were served by views standing
apart from it: the identity verification summary and attempts, the preference
document and one preference of it, and setting and clearing the image, each
declaring a flat list of classes of its own, none of them reading what the
account resource admits a caller by. A rule declared for that resource
therefore did not reach them. Measured with a rule admitting nobody added to
the resource: the account read answered 403 while the summary, the attempts
and the preferences answered 200, one preference and an upload reached their
handlers, and clearing the image answered 204 and cleared the stored image --
a change to an account carried out while every read of the same account was
refused. Deactivating an account read nothing of the resource either.

The classes the resource admits a caller by are now declared once and carried
by every address that works on one of its accounts, which applies them first
and the classes of its own address after. The same measurement now refuses all
eight, and the stored image is left alone. The declaration is read from the
shared tuple rather than from the collection's own attribute because that
attribute is added to in place by the first request to reach a view that reads
a token, which would otherwise make admission depend on which addresses a
process had served already.

The account an address names is resolved through the resource as well, by one
rule: the record stored under exactly the name given, or the single record the
name came back with, and otherwise a refusal. Three operations resolved the
record they write to with a lookup that takes one record and fails outright on
more, each catching only the not-found case, while the class in front of them
had settled the question on the name as text. Making a password unusable,
confirming a profile name and recording an image can therefore no longer land
on a record nobody addressed: each is refused with 400 and writes nothing.
Recording an image is looked up again by the writing helper, which this
version does not own, so the refusal is raised where that lookup comes back
with more than one record.

The preference addresses keep their own two-tier rule -- a read of somebody
else's is a privilege, a change of it belongs to the owner -- and declare it
through the hook the shared gate calls, so both tiers still apply and the
resource's rules apply before them.

One test outside this version's own modules moves with it: the preference
guard harness handed a bare request to classes that are given the wrapped one
by the view, and the classes carried in from the resource read the request
through it, so the harness now authenticates the request the way the test
client does.

Tests: every read and every change of one account measured twice, once as they
stand and once with a rule admitting nobody added to the resource, with the
stored image asserted untouched after the refusal; a deactivation refused with
the privilege for it held, so that the refusal measured is the resource's; and
each of the three write targets, refused when the address names no one account
and landing on the account stored under the name when it does. Removing the
shared gate from the four views, or emptying the shared declaration, turns the
refusal tests red.
The answer to an OPTIONS request is built from the parsers and the serializer
declared on the view, which belong to the view rather than to the address
being asked about, and two addresses of this version were described wrongly
because of it.

The preference document of one account is read and changed as a merge patch
and a body in any other media type is refused, which is what the published
document says of it. The description of that address named the three media
types the platform reads bodies in everywhere else and left out the one it
takes, because the parsers follow the action being served and the action
recorded for this question is not the change. A caller negotiating from the
description -- the one mechanism offered for it -- therefore sent a body in a
media type the address refuses and was answered 415, with the two published
descriptions of one address contradicting each other. The parsers now follow
the methods the address serves, and the description reports the ones it would
read a body with.

Confirming a requested profile name carries out the change already asked for
and reads no body, which is what the published operation says. The description
of that address offered a body with a required name in it, built from the
view's serializer, so a client or a form driven by it asked the operator for a
name before confirming the change and sent a body the address ignores. An
action that reads no body is now declared as such and no body is described for
it. The address that asks for a name still describes the one it takes.

Tests: the media types named by each preference address, a change sent in
every media type the document address names being carried out, no body
described for a confirmation, and the body still described for asking. Each
fails against the previous code, the negotiated change with the 415 this
corrects, and each half of the description turns its own tests red when taken
back off.
…ently

An address can have been held and left more than once, so more than one
retirement of it can sit inside the cooling-off period. This version reports
the most recent, being the only one a caller could still undo; the superseded
version reads the same window with a lookup that takes a single record and so
fails outright on the second. The window boundaries are the same in both.

The selection is on a path declared to answer as the superseded one answers,
and this is the one input they answer differently for, so the selector now
says so where the next reader of it will be.
…ddresses too

Studio's name pin covered the addresses this version replaced before the
retirement pipeline joined them; it now covers those as well, each under the
name both documents already carry -- checked against the other service's
document, where none of them is named differently.
The repository's pylint reports four attributes of one retirement test set
outside the constructor. They are declared empty where the rest of the case
is set up, so the names exist before the helper that pairs two records
assigns them, and the helper is unchanged.
The partial update of a version 1 retirement was the only reference to the
retirement-status model, and that operation now publishes the object its
handler really reads, so nothing generates the model any more. The service
serving the newer version of this API appends the model to every document it
builds; the service serving only the older one did not, so a client generated
from its document of everything it serves lost that class.

The definition now sits beside the rest of this API's schema behaviour and is
read from there by both services and by the published reference. It is kept in
a module that imports nothing, because a settings module reads it while the
settings are still being assembled, which is too early for any module that
reads a setting of its own.
…PI's version 2

Registering, signing in and resetting a password are reached today at
addresses the rest of the user API no longer uses: a singular `account`
segment with a second collection nested under it and no identifier between
them, a verb (`token/validate`), a route without a trailing slash, and one
route answering two versions through a `{api_version}` parameter. This adds
the five addresses version 2 names them by:

  /api/user/v2/registrations/                       GET, POST
  /api/user/v2/registration_validations/            POST
  /api/user/v2/login_sessions/                      GET, POST
  /api/user/v2/password_resets/                     GET
  /api/user/v2/password_reset_token_validations/    POST

Nothing changes for any caller of the existing addresses: every route, URL
name and view of the authentication app stays exactly where it was, and no
file of that app is edited.

Each new address is served by a subclass of the view already serving the
older one. The subclass adds `permission_classes = (AllowAny,)`, which is
what those views already allow, and the description it is published with,
and nothing else. Every request is answered by the inherited, decorated
method, so authentication, CSRF handling, cookies, bodies, status codes and
the third-party sign-in flow are the older address's. That includes the
limits on how often a request may be sent: those counters are keyed on the
decorated method and on the view class's name, so the subclasses keep their
parents' names and the counters stay shared between old and new addresses
(spend the limit at one, be refused at the other). Sign-in is bound to
version 2 by its route, and registration answers as the version 2
registration address does, `confirm_email` included.

The bodies are published as shapes written in place, since none of these
views uses a serializer: the form descriptions, the registration and
sign-in success and refusal bodies, the token and validation results, and
DRF's `{detail}` refusals, each only for the codes the handler is seen to
produce. Sign-in publishes the version 2 fields it really reads and the
same security block as the other addresses here.

The new tests send every request to the older address and to the new one
and compare status, media type, cookies and body; cover the callers each
view refuses (CSRF, malformed credentials, rate limits with a real cache,
the switches that close registration or require another provider, a
deployment's own filter steps); pin query counts and that reading a form
writes nothing but the session; and hold every captured body against the
shape the service-wide document publishes for it.
…es superseded

Each of the nine operations the new version 2 addresses replace is now
published with `deprecated: true`, in the service's own document and in the
service-wide documents of the LMS and of Studio, which both serve them:

  /api/user/v1/account/registration/                   GET, POST
  /api/user/v2/account/registration/                   GET, POST
  /api/user/v1/account/password_reset/                 GET
  /api/user/v1/account/password_reset/token/validate/  POST
  /api/user/v1/validation/registration                 POST
  /api/user/{api_version}/account/login_session/       GET, POST

The six published keys are added to the existing superseded-address tuple,
spelled exactly as the generator publishes them, which both admits them to
the service's own document and lets the existing hook mark them. No route,
URL name, operationId or component changes, and nothing is written on the
views themselves. The unversioned `/user_api/...` twins and `/create_account`
are left unmarked, as the twins of every other superseded user address are.

Eight of the nine operations published no body at all, or only a 200 with
none. They now publish the same bodies and codes as the address replacing
each, from the same shapes, so the two cannot drift: the form descriptions,
the registration request and its 400/403/409 refusals, the token and
validation checks with their refusals. The schema gate therefore flags two
lines of each service-wide document as a request body newly required: the two
registration POSTs. Neither published any request body before; both now
publish the registration body as required, because their handler refuses an
empty body with a 400. The
posted sign-in keeps the description its handler has always carried, since
one key serves two versions with different request fields, and a
description on the handler is published in place of any table entry; its
two components stay referenced.

Tests pin the marks in every document, the published names and groups, that
each superseded operation publishes what its replacement does, and that the
hook and the filter leave every neighbour alone: the twins, `/create_account`,
`/login_ajax`, unpublished spellings of the keys, the new addresses and the
version's other addresses.
Two comments in the version's URLconf still said the mount is matched ahead
of the patterns installed plugins contribute, and that the authentication
app's addresses under the prefix are therefore reached through this mount.
Since the mount moved after the plugin patterns, the authentication app,
which is itself contributed as a plugin, answers its own addresses first, and
an address any plugin registers under the prefix reaches that plugin. The
comments now say so: naming the app's addresses keeps the fallback from
matching them, which is what leaves an address sent without its slash to be
redirected rather than refused. No route changes.
…fore any request runs

The token check is answered by views that read signed tokens, and the
platform's middleware adds a permission to such a view's class, in place,
the first time one is requested. Two checks read the classes after the
fact, so they failed whenever a token check had run earlier in the same
process: under a random test order, or with the token test named first.

They now read the classes as the test module is imported, which pytest
does for every module before the first test runs. What they guard is
unchanged: the version 2 views declare only who may call them, and the
views they extend declare no permission of their own. Both still fail
when either declaration changes.
…ly null

A site can add fields to the registration form through a form it names in
its settings. Each such field is described with that form field's label
and starting value, and a Django form field has neither unless it is given
them, so the form description then carries null for both. The published
input shape said both were always text, so on such a site the registration
form at version 2 and at the addresses it replaces failed the shape every
document publishes for it, and a client generated from the document would
reject the whole form.

Both members are now published as text or null, and the starting value's
description says it is null in that case. The new test names an extension
form, reads the form at every address that serves it, and holds each body
against the shape both documents of the service publish.
…ble body

Every registration, sign-in and password reset operation, at version 2 and
at the addresses it replaces, refuses a request whose Accept header names
nothing it can answer in (406), and every one of them that reads a body
refuses one in a media type it cannot read (415). Both are settled before
the handler runs and answered as {"detail": ...}. None of them published
either code, although every other operation of version 2 publishes 406 and
every other one reading a body publishes 415, so a client generated from
any document met a status it was never told about.

Each response table now carries both codes, in the body these views really
answer with and with the descriptions the rest of the version uses. The
tables are shared with the descriptions of the older addresses, so Studio's
document gains the same codes for them.

The new test sends each refusal to every address and holds the answer
against what each document publishes for that code, so a code the handler
produces that is missing from a table fails it even where the pinned list
of codes was changed to match. A second test reads which fields the stock
registration form requires and requires the published registration body to
name exactly those, and Studio's test pins the same list and codes.
…ut of the standardized document

Listing /api/user/{api_version}/account/login_session/ among the superseded
addresses both marks it deprecated wherever it is published and admits it
into the LMS document of the standardized APIs, where it had never been.
Signing in by posting there is published with the description its handler
carries: a request naming `email`, which version 2 does not read, and a
success body requiring members the sign-in never answers with. Nothing in
the schema module can replace a description a handler carries, so
admitting the address put a description known to be wrong into that
document.

The address is now left out of that document, as it was before, while both
service-wide documents still mark both of its operations deprecated and
still carry the two components its description names. The address
replacing it is published in all three as before.

The tests pin that the address and its two components stay out of the
standardized document, and that each superseded operation is marked in the
documents that publish it.
…-in publishes

The sign-in at /api/user/v2/login_sessions/ needs the CSRF token exactly
as the older address does, and the older one publishes that need as a
`csrf` security requirement. The new one published no requirement at all,
behind a comment saying no published document declares that scheme. The
reference generated from docs.docs_settings, which docs/lms-openapi.yaml is
built from, does declare it (the X-CSRFToken header), so a client generated
from it would send the token to the older sign-in and not to the new one,
and its migrated call would be refused.

The new sign-in now keeps the requirement of the view it extends, so every
document publishes the same security for both sign-in addresses. The
documents the services serve still do not declare the scheme, as before
for the older address.

The test requires the new sign-in's security to be the older sign-in's in
both of the service's documents, and every other address here to need
nothing.
The check that reading a form writes nothing recognised only statements
starting INSERT INTO, UPDATE or DELETE FROM. A bulk create that ignores
conflicts sends INSERT OR IGNORE INTO on SQLite and INSERT IGNORE INTO on
MySQL, so a form read doing one was never counted and passed. A statement
now counts as a write when it starts with INSERT, UPDATE, DELETE or REPLACE,
whatever follows, and a write whose table cannot be read is still listed.
Reading a form was guarded only by a capture of the statements it sends
to the database. A Django signal or an Open edX event sent from a form
read, whose receivers do their work elsewhere, sent no statement and so
passed. Each read of the registration, sign-in and password reset forms,
GET and HEAD, at the older address and at this one, now records every
signal sent and every event sent, turned on or not, and expects none
beyond what every request sends and the saving of the caller's session.
…r way

Every capture of what reading a form writes or sends used a new caller
with no sign-in through another provider under way, so the part of the
registration and sign-in form builders that runs only partway through such
a sign-in never ran under it. A write added there passed. Three more
cases now read each form with a sign-in under way: through a provider
that fills the profile in, through one that does not, and through one
this site does not know. Each asserts the same writes and signals at both
addresses, and that the registration form was really built for that
sign-in.
…h 403

The token check is identified by the platform's JWT authentication, so the
platform's middleware adds the permission that refuses a JWT issued to a
restricted application, at the older address and at this one alike. The
published 403 named only the missing CSRF token, and no test sent such a
JWT. The 403 now names both causes, and two tests run at both addresses:
a JWT issued to a restricted application is refused with the same body,
and a session whose account was since made inactive is answered as a
caller who is not signed in.
The sign-in limit tests spent failures from one client, so only the limit
per client was ever exceeded; nothing showed that the limit per account
is counted together at the new sign-in address and the older one. Failures
naming one account, each from a client of its own, are now spent at one
address and refused at the other, both ways, while another account is
still let in. The directions not yet sent are added too: failures at the
version one sign-in and at the older sign-in function count at the new
address, failures there count at the older version two address, and
registrations sent to the version one address or to the older
registration function count at the new one.
A registration that completes a sign-in through another provider carries
no password: the server generates one. The authentication front end leaves
the password out while such a sign-in is under way in the session, and a
client that signed the person in with the provider itself sends the
provider and its access token instead. The published request required a
password of every registration, so a validator or a strict client built
from either document refused both.

The shape now requires the fields every registration needs and, beside
them, one of three things: a password, the provider of the sign-in under
way, or a provider with its access token. An ordinary registration is
still published as needing a password, and the tests send each body to
every registration address after holding it against both documents.
Registration and signing in read a submitted form, which is parsed when
the handler first reads it. A body that cannot be parsed in the media type
it is sent as, such as a multipart body with no boundary, is refused with
400 and a body holding `detail` alone, before the handler can answer with
its own. The 400 of both was published with the handler's body only, so a
client typed from either document could not read that answer: the
registration body needs each member to be a list and the sign-in body
needs `success`.

Each 400 now offers the handler's body or the `{detail}` body, and says
when the second is sent. The older registration addresses share the
description. The test helper that looks for undeclared members now holds a
body to the one shape it satisfies, so the existing checks of these 400s
keep their strength.
…imit

The registration limit counts the requests to register sent from an
address, not the accounts they created: a request refused for a missing
field counts as much as one that registers an account. The published 403
and the operation description said the refusal came after too many
accounts were registered, so an integrator budgeting on accounts created
would be refused sooner than the document said. The existing limit tests
already reach the 403 after refused registrations alone.
Where a site checks passwords against lists of exposed passwords, signing
in with a correct password can still be answered with 400. A password
found often must be changed first and the caller is not signed in. One
found less often is answered with `nudge-password-change` after the caller
has been signed in: the session cookie is set, but not the cookies a
successful sign-in adds. The published 400 said only that signing in had
failed, so a client following it would treat a signed-in caller as signed
out.

The 400 now names both cases and what each leaves behind, and
`redirect_url` says what it holds in the second. The new tests reach each
side of the two thresholds and hold the answer against both documents.
…s in

Two docstrings said the mount for version 2 is matched before the rest
of the user surface and before the authentication app's patterns, so that
an address that app adds under the prefix is answered by this version.
The service matches both before the mount: the include for the rest of
the user surface comes first, then the patterns installed applications
contribute, then the mount. An address the authentication app adds
reaches it either way; only its spelling without the closing slash is
answered by the fallback rather than redirected, until it is named.

The docstrings now say that, and a new test reads the order from the
resolver: the authentication app's routes and the rest of the user
surface come before the mount, and each of the two addresses is matched
first by the app.
…ollections

The comment above the routes said registering an account creates
something no address here reads back. The account a registration creates
is read at accounts/{username}/ like any other. The real reasons are that
each route is answered by a subclass of the authentication app's own view,
so a request runs that view's one code path with its decorators, its CSRF
handling and the limits it shares with the older addresses, and that
registering and signing in answer with a session and where to go next
rather than with the account.

The comment now says that, and a test registers an account and reads it
back at its own address with the session the registration set.
The fallback that refuses an unknown address under version 2 was described
as answering with the same JSON body as every other failure of this
version. The registration, sign-in and password reset addresses of this
version are answered by the authentication app's views, with the bodies
those views have always used, so that is no longer so. The view's
docstring and the comment above its route now say the fallback answers
with the JSON error body most views of this version use, and that those
addresses answer with their own.
…rries

The version 2 sign-in answers 403 with a JSON body in one case only: the
caller signed in through another provider with an account linked to none
here. That body always carries a message (`value`) and a name
(`error_code`, which is `third-party-auth-with-no-linked-account`), but the
documents published it with the shape of the 400 refusals, which marks
only `success` as always present. A client generated from them would treat
both members as optional.

The 403 now marks `success`, `value` and `error_code` as always present.
Nothing changes in what the endpoint answers. The 400 refusals keep their
shape, because each of those members is left out of one of them in some
case.
Checking a real response against the document catches a wrong type or a
missing member, but not a member the document stops promising, because the
body still satisfies a schema that requires less. Dropping `user_id` from
a registration's 200 body, or `is_valid` from the token check's 200 body,
left every test green.

Every real body the suite receives from these addresses is now also held
to the members its operation and code publish as always present. Those
must be exactly the members the body carries, less the ones a table lists
as carried in some cases only, each with its case named. Each listed
member is shown carried by one real body and left out of another, so the
table cannot hide a member that is always there.

Requests are added for the cases that leave a member out: a step the site
adds that refuses without naming the refusal or without a message, a
username held back for retired accounts, and a password found on lists of
exposed passwords.
@Abdul-Muqadim-Arbisoft

Abdul-Muqadim-Arbisoft commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Evidence for this PR: documents, URL names, test ids, mutations, query counts, gates, lint

Trees: base = feat/api-adr-user-v2-phase-d1 @ ed83beab3d (#39330, the stack this PR sits on), head =
47925143f3 (this PR's 22 commits), master = d178206575. Much of what follows was run at 7325fef195, the
head before commits 21 and 22 (fix: publish what the one sign-in refusal answered with 403 always carries and
test: hold the members each response promises to the bodies really sent), which touch only authn_schemas.py and
test_authn_addresses.py. Each section names its trees, and everything was generated or run inside the test image
from git archive <sha> of the tree named. The compliance matrix is in the PR body.

This PR now has 23 commits, head be57050bad. The sections down to the lint table were written for the first 22,
and head in them means 47925143f3. The 23rd commit, its evidence and the current head have the last section,
"The 23rd commit". Four passages moved here from the PR body for space are marked as such.

The 22 commits (git log --reverse ed83beab3d..47925143f3; 13 files, +3343/−38, no merge)

 1. a92f81cad8 feat: serve registration, sign-in and password reset under the user API's version 2
 2. d619b1e478 feat: mark the older registration, sign-in and password reset addresses superseded
 3. c6d0bdc9ba docs: describe the order this version's mount is matched in as it now is
 4. 4c360de481 test: read what each sign-in, registration and reset view declares before any request runs
 5. 1e5cb05696 fix: publish the label and the hint of a registration input as possibly null
 6. 1ca0e94836 fix: publish the refusals of an unusable Accept header and an unreadable body
 7. c1ffa84f92 fix: keep the sign-in address that takes its version as a parameter out of the standardized document
 8. 7f188cd408 fix: publish the new sign-in with the CSRF requirement the older sign-in publishes
 9. ae1e8e318c test: count a database write by its first keyword
10. de99ae451e test: check that reading a form sends no signal and no event
11. 65a20c3120 test: read the forms while a sign-in through another provider is under way
12. 8876b0df01 fix: publish every refusal the password reset token check answers with 403
13. f6eca9d552 test: show both sign-in limits shared in every direction
14. 023201d700 fix: publish the registrations that need no password
15. c9fec71ba4 fix: publish the refusal of a body that cannot be parsed
16. 9bd578ecf4 docs: say that refused registrations count towards the registration limit
17. e2d0b5a9d5 docs: describe the sign-in answered with 400 after a correct password
18. e10079201c test: describe the order the service really matches the user addresses in
19. 155cfc6652 docs: give the true reasons the sign-in and registration routes are collections
20. 7325fef195 docs: say which error body the fallback for unknown addresses shares
21. 024dd40a74 fix: publish what the one sign-in refusal answered with 403 always carries
22. 47925143f3 test: hold the members each response promises to the bodies really sent

Five documents, base → 7325fef195

Served by the test client (/lms-api/schema/, /api-docs/schema/, /authoring-api/schema/), plus the document
docs.docs_settings produces (manage.py spectacular, test databases). Verdicts are schema_diff.py compare,
verbatim.

document paths operationIds components.schemas operations changed in place verdict
LMS /lms-api/schema/ 73 → 83 (0 removed; +5 new, +5 superseded keys) 92 → 106 (0 removed, +14) 72 → 72 0 RESULT: PASS (0 BREAKING, 0 WARN, 15 INFO)
LMS /api-docs/ 519 → 524 (0 removed, +5) 689 → 696 (0 removed, +7) 249 → 249 9 RESULT: FAIL (2 BREAKING, 0 WARN, 17 INFO), the two declared lines
Studio /api-docs/ 248 → 248 348 → 348 210 → 210 9 RESULT: FAIL (2 BREAKING, 0 WARN, 9 INFO), the same two lines
Studio /authoring-api/schema/ 69 → 69 93 → 93 94 → 94 0; byte-identical (sorted-JSON sha256 4c19412df22d… at base, 7325fef195 and head) RESULT: PASS (0 BREAKING, 0 WARN, 0 INFO)
docs.docs_settings 508 → 513 (0 removed, +5) 679 → 686 (0 removed, +7) 243 → 243 9 RESULT: FAIL (2 BREAKING, 0 WARN, 17 INFO), the same two lines

The nine operations changed in place are the same in both service-wide documents and in docs.docs_settings.
Each gains deprecated: true. The six that read a body or answer a form also gain requestBody (the four POSTs
below) and responses:

GET  /api/user/v1/account/registration/                   deprecated, responses
POST /api/user/v1/account/registration/                   deprecated, requestBody, responses
GET  /api/user/v2/account/registration/                   deprecated, responses
POST /api/user/v2/account/registration/                   deprecated, requestBody, responses
GET  /api/user/v1/account/password_reset/                 deprecated, responses
POST /api/user/v1/account/password_reset/token/validate/  deprecated, requestBody, responses
POST /api/user/v1/validation/registration                 deprecated, requestBody, responses
GET  /api/user/{api_version}/account/login_session/       deprecated, responses
POST /api/user/{api_version}/account/login_session/       deprecated

Published shapes of the superseded registration POSTs (moved from the PR body). In both service-wide documents
and in /lms-api/schema/ they are the replacement's. password is one of three anyOf alternatives rather than a
top-level required member, and the 400 is oneOf the field-keyed body and {detail}. Neither is a removal:
at base these operations published no request body and no typed 400.

In /lms-api/schema/ the five superseded keys enter the document already marked, and the templated sign-in key
is left out (PR body, 0027 gap 2). The new operations are published as v2_registrations_retrieve/_create,
v2_registration_validations_create, v2_login_sessions_retrieve/_create, v2_password_resets_retrieve and
v2_password_reset_token_validations_create in /lms-api/schema/, and with api_user_ in front in the
service-wide documents.

The same documents, 7325fef195 → head

Generated at both trees by the same probe. One leaf differs, the same one in the three LMS documents. Studio's two
documents are byte-identical.

document bytes sorted-JSON sha256 leaves differing paths / operationIds / components
LMS /lms-api/schema/ 361966 → 362009 fc5ede9974ca → 68435e73af0a 1 83 / 106 / 72, none removed or added
LMS /api-docs/ 1122424 → 1122467 3a75957a8e4d → c7730d9f3e51 1 524 / 696 / 249, none removed or added
Studio /api-docs/ 685059 → 685059 7762f5d7371d, byte-identical 0 248 / 348 / 210
Studio /authoring-api/schema/ 279389 → 279389 4c19412df22d, byte-identical 0 69 / 93 / 94
docs.docs_settings 1699428 → 1699531 708b15952362 → 2e72fa9521f6 1 513 / 686 / 243, none removed or added
paths./api/user/v2/login_sessions/.post.responses.403.content.application/json.schema.required
    ["success"]  ->  ["success", "value", "error_code"]

schema_diff.py compare reads all five pairs as RESULT: PASS (0 BREAKING, 0 WARN, 0 INFO). It does not compare
a response's required list, so this leaf diff is the evidence. The change adds a promise about a response; no
request shape, code, path or name changed.

The same documents, master → head (measured at 7325fef195)

document paths operationIds components.schemas verdict master → head verdict master → base
LMS /lms-api/schema/ 19 → 83 (0 removed) 25 → 106 (0 removed) 8 → 72 (0 removed) RESULT: PASS (0 BREAKING, 7 WARN, 83 INFO) RESULT: PASS (0 BREAKING, 7 WARN, 69 INFO)
LMS /api-docs/ 495 → 524 (0 removed) 658 → 696 (0 removed) 198 → 249 (0 removed) RESULT: FAIL (30 BREAKING, 37 WARN, 85 INFO) RESULT: FAIL (28 BREAKING, 37 WARN, 69 INFO)
Studio /api-docs/ 248 → 248 348 → 348 198 → 210 (0 removed) RESULT: FAIL (30 BREAKING, 33 WARN, 46 INFO) RESULT: FAIL (28 BREAKING, 33 WARN, 37 INFO)
Studio /authoring-api/schema/ 69 → 69 93 → 93 93 → 94 (0 removed) RESULT: PASS (0 BREAKING, 0 WARN, 1 INFO) RESULT: PASS (0 BREAKING, 0 WARN, 1 INFO)
docs.docs_settings 484 → 513 (0 removed) 648 → 686 (0 removed) 192 → 243 (0 removed) RESULT: FAIL (30 BREAKING, 37 WARN, 85 INFO) RESULT: FAIL (28 BREAKING, 37 WARN, 69 INFO)

The BREAKING and WARN lines of master → head differ from those of master → base by exactly the two declared
request body newly required lines in each service-wide document, and by nothing else. Every other line is the
stack's, already present at base. Nothing is removed against master in any document, at base or at head. The one
INFO line of Studio's /authoring-api/schema/ is the component PatchedUserRetirementStatus, which #39330 adds to
every document Studio builds; no operation refers to it, and it is the same at base.

URL names (the URL gate's walker, run in each tree; head here is 7325fef195, and the last two commits touch no route)

service base → head master → head
LMS 3059 → 3064 routes; 2537 → 2542 names; 0 removed; +5: user_v2:registration_list, registration_validation_list, login_session_list, password_reset_list, password_reset_token_validation_list 3034 → 3064 routes; 2513 → 2542 names; 0 removed
Studio 2193 routes, 1790 names, unchanged unchanged

url_snapshot.py check, base → head: LMS RESULT: PASS (0 FAIL, 1 WARN) (R10's verb heuristic on
login_sessions) and Studio RESULT: PASS (0 FAIL, 0 WARN). Master → head: LMS RESULT: FAIL (2 FAIL, 3 WARN).
The two FAILs (R6, R8 on api/user/v2/{_}., the stack's fallback route) are already in master → base
(RESULT: FAIL (2 FAIL, 2 WARN)). This PR adds only the R10 WARN.

Test ids (--collect-only, LC_ALL=C sort -u, comm)

set base 7325fef195 head removed added, base → head
LMS: user_api/, profile_images/, lms/lib/tests/, lms/tests.py, openedx/core/tests/test_apidocs.py 2125 2265 2266 0 141: 138 in test_authn_addresses.py, 2 in lms/lib/tests/test_spectacular.py, 1 in test_url_mount.py
user_authn/ 556 556 556 0 0
Studio cms/lib/tests/ 35 40 40 0 5 in cms/lib/tests/test_spectacular.py

Both steps were measured; neither removes an id. Run at 7325fef195: the LMS set 2265 passed, user_authn/ 555
passed and 1 skipped (a skip the test settings cause in test_register.py, the same at base), Studio 40 passed. Run at head: v2/tests + lms/lib/tests 1530 passed, Studio 40 passed, and
test_authn_addresses.py 138 passed under each of pytest-randomly seeds 1 to 5.

Changes between this PR's own commits (for commit-by-commit review; none is visible against base):

  • Renamed: ConformingDocumentTests::test_signing_in_publishes_the_security_every_other_address_here_publishes
    became ::test_signing_in_publishes_the_security_the_older_sign_in_publishes in fix: publish the new sign-in with the CSRF requirement the older sign-in publishes. Its old assertion (csrf absent from the new sign-in's
    security) is now the opposite.
  • Same id, wider body: LoginLimitTests::test_failures_here_count_at_every_older_address gains the direction
    from the new address to /api/user/v2/account/login_session/ (test: show both sign-in limits shared in every direction).
  • Expectation changed: Studio SupersededUserOperationTest::test_a_registration_names_the_fields_the_stock_form_requires
    (added in fix: publish the refusals of an unusable Accept header and an unreadable body) and
    RefusedRepresentationTests::test_the_fields_published_as_required_are_the_ones_the_stock_form_requires. In
    fix: publish the registrations that need no password, required became name, username, email, honor_code
    plus the anyOf of the three ways of registering.
  • Moved: in fix: keep the sign-in address that takes its version as a parameter out of the standardized document, the templated sign-in key moves to SUPERSEDED_USER_ADDRESSES_LEFT_OUT in test_schema.py and
    becomes a neighbour the LMS filter does not admit. The "account" tag group that feat: mark … had added is
    removed again, so the tag-group test is unchanged against base.
  • Same ids, wider bodies: in test: hold the members each response promises to the bodies really sent, every
    real-response check of PublishedShapeTests, RefusedRepresentationTests, ExtensionFormShapeTests and
    SignInWithAnExposedPasswordTests also holds each body to the members its operation publishes as always
    present, and requests were added that leave a conditional member out. One test is new:
    test_the_members_published_as_always_present_are_held_to_the_ones_each_body_carries.

Existing assertions this PR changes (against base; moved from the PR body)

  • test_schema.py::test_only_this_version_and_the_addresses_it_supersedes_are_published: the expected set gains
    the five new addresses and the five superseded keys this document admits.
  • test_schema.py::test_addresses_owned_by_other_apps_are_not_published: /api/user/v1/account/registration/ and
    /api/user/v2/account/registration/ are removed from its list, because both are now published as
    superseded. Nothing replaces them, so the test now checks four addresses instead of six. Their presence is
    asserted by the test above and by test_every_superseded_address_has_a_published_replacement.
  • test_schema.py::test_hook_leaves_other_resources_alone: /api/user/v2/account/registration/ moves from
    untouched to marked, and its unmarked twin /user_api/v2/account/registration/ takes its place.
  • test_schema.py::test_every_superseded_address_has_a_published_replacement: the tuple must equal the published
    superseded addresses plus the one left out (the templated sign-in key). That key must be absent from the
    document, and its replacement must be present.
  • lms/lib/tests/test_spectacular.py::LmsApiFilterTest::test_keeps_enrollment_this_version_and_the_addresses_it_supersedes:
    /api/user/v2/account/registration/ moves from the inputs left out to the expected result, and
    /user_api/v2/account/registration/ takes its place among the inputs left out.
  • lms/lib/tests/test_spectacular.py::LmsApiFilterTest::test_under_the_version_prefix_the_view_package_decides:
    the input route changes from /api/user/v2/account/registration/ to /api/user/v2/login_sessions/, because
    the old input is now admitted as superseded and can no longer show the view package deciding. The assertion
    itself is unchanged: authentication-app views are not admitted under the prefix, and version 2's are.
  • test_views.py::AddressSelfDescriptionTests::test_every_mounted_address_is_described: the routed view
    classes go from 17 to 22, adding the five here.
  • Tables extended, not changed: test_schema.py SUPERSEDED_USER_ADDRESSES (+5), test_urls.py
    EXPECTED_ADDRESSES and the RouteShapeTests name list (+5 each), and Studio's SUPERSEDED_USER_OPERATION_IDS
    (+9) and SUPERSEDED_USER_EXCHANGES (+8). The existing tests that read them now cover the new entries too.
  • Docstrings only: test_url_mount.py::SharedPrefixTests and
    test_urls.py::MalformedAddressTests::test_every_address_left_to_the_other_app_is_one_that_app_serves now
    describe the real match order. One test pins that order:
    SharedPrefixTests::test_the_authentication_app_and_the_rest_of_the_user_surface_are_matched_before_this_mount.

The 23rd commit changes no existing assertion; it adds ServedCredentialSchemesTest to both
lib/tests/test_spectacular.py.

Mutations: each behaviour reverted inside a container copy, and the suite going red

At head 47925143f3, each on a copy of head's tree; test_authn_addresses.py has 138 tests:

mutation red
M1: user_id dropped from the registration's authenticated_user.required PublishedShapeTests::test_a_registration_and_its_refusals (1 failed, 137 passed). v2/tests + lms/lib/tests: 1 failed, 1529 passed
M2: is_valid dropped from the token check's 200 required PublishedShapeTests::test_a_token_check_and_its_refusals. v2/tests + lms/lib/tests: 1 failed, 1529 passed
M3: validation_decisions dropped from the validation's 200 required PublishedShapeTests::test_a_validation_and_its_refusals
M4: success dropped from the sign-in's 200 required PublishedShapeTests::test_a_sign_in_and_its_refusals
M5 to M7: a member carried in some cases only made always present (the validation's username_suggestions; every form field's options; one member of the sign-in refusals) M5 ::test_a_validation_and_its_refusals; M6 ::test_the_forms and ExtensionFormShapeTests::test_a_field_the_site_adds_is_described_as_published; M7 ::test_a_sign_in_and_its_refusals and both SignInWithAnExposedPasswordTests
M8: the new sign-in's 403 given the 400 refusals' shape again PublishedShapeTests::test_a_sign_in_and_its_refusals
M9: user_id dropped from required and listed as conditional, to hide the loosening PublishedShapeTests::test_a_registration_and_its_refusals, at the guard that each listed member is seen both carried and left out
M10: the one request whose 409 leaves out a listed member removed the same test, at the same guard
M11: detail dropped from the DRF refusal's required 8 tests across PublishedShapeTests, RefusedRepresentationTests and SignInWithAnExposedPasswordTests
M12: the check told to ignore the list of conditional members 8 tests, the unit test among them

At 7325fef195, before those pins existed, the M1 and M2 loosenings left v2/tests + lms/lib/tests (1529
passed) and Studio (40 passed) green, which is why they were added. A type change at that tree (the token check's
is_valid published as text) was already red through real-response validation
(PublishedShapeTests::test_a_token_check_and_its_refusals, 1 failed, 268 passed), while Studio's exchange table
stayed green: it reads the same constants the document is built from.

At 7e6a731143, the same 20 commits as 7325fef195 on #39330's previous push f01a160802. The two files these
mutate, openedx/core/djangoapps/user_api/schema.py and lms/lib/spectacular.py, are identical at head:

mutation red
the templated sign-in key dropped from SUPERSEDED_USER_PATHS Studio 3 failed, 36 passed, including SupersededUserOperationTest::test_every_operation_of_those_addresses_is_marked_superseded (reads Studio's own document); LMS 4 failed, including SupersededDocumentTests::test_each_operation_is_marked_superseded_in_both_documents and lms/lib/tests/test_spectacular.py::MarkSupersededUserPathsTest::test_the_authentication_addresses_are_deprecated_and_their_neighbours_are_not
the version 1 registration read's entry removed from superseded_bodies() Studio 2 failed (the exchange test and the media-type refusal test); LMS 3 failed, including SupersededDocumentTests::test_each_operation_publishes_what_the_one_replacing_it_publishes
the LMS filter admitting the templated key LMS 6 failed, including SupersededDocumentTests::test_the_narrow_document_leaves_out_the_address_that_takes_its_version_as_a_parameter; Studio 39 passed, as it should, since Studio has no such filter

During the build, at commit 99dda75f28 (tree c67341fca4, on an earlier tip of the stack, d0f1d502bc).
The runtime code these exercise has not changed since:

mutation red
drop api_version="v2" from the sign-in route 16 tests (every sign-in parity test)
registration given a post of its own under a fresh rate limit both shared-counter tests of RegistrationLimitTests
rename the registration or the validation class both shared-counter tests of each
validation post under a fresh rate limit RegistrationValidationLimitTests::* (2)
token check without its throttle the two shared-counter tests of TokenValidationLimitTests
token check without the platform authentication CSRF 403, malformed-JWT 401, JWT and own-token tests (7)
registration declaring the default authentication pair RegistrationParityTests::test_credentials_in_a_header_are_not_read
sign-in post calling the sign-in function directly (no csrf_protect) LoginParityTests::test_a_sign_in_without_a_csrf_token_is_refused
each form get without ensure_csrf_cookie (registration, reset, sign-in) the byte and cookie parity test of each; test_the_token_the_form_sets_lets_the_caller_sign_in
registration at the new address answered as version 1 both confirm_email version tests
registration routed after the fallback 31 tests
a slash made optional on a new route the slashless-refusal and reverse tests
a new address added to the superseded tuple ConformingDocumentTests::test_no_operation_is_marked_superseded and the filter tests
a superseded body entry removed the same-as-replacement test (LMS) and the exchange test (Studio)
a table entry added for the posted sign-in survives, as designed: the handler's own description is published in place of any table entry, which is why it has none; its published exchange is pinned

During review, at the intermediate commits of this PR where each test was added (on the earlier base
24b8a7bde5); not re-run at head:

mutation red
a JWT from a restricted application let through (middleware adds nothing; or the new token check composes the permission so the middleware skips it) TokenValidationParityTests::test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused
the session authentication accepting an inactive account TokenValidationParityTests::test_a_session_whose_account_was_made_inactive_is_answered_as_no_session
the sign-in limit per account kept per address; or one counter for every account both per-account LoginLimitTests
the sign-in or registration limit per client kept per address; or only the version 1 address keeping its own the direction tests of LoginLimitTests / RegistrationLimitTests
an openedx-event, a Signal.send, a send_robust, or a post_save for another model sent from a form GET; the event sent while events are turned off test_a_read_sends_no_signal_or_event_at_either_address in both capture classes
406 dropped from a table and from the pinned list; 415 the same; country added to required RefusedRepresentationTests (LMS) and the Studio refusal and required-list tests
the registration shape requiring a password again; the password dropped with nothing in its place; the token way removed RegistrationRequestShapeTests (and Studio's required-list test)
the registration or sign-in 400 without {detail}; detail published as a list test_each_operation_reading_a_form_publishes_the_refusal_of_one_it_cannot_parse
the password-change answer after a correct password turned off, or reached without signing in SignInWithAnExposedPasswordTests::*
this version's mount matched before the installed apps' routes SharedPrefixTests::test_the_authentication_app_and_the_rest_of_the_user_surface_are_matched_before_this_mount
a declaration added to the older token check, or a doubled one on the new ViewShapeTests::test_the_extended_views_are_left_as_they_were, ::test_each_view_declares_only_who_may_call_it (in the order where the JWT middleware has already run)

Query counts (equal on both mounts; test settings), measured at 0156789248 on the earlier base 24b8a7bde5

operation queries, older address = new address writes
registration form GET (third-party sign-in on / off) 12 / 7 the session row only, with third-party sign-in on
password reset form GET 9 none
sign-in form GET (on / off) 14 / 9 the session row only, with third-party sign-in on
successful registration 62 identical lists
sign-in: success / failure 27 / 18 identical lists
token check 10 none
registration validation 15 none

HEAD equals GET on all three forms. The suite pins the equality (test_*_takes_as_many_queries_at_either_address),
and those tests pass at head. The absolute numbers have not been measured again on ed83beab3d.

A replacement of the older view's handler at runtime (moved from the PR body)

Each version 2 method is drf-spectacular's per-view copy of the extended view's method. It calls the function object
the older class held when this version's URLconf first loaded. A replacement on the older class made after that
reaches only the older addresses. This was measured during review, on the earlier base 24b8a7bde5, with
mock.patch.object(register.RegistrationView, "post", …) after a first resolve():
/api/user/v2/account/registration/ answered 418 while /api/user/v2/registrations/ answered 400, and the sign-in
behaved the same way. A replacement made before the URLconf loads reaches both. A method written on the subclass would
avoid this but start the rate limits counting afresh, so the classes define none.

Gate runs (run_gates.sh, MongoDB attached, --allow on the 13 files of the diff)

LMS against base ed83beab3d        LMS against master d178206575      Studio against base ed83beab3d
at head 47925143f3                 at head 47925143f3                 at head 47925143f3
  versions   PASS                    versions   PASS                    versions   PASS
  hygiene    PASS (0 FAIL, 0 WARN)   hygiene    FAIL (1 FAIL, 5 WARN)   hygiene    PASS (0 FAIL, 0 WARN)
  schema     PASS (0 BREAKING,       schema     PASS (0 BREAKING,       schema     FAIL: check-present,
             12 WARN, 15 INFO)                  88 WARN, 83 INFO)                  no /api/user/v2/ path
  urls       PASS (0 FAIL, 1 WARN)   urls       FAIL (2 FAIL, 3 WARN)   urls       PASS (0 FAIL, 0 WARN)
  old-tests  820 passed, 4 skipped   old-tests  820 passed, 4 skipped   old-tests  177 passed, 384 skipped
  tests      1528 passed             tests      1528 passed             tests      40 passed
  • All three runs were made at head. Each gives the same RESULT, WARN, BREAKING and FAIL lines as at 7325fef195; only
    the LMS tests count moves, 1527 → 1528, which is the new unit test.
  • The two FAILs against master are both outside this PR's diff: hygiene H3 at user_api/v2/tests/test_views.py:1500,
    and urls R6/R8 on the version's /api/user/v2/ fallback route. Both come from the earlier phases of the stack.
    Studio's check-present fails because Studio serves no /api/user/v2/ path, by design.
  • Versions gate against base: 3 files added inside user_api/v2/ (authn_views.py, authn_schemas.py,
    tests/test_authn_addresses.py). 6 are allowed: urls.py, views.py, and tests/test_{schema,urls,url_mount,views}.py
    (the last four flagged OLD TEST CHANGED, each change listed above under "Existing assertions this PR changes"). 4 other files sit outside any
    version directory: openedx/core/djangoapps/user_api/schema.py, lms/lib/spectacular.py and the two
    lib/tests/test_spectacular.py. No frozen surface is touched.
  • Hygiene against master: FAIL H3 openedx/core/djangoapps/user_api/v2/tests/test_views.py:1500 (class RecordingScopePolicy), plus WARNs on v2/paginators.py:11 and feat: standardize the account retirement pipeline as /api/user/v2/ #39330's four retirement viewsets. None is a line
    this PR adds; its only line in test_views.py is the 17 → 22 count at :1670.
  • Studio schema: the check that /api/user/v2/ paths exist fails because Studio serves none, by design (PR
    body, Deprecations). Its document comparison is in the tables above.
  • Of the schema gate's 12 WARNs against base, 4 are the generator not resolving the platform's default authenticators
    for the two token-check operations, both now in this document. The other 8 are the same warning for feat: standardize the account retirement pipeline as /api/user/v2/ #39330's four
    retirement viewsets, reported as new only because a docstring edit in v2/views.py moved their line numbers by two.
    (Moved from the PR body.)

Repository CI lint (each step on a snapshot of one commit)

step base ed83beab3d 7325fef195 head 47925143f3
ruff check . EXIT=0 EXIT=0 EXIT=0
lint-imports EXIT=0, 4 contracts kept, 0 broken the same the same
pylint lms-2 / openedx-2 / cms 0 / 0 / 0 messages 0 / 0 / 0 0 / 0 / 0
mypy, xsslint, pii_check, check_keywords (lms, cms), verify-dunder-init EXIT=0 EXIT=0 EXIT=0
commitlint none 20 commits, EXIT=0 22 commits, EXIT=0

No new finding at head, and base and head pass every step.

The 23rd commit: be57050bad fix: describe the CSRF token in every document whose sign-in names it

Trees: everything in this section was run at 8eb071be02 except the last paragraph. Head be57050bad is the
same commit with one statement changed: Studio's append in cms/envs/common.py, from dict(CREDENTIAL_SCHEMES) to
{'csrf': CREDENTIAL_SCHEMES['csrf']}, with the comment above it extended and one sentence added to the commit
message. Both build the same mapping today: CREDENTIAL_SCHEMES holds only csrf. At head the commit is 5 files,
+178/−4 (schema_components.py, both envs/common.py, both lib/tests/test_spectacular.py); the diff from base is
16 files, +3521/−42.

Which operations name csrf. Only POSTs to a sign-in, in every document at every tree; no GET names it.

document master d178206575 and base ed83beab3d 47925143f3 8eb071be02
LMS /lms-api/schema/ none the new sign-in; not declared the new sign-in; declared
LMS /api-docs/ the older sign-in (/api/user/… and /user_api/… keys); not declared those and the new sign-in; not declared all three; declared
Studio /api-docs/ the older sign-in (two keys); not declared the same the same; declared
Studio /authoring-api/schema/ none none none; declares csrf, as every document built from Studio's settings does
docs.docs_settings the older sign-in; declared by its own settings the older and the new sign-in; declared the same, unchanged

Documents, 47925143f3 → 8eb071be02 (served by the test client, plus docs.docs_settings, as above):

document bytes paths / operationIds / components.schemas securitySchemes leaves differing
LMS /lms-api/schema/ 362009 → 362254 83 / 106 / 72, none removed or added 1 → 2, +csrf 4
LMS /api-docs/ 1122467 → 1122712 524 / 696 / 249, none removed or added 1 → 2, +csrf 4
Studio /api-docs/ 685059 → 685304 248 / 348 / 210, none removed or added 1 → 2, +csrf 4
Studio /authoring-api/schema/ 279389 → 279661 69 / 93 / 94, none removed or added 0 → 1, +csrf 4
docs.docs_settings 1699531, byte-identical 513 / 686 / 243 4 → 4 0

The four leaves, the same in each served document:

components.securitySchemes.csrf.type         absent -> "apiKey"
components.securitySchemes.csrf.name         absent -> "X-CSRFToken"
components.securitySchemes.csrf.in           absent -> "header"
components.securitySchemes.csrf.description  absent -> "The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, sent in this header along with that cookie."

schema_diff.py compare reads all five pairs as RESULT: PASS (0 BREAKING, 0 WARN, 0 INFO): it does not look at
securitySchemes, so the leaf diff is the evidence. Every schema_diff.py report against base and against master at
8eb071be02 is line-for-line the one at 47925143f3, and neither removes a path, operationId or component name in
any of the five documents. docs/docs_settings.py sets securitySchemes itself, replacing what the service appends,
so its document keeps its own csrf entry (same type, header and location, other wording).

Tests (ServedCredentialSchemesTest; LMS 2 in lms/lib/tests/test_spectacular.py, Studio 3 in
cms/lib/tests/test_spectacular.py). Each requests its service's two served documents through their real views and
settings, and holds every credential an operation names to a scheme the same document declares. One test per service
shows a sign-in naming csrf, so that check is not vacuous, and compares the scheme with a literal written out in the
test. Studio's third holds every csrf scheme either document declares to that literal. Before the fix: LMS 2
failed; Studio 2 failed and 1 passed, vacuously, since neither document declared the scheme. After: LMS module 33
passed, Studio module 43 passed.

Mutations (each on a copy of 8eb071be02, both modules run):

mutation LMS module (33) Studio module (43)
M1: the LMS append dropped 2 failed 43 passed (not its settings)
M2: the Studio append dropped 33 passed 2 failed
M3: the header named X-CSRF-Token 1 failed 2 failed
M4: the description naming a csrf_token cookie 1 failed 2 failed
M5: in set to query 1 failed 2 failed
M6: the scheme appended as csrfToken, a name no operation uses 2 failed 2 failed

Gates at 8eb071be02 (--allow on the 16 files; each tree clean, none SKIPPED):

LMS against base ed83beab3d        LMS against master d178206575      Studio against base ed83beab3d
  versions   PASS                    versions   PASS                    versions   PASS
  hygiene    PASS (0 FAIL, 0 WARN)   hygiene    FAIL (1 FAIL, 5 WARN)   hygiene    PASS (0 FAIL, 0 WARN)
  schema     PASS (0 BREAKING,       schema     PASS (0 BREAKING,       schema     FAIL: check-present,
             12 WARN, 15 INFO)                  88 WARN, 83 INFO)                  no /api/user/v2/ path
  urls       PASS (0 FAIL, 1 WARN)   urls       FAIL (2 FAIL, 3 WARN)   urls       PASS (0 FAIL, 0 WARN)
  old-tests  820 passed, 4 skipped   old-tests  820 passed, 4 skipped   old-tests  177 passed, 384 skipped
  tests      1530 passed             tests      1530 passed             tests      43 passed

Every RESULT, WARN, BREAKING and FAIL line is the one at 47925143f3; only the tests counts move (1528 → 1530,
1528 → 1530, 40 → 43), by the five new tests. Versions gate: 3 files added inside user_api/v2/; 8 allowed, the six
listed above plus lms/envs/common.py and cms/envs/common.py; 5 other files, the four listed above plus
user_api/schema_components.py. Hygiene against base scans 16 files.

Wide runs and test ids at 8eb071be02. The LMS set of the test-id table: 2268 passed; user_authn/: 555
passed, 1 skipped; Studio cms/lib/tests/ and cms/tests.py: 47 passed. Ids against 47925143f3: LMS set 2266 →
2268, user_authn/ 556 → 556, Studio cms/lib/tests/ 40 → 43, none removed; the five added are the new tests.
test_authn_addresses.py: 138 passed under each of pytest-randomly seeds 1 to 5.

At head be57050bad: cms/lib/tests/test_spectacular.py 43 passed; ruff check . clean; repository CI lint,
every step EXIT=0, with output equal to 8eb071be02's apart from timing lines and line order (pylint lms-2 /
openedx-2 / cms 0 messages; lint-imports 4 contracts kept, 0 broken); commitlint 23 commits, EXIT=0.

The sign-in operations publish a security requirement named `csrf`, but
no document either service serves declared a scheme by that name; only
the settings the published reference is generated from did. A document
naming a credential it does not describe is invalid, and a client
generated from it has no way to send that credential. The older sign-in
has named the token this way in both services' service-wide documents
since before this version, and the new sign-in repeats it in both LMS
documents.

The scheme is now defined once in the user API, beside the components
both services already append, and both services append it: the value
of the `csrftoken` cookie the forms set, sent in the `X-CSRFToken`
header. Studio's document of its own API carries the scheme as well,
because every document built from those settings carries what they
append. Studio appends that one scheme rather than the whole set, since
no operation it serves names any other credential.

The tests request each document at the address it is served from, hold
every credential its operations name to a scheme it declares, and pin
how the token is described with the text written out.
@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

One commit added: be57050bad fix: describe the CSRF token in every document whose sign-in names it (a
fast-forward from 47925143f3; the PR is now 23 commits on ed83beab3d).

What it fixes. The sign-in operations publish a csrf security requirement, but no document either service
serves declared a scheme by that name; only the docs.docs_settings document did. That was already so on master for
the older sign-in (/api/user/{api_version}/account/login_session/ and its /user_api/ twin, in both services'
/api-docs/), and this PR's POST /api/user/v2/login_sessions/ repeated it in both LMS documents: gap (3) of the
0027 row, now closed. The scheme is defined once, CREDENTIAL_SCHEMES in
openedx/core/djangoapps/user_api/schema_components.py (apiKey, header X-CSRFToken: the value of the csrftoken
cookie the forms set), and both services' settings append it; Studio appends only csrf, the one credential its
operations name. No authentication, CSRF or response behaviour changes, and no operation's security changes.

Evidence. Measured at 8eb071be02, which differs from head in one statement: Studio's append went from
dict(CREDENTIAL_SCHEMES) to {'csrf': CREDENTIAL_SCHEMES['csrf']}, the same mapping today because it holds only
csrf. Against 47925143f3, each of the four served documents (LMS /lms-api/schema/ and /api-docs/, Studio
/api-docs/ and /authoring-api/schema/) gains exactly the 4 leaves of components.securitySchemes.csrf, and the
docs.docs_settings document is byte-identical. schema_diff.py does not look at securitySchemes and reads every
pair as PASS, so the leaf diff is the evidence. Five new tests (ServedCredentialSchemesTest, 2 LMS and 3 Studio)
request the served documents and hold every credential an operation names to a scheme the same document declares.
Four of them failed before the fix, and each of six mutations turns one or both modules red: either append dropped,
or the header, description, location or name of the scheme changed. Gates at 8eb071be02 give every RESULT line they
gave at 47925143f3, with tests LMS 1530 and Studio 43, and no test id removed. At head, Studio's
cms/lib/tests/test_spectacular.py gives 43 passed, and ruff and the repository CI lint pass every step (commitlint
23/23). The details are in the last section of the first comment.

Merging with Phase D2 (a sibling on #39330 that also adds CREDENTIAL_SCHEMES). Whichever merges second keeps
all three entries (jwt, bearer, csrf) and keeps Studio's csrf-only append. Phase D2's Studio test is resolved
by tightening named - described to == set(), not by exempting csrf. The full rule is in the PR body, under
"Merging with Phase D2".

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant