Skip to content

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

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

Abdul-Muqadim-Arbisoft wants to merge 130 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 under the umbrella #38137, following docs/decisions/ and OEP-69.

This PR serves registration, sign-in, password reset and registration validation at five conforming addresses under /api/user/v2/. It is a change to addresses and documentation only. Each new address is served by a subclass of the view that serves the older address. The subclass adds permission_classes = (AllowAny,), the restricted-application guard every view of this version carries, and its published description. Bodies, status codes, authentication, CSRF, cookies and rate limits are inherited unchanged, and no file under user_authn/ is edited.

Why these addresses, and why in version 2. The older addresses use a singular collection (account), nest one collection under another with no identifier between them, take the version as a path parameter ({api_version}), lack a trailing slash, or carry a verb. Registration and sign-in already have a published version 2 contract, and the new addresses keep exactly that contract. Password reset and registration validation join version 2 with their contract unchanged.

Stack and merge order: #39297 → #39314 → #39330 → #39331 (this PR, on #39330). #39334 is a sibling, also on #39330. See "Merging with #39334".

23 commits, 15 files, +3462 / −13 (this PR's own range):

commits what
from feat: serve registration, sign-in and password reset under the user API's version 2 to fix: describe the CSRF token in every document whose sign-in names it (20) the feature, the marking of the older addresses, and review corrections to published shapes, comments and tests. None of them changes how an operation answers
from test: generate the LMS user documents with the authentication app's mount to test: generate the authentication addresses' documents once, from the user mounts (3) corrections from the 2026-11-04 review (tests only)

Operations

superseded replacement
GET, POST /api/user/v1/account/registration/ and /api/user/v2/account/registration/ GET, POST /api/user/v2/registrations/
POST /api/user/v1/validation/registration POST /api/user/v2/registration_validations/
GET, POST /api/user/{api_version}/account/login_session/ (v1 and v2) GET, POST /api/user/v2/login_sessions/
GET /api/user/v1/account/password_reset/ GET /api/user/v2/password_resets/
POST /api/user/v1/account/password_reset/token/validate/ POST /api/user/v2/password_reset_token_validations/ (the token stays in the body)

Declared differences a caller can see

  • Sign-in and registration at the new addresses answer as version 2. Sign-in reads email_or_username and refuses email alone. Registration offers and requires a configured confirm_email. A version 1 client therefore changes these fields as well as the address.
  • OPTIONS descriptions differ, because each class describes itself. Everything else is compared on both mounts: status, Content-Type, the cookies set, and the body.
  • An address sent without its trailing slash is redirected (301), as every address of the service is.
  • /api/user/v2/ answers in two error shapes. The views of feat: standardize the user accounts, profile image, verification and name-change API #39297–feat: standardize the account retirement pipeline as /api/user/v2/ #39330 answer with the RFC 9457 envelope. These five answer with the authentication app's own bodies (error_code, context.failure_count, field-keyed user_message), and with DRF's {detail} for the refusals DRF produces itself (405, 406, 415, an unparsable body).
  • Rate-limit counters are shared between the old and new addresses, because each subclass keeps its parent's name. If a caller spends the limit at one address, the other refuses it.
  • A restricted-application token is refused 403 at the token check, as the older token check refuses it. The other four views read no JWT, so for them the guard has no effect.

Published documents

  • The nine superseded operations are marked deprecated: true in /lms-api/schema/ and in both services' /api-docs/. Studio serves them too, but it mounts none of the replacements.
  • POST /api/user/v1/account/registration/ and POST /api/user/v2/account/registration/ published no request body until now. They now publish the registration body, marked required, because the handler refuses an empty request. This is the same handler, and it is not changed.
  • The older sign-in, whose version is a path parameter, is left out of /lms-api/schema/. Its frozen handler describes the version 1 request. It is still marked in both service-wide documents.
  • Every served document describes the csrf security scheme that its sign-in operations name. Both services append it from one definition. Studio's Authoring API document gains the scheme without using it.

ADRs applied

The evidence for each box is in the compliance matrix in the evidence comment.

  • 0025 serializers — deferred to the package's next version: the five classes add no handler of their own (a handler written on a subclass would restart the rate-limit counts), so every body comes from the frozen views. Every exchange is documented in the schema instead
  • 0026 permissions — permission_classes = (AllowAny,) on all five views; every v1 check is inherited unchanged; restricted-application guard on every view
  • 0027 schema / docs — every new operation publishes each code it is measured to answer; published members are held to real bodies; each document describes every credential its operations name
  • 0028 viewsets — APIView subclasses: these collections are only posted to or described
  • 0029 errors — deferred to the package's next version: clients read the legacy error members (Error-format decision)
  • 0030 GET idempotence — form reads write only the caller's session entry and send no other signal or event, the same on both mounts
  • 0031 merged endpoints — considered, not applied: these are different resources with different rate limits
  • 0032 pagination — n/a: no operation returns a list
  • 0033 filtering — n/a: no list and no filter parameter
  • 0034 authentication — inherited: four views keep authentication_classes = [], and the token check keeps the platform default
  • 0035 MFE config — n/a
  • 0036 nested JSON — n/a: bodies are flat or one level deep
  • 0037 versioning — extends the unreleased v2; the superseded views are untouched
  • 0038 URL structure — plural nouns, trailing slash, the version as a literal, no nesting without an identifier; every legacy URL name kept
  • OEP-66 queryset scoping — n/a as a mechanism: no list and no queryset
  • OEP-69 conventions — snake_case, version-free <basename>_list names

In-place edits

No user_authn view or URLconf is in the diff. Outside a version directory:

file why
cms/envs/common.py APPEND_COMPONENTS = {'securitySchemes': {'csrf': …}}, the one credential Studio's sign-in names. No schemas entry is added
cms/lib/spectacular.py the six superseded authentication paths are added to Studio's SUPERSEDED_PATHS
lms/envs/common.py APPEND_COMPONENTS gains securitySchemes: dict(CREDENTIAL_SCHEMES); the import names it
lms/lib/spectacular.py a superseded path that takes its version as a parameter is not admitted into /lms-api/schema/
cms/lib/tests/test_spectacular.py, lms/lib/tests/test_spectacular.py tests for the above; the LMS user-document fixture also takes the authentication app's mount
user_api/schema.py, user_api/schema_components.py (from earlier in the stack) six superseded keys and eight superseded bodies; CREDENTIAL_SCHEMES['csrf']

Error-format decision

Unchanged here; it moves to the package's next version, /api/user/v3/. The authentication frontend and the legacy sign-in page parse the legacy members, so the new addresses answer with exactly those bodies and document them. The following are recorded for that version, unchanged here:

  • Django's HTML 403 for a missing CSRF token.
  • The HTML 403s behind two non-default switches.
  • A 200 carrying an error body, when a deployment's own registration filter step refuses a registration without giving a status.

Deprecations

The nine operations above are marked deprecated: true, with no header and no note in code. They are tracked on #39128 (entry 6565029569). The unversioned /user_api/… twins and /create_account are not marked.

Before removal:

  1. The stock LMS sign-in page posts the version 1 sign-in, because the form description's submit_url names it. That 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/. It has to name /api/user/v2/registrations/.
  3. Move these callers first: the Android app posts the v1 registration, and the authentication frontend posts the v2 registration, the v2 sign-in and the v1 validation.

Authorization

No authorization changes. Every legacy check is inherited and tested on both mounts. That covers the rate limits (shared), CSRF, the third-party sign-in switches, the token's owner check and the deployment's filter steps. None of these moves into a permission class, because each answers with a status or a body that clients read.

Operational note

Traffic at the new addresses is reported under openedx.core.djangoapps.user_api.v2.authn_views.* transaction names. Dashboards that key on the user_authn.views.* names will see only the older addresses' share.

Merging with #39334

Both PRs are built on #39330, so whichever one merges second meets this. The recipe describes the
resulting content of each file, not a choice of "ours" or "theirs", so it holds in either direction.

It was derived from a real local merge, git merge --no-ff of #39334's head into this PR's head (trees identical to the heads first pushed
here; #39334's later commit that lifts its limit on mappings per request changes no conflicted hunk: git merge-tree reports the same seven files with the same conflict content, and the collected-id counts are unchanged), followed by origin/master d2dbc6a593. Every check below was run on that tree.

1. Which files conflict

git merge-tree --write-tree --name-only <this head> <#39334 head> reports seven files:

  1. cms/lib/spectacular.py
  2. cms/lib/tests/test_spectacular.py
  3. openedx/core/djangoapps/user_api/schema.py
  4. openedx/core/djangoapps/user_api/schema_components.py
  5. openedx/core/djangoapps/user_api/v2/tests/test_schema.py
  6. openedx/core/djangoapps/user_api/v2/tests/test_urls.py
  7. openedx/core/djangoapps/user_api/v2/tests/test_views.py

Three more files change on both sides and auto-merge: lms/envs/common.py, lms/lib/tests/test_spectacular.py
and openedx/core/djangoapps/user_api/v2/urls.py. Keep them as merged. Two of #39334's tests still need a one-line
edit after the merge (step 3).

2. How to resolve each conflict

cms/lib/spectacular.py, SUPERSEDED_PATHS. Keep both sides' entries:

Studio serves all eight and serves no replacement for any of them.

openedx/core/djangoapps/user_api/schema.py, SUPERSEDED_USER_PATHS. Keep both sides' entries: #39334's two
and this PR's six. The tuple then holds 38 entries, with no duplicate.

openedx/core/djangoapps/user_api/schema_components.py. Both sides add CREDENTIAL_SCHEMES. Write a single
mapping holding jwt and bearer from #39334 and csrf from this PR, each entry exactly as its side wrote it. Replace
both comments with one comment that does not count the entries:

# How a caller sends each credential this API takes beside the session cookie.
# The generator describes the cookie by itself, from the class that reads it,
# and knows nothing of the credentials below, so they are written out here and
# each service that serves this API appends the ones its operations name. An
# operation naming a credential its document does not describe makes that
# document invalid, and a client generated from it has no way to send it.

Leave the settings alone:

  • lms/envs/common.py auto-merges to a single 'securitySchemes': dict(CREDENTIAL_SCHEMES),.
  • cms/envs/common.py is changed by this PR only. Studio keeps {'csrf': CREDENTIAL_SCHEMES['csrf']}, the one
    credential its own operations name.

cms/lib/tests/test_spectacular.py has two hunks.

  • SUPERSEDED_USER_OPERATION_IDS. Keep both sides' entries: feat: standardize the two account operations the retirement pipeline does not drive #39334's two and this PR's eleven.
  • SupersededUserOperationTest.
    • Keep this PR's two tests, test_signing_in_by_posting_keeps_what_it_has_always_published and
      test_no_address_replacing_a_superseded_one_is_published_here.
    • Keep feat: standardize the two account operations the retirement pipeline does not drive #39334's test_no_operation_here_names_a_credential_only_the_other_service_describes, and tighten it, do
      not exempt it.
      After the merge, Studio's sign-in names csrf, which is now in CREDENTIAL_SCHEMES, so feat: standardize the two account operations the retirement pipeline does not drive #39334's
      last assertion no longer holds. Make three changes:
      • assert named - described <= {"csrf"}, ... becomes assert named - described == set(), sorted(named - described).
      • assert named & set(CREDENTIAL_SCHEMES) == set() becomes
        assert described & set(CREDENTIAL_SCHEMES) <= named, sorted((described & set(CREDENTIAL_SCHEMES)) - named).
        Studio may describe an appended scheme only if one of its operations names it.
      • Replace the docstring with:
        Every credential an operation served here names is one this document describes.
        
        The other service appends a scheme for each credential an operation of
        it names, and this one appends only those its own operations name. An
        operation served here naming a credential this document does not
        describe would leave a client generated from it with a name it cannot
        resolve.
        
    • CREDENTIAL_SCHEMES is still used, so its import stays and ruff is clean.

openedx/core/djangoapps/user_api/v2/tests/test_urls.py, EXPECTED_ADDRESSES. Keep both sides' entries:
#39334's user_v2:username_replacement_list and this PR's five.

openedx/core/djangoapps/user_api/v2/tests/test_views.py,
AddressSelfDescriptionTests.test_every_mounted_address_is_described.
This is a count, not a table. Write
assert len(described) == 22, sorted(described): the base mounts 16 views, #39334 adds 1 and this PR adds 5.

openedx/core/djangoapps/user_api/v2/tests/test_schema.py has five hunks.

  1. Superseded-address tables.

  2. The untouched list of test_hook_leaves_other_resources_alone. Both sides removed entries here, so
    apply both removals:

    From this hunk, only /user_api/v2/account/registration/ remains. Keeping both sides here fails the test.

  3. The marked list of the same test. Keep both sides' entries: this PR's /api/user/v2/account/registration/ and
    feat: standardize the two account operations the retirement pipeline does not drive #39334's deactivate_logout and replace_usernames.

  4. The head of test_every_superseded_address_has_a_published_replacement. Keep feat: standardize the two account operations the retirement pipeline does not drive #39334's docstring and both
    assertions:

    assert set(SUPERSEDED_USER_ADDRESSES) | set(SUPERSEDED_USER_ADDRESSES_LEFT_OUT) == set(SUPERSEDED_USER_PATHS)
    assert set(SUPERSEDED_USER_METHODS) == set(SUPERSEDED_USER_PATHS)
  5. The body of the same test.

The merge also applies one deletion without a conflict. #39334 deletes test_addresses_owned_by_other_apps_are_not_published,
which this PR left as #39330 wrote it. Keep it deleted. After #39334, both addresses it asserts are absent
(deactivate_logout, replace_usernames) are published as superseded.

3. Edits the merge needs where nothing conflicts

This PR's sign-in names csrf in the LMS documents. Two of #39334's exact-set assertions do not conflict textually,
but they fail after the merge until "csrf" is added:

  • v2/tests/test_schema.py::test_every_credential_an_operation_publishes_is_one_the_document_describes:
    assert named == {"cookieAuth", "jwt", "bearer", "csrf"}.
  • lms/lib/tests/test_spectacular.py::PublishedDocumentTest::test_every_credential_an_operation_names_is_described_by_the_document:
    the same change.

One exemption has nothing left to exempt:
lms/lib/tests/test_spectacular.py::ServiceWideDocumentSettingsTest::test_the_credentials_this_service_describes_reach_the_document_it_publishes.
Its first assertion now requires csrf to be described.

  • Tighten assert named - described <= {"csrf"}, ... to assert named - described == set(), sorted(named - described).
  • Cut its docstring to the first line: "The reference a client is generated from carries the schemes the service appends."

4. Checks to run before trusting the result

  1. No conflict markers: git grep -nE '^(<<<<<<<|=======|>>>>>>>|\|\|\|\|\|\|\|)( |$)' -- '*.py' prints nothing.

  2. Structure of every conflicted or auto-merged module. Parse each of the ten files and compare it with both
    parents and the base. Expected = (feat: standardize the two account operations the retirement pipeline does not drive #39334 ∪ this PR) minus what either side deleted. Check:

    • every class, every method in its class, every module-level function and every module-level assignment
      matches expected;
    • no test sits at module level that was a method in a parent;
    • no dict or set literal holds a duplicated key;
    • no name is bound twice in one scope.

    The last check is not academic. One of the two merges made for this recipe replayed the same hunk resolutions
    and still produced a second, identical SUPERSEDED_USER_ADDRESSES_LEFT_OUT = {...} block after
    SUPERSEDED_USER_METHODS, because the hunk boundaries differed. ruff and pylint both pass with the duplicate in place, and the two
    values are identical, so no test notices it either.

  3. ruff check . reports "All checks passed!", with no F401 (unused import).

  4. Tests. Run the LMS targets under lms.envs.test and the Studio targets under cms.envs.test:

    • LMS: openedx/core/djangoapps/user_api/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/oauth_dispatch/ common/djangoapps/student/tests/ lms/lib/tests/ lms/tests.py openedx/core/tests/test_apidocs.py openedx/core/djangolib/testing/tests/
    • Studio: cms/lib/tests/ cms/tests.py openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/
  5. Collected test ids. Compare as sets under LC_ALL=C, not as counts. The merged ids must equal ids(feat: standardize the two account operations the retirement pipeline does not drive #39334) ∪
    ids(this PR) minus exactly one id: v2/tests/test_schema.py::test_addresses_owned_by_other_apps_are_not_published.

  6. Served documents (LMS /lms-api/schema/ and /api-docs/schema/, Studio /authoring-api/schema/ and
    /api-docs/schema/, and the documentation settings). Check:

    • 0 dangling $ref;
    • nothing of either parent's documents is lost;
    • every credential an operation names is described in the same document;
    • the LMS documents describe bearer, cookieAuth, csrf and jwt;
    • Studio's /api-docs/ adds csrf and nothing else.

5. Expected numbers (merged tree plus origin/master d2dbc6a593)

Tests (collected / result):

tree LMS Studio
#39334 + master 3710 2806
this PR + master 3672 2775
merged + master 3854 / 3846 passed, 8 skipped, 0 failed 2950 / 391 passed, 2559 skipped, 0 failed

Served documents (paths / operations; 0 dangling $ref in every one):

document paths / operations security schemes
LMS /lms-api/schema/ 86 / 110 bearer, cookieAuth, csrf, jwt
LMS /api-docs/schema/ 475 / 624 bearer, cookieAuth, csrf, jwt
Studio /authoring-api/schema/ 69 / 93 csrf
Studio /api-docs/schema/ 253 / 353 cookieAuth, csrf
documentation settings 464 / 614 —

No path, operation, operationId or component of master, #39334 or this PR is lost. Every credential an operation names is
described in the same document. drf-spectacular reports 0 errors that master does not already report. The only
new warnings are of one kind, "could not resolve authenticator", which master already emits on hundreds of views.

ruff: "All checks passed!", also with --select F401,F811,F841 over the touched packages.

Deliberate deviations

Known pre-existing behaviour, left as it is

Evidence

Full tables are in the evidence comment.

Gates (PR base #39330):

gate result
versions FAIL 4 against #39330 (an artefact of the stacked base); PASS against master
hygiene PASS (0/0)
schema 0 BREAKING, 4 WARN, 15 INFO
urls PASS, 0 FAIL, 1 WARN; routes 3058 → 3063
old-tests 1258 passed, 4 skipped
tests 1698 passed
  • Repo CI lint: no step adds a finding. commitlint passes on every commit.
  • Test runs at this head (ae4332cd28), MongoDB attached: Studio (cms/lib/tests/, cms/tests.py, user_api/) 216 passed, 2003 skipped, 0 failed. LMS, serial (user_api/, profile_images/, user_authn/, lms/lib/tests/, lms/tests.py, openedx/core/tests/test_apidocs.py): 2922 passed, 1 skipped, 1 failed. The failure is master's own profile_images/tests/test_views.py::ProfileImageViewPostTestCase::test_upload_staff, which found an image already stored under its account's name in the upload directory. That checkout's upload directory had been shared with an earlier, interrupted run (the shared-location issue noted in feat: standardize the user accounts, profile image, verification and name-change API #39297). On a fresh checkout of this head, profile_images/ passes serially: 130 passed. user_api/v2/: 1597 passed, also under a random order.
  • Merged with feat: standardize the two account operations the retirement pipeline does not drive #39334 and origin/master d2dbc6a593 (the merge described above; this PR's side is tree-identical to this head): LMS 3846 passed, 8 skipped, 0 failed; Studio 391 passed, 2559 skipped, 0 failed. This PR merged with master alone was collected (3672 LMS, 2775 Studio ids, 0 master ids lost) but not run at this head.

Follow-ups

  • Package next version (/api/user/v3/): serializers and the standardized error envelope, JSON in place of Django's HTML CSRF page, and a sign-in form description that names the version 2 fields.
  • No SDK change: none of these operations is tagged for the generated client.

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.
@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.

@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".

…image APIs as /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/
  POST   /api/user/v2/accounts/{username}/profile_image/
  DELETE /api/user/v2/accounts/{username}/profile_image/

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.

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.

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.
…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.
…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.
Abdul-Muqadim-Arbisoft and others added 23 commits November 5, 2026 19:11
…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.
…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.
…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.
…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.
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.
…ount

The LMS schema tests generate the user documents from the user mounts
only. The registration, sign-in and password reset addresses this version
supersedes are served from the authentication app's own mount, so neither
document those tests generate carried them. The checks made over every
operation of the published document (each name unique, no name told apart
by a numeral, one area per tag group) ran without any of those operations
in it, and stayed green whatever the documents said about them.

The documents are now generated with that mount as well, matched by its
module as the user API's own schema tests already match it. A new test
pins, in each document, the operations of those addresses it carries and
their superseded mark: seven in both, and the two of the sign-in that
takes its version as a parameter in the document covering the whole
service only. It fails if the mount drops out of the generation again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ubject it can see

Several tests of what the documents say about the registration, sign-in
and password reset addresses check what a document leaves out, or run
over what a filter leaves of it, and passed just as well with nothing to
check:

- Studio's check that every credential an operation names is described
  ran over its authoring document as well, whose operations name none.
- That check, in both services, passed if no operation named any
  credential at all.
- Studio's check that every document describing the CSRF token describes
  it alike passed if no document described it.
- Studio's check that no replacing address is published passed if the
  generation reached no address under the version two prefix.
- The checks that the narrower LMS document leaves out the sign-in that
  takes its version as a parameter, and its models, passed if that
  address and those models were generated nowhere.

Each now asserts its subject first: the credentials the sign-in and the
other operations name, the document whose older sign-in names the token,
the superseded registration Studio serves under the version two prefix,
and the sign-in with its models in the document covering the whole
service, which is generated from the same mounts. The user API's schema
tests gain one test for that sign-in, next to the one listing each
superseded address's replacement: it is in the document covering the
whole service and not in the narrower one. Removing the subject from the
document turns each of these tests red; before, it left them green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… user mounts

The tests of what the two LMS documents publish for the registration,
sign-in and password reset addresses generated the whole service's
document in each of seven test classes, once or twice each: thirteen
generations of every endpoint the service serves, to read the
fourteen operations of the narrower document and the sixteen of the one
covering the whole service.

Both documents are now generated once, from the mounts the user API's
schema tests generate theirs from, which include the authentication
app's mount, and under the settings each is published with. The whole
document's path prefix is pinned to the root it derives from every
address the service serves, as those tests pin it. Each class reads a
copy, so no test can change what another one reads.

The operations and the models they refer to come out identical to the
ones in the whole documents, with third-party sign-in turned on and off.
Every check reads only these addresses' operations and fails on a
missing one, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

Rebased onto the rewritten #39330, plus this round's corrections. It is still 23 commits: three that only described the removed catch-all route were dropped, and this round's fixes were added.

This branch was rewritten and force-pushed. The description is replaced and an evidence comment follows.

What changed in the history. Each correction now sits in the PR whose code it changes. Before, some corrections to #39297's code (for example, the JWT class on the account and image views) existed only in #39314. Merging #39297 alone would have left them out. Now each PR is complete once the PRs before it merge (order: #39297 → #39314 → #39330 → #39334 / #39331). The corrections from the review round of 2026-11-04 are added as commits at the end of each PR. Commits that changed a lower PR's code moved down, with their authors and messages kept. Some fixes were folded into earlier commits of the same PR so that every commit passes the suites.

How to review the rewrite.

git fetch origin pull/39331/head:pr-39331 be57050bad
git range-diff ed83beab3d..be57050bad pr-39330..pr-39331

The new commits of the 2026-11-04 round are listed in the description's commit table. Each one names its subject, and each behaviour change comes with a test that fails without it.

@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

Evidence for #39331

Each test is named as module::Class::test as it exists at this head. test_authn_addresses.py is under openedx/core/djangoapps/user_api/v2/tests/.

Declared differences → tests

declared item test
each method is the extended view's own; nothing redeclared but permission_classes test_authn_addresses.py::ViewShapeTests::test_each_method_hands_the_request_to_the_one_it_extends, ::test_each_view_declares_only_who_may_call_it, ::test_how_a_caller_is_identified_is_the_extended_views
refused methods answered alike on both mounts test_authn_addresses.py::RegistrationParityTests::test_the_methods_it_does_not_serve_are_refused_alike (and the same test in PasswordResetFormParityTests, TokenValidationParityTests, RegistrationValidationParityTests, LoginParityTests)
slashless address → 301 test_authn_addresses.py::ConformingAddressTests::test_an_address_sent_without_its_slash_is_redirected_to_it
restricted-application token at the token check, as v1 test_authn_addresses.py::TokenValidationParityTests::test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused
E's views are inside the structural walk test_restricted_applications.py::EveryOperationCarriesTheRefusalTests::test_every_routed_view_inherits_the_guard
shared rate limits test_authn_addresses.py::RegistrationLimitTests, LoginLimitTests
registration body required (the handler refuses an empty one) test_authn_addresses.py::RegistrationParityTests::test_an_empty_registration_names_every_field_it_needs
templated sign-in left out of /lms-api/schema/ test_authn_addresses.py::SupersededDocumentTests::test_the_narrow_document_leaves_out_the_address_that_takes_its_version_as_a_parameter; test_schema.py::test_the_superseded_address_left_out_is_generated_from_the_same_mounts
superseded operations marked in each document test_authn_addresses.py::SupersededDocumentTests::test_each_operation_is_marked_superseded_in_both_documents; lms/lib/tests/test_spectacular.py::SupersededAuthenticationAddressTest::test_each_document_carries_each_of_their_operations_marked_superseded
Studio publishes no replacement cms/lib/tests/test_spectacular.py::SupersededUserOperationTest::test_no_address_replacing_a_superseded_one_is_published_here
csrf described wherever it is named lms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::test_every_credential_an_operation_names_is_described_in_the_same_document; cms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::test_every_credential_an_operation_names_is_described_in_the_same_document; test_authn_addresses.py::ConformingDocumentTests::test_signing_in_publishes_the_security_the_older_sign_in_publishes
published members held to real bodies test_authn_addresses.py::test_the_members_published_as_always_present_are_held_to_the_ones_each_body_carries
the token check answers only about the caller's own account test_authn_addresses.py::TokenValidationParityTests::test_a_signed_in_caller_is_told_only_about_its_own_account
the authentication addresses are kept in the LMS filter lms/lib/tests/test_spectacular.py::LmsApiFilterUserAddressTest::test_keeps_the_authentication_addresses_this_version_supersedes_and_none_beside_them

Gate report

run_gates.sh (d4e6de0), with the same settings and sets as #39297. The run used a tree that differs from this head in two test files only: the C1803 assertion inherited from #39297, and the four retirement permission tests that #39330's last commit changes.

gate vs #39330 vs d178206575
versions FAIL 4 (same unreleased version, stacked base) PASS
hygiene PASS (0/0) FAIL (1 FAIL, 5 WARN), inherited
schema (/lms-api/) 0 BREAKING, 4 WARN, 15 INFO 0 / 88 / 83
urls PASS (0 FAIL, 1 WARN: R10 verb heuristic on login_session_list), 3058 → 3063 same WARN, 3034 → 3063
old-tests 1258 passed, 4 skipped —
tests 1698 passed —

Repo CI lint: ruff, xsslint, pii_check, check_keywords, lint-imports ("lms and cms are independent" KEPT), mypy, verify-dunder-init and pylint add 0 findings against #39330. commitlint passes on every commit.

Test runs at this head (ae4332cd28), MongoDB attached:

run result
Studio, with the learning-app stub: cms/lib/tests/, cms/tests.py, user_api/ 216 passed, 2003 skipped, 0 failed
LMS, serial: user_api/, profile_images/, user_authn/, lms/lib/tests/, lms/tests.py, openedx/core/tests/test_apidocs.py 2922 passed, 1 skipped, 1 failed (below)
profile_images/ (v1 and v2), serial, on a fresh checkout of this head 130 passed
test_retirement_views.py: file order, reversed order, -p randomly seeds 1, 2, 3 and 12345; each of its 28 classes alone 256 passed in each run; 28/28
test_retirement_views.py at every commit of this PR 23/23 green
user_api/v2/, in file order and with -p randomly seed 7 1597 passed, both runs
merged with #39334 and origin/master d2dbc6a593 (this PR's side tree-identical to this head) LMS 3846 passed, 8 skipped, 0 failed; Studio 391 passed, 2559 skipped, 0 failed
this PR merged with master alone collected only: 3672 LMS and 2775 Studio ids, 0 master ids lost

The one LMS failure is master's own profile_images/tests/test_views.py::ProfileImageViewPostTestCase::test_upload_staff. It asserts that no image is stored under a generated account name, and found one: that checkout's upload directory had been shared with an earlier, interrupted run. This is the shared-location test-isolation issue noted in #39297, and the same file passes on a fresh checkout (row 3).

Changed by the corrections (tests only)

  • The LMS user-document fixture also takes the authentication app's mount. Without it, the shared operationId and tag checks never saw this PR's superseded operations. SupersededAuthenticationAddressTest pins them.
  • Five document checks now assert first that their subject is present: the credential-scheme checks in both services, Studio's "no replacement published here" check, the left-out sign-in check, and the narrow-document check.
  • test_authn_addresses.py builds its two documents once, from the user mounts, instead of 13 whole-service generations.
  • Earlier tests that relied on the catch-all route are adapted:
    • ConformingAddressTests::test_an_address_sent_without_its_slash_is_refused_in_json became …_is_redirected_to_it.
    • SharedPrefixTests::test_the_authentication_app_and_the_rest_of_the_user_surface_are_matched_before_this_mount was removed, because the mount order it asserted no longer holds.

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