Repository navigation
feat: standardize the registration, sign-in and password reset APIs - #39331
Abdul-Muqadim-Arbisoft wants to merge 82 commits into
Conversation
The user directory, the stored-preference listings and the time-zone
reference list were the least conforming endpoints of the User API: the
only gate was the deprecated X-Edx-Api-Key header, which ignores
request.user entirely, so any holder of the key could read every user's
email address and full preference mapping. The listings were addressed by
database id, two of them put the parent key in the path instead of the
query string, the time-zone list returned an unbounded array, the stored
preference row embedded its owner's entire record (one extra query per
row), and the forum-role listing created the role it was asked about on
every GET.
Each of those is a contract change, so they land on a new version and
/api/user/v1/ is untouched and keeps serving them.
What callers get instead:
GET /api/user/v2/users/ (also ?preference_key=,
?forum_role=&course_key=)
GET /api/user/v2/users/{username}/
GET /api/user/v2/user_preferences/
GET /api/user/v2/user_preferences/{username},{key}/
GET /api/user/v2/time_zones/
Authenticated staff callers rather than an API key, the standard error
envelope, the standard pagination envelope on all three lists, filter
sets and a deterministic ordering, and the ?view= and ?fields= response
presets. The forum-role filter no longer creates a role, and an
unparsable course key is a validation error rather than a 500.
Being refused is part of the contract, so the refusals are explicit. A
deactivated staff account is turned away on both paths rather than only
one: the session authenticator already rejected it, but IsAdminUser reads
only is_staff and the JWT authenticator does not read is_active, so such
an account could read the whole directory until its token expired. These
addresses require an active user.
An unrecognised query parameter is a 400 rather than silence. A client
porting from a superseded address by changing the version segment alone
would otherwise pass a filter this version does not define, have it
ignored, and receive every row believing the page was filtered. The
allowlist is derived from the paginator, the ordering backend, the filter
set and the query serializer, so it cannot drift away from the filters it
describes.
Shared building blocks are used rather than rewritten:
StandardizedErrorMixin, DefaultPagination, IterablePaginationMixin,
ScopedQuerysetMixin with FullScopePolicy, MinimalViewMixin and project.
The two local classes each supply one predicate the library does not,
and each docstring says which.
The tests pin behaviour rather than structure: every allowed and every
refused caller with the error type asserted, value-level parity with each
superseded address, query counts that stay constant across 1, 5 and 25
rows where the superseded listing pays per row, SQL captures proving no
GET writes, and the URL literals each name reverses to.
The LMS drf-spectacular pre-processing hook is an allowlist and admitted only /api/enrollment/vN/, so everything under /api/user/ generated, routed and tested while being invisible in the published document. The new v2 addresses would have shipped undocumented and the schema gate would have reported the prefix missing with no clue as to why. The filter now admits exactly what this change owns: the v2 addresses, matched by their view package rather than by path prefix, plus the seven legacy paths they supersede, so each superseded address appears beside its replacement. It is deliberately not a /api/user/vN/ prefix. Admitting the whole prefix pulled thirty-eight further v1 operations into the document -- undocumented request bodies and internal docstrings published as client-facing text -- none of which this change owns or documents. SCHEMA_PATH_PREFIX becomes a two-branch pattern, stripped only to derive tags and operationIds. Every existing enrollment operationId was compared before and after and is unchanged, and so is every operationId, path and component of the service-wide /api-docs document: a generated client derives a module per operationId and a model per component, so a rename there deletes a module as surely as a removal does. The title follows the contents, and the three comments that named the old title follow it. Deprecation is marked where it belongs, outside the frozen modules: the existing post-processing hook gains a tuple of the seven exact legacy paths this change supersedes. Exact paths, not a prefix, so an address whose replacement nobody has written yet can never be marked -- the hook test includes neighbouring user addresses, the unversioned twins and another area's paths and asserts they are left alone. POSTPROCESSING_HOOKS is unchanged: setting it replaces drf-spectacular's defaults, so the enum hook stays restated and stays first.
…api/user/v2/
The account surface was the largest non-conforming part of the User API and the
one most callers depend on: frontend-platform's account fetch, the account and
profile MFEs, and both mobile apps all read it. Ten of its operations answered
in shapes nothing documented, refused malformed input with server faults, and
addressed one account's records through a permission that read a username from
somewhere other than the address.
Every change here is a contract change, so they land on a new version:
/api/user/v1/ is untouched and keeps serving every one of its callers.
What this version answers:
GET /api/user/v2/accounts/ (?username=, ?email=,
?lms_user_id=, ?view=,
?fields=, ?ordering=)
GET /api/user/v2/accounts/me/
GET /api/user/v2/accounts/{username}/
PATCH /api/user/v2/accounts/{username}/
POST /api/user/v2/accounts/{username}/deactivation/
POST /api/user/v2/email_searches/
POST /api/user/v2/name_changes/
POST /api/user/v2/name_changes/{username}/confirmations/
GET /api/user/v2/accounts/{username}/verification_status/
GET /api/user/v2/accounts/{username}/verifications/
A refusal is part of the contract, so the refusals are explicit. The request
body is measured against a serializer before the account service reads it, so a
wrongly typed property is a validation envelope rather than a server fault; a
failure inside the service is a server fault rather than a validation envelope.
A permission reads the username from the address and from nowhere else, and
compares it exactly, because the handler behind it selects the record by that
same value. An account that is no longer active is refused on both halves of the
default authentication pair rather than only one.
Lists answer in the standard envelope, bounded and ordered by username; a filter
that matches nothing answers an empty page rather than reporting the address as
missing, and a status that has never been established answers "none" rather than
reporting the account as missing. An email search is bounded at a hundred
addresses, which bounds its answer.
The schema describes what the code does: the superseded addresses publish the
media types they actually read, every operation publishes the codes it really
answers, and the response components carry the properties the bodies really
hold, with presets modelled rather than implied.
The tests pin behaviour rather than structure. Every allowed and every refused
caller is named with the error type asserted; value-level parity is checked
against each superseded address; query counts are measured on the earlier
endpoint first and asserted not to grow with the collection; SQL captures prove
no read writes, including writes deferred to commit; and each guard was checked
by breaking what it covers and watching the right tests fail.
…rofile_image/
The profile-image endpoints are addressed under the User API but served by a
view that belongs to the profile_images app, so the conforming version lives in
that app's own new version package and is mounted into the user version two
URLconf exactly as the earlier view is imported today. The app's existing
modules are untouched, and the image validation they own is called rather than
copied.
POST /api/user/v2/accounts/{username}/profile_image/
DELETE /api/user/v2/accounts/{username}/profile_image/
An upload is read before anything is stored, so a truncated file, metadata that
cannot be parsed, or a header declaring more pixels than the site will ever draw
are answered as the caller's error rather than as a server fault, while a
failure to store a well-formed image stays a server fault. The media types the
address reads are published, and the body is published as required.
A caller may set and clear the image on the account the address names and on no
other, staff included, since clearing an image is a change to an account rather
than a moderation tool. The permission reads the username from the address and
compares it exactly, because that is the value the stored names are built from,
so the account a caller is admitted for is always the account written to.
The tests drive real uploads: each refusal with its error type, parity against
the earlier address for the bodies both answer, and a check that a caller
holding another spelling of the owner's name is refused and stores nothing.
This package is shared with the CMS, and the CI shard that runs it under cms.envs.test could not import this module at all: it read settings.ACCOUNT_VISIBILITY_CONFIGURATION, an LMS setting, in the module body. Collection failed, which failed the shard, which cancelled the rest of the matrix. Every test here that reads these sets is already @skip_unless_lms, but a module body runs before any skip can apply, so the setting is now read through the same guard user_api/accounts/tests/test_views.py uses for it, and the field sets fall back to empty when it is absent. Under cms.envs.test this module now collects, and the failures left in these two packages are the same 44 as on master, in the same tests -- an artefact of running two of the shard's sixty-nine paths rather than anything in this branch. Under lms.envs.test nothing changes.
Six operations move to this version's conventions: a learner's preference
document and its individual members, addressed under the account they belong
to, and the email opt-in as a collection of its own.
GET, PATCH /api/user/v2/accounts/{username}/preferences/
GET, PUT, DELETE /api/user/v2/accounts/{username}/preferences/{key}/
POST /api/user/v2/email_opt_ins/
A preference name is the whole final segment, punctuation included, so a name
carrying a slash has no address of its own; it is read with the whole document
and removed by sending a null for it there.
The superseded /api/user/v1/ addresses keep answering exactly as they did and
are marked deprecated in the published LMS schema. The unversioned twin
/user_api/v1/preferences/email_opt_in/ is the same view at a second address: it
keeps resolving and carries no schema mark, which is how the other unversioned
twins are treated.
…uth class Four views declare their own authentication_classes so that a caller who has not yet confirmed their email address can still reach their own account, its picture, its preferences and its email choice: the session class the platform configures refuses such a caller, so that half is replaced. For the token half they named DefaultJwtAuthentication. That class exists only to be named in DEFAULT_AUTHENTICATION_CLASSES -- its own module says it is only meant for that -- and the using_default_auth_classes attribute it records exists so an operator can find the endpoints that are on the defaults. A view that declares its own classes is not one of those, so recording the attribute filed a false signal and polluted the query the attribute serves. Nothing on master declares the class: two files name it, both definitional, against the library class used throughout the rest of the platform. The four views now name JwtAuthentication, the class the platform's own pair is built on. Two of these addresses, the account and its picture, were added earlier on this branch and are corrected in place here. The guards assert the attribute is absent, each alongside a control request to a sibling address that declares no classes and must still record it, so a pin cannot pass merely because a patch target was mistyped.
An audit across the three phases on this branch found four defects, each introduced by an earlier phase and each visible to a caller or to a generated client. They are corrected together here, as the JWT class was, rather than left to differ between sibling addresses of one version. The profile image published a username with no pattern, the only one of the eleven username path parameters in this version without it, so a generated client validated the name before the request on nine addresses of the resource and not on the tenth. The parameter helpers and the username pattern move from views.py into view_utils.py, which that module already imports from, and the parameter is built with the helper. The helper being private to one module was the cause, and a further module would have hit it again. The stored-preference resource pinned one operation id inline, which applies to every document, so the service-wide document published that operation beside a collection read carrying a different prefix, and a client generated from it derived the two from different modules. The pin moves into the per-document table beside the entries that already do this, so the resource's operations share their leading tokens in each document. One id changes, in the service-wide document only, to a name that document has never published. The username filter accepted a single exact name on two collections and a comma-separated list on a third, so a caller who learned the list form on the account collection was answered a 200 with an empty page rather than told the filter does not take a list. All three now accept the list, through one shared selector, matching the member address of that resource, which already reads a comma as a separator. The email search was the one collection in this version reading accounts directly without declaring a scoping seam, while the two collections over the same table declare one. It now declares the same policy through a queryset the shared mixin can wrap. No caller gains or loses access and no row changes: the policy is a pass-through today, and the seam is what was missing.
Three operations describe the body they read in place rather than through a serializer. A shape given in place carries no field the generator can read requiredness from, so the document published no mark at all and a generated client made the body an argument the caller may omit, while each handler answers a request carrying no body with a 400. The addresses are named explicitly and each is driven in the tests with its form named and no body behind it, so the mark the document carries is held to what the handler answers. The write of one stored preference is left unmarked on the same evidence: it takes a request with no body.
…er them Both documents already published these four addresses with a 200 and no body before their bodies were described. No handler answers that: each answers 204. The corrected code is now pinned in both documents and each handler is driven on its success path in the same test, so the published code and the answered one can only move together. Replaces the pin that covered two of the four in one document.
…here too Studio mounts the user API at the same addresses the LMS does and publishes its own unauthenticated document at /api-docs/, built from its own settings, so none of the marking applied in the LMS reached it: all twenty-five of those operations were published there as current while the LMS published them as superseded. A client generated from Studio's document therefore offered a caller an address the LMS document steers them off. The list of superseded addresses and the hook that reads it are shared rather than restated, so the two services cannot drift apart. Measured on the two generated documents: Studio marks 25 of 25, as the LMS does, and nothing else in Studio's document changes -- no path, operation, operationId or component is added or lost. The narrow Authoring API document Studio also serves carries none of these addresses, so the hook cannot reach it.
…ettings The documentation settings replaced the appended components wholesale rather than adding the security schemes to them, so any component name the service settings append was dropped from the settings the published reference is generated from. Nothing showed it: the name simply stops being in the reference, and in every client built from it, while every count stays where it was. They now add to what they inherit. The test loads those settings in a process of its own -- loading them here would turn every feature flag on and add Studio's applications to the settings this process already runs under -- and checks that a component the service settings append arrives alongside the security schemes.
Asking for a page of nothing, of fewer than nothing, or of something that is not a whole number of rows was answered 200 with the default page of ten, on every collection of this version that pages its rows. The framework drops a size it cannot honour and falls back without telling the caller, so the page reads as the one that was asked for; the links on it carry the value back, so a client that follows them keeps sending a number nothing reads. The shared paginator now reads the value itself and refuses one no page can have, the way this version already refuses a query parameter it does not read, a sort field it does not offer and a boolean naming neither side. A number above what a page carries is still answered with a full page, which is what it has always meant, and a value carrying nothing still leaves the size alone.
The standard error body carries one line for a person to read beside the list of what each parameter or property was refused for. Where the refusal named parameters, that line was produced by printing the list itself, so a caller read back the brackets, the quoting and the class names that write a data structure out instead of a sentence. Everything that puts the line in front of a person showed it: the message of the exception a generated client raises, a line in an integration log, the notification a page puts up. The line is now written from the same values as a sentence, naming each parameter and what it was refused for; a refusal that names nothing in particular is spelled on its own. The list itself is untouched, and a refusal that never had a list -- an address that matches nothing, a caller that may not read, a method the address does not serve -- keeps the line it had.
…ved it to A member address of the account collection was answered by matching the name it carried against the name each record that came back is stored under, by a reduced form of that name failing that, and anything matching neither was dropped. The database settles which stored records a name resolves to, and the name it resolves to need not be written the way the address wrote it: where it resolved to a record neither comparison matched, the list came back empty, the member handler indexed the first element of it, and an authenticated caller was answered 500. The superseded address answers 200 with that record, so the replacement was worse than the address it stands in for, and a client or MFE that pads or transliterates a name turned a working read into a server fault. A member address is now answered from the records the lookup resolved it to: the one stored under exactly the name given when there is one, and otherwise the single record that came back. An address the lookup resolves to several records, none of them stored under the name given, names no one of them, so it is refused with 400 rather than answered with whichever came back first. A lookup that comes back with nothing is reported as an absent address rather than as a fault. The page of a list keeps the order its names were asked in, which is what the matching is for; a record it resolved to under a name none of them is written as is now carried at the end of the page rather than left out of it in silence. Tests: a member address the lookup resolves to a record stored differently, one it resolves to nothing, one it resolves to several records with and without an exact match among them, and a page carrying a record resolved under another name. Each fails against the previous code, the first three with the 500 this corrects.
The account collection selects by the email address of an owner with a lookup that takes one record and refuses to answer at all where there is more than one. Nothing in the user model keeps one address to one account, so a staff caller looking up an address two accounts hold was answered 500, with nothing in the answer to say why. The superseded address fails the same way, so this was carried forward rather than introduced. The selector now names every account holding the address, and the page carries them all. A collection is what this address answers, and a page of the accounts holding an address is already what the search over addresses beside it answers with, so the page is the answer here whether the address is held once or several times. An address no account holds still leaves the page empty. Tests: the selector asked for an address two accounts hold, and the collection read by a staff caller for the same address, asserting both accounts in the page. Both fail against the previous code, the second with the 500 this corrects.
…source The accounts are one collection however many addresses reach into them, and six operations that read or change a row of it were served by views standing apart from it: the identity verification summary and attempts, the preference document and one preference of it, and setting and clearing the image, each declaring a flat list of classes of its own, none of them reading what the account resource admits a caller by. A rule declared for that resource therefore did not reach them. Measured with a rule admitting nobody added to the resource: the account read answered 403 while the summary, the attempts and the preferences answered 200, one preference and an upload reached their handlers, and clearing the image answered 204 and cleared the stored image -- a change to an account carried out while every read of the same account was refused. Deactivating an account read nothing of the resource either. The classes the resource admits a caller by are now declared once and carried by every address that works on one of its accounts, which applies them first and the classes of its own address after. The same measurement now refuses all eight, and the stored image is left alone. The declaration is read from the shared tuple rather than from the collection's own attribute because that attribute is added to in place by the first request to reach a view that reads a token, which would otherwise make admission depend on which addresses a process had served already. The account an address names is resolved through the resource as well, by one rule: the record stored under exactly the name given, or the single record the name came back with, and otherwise a refusal. Three operations resolved the record they write to with a lookup that takes one record and fails outright on more, each catching only the not-found case, while the class in front of them had settled the question on the name as text. Making a password unusable, confirming a profile name and recording an image can therefore no longer land on a record nobody addressed: each is refused with 400 and writes nothing. Recording an image is looked up again by the writing helper, which this version does not own, so the refusal is raised where that lookup comes back with more than one record. The preference addresses keep their own two-tier rule -- a read of somebody else's is a privilege, a change of it belongs to the owner -- and declare it through the hook the shared gate calls, so both tiers still apply and the resource's rules apply before them. One test outside this version's own modules moves with it: the preference guard harness handed a bare request to classes that are given the wrapped one by the view, and the classes carried in from the resource read the request through it, so the harness now authenticates the request the way the test client does. Tests: every read and every change of one account measured twice, once as they stand and once with a rule admitting nobody added to the resource, with the stored image asserted untouched after the refusal; a deactivation refused with the privilege for it held, so that the refusal measured is the resource's; and each of the three write targets, refused when the address names no one account and landing on the account stored under the name when it does. Removing the shared gate from the four views, or emptying the shared declaration, turns the refusal tests red.
The answer to an OPTIONS request is built from the parsers and the serializer declared on the view, which belong to the view rather than to the address being asked about, and two addresses of this version were described wrongly because of it. The preference document of one account is read and changed as a merge patch and a body in any other media type is refused, which is what the published document says of it. The description of that address named the three media types the platform reads bodies in everywhere else and left out the one it takes, because the parsers follow the action being served and the action recorded for this question is not the change. A caller negotiating from the description -- the one mechanism offered for it -- therefore sent a body in a media type the address refuses and was answered 415, with the two published descriptions of one address contradicting each other. The parsers now follow the methods the address serves, and the description reports the ones it would read a body with. Confirming a requested profile name carries out the change already asked for and reads no body, which is what the published operation says. The description of that address offered a body with a required name in it, built from the view's serializer, so a client or a form driven by it asked the operator for a name before confirming the change and sent a body the address ignores. An action that reads no body is now declared as such and no body is described for it. The address that asks for a name still describes the one it takes. Tests: the media types named by each preference address, a change sent in every media type the document address names being carried out, no body described for a confirmation, and the body still described for asking. Each fails against the previous code, the negotiated change with the 415 this corrects, and each half of the description turns its own tests red when taken back off.
…ently An address can have been held and left more than once, so more than one retirement of it can sit inside the cooling-off period. This version reports the most recent, being the only one a caller could still undo; the superseded version reads the same window with a lookup that takes a single record and so fails outright on the second. The window boundaries are the same in both. The selection is on a path declared to answer as the superseded one answers, and this is the one input they answer differently for, so the selector now says so where the next reader of it will be.
…ot to a maintainer
…ddresses too Studio's name pin covered the addresses this version replaced before the retirement pipeline joined them; it now covers those as well, each under the name both documents already carry -- checked against the other service's document, where none of them is named differently.
The repository's pylint reports four attributes of one retirement test set outside the constructor. They are declared empty where the rest of the case is set up, so the names exist before the helper that pairs two records assigns them, and the helper is unchanged.
The partial update of a version 1 retirement was the only reference to the retirement-status model, and that operation now publishes the object its handler really reads, so nothing generates the model any more. The service serving the newer version of this API appends the model to every document it builds; the service serving only the older one did not, so a client generated from its document of everything it serves lost that class. The definition now sits beside the rest of this API's schema behaviour and is read from there by both services and by the published reference. It is kept in a module that imports nothing, because a settings module reads it while the settings are still being assembled, which is too early for any module that reads a setting of its own.
…PI's version 2
Registering, signing in and resetting a password are reached today at
addresses the rest of the user API no longer uses: a singular `account`
segment with a second collection nested under it and no identifier between
them, a verb (`token/validate`), a route without a trailing slash, and one
route answering two versions through a `{api_version}` parameter. This adds
the five addresses version 2 names them by:
/api/user/v2/registrations/ GET, POST
/api/user/v2/registration_validations/ POST
/api/user/v2/login_sessions/ GET, POST
/api/user/v2/password_resets/ GET
/api/user/v2/password_reset_token_validations/ POST
Nothing changes for any caller of the existing addresses: every route, URL
name and view of the authentication app stays exactly where it was, and no
file of that app is edited.
Each new address is served by a subclass of the view already serving the
older one. The subclass adds `permission_classes = (AllowAny,)`, which is
what those views already allow, and the description it is published with,
and nothing else. Every request is answered by the inherited, decorated
method, so authentication, CSRF handling, cookies, bodies, status codes and
the third-party sign-in flow are the older address's. That includes the
limits on how often a request may be sent: those counters are keyed on the
decorated method and on the view class's name, so the subclasses keep their
parents' names and the counters stay shared between old and new addresses
(spend the limit at one, be refused at the other). Sign-in is bound to
version 2 by its route, and registration answers as the version 2
registration address does, `confirm_email` included.
The bodies are published as shapes written in place, since none of these
views uses a serializer: the form descriptions, the registration and
sign-in success and refusal bodies, the token and validation results, and
DRF's `{detail}` refusals, each only for the codes the handler is seen to
produce. Sign-in publishes the version 2 fields it really reads and the
same security block as the other addresses here.
The new tests send every request to the older address and to the new one
and compare status, media type, cookies and body; cover the callers each
view refuses (CSRF, malformed credentials, rate limits with a real cache,
the switches that close registration or require another provider, a
deployment's own filter steps); pin query counts and that reading a form
writes nothing but the session; and hold every captured body against the
shape the service-wide document publishes for it.
…es superseded
Each of the nine operations the new version 2 addresses replace is now
published with `deprecated: true`, in the service's own document and in the
service-wide documents of the LMS and of Studio, which both serve them:
/api/user/v1/account/registration/ GET, POST
/api/user/v2/account/registration/ GET, POST
/api/user/v1/account/password_reset/ GET
/api/user/v1/account/password_reset/token/validate/ POST
/api/user/v1/validation/registration POST
/api/user/{api_version}/account/login_session/ GET, POST
The six published keys are added to the existing superseded-address tuple,
spelled exactly as the generator publishes them, which both admits them to
the service's own document and lets the existing hook mark them. No route,
URL name, operationId or component changes, and nothing is written on the
views themselves. The unversioned `/user_api/...` twins and `/create_account`
are left unmarked, as the twins of every other superseded user address are.
Eight of the nine operations published no body at all, or only a 200 with
none. They now publish the same bodies and codes as the address replacing
each, from the same shapes, so the two cannot drift: the form descriptions,
the registration request and its 400/403/409 refusals, the token and
validation checks with their refusals. The schema gate therefore flags two
lines of each service-wide document as a request body newly required: the two
registration POSTs. Neither published any request body before; both now
publish the registration body as required, because their handler refuses an
empty body with a 400. The
posted sign-in keeps the description its handler has always carried, since
one key serves two versions with different request fields, and a
description on the handler is published in place of any table entry; its
two components stay referenced.
Tests pin the marks in every document, the published names and groups, that
each superseded operation publishes what its replacement does, and that the
hook and the filter leave every neighbour alone: the twins, `/create_account`,
`/login_ajax`, unpublished spellings of the keys, the new addresses and the
version's other addresses.
Two comments in the version's URLconf still said the mount is matched ahead of the patterns installed plugins contribute, and that the authentication app's addresses under the prefix are therefore reached through this mount. Since the mount moved after the plugin patterns, the authentication app, which is itself contributed as a plugin, answers its own addresses first, and an address any plugin registers under the prefix reaches that plugin. The comments now say so: naming the app's addresses keeps the fallback from matching them, which is what leaves an address sent without its slash to be redirected rather than refused. No route changes.
…fore any request runs The token check is answered by views that read signed tokens, and the platform's middleware adds a permission to such a view's class, in place, the first time one is requested. Two checks read the classes after the fact, so they failed whenever a token check had run earlier in the same process: under a random test order, or with the token test named first. They now read the classes as the test module is imported, which pytest does for every module before the first test runs. What they guard is unchanged: the version 2 views declare only who may call them, and the views they extend declare no permission of their own. Both still fail when either declaration changes.
…ly null A site can add fields to the registration form through a form it names in its settings. Each such field is described with that form field's label and starting value, and a Django form field has neither unless it is given them, so the form description then carries null for both. The published input shape said both were always text, so on such a site the registration form at version 2 and at the addresses it replaces failed the shape every document publishes for it, and a client generated from the document would reject the whole form. Both members are now published as text or null, and the starting value's description says it is null in that case. The new test names an extension form, reads the form at every address that serves it, and holds each body against the shape both documents of the service publish.
…ble body
Every registration, sign-in and password reset operation, at version 2 and
at the addresses it replaces, refuses a request whose Accept header names
nothing it can answer in (406), and every one of them that reads a body
refuses one in a media type it cannot read (415). Both are settled before
the handler runs and answered as {"detail": ...}. None of them published
either code, although every other operation of version 2 publishes 406 and
every other one reading a body publishes 415, so a client generated from
any document met a status it was never told about.
Each response table now carries both codes, in the body these views really
answer with and with the descriptions the rest of the version uses. The
tables are shared with the descriptions of the older addresses, so Studio's
document gains the same codes for them.
The new test sends each refusal to every address and holds the answer
against what each document publishes for that code, so a code the handler
produces that is missing from a table fails it even where the pinned list
of codes was changed to match. A second test reads which fields the stock
registration form requires and requires the published registration body to
name exactly those, and Studio's test pins the same list and codes.
…ut of the standardized document
Listing /api/user/{api_version}/account/login_session/ among the superseded
addresses both marks it deprecated wherever it is published and admits it
into the LMS document of the standardized APIs, where it had never been.
Signing in by posting there is published with the description its handler
carries: a request naming `email`, which version 2 does not read, and a
success body requiring members the sign-in never answers with. Nothing in
the schema module can replace a description a handler carries, so
admitting the address put a description known to be wrong into that
document.
The address is now left out of that document, as it was before, while both
service-wide documents still mark both of its operations deprecated and
still carry the two components its description names. The address
replacing it is published in all three as before.
The tests pin that the address and its two components stay out of the
standardized document, and that each superseded operation is marked in the
documents that publish it.
…-in publishes The sign-in at /api/user/v2/login_sessions/ needs the CSRF token exactly as the older address does, and the older one publishes that need as a `csrf` security requirement. The new one published no requirement at all, behind a comment saying no published document declares that scheme. The reference generated from docs.docs_settings, which docs/lms-openapi.yaml is built from, does declare it (the X-CSRFToken header), so a client generated from it would send the token to the older sign-in and not to the new one, and its migrated call would be refused. The new sign-in now keeps the requirement of the view it extends, so every document publishes the same security for both sign-in addresses. The documents the services serve still do not declare the scheme, as before for the older address. The test requires the new sign-in's security to be the older sign-in's in both of the service's documents, and every other address here to need nothing.
The check that reading a form writes nothing recognised only statements starting INSERT INTO, UPDATE or DELETE FROM. A bulk create that ignores conflicts sends INSERT OR IGNORE INTO on SQLite and INSERT IGNORE INTO on MySQL, so a form read doing one was never counted and passed. A statement now counts as a write when it starts with INSERT, UPDATE, DELETE or REPLACE, whatever follows, and a write whose table cannot be read is still listed.
Reading a form was guarded only by a capture of the statements it sends to the database. A Django signal or an Open edX event sent from a form read, whose receivers do their work elsewhere, sent no statement and so passed. Each read of the registration, sign-in and password reset forms, GET and HEAD, at the older address and at this one, now records every signal sent and every event sent, turned on or not, and expects none beyond what every request sends and the saving of the caller's session.
…r way Every capture of what reading a form writes or sends used a new caller with no sign-in through another provider under way, so the part of the registration and sign-in form builders that runs only partway through such a sign-in never ran under it. A write added there passed. Three more cases now read each form with a sign-in under way: through a provider that fills the profile in, through one that does not, and through one this site does not know. Each asserts the same writes and signals at both addresses, and that the registration form was really built for that sign-in.
…h 403 The token check is identified by the platform's JWT authentication, so the platform's middleware adds the permission that refuses a JWT issued to a restricted application, at the older address and at this one alike. The published 403 named only the missing CSRF token, and no test sent such a JWT. The 403 now names both causes, and two tests run at both addresses: a JWT issued to a restricted application is refused with the same body, and a session whose account was since made inactive is answered as a caller who is not signed in.
The sign-in limit tests spent failures from one client, so only the limit per client was ever exceeded; nothing showed that the limit per account is counted together at the new sign-in address and the older one. Failures naming one account, each from a client of its own, are now spent at one address and refused at the other, both ways, while another account is still let in. The directions not yet sent are added too: failures at the version one sign-in and at the older sign-in function count at the new address, failures there count at the older version two address, and registrations sent to the version one address or to the older registration function count at the new one.
A registration that completes a sign-in through another provider carries no password: the server generates one. The authentication front end leaves the password out while such a sign-in is under way in the session, and a client that signed the person in with the provider itself sends the provider and its access token instead. The published request required a password of every registration, so a validator or a strict client built from either document refused both. The shape now requires the fields every registration needs and, beside them, one of three things: a password, the provider of the sign-in under way, or a provider with its access token. An ordinary registration is still published as needing a password, and the tests send each body to every registration address after holding it against both documents.
Registration and signing in read a submitted form, which is parsed when
the handler first reads it. A body that cannot be parsed in the media type
it is sent as, such as a multipart body with no boundary, is refused with
400 and a body holding `detail` alone, before the handler can answer with
its own. The 400 of both was published with the handler's body only, so a
client typed from either document could not read that answer: the
registration body needs each member to be a list and the sign-in body
needs `success`.
Each 400 now offers the handler's body or the `{detail}` body, and says
when the second is sent. The older registration addresses share the
description. The test helper that looks for undeclared members now holds a
body to the one shape it satisfies, so the existing checks of these 400s
keep their strength.
…imit The registration limit counts the requests to register sent from an address, not the accounts they created: a request refused for a missing field counts as much as one that registers an account. The published 403 and the operation description said the refusal came after too many accounts were registered, so an integrator budgeting on accounts created would be refused sooner than the document said. The existing limit tests already reach the 403 after refused registrations alone.
Where a site checks passwords against lists of exposed passwords, signing in with a correct password can still be answered with 400. A password found often must be changed first and the caller is not signed in. One found less often is answered with `nudge-password-change` after the caller has been signed in: the session cookie is set, but not the cookies a successful sign-in adds. The published 400 said only that signing in had failed, so a client following it would treat a signed-in caller as signed out. The 400 now names both cases and what each leaves behind, and `redirect_url` says what it holds in the second. The new tests reach each side of the two thresholds and hold the answer against both documents.
…s in Two docstrings said the mount for version 2 is matched before the rest of the user surface and before the authentication app's patterns, so that an address that app adds under the prefix is answered by this version. The service matches both before the mount: the include for the rest of the user surface comes first, then the patterns installed applications contribute, then the mount. An address the authentication app adds reaches it either way; only its spelling without the closing slash is answered by the fallback rather than redirected, until it is named. The docstrings now say that, and a new test reads the order from the resolver: the authentication app's routes and the rest of the user surface come before the mount, and each of the two addresses is matched first by the app.
…ollections
The comment above the routes said registering an account creates
something no address here reads back. The account a registration creates
is read at accounts/{username}/ like any other. The real reasons are that
each route is answered by a subclass of the authentication app's own view,
so a request runs that view's one code path with its decorators, its CSRF
handling and the limits it shares with the older addresses, and that
registering and signing in answer with a session and where to go next
rather than with the account.
The comment now says that, and a test registers an account and reads it
back at its own address with the session the registration set.
The fallback that refuses an unknown address under version 2 was described as answering with the same JSON body as every other failure of this version. The registration, sign-in and password reset addresses of this version are answered by the authentication app's views, with the bodies those views have always used, so that is no longer so. The view's docstring and the comment above its route now say the fallback answers with the JSON error body most views of this version use, and that those addresses answer with their own.
…rries The version 2 sign-in answers 403 with a JSON body in one case only: the caller signed in through another provider with an account linked to none here. That body always carries a message (`value`) and a name (`error_code`, which is `third-party-auth-with-no-linked-account`), but the documents published it with the shape of the 400 refusals, which marks only `success` as always present. A client generated from them would treat both members as optional. The 403 now marks `success`, `value` and `error_code` as always present. Nothing changes in what the endpoint answers. The 400 refusals keep their shape, because each of those members is left out of one of them in some case.
Checking a real response against the document catches a wrong type or a missing member, but not a member the document stops promising, because the body still satisfies a schema that requires less. Dropping `user_id` from a registration's 200 body, or `is_valid` from the token check's 200 body, left every test green. Every real body the suite receives from these addresses is now also held to the members its operation and code publish as always present. Those must be exactly the members the body carries, less the ones a table lists as carried in some cases only, each with its case named. Each listed member is shown carried by one real body and left out of another, so the table cannot hide a member that is always there. Requests are added for the cases that leave a member out: a step the site adds that refuses without naming the refusal or without a message, a username held back for retired accounts, and a password found on lists of exposed passwords.
Evidence for this PR: documents, URL names, test ids, mutations, query counts, gates, lintTrees: base = This PR now has 23 commits, head The 22 commits (
|
| 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_publishesinfix: publish the new sign-in with the CSRF requirement the older sign-in publishes. Its old assertion (csrfabsent from the new sign-in's
security) is now the opposite. - Same id, wider body:
LoginLimitTests::test_failures_here_count_at_every_older_addressgains 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 infix: 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,requiredbecamename, username, email, honor_code
plus theanyOfof 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 toSUPERSEDED_USER_ADDRESSES_LEFT_OUTintest_schema.pyand
becomes a neighbour the LMS filter does not admit. The"account"tag group thatfeat: 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 ofPublishedShapeTests,RefusedRepresentationTests,ExtensionFormShapeTestsand
SignInWithAnExposedPasswordTestsalso 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 bytest_every_superseded_address_has_a_published_replacement.test_schema.py::test_hook_leaves_other_resources_alone:/api/user/v2/account/registration/moves from
untouchedtomarked, 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.pySUPERSEDED_USER_ADDRESSES(+5),test_urls.py
EXPECTED_ADDRESSESand theRouteShapeTestsname list (+5 each), and Studio'sSUPERSEDED_USER_OPERATION_IDS
(+9) andSUPERSEDED_USER_EXCHANGES(+8). The existing tests that read them now cover the new entries too. - Docstrings only:
test_url_mount.py::SharedPrefixTestsand
test_urls.py::MalformedAddressTests::test_every_address_left_to_the_other_app_is_one_that_app_servesnow
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 LMStestscount moves, 1527 → 1528, which is the new unit test. - The two FAILs against master are both outside this PR's diff: hygiene
H3atuser_api/v2/tests/test_views.py:1500,
and urlsR6/R8on the version's/api/user/v2/fallback route. Both come from the earlier phases of the stack.
Studio'scheck-presentfails 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, andtests/test_{schema,urls,url_mount,views}.py
(the last four flaggedOLD 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.pyand 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 onv2/paginators.py:11and 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 intest_views.pyis 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 inv2/views.pymoved their line numbers by two.
(Moved from the PR body.)
Repository CI lint (each step on a snapshot of one commit)
| step | base ed83beab3d |
7325fef195 |
head 47925143f3 |
|---|---|---|---|
ruff check . |
EXIT=0 | EXIT=0 | EXIT=0 |
| lint-imports | EXIT=0, 4 contracts kept, 0 broken | the same | the same |
| pylint lms-2 / openedx-2 / cms | 0 / 0 / 0 messages | 0 / 0 / 0 | 0 / 0 / 0 |
| mypy, xsslint, pii_check, check_keywords (lms, cms), verify-dunder-init | EXIT=0 | EXIT=0 | EXIT=0 |
| commitlint | none | 20 commits, EXIT=0 | 22 commits, EXIT=0 |
No new finding at head, and base and head pass every step.
The 23rd commit: be57050bad fix: describe the CSRF token in every document whose sign-in names it
Trees: everything in this section was run at 8eb071be02 except the last paragraph. Head be57050bad is the
same commit with one statement changed: Studio's append in cms/envs/common.py, from dict(CREDENTIAL_SCHEMES) to
{'csrf': CREDENTIAL_SCHEMES['csrf']}, with the comment above it extended and one sentence added to the commit
message. Both build the same mapping today: CREDENTIAL_SCHEMES holds only csrf. At head the commit is 5 files,
+178/−4 (schema_components.py, both envs/common.py, both lib/tests/test_spectacular.py); the diff from base is
16 files, +3521/−42.
Which operations name csrf. Only POSTs to a sign-in, in every document at every tree; no GET names it.
| document | master d178206575 and base ed83beab3d |
47925143f3 |
8eb071be02 |
|---|---|---|---|
LMS /lms-api/schema/ |
none | the new sign-in; not declared | the new sign-in; declared |
LMS /api-docs/ |
the older sign-in (/api/user/… and /user_api/… keys); not declared |
those and the new sign-in; not declared | all three; declared |
Studio /api-docs/ |
the older sign-in (two keys); not declared | the same | the same; declared |
Studio /authoring-api/schema/ |
none | none | none; declares csrf, as every document built from Studio's settings does |
docs.docs_settings |
the older sign-in; declared by its own settings | the older and the new sign-in; declared | the same, unchanged |
Documents, 47925143f3 → 8eb071be02 (served by the test client, plus docs.docs_settings, as above):
| document | bytes | paths / operationIds / components.schemas |
securitySchemes |
leaves differing |
|---|---|---|---|---|
LMS /lms-api/schema/ |
362009 → 362254 | 83 / 106 / 72, none removed or added | 1 → 2, +csrf |
4 |
LMS /api-docs/ |
1122467 → 1122712 | 524 / 696 / 249, none removed or added | 1 → 2, +csrf |
4 |
Studio /api-docs/ |
685059 → 685304 | 248 / 348 / 210, none removed or added | 1 → 2, +csrf |
4 |
Studio /authoring-api/schema/ |
279389 → 279661 | 69 / 93 / 94, none removed or added | 0 → 1, +csrf |
4 |
docs.docs_settings |
1699531, byte-identical | 513 / 686 / 243 | 4 → 4 | 0 |
The four leaves, the same in each served document:
components.securitySchemes.csrf.type absent -> "apiKey"
components.securitySchemes.csrf.name absent -> "X-CSRFToken"
components.securitySchemes.csrf.in absent -> "header"
components.securitySchemes.csrf.description absent -> "The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, sent in this header along with that cookie."
schema_diff.py compare reads all five pairs as RESULT: PASS (0 BREAKING, 0 WARN, 0 INFO): it does not look at
securitySchemes, so the leaf diff is the evidence. Every schema_diff.py report against base and against master at
8eb071be02 is line-for-line the one at 47925143f3, and neither removes a path, operationId or component name in
any of the five documents. docs/docs_settings.py sets securitySchemes itself, replacing what the service appends,
so its document keeps its own csrf entry (same type, header and location, other wording).
Tests (ServedCredentialSchemesTest; LMS 2 in lms/lib/tests/test_spectacular.py, Studio 3 in
cms/lib/tests/test_spectacular.py). Each requests its service's two served documents through their real views and
settings, and holds every credential an operation names to a scheme the same document declares. One test per service
shows a sign-in naming csrf, so that check is not vacuous, and compares the scheme with a literal written out in the
test. Studio's third holds every csrf scheme either document declares to that literal. Before the fix: LMS 2
failed; Studio 2 failed and 1 passed, vacuously, since neither document declared the scheme. After: LMS module 33
passed, Studio module 43 passed.
Mutations (each on a copy of 8eb071be02, both modules run):
| mutation | LMS module (33) | Studio module (43) |
|---|---|---|
| M1: the LMS append dropped | 2 failed | 43 passed (not its settings) |
| M2: the Studio append dropped | 33 passed | 2 failed |
M3: the header named X-CSRF-Token |
1 failed | 2 failed |
M4: the description naming a csrf_token cookie |
1 failed | 2 failed |
M5: in set to query |
1 failed | 2 failed |
M6: the scheme appended as csrfToken, a name no operation uses |
2 failed | 2 failed |
Gates at 8eb071be02 (--allow on the 16 files; each tree clean, none SKIPPED):
LMS against base ed83beab3d LMS against master d178206575 Studio against base ed83beab3d
versions PASS versions PASS versions PASS
hygiene PASS (0 FAIL, 0 WARN) hygiene FAIL (1 FAIL, 5 WARN) hygiene PASS (0 FAIL, 0 WARN)
schema PASS (0 BREAKING, schema PASS (0 BREAKING, schema FAIL: check-present,
12 WARN, 15 INFO) 88 WARN, 83 INFO) no /api/user/v2/ path
urls PASS (0 FAIL, 1 WARN) urls FAIL (2 FAIL, 3 WARN) urls PASS (0 FAIL, 0 WARN)
old-tests 820 passed, 4 skipped old-tests 820 passed, 4 skipped old-tests 177 passed, 384 skipped
tests 1530 passed tests 1530 passed tests 43 passed
Every RESULT, WARN, BREAKING and FAIL line is the one at 47925143f3; only the tests counts move (1528 → 1530,
1528 → 1530, 40 → 43), by the five new tests. Versions gate: 3 files added inside user_api/v2/; 8 allowed, the six
listed above plus lms/envs/common.py and cms/envs/common.py; 5 other files, the four listed above plus
user_api/schema_components.py. Hygiene against base scans 16 files.
Wide runs and test ids at 8eb071be02. The LMS set of the test-id table: 2268 passed; user_authn/: 555
passed, 1 skipped; Studio cms/lib/tests/ and cms/tests.py: 47 passed. Ids against 47925143f3: LMS set 2266 →
2268, user_authn/ 556 → 556, Studio cms/lib/tests/ 40 → 43, none removed; the five added are the new tests.
test_authn_addresses.py: 138 passed under each of pytest-randomly seeds 1 to 5.
At head be57050bad: cms/lib/tests/test_spectacular.py 43 passed; ruff check . clean; repository CI lint,
every step EXIT=0, with output equal to 8eb071be02's apart from timing lines and line order (pylint lms-2 /
openedx-2 / cms 0 messages; lint-imports 4 contracts kept, 0 broken); commitlint 23 commits, EXIT=0.
The sign-in operations publish a security requirement named `csrf`, but no document either service serves declared a scheme by that name; only the settings the published reference is generated from did. A document naming a credential it does not describe is invalid, and a client generated from it has no way to send that credential. The older sign-in has named the token this way in both services' service-wide documents since before this version, and the new sign-in repeats it in both LMS documents. The scheme is now defined once in the user API, beside the components both services already append, and both services append it: the value of the `csrftoken` cookie the forms set, sent in the `X-CSRFToken` header. Studio's document of its own API carries the scheme as well, because every document built from those settings carries what they append. Studio appends that one scheme rather than the whole set, since no operation it serves names any other credential. The tests request each document at the address it is served from, hold every credential its operations name to a scheme it declares, and pin how the token is described with the text written out.
|
One commit added: What it fixes. The sign-in operations publish a Evidence. Measured at Merging with Phase D2 (a sibling on #39330 that also adds |
Summary
Part of [API] User, #39058, under the API standardization umbrella #38137 (FC-0118). This PR applies the platform's REST API decisions and OEP-69 to the last group of the user API: registration, sign-in, password reset and registration validation. The decisions are
0025 ·
0026 ·
0027 ·
0028 ·
0029 ·
0030 ·
0031 ·
0032 ·
0033 ·
0034 ·
0035 ·
0036 ·
0037 ·
0038,
plus OEP-66 for scoping.
Stacking and merge order: #39297, then #39314, then #39330, then this PR. This branch is built on #39330
(
feat/api-adr-user-v2-phase-d1@ed83beab3d), which is built on #39314 (feat/api-adr-user-v2-phase-c@cab976a910); #39297 is a sibling in the same series and merges first. Those three carry the version 2 package(
openedx/core/djangoapps/user_api/v2/), its fallback route, and the superseded-address machinery inopenedx/core/djangoapps/user_api/schema.py, which the LMS and Studio share and this PR extends. This PR's diff isthe 23 commits on top of
feat/api-adr-user-v2-phase-d1@ed83beab3d, headbe57050bad. Every "base" belowmeans that commit. For the sibling Phase D2, see "Merging with Phase D2".
This is a URL-conformance and documentation change only. Nothing changes in any body, status code,
authentication, permission, CSRF, cookie or rate-limit behaviour at any address, old or new. No file under
openedx/core/djangoapps/user_authn/is edited.Why new addresses, and why in version 2
The nine operations are reached today at addresses the rest of the user API no longer uses:
/api/user/{v1,v2}/account/registration/account, and a second collection nested under it with no identifier between them (rules 2, 8, 9)/api/user/{api_version}/account/login_session/{api_version}path parameter (rules 6, 7)/api/user/v1/validation/registration/api/user/v1/account/password_reset/token/validate/Registration and sign-in already have a published version 2 contract:
/api/user/v2/account/registration/and the
v2arm of the sign-in route, which the authentication front end calls today. One version cannot carrytwo contracts for the same operation, so the conforming addresses keep exactly the version 2 contract. They are
added to the existing version 2 package, which has not been released yet. A rename alone is not a contract
change and needs no version bump (ADR 0037; the migration paragraph of ADR 0038). Password reset and
registration validation exist only in version 1. They join version 2 with their contract unchanged.
Change table
The diff is 23 commits. The first three carry the change. Nineteen came out of review and each corrects a
published description, a comment or a test of these three. The last declares, in every served document, the CSRF
scheme the sign-ins name. None of them changes how any operation answers.
feat: serve registration, sign-in and password reset under the user API's version 2GET,POST /api/user/v2/registrations/·POST /api/user/v2/registration_validations/·GET,POST /api/user/v2/login_sessions/·GET /api/user/v2/password_resets/·POST /api/user/v2/password_reset_token_validations/permission_classes = (AllowAny,)and its published description. Bodies and codes are documentedfeat: mark the older registration, sign-in and password reset addresses supersededdeprecated: truein the LMS and Studio documents. Eight of them now publish the bodies and codes their replacement publishesdocs: describe the order this version's mount is matched in as it now isuser_api/v2/urls.pycorrected; no route changesfix:/test:/docs:)UnknownAddressView's among themfix: describe the CSRF token in every document whose sign-in names itcsrf: the older one in both services'/api-docs/(as on master), the new one in both LMS documentscomponents.securitySchemes.csrf, from one definition both services append; Studio appends only that schemeThe new addresses
user_v2:)GET /api/user/v2/registrations/registration_listauthn_views.RegistrationView(register.RegistrationView)GET /api/user/v1/account/registration/,GET /api/user/v2/account/registration/POST /api/user/v2/registrations/registration_listPOSTof bothPOST /api/user/v2/registration_validations/registration_validation_listauthn_views.RegistrationValidationView(...)POST /api/user/v1/validation/registrationGET /api/user/v2/login_sessions/login_session_listauthn_views.LoginSessionView(login.LoginSessionView); the route bindsapi_version="v2"GET /api/user/{api_version}/account/login_session/(v1 and v2)POST /api/user/v2/login_sessions/login_session_listPOSTof the same key (v1 and v2)GET /api/user/v2/password_resets/password_reset_listauthn_views.PasswordResetView(...)GET /api/user/v1/account/password_reset/POST /api/user/v2/password_reset_token_validations/password_reset_token_validation_listauthn_views.PasswordResetTokenValidation(...)POST /api/user/v1/account/password_reset/token/validate/The token stays in the request body, because a secret is never put in an address.
Why a subclass and not a second route to the same view. Every behaviour is inherited through the parent's
decorated methods:
ensure_csrf_cookie,csrf_exempt,csrf_protect, theratelimitdecorators,sensitive_post_parameters, the third-party sign-in flow and the cookies. So there is still one code path. Aclass of its own is needed for the documentation only. The sign-in handler carries its own
extend_schemadescribing the version 1 request (
email), and drf-spectacular publishes a method's own annotation in place ofanything else. A second route to the same view would therefore document a body that version 2 refuses. All five
addresses use the same shape.
The rate-limit counters stay shared between the old and new addresses. django-ratelimit groups its counters by
the decorated method and the view class's name, so each subclass keeps its parent's name. Spend the limit at one
address and the other refuses you. This is tested in both directions for registration (both older addresses and
/create_accountagainst the new one), the token check, registration validation, and the sign-in limit perclient (both versions of the older address and
/login_ajaxagainst the new one). The sign-in limit per accountis tested in both directions between
/api/user/v2/account/login_session/and the new address.ADRs applied
permission_classes = (AllowAny,)declared on all five views; every legacy check is inherited unchanged (Authorization table).8eb071be02, in all five documents; each served document declares every credential its operations name; recorded gaps in the matrix.APIViewsubclasses: collections that are only posted to or described; registering and signing in answer with a session and a redirect, not a resource read back here.error_code,context.failure_count, field-keyeduser_messagelists,value);/api/user/v2/therefore answers in two error shapes until then (Error-format decision).authentication_classes = [], the token check keeps the platform default; nothing redeclared.deprecated: true; one recorded deviation: no docstring marker.snake_case, version-free,<basename>_listlike the version's router names.Compliance matrix
Tests are in
openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.pyunless another file is named."Studio" means
cms/lib/tests/test_spectacular.py. Gate lines come from the LMS run at8eb071be02againstbase
ed83beab3d, unless a row names the run against master, made at the same tree (Gate report below says how8eb071be02and head differ).error_code,context.failure_count, field-keyeduser_messagelists). The token check and registration validation answer with a DRFResponseof a dict. Bringing serializers in means handler code of their own. For registration and sign-in it also means different bytes and error members. The ADR's version clause applies here: these responses cannot move to serializers without new handler code, so they ride the package's next version. No serializer is written; every exchange is documented in the schema instead (0027).ViewShapeTests::test_each_method_hands_the_request_to_the_one_it_extends;PublishedShapeTests::*(5, each published body validated against real responses)permission_classes = (AllowAny,)is declared on all five views. The token check identifies callers with the platform's JWT and session authentication. On it, the platform's JWT middleware addsNotJwtRestrictedApplicationat runtime, as it does on the older token check, so a JWT issued to a restricted application is refused with 403 at both. Every legacy check stays inline and is inherited: the rate limits, CSRF, the third-party sign-in switches, the token's owner check and the deployment's filter steps. None moves into a permission class, because each answers with a status or body that clients read.ViewShapeTests::test_each_view_declares_only_who_may_call_it,::test_the_extended_views_are_left_as_they_were;TokenValidationParityTests::test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused; deny paths per address under Authorizationrequiredwhere an empty body is refused) and every code the handler is measured to answer with, including 406 for an unusableAcceptand 415 for an unreadable body. The registration request names the three ways of registering (password, orsocial_auth_provider, orproviderwithaccess_token) asanyOfalternatives. The 400 of registration and sign-in isoneOfthe field-keyed body and DRF's{detail}for a body that cannot be parsed. Eight superseded operations publish the same objects. Every real body the suite receives at these operations is also held to the members its operation and code publish as always present: they must equal the members the body carries, less those a table in the test module lists as carried in some cases only, each with its case, and each listed member is shown carried by one real body and left out of another. That check found one wrong shape, now corrected: the new sign-in's 403 (a provider account linked to no account here) always carriesvalueanderror_code, but was published with the 400 refusals' shape, which promisessuccessalone. It now promises all three; the schema gate cannot see arequiredlist, so the evidence is the one-leaf document diff (Backward compatibility). Recorded gaps: (1) no documented 4xx references the sharedErrorResponsecomponent, because these views do not produce it (0029). (2) The older posted sign-in keeps the description on its own frozen handler: the version 1 request (email), and neither 406, 415 nor the{detail}form of its 400, although it answers all three. Fixing that needs an edit to the frozen view, so that one address is left out of/lms-api/schema/; it is still marked in both service-wide documents. (3) Closed: the sign-in operations name acsrfsecurity requirement (the older sign-in in both services'/api-docs/, as on master; the new sign-in in both LMS documents), and until this change only thedocs.docs_settingsdocument declared that scheme. Both services now append it from one definition,CREDENTIAL_SCHEMESinopenedx/core/djangoapps/user_api/schema_components.py(apiKey, headerX-CSRFToken), so every served document declares every credential its operations name. Studio's/authoring-api/schema/, built from the same settings, carries the scheme although none of its operations names it. Thedocs.docs_settingsdocument still declares its owncsrf, unchanged. (4) The registration request is stricter than the server in one case. During a third-party sign-in, a body with neitherpasswordnorsocial_auth_provideris accepted (measured 200), and no alternative admits it. The form lists that member whenever a known provider's sign-in is under way. The two third-party ways apply only where sign-in through other providers is on, which the shape's description says; elsewhere such a body is refused with the published 400. (5) Studio's service-wide document publishes the form and success bodies of the four registration operations, as every document does. With stock Studio settings, the form GET and a valid registration are answered with 500 instead, becauseREGISTRATION_FIELD_ORDERandENABLE_AUTHN_REGISTER_HIBP_POLICYare LMS settings. (6) A field a site adds whose initial value or choice values are not text is emitted as it is (for exampletrue), while the shape publishes text or null. (7) Some optional members are published that no answer from the platform's own code carries:redirectin the new sign-in's refusals, and on its 403 the optional members of the 400 refusals' shape, which it shares.ConformingDocumentTests::*(9),SupersededDocumentTests::*(7),PublishedShapeTests::*(5),RefusedRepresentationTests::*(3),RegistrationRequestShapeTests::*(3),ExtensionFormShapeTests::test_a_field_the_site_adds_is_described_as_published,SignInWithAnExposedPasswordTests::*(2),test_the_members_published_as_always_present_are_held_to_the_ones_each_body_carries(twelve mutations, first comment); StudioSupersededUserOperationTest::test_each_superseded_operation_publishes_what_it_reads_and_what_it_answers_with,::test_a_registration_names_the_fields_the_stock_form_requires,::test_each_registration_sign_in_and_reset_operation_publishes_its_refusals_of_a_media_type; schema gateRESULT: PASS (0 BREAKING, 12 WARN, 15 INFO);lms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::*(2),cms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::*(3) (six mutations, each red)APIViewsubclasses. Each address is a collection that is only ever posted to or described. Registering and signing in answer with a session and where to go next rather than with the account, which is read ataccounts/{username}/like any other. The reset form, the token check and the validation describe or check without creating a resource. A ViewSet conversion would also start the rate limits counting afresh.ViewShapeTests::test_each_method_hands_the_request_to_the_one_it_extends;ConformingAddressTests::test_each_address_is_answered_by_a_view_extending_the_one_serving_the_older_address;RegistrationParityTests::test_the_account_registered_is_read_where_every_account_isdborcached_dbsession engine, that session write is aSessionmodel save, which sends Django'spre_saveandpost_saveforSession. The test suite runs thedbengine, and the signal test excludes exactly those two signals, for theSessionsender only. Under the default cache engine no model is saved.FormReadWithThirdPartyAuthTests,FormReadWithoutThirdPartyAuthTests,FormReadDuringThirdPartySignInTests,…WithoutProfileFillingTests,…ThroughAnUnknownProviderTests(13 tests:test_a_read_writes_only_the_session_and_the_same_way_at_both_addresses,test_a_read_sends_no_signal_or_event_at_either_address,test_each_form_is_built_for_the_sign_in_under_way); spy self-teststest_each_way_of_sending_is_recorded_even_with_events_turned_off,test_a_write_is_told_by_its_first_keyword_whatever_follows_itauthentication_classes = [], commented at the parent's declaration: they are anonymous entry points, and a session the caller holds is deliberately not consulted. The token check keeps the platform default, and a session whose account was made inactive is answered as no session at both addresses. Nothing is redeclared in new code. The new sign-in publishes the older sign-in'scsrfrequirement, and every served document now declares that scheme (apiKey, headerX-CSRFToken), appended by both services from one definition; no authentication or CSRF behaviour changes.ViewShapeTests::test_how_a_caller_is_identified_is_the_extended_views;RegistrationParityTests::test_credentials_in_a_header_are_not_read,LoginParityTests::test_credentials_in_a_header_are_not_read;TokenValidationParityTests::test_credentials_that_cannot_be_read_are_refused,::test_a_session_whose_account_was_made_inactive_is_answered_as_no_session;ConformingDocumentTests::test_signing_in_publishes_the_security_the_older_sign_in_publishes;lms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::*(2),cms/lib/tests/test_spectacular.py::ServedCredentialSchemesTest::*(3)deprecated: true. Deviation: the superseded views carry no docstring marker, contrary to the ADR's docstring clause. The ground is the FC-0118 review direction given on #39100 and #39102: the PR that introduces a replacement adds no deprecation note in code. #39128 is already accepted, so OEP-21's one-lineDeprecated <link>marker would be allowed; if a reviewer asks for a marker, that one line is what gets added.RESULT: PASS(against base and against master, both at8eb071be02);SupersededDocumentTests::test_each_operation_is_marked_superseded_in_both_documents; StudioSupersededUserOperationTest::test_every_operation_of_those_addresses_is_marked_superseded, which reads Studio's own document, is green at base and head, and goes red when one key is dropped from the tuple (mutations, first comment)registrations,registration_validations,login_sessions,password_resetsandpassword_reset_token_validationsare plural nouns with a closing slash and the version as a literal, with no nesting without an identifier, a POST to a noun, and the token in the body. Every legacy URL name is kept; one route serves one address.ConformingAddressTests::*(7);test_urls.py::RouteShapeTests::test_every_address_is_named_and_ends_in_a_slash; urls gateRESULT: PASS (0 FAIL, 1 WARN). The WARN is the verb heuristic matching "login" inlogin_sessions, which names the session a POST createsAllowAny. Layer 2: the one per-caller rule, under which a signed-in caller is told a token is valid only for its own account, is a per-request answer and is inherited. Layer 3: there is no filter, sort or shaping parameter.TokenValidationParityTests::test_a_signed_in_caller_is_told_only_about_its_own_accountsnake_caseand version-free,<basename>_listas the version's router names them.test_urls.py::UrlLiteralTests::test_names_are_snake_case_and_carry_no_version,::test_reverse_literals;ConformingAddressTests::test_each_name_reverses_to_its_addressBackward compatibility
Trees: base =
feat/api-adr-user-v2-phase-d1@ed83beab3d(#39330), head =be57050bad, master =d178206575. Each number names the pair it compares. Full outputs: first comment. The documents,gates, test ids and wide runs were made at
8eb071be02, which differs from head in one statement of Studio'ssettings only (Gate report). URL names were dumped at
7325fef195; no later commit touches a route.7325fef195: LMS3059 → 3064 routes, 2537 → 2542 names, 0 removed, 5 added under
user_v2; Studio 2193 routes and 1790 names,unchanged. Master →
7325fef195: LMS 2513 → 2542 names, 0 removed. The URL gate at8eb071be02counts the same3059 → 3064 routes.
user_authn/urls_common.pyand everyuser_authnview module are absent from the diff.test_authn_addresses.py, 138 tests at8eb071be02). The tests comparestatus,
Content-Type, the set of cookies set, and the body: byte-equal for the three forms, and member formember for the POSTs (per-request values such as a generated username are compared by type). Covered: success;
every refusal (empty, duplicate, country, version 2's
confirm_email, wrong password, inactive account,missing field, a deployment's own filter steps, the two switches that close registration or require another
provider, an unlinked third-party account, an unparsable body); CSRF on both sides; malformed credentials and
a restricted-application JWT; rate limits with a real cache, shared both ways;
HEAD,OPTIONSand refusedmethods.
OPTIONSdescription, because each class describes itself. It isdeclared in the parity harness as
OPTIONS_DIFFERENCESand asserted to occur.email_or_usernameis read, andemailalone is refused,as at the v2 arm of the old route. Registration at the new address answers as version 2. A configured
confirm_emailis offered and required, as at/api/user/v2/account/registration/.test_*_takes_as_many_queries_at_either_address,green at
8eb071be02); the absolute numbers in the first comment are from0156789248, on the earlier base24b8a7bde5.8eb071be02: LMS 820 passed, 4 skipped(
user_authn/,lms/tests.py, the third-party sign-in specs,profile_images/,test_apidocs.py); Studio 177passed, 384 skipped. No file of those suites is in the diff.
8eb071be02, none removed: LMS (user_api/,profile_images/,lms/lib/tests/,lms/tests.py,test_apidocs.py) 2125 → 2268;user_authn/556 → 556; Studiocms/lib/tests/35 → 43. Eachwas measured in three steps, base →
7325fef195→47925143f3→8eb071be02, and no step removes an id.8eb071be02: zero removed paths, operationIds andcomponentsnames of any kind in all fivedocuments (LMS
/lms-api/schema/, LMS and Studio/api-docs/, Studio/authoring-api/schema/, and thedocs.docs_settingsdocument). The schema gate flags two lines in each service-wide document, declared below.Two later changes are invisible to
schema_diff.py, which reads each pair asPASS (0 BREAKING, 0 WARN, 0 INFO)in every document, so the leaf diffs are the evidence. From
7325fef195to47925143f3, one leaf changes, in thethree LMS documents only: the new sign-in's 403
requiredgoes fromsuccesstosuccess,value,error_code(0027 row); the tool does not compare a response'srequiredlist. From47925143f3to8eb071be02, each of the four served documents gainscomponents.securitySchemes.csrf(4 leaves, nothingelse) and the
docs.docs_settingsdocument is byte-identical; the tool does not look atsecuritySchemes.8eb071be02:schema_diff.pyreports exactly the two declared lines per service-widedocument beyond master → base, and nothing else; it cannot see the added
csrfscheme. No path, operationId orcomponent is removed against master in any of the five documents, at base or at
8eb071be02. The one schemacomponent Studio's
/authoring-api/schema/holds beyond master's comes from feat: standardize the account retirement pipeline as /api/user/v2/ #39330 (first comment).version's URLconf loads (measured; first comment).
Declared schema-gate lines (documentation correction, not a contract change)
These two operations published no request body at all until now. They now publish the registration body and
mark it required, because the handler refuses a request without one. An empty registration is answered 400,
naming every required field, at both addresses and at the replacement
(
RegistrationParityTests::test_an_empty_registration_names_every_field_it_needs). The handler is unchanged, andthe document now says what it always did. The gate cannot tell a corrected description from a tightened
contract.
In-place edits
user_api/v2/is the current version package. It has not been released, and it arrives with this stack, so itsnew modules and routes extend the target version. The versions gate at
8eb071be02against base givesRESULT: PASS: 3 files added insideuser_api/v2/, 8 allowed (six inuser_api/v2/and the two settingsmodules), and 5 other files outside any version directory. All 16 follow, with their category.
user_api/v2/authn_views.py,authn_schemas.py,tests/test_authn_addresses.py(new)user_api/v2/urls.pytest_url_mount.py,test_urls.pyuser_api/v2/views.pyUnknownAddressViewsays that most views of this version answer with the JSON envelope and that the five addresses here answer with the bodies of the views they extend. Before, it claimed one body for every failure of the versionopenedx/core/djangoapps/user_api/schema.pySUPERSEDED_USER_PATHS, which the existing hookopenedx.core.djangoapps.user_api.schema.mark_superseded_user_pathsmarks in both services' documents. Eight entries were added tosuperseded_bodies(), with one lazy import ofauthn_schemas; they change what eight frozen operations publish for every reader of the service-wide documents, and two of those changes are the declared lines above. Evidence: five documents generated at base,7325fef195,8eb071be02and masterlms/lib/spectacular.py_is_published_user_pathadmits a superseded address only when it does not take its version as a parameter (_VERSION_PARAMETER), so the templated sign-in key stays out of/lms-api/schema/(0027 gap 2) and is still marked in both service-wide documents. At base no superseded key had{api_version}, so every earlier entry is admitted as before. Evidence:SupersededDocumentTests::test_the_narrow_document_leaves_out_the_address_that_takes_its_version_as_a_parameter,lms/lib/tests/test_spectacular.py::LmsApiFilterTest::test_keeps_the_authentication_addresses_this_version_supersedes_and_none_beside_themopenedx/core/djangoapps/user_api/schema_components.pyCREDENTIAL_SCHEMES, holding thecsrfscheme (apiKey, headerX-CSRFToken); the module docstring is corrected to match, and the module still imports nothing. Evidence:ServedCredentialSchemesTestin both serviceslms/envs/common.py,cms/envs/common.pyAPPEND_COMPONENTSgainssecuritySchemes: the LMS appendsdict(CREDENTIAL_SCHEMES), and Studio{'csrf': CREDENTIAL_SCHEMES['csrf']}, the only credential its operations name. Studio imports nothing fromlms. Evidence: the leaf diff abovelms/lib/tests/test_spectacular.py,cms/lib/tests/test_spectacular.py,user_api/v2/tests/test_schema.py,test_urls.py,test_url_mount.py,test_views.pycms/lib/tests/test_spectacular.pyonly gains tests and table entries, and bothlib/tests/test_spectacular.pygainServedCredentialSchemesTest, which requests each served documentNo hook or schema class was added or reordered, and the settings modules change only as above. The superseded
views are not in the diff: they carry no deprecation notice.
Each existing assertion this PR changes against base (seven tests in three files, plus tables extended and two
docstrings) is listed in the first comment.
Merging with Phase D2 (#39334, a sibling branch, also built on #39330)
Whichever of this PR and Phase D2 merges second meets these. Beyond them, the only conflicts are entries both
branches add to the same tables (
SUPERSEDED_USER_PATHSand the test tables beside it, in five files); keep bothsides' entries:
openedx/core/djangoapps/user_api/schema_components.py: both addCREDENTIAL_SCHEMES. Join the entries and keepall three:
jwtandbearerfrom Phase D2 (the credentials its mobile clients send) andcsrffrom this PR.Correct the comment above the mapping so it no longer counts the entries. It should say that the schema class
describes the session cookie and nothing else.
lms/envs/common.py: both add the same'securitySchemes': dict(CREDENTIAL_SCHEMES),line, which merges cleanly.'securitySchemes': {'csrf': CREDENTIAL_SCHEMES['csrf']}. It describes only the token its own sign-innames, not credentials no Studio operation uses.
test_no_operation_here_names_a_credential_only_the_other_service_describesgoes red afterthe merge, because Studio's sign-in now names
csrf, which joins the mapping. Resolve it by tightening, not byexempting. Delete the
named & set(CREDENTIAL_SCHEMES) == set()assertion, tightennamed - described <= {"csrf"}tonamed - described == set(), and drop the docstring's last sentence about theone named exception. The tightened form is strictly stronger, and it holds no hard-coded name.
The conflicts above are table appends, and a union of them can parse and pass every test while a test silently
stops running. Before trusting a resolution: parse each resolved file and check that every expected test is a method
inside the class it belongs to (not merely present in the file); check that no
def test_...sits at module level;check that no dict or set literal holds a duplicated key; and compare the collected test ids against both parents
under
LC_ALL=C, not their counts.Error-format decision
Unchanged; versioned later. The legacy bodies are what clients parse today. The authentication front end reads
error_code,context.failure_countand the field-keyeduser_messagelists, and the legacy sign-in pagereads
error_codeandvalue, among other members. So the new addresses answer with exactly those bodies anddocument them as they are. Three consequences, stated so no one has to discover them:
/api/user/v2/answers in two error shapes. Most views of feat: standardize the user accounts, profile image, verification and name-change API #39297, feat: standardize the user preferences and email opt-in API #39314 and feat: standardize the account retirement pipeline as /api/user/v2/ #39330 answer with thestandardized envelope. So does the version's fallback route, and the fallback is what answers each new address
sent without its closing slash. The five new addresses answer with the authentication app's bodies, and with
DRF's
{detail}for the refusals DRF itself produces (406, 415, 405, the unparsable body). A version 2 clientof both families needs both parsers. This is pinned by
RegistrationParityTests::test_the_methods_it_does_not_serve_are_refused_alikeand by the fallback envelopetests in
test_urls.py./api/user/v3/. This API versions per package, sothe standardized envelope (and JSON in place of Django's HTML page for a missing CSRF token) reaches these
five resources with that version, not separately.
behind two non-default switches; and a 200 carrying an error body. The 200 comes back when a
deployment's own filter step refuses a registration without giving a status (the
StudentRegistrationRequestedfilter in
register.py). The published 200 shape does not describe that body. It is reachable at the newregistration address exactly as at the older ones, and stock settings configure no such step. The suite sends
no such request, so the checks of published members against real bodies (0027 row) do not cover it.
Deprecations
The nine operations below are superseded by the addresses above. They are marked
deprecated: truein thepublished schemas: the LMS's own document, and the service-wide documents of the LMS and of Studio, which both
serve them. The superseded views are unchanged: there is no response header and no deprecation note in code.
The replacements are served by the LMS only. Studio mounts no version 2 URLconf. Its document marks the nine
operations because Studio serves them, but a client following a deprecation to a replacement address on the
Studio host gets a 404 (
SupersededUserOperationTest::test_no_address_replacing_a_superseded_one_is_published_here).GET /api/user/v1/account/registration/GET /api/user/v2/registrations/confirm_emailfield is now listed (version 2)POST /api/user/v1/account/registration/POST /api/user/v2/registrations/confirm_emailis enforced (version 2)GET /api/user/v2/account/registration/GET /api/user/v2/registrations/POST /api/user/v2/account/registration/POST /api/user/v2/registrations/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/POST /api/user/v1/validation/registrationPOST /api/user/v2/registration_validations/GET /api/user/{api_version}/account/login_session/(v1, v2)GET /api/user/v2/login_sessions/POST /api/user/{api_version}/account/login_session/(v1, v2)POST /api/user/v2/login_sessions/email_or_usernamein place ofemail, which is the existing difference between the two versionsThe unversioned twins are not marked:
/user_api/v1/account/registration/,/user_api/v2/account/registration/,/user_api/{api_version}/account/login_session/,/user_api/v1/account/password_reset/and/user_api/v1/account/password_reset/token/validate/. Neither is/create_account. The/user_api/v1/twins of every address superseded earlier in this series are not markedeither, and deprecating the twins is one decision for all of them at once.
Removal prerequisites. The DEPR entry carries these too.
ENABLE_AUTHN_MICROFRONTENDis off, which is itsdefault. That page posts to the form description's
submit_url, which names the v1 sign-in address withthe v1 field
email. Before the v1 arm can be removed, the form description has to namePOST /api/user/v2/login_sessions/andemail_or_username.submit_urlnames/api/user/v2/account/registration/for every non-v1 request,including those at the new address. Before that address can be removed, it has to name
POST /api/user/v2/registrations/.POST /api/user/v1/account/registration/. The authenticationfront end posts the v2 registration and sign-in addresses and the v1 validation address.
This is tracked on the FC-0118 umbrella DEPR ticket, #39128, which removes them one named release after the
release this ships in. This area's entry is posted there. Of the umbrella's markers, only
the schema flag is applied: there is no
Warningheader and no separate docs page.Authorization
No authorization changes. Every legacy check stays where it is and is reached unchanged through inheritance.
None moves into a permission class, because each answers with a status or body that clients read.
[], commented at the parent)ratelimit→ 403forbidden-request, counter shared with both older addresses and/create_account(refused registrations count too); required third-party sign-in → 403 HTML; account creation closed → 403 HTML; a filter step; duplicate → 409; unknown country → 400; CSRF exemptRegistrationLimitTests::*(4);RegistrationParityTests::test_a_site_that_requires_another_provider_refuses_a_password,::test_a_site_that_creates_no_accounts_here_refuses_the_registration,::test_a_step_the_deployment_adds_refuses_it_at_both,::test_credentials_in_a_header_are_not_readPasswordResetFormParityTests::test_the_methods_it_does_not_serve_are_refused_alikeis_valid: false; a session of an inactive account is answered as no sessionTokenValidationLimitTests::*(3);TokenValidationParityTests::test_a_session_caller_sending_no_csrf_token_is_refused,::test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused,::test_credentials_that_cannot_be_read_are_refused,::test_a_signed_in_caller_is_told_only_about_its_own_account,::test_a_session_whose_account_was_made_inactive_is_answered_as_no_sessionratelimit(block=True)→ 403, sharedRegistrationValidationLimitTests::*(2)csrf_protect→ 403 HTML; the limit per client → 400, shared with both versions and/login_ajax, tested each way; the limit per account → 400, shared with/api/user/v2/account/login_session/, tested each way; required third-party sign-in → 403 HTML; unlinked third-party account → 403 JSON; a filter step; wrong password or inactive account → 400LoginParityTests::test_a_sign_in_without_a_csrf_token_is_refused;LoginLimitTests::*(5);LoginParityTests::test_a_site_that_requires_another_provider_refuses_a_password,::test_a_provider_account_linked_to_no_account_here_is_refused,::test_a_wrong_password_is_refused,::test_an_inactive_account_is_refusedThe area has no role check, and none is added.
Gate report
run_gates.sh, MongoDB attached,--allowon all 16 files of the diff, each tree clean. None SKIPPED. All threeruns are at
8eb071be02. Headbe57050badis the same 23 commits with one statement of the last one changed:Studio's append in
cms/envs/common.py, fromdict(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_SCHEMESholds onlycsrf. At head,cms/lib/tests/test_spectacular.pygives 43passed,
ruffpasses, and the repository CI lint below passes every step.ed83beab3dd178206575ed83beab3dRESULT: PASSRESULT: PASSRESULT: PASSRESULT: PASS (0 FAIL, 0 WARN), 16 filesRESULT: FAIL (1 FAIL, 5 WARN): all on the stack's lines (H3 atv2/tests/test_views.py:1500; H3/H7 WARNs on earlier classes of the stack)RESULT: PASS (0 FAIL, 0 WARN), 16 filesRESULT: PASS (0 BREAKING, 12 WARN, 15 INFO)RESULT: PASS (0 BREAKING, 88 WARN, 83 INFO)RESULT: FAIL — fewer than 1 paths: Studio serves no/api/user/v2/path, by designRESULT: PASS (0 FAIL, 1 WARN)RESULT: FAIL (2 FAIL, 3 WARN): R6/R8 on the stack's fallback routeapi/user/v2/{_}., already failing master → baseRESULT: PASS (0 FAIL, 0 WARN)Every RESULT, WARN, BREAKING and FAIL line equals the one at
47925143f3; only thetestscounts move (LMS 1528→ 1530 in both runs, Studio 40 → 43): the five new tests. The first comment explains the 12 WARNs.
Repository CI lint (the steps of
quality-checks,pylint-checksfor the groups holding these files,lint-imports,ci-static-analysis,verify-dunder-initandcommitlint, each run on a snapshot of onecommit): base
ed83beab3d,8eb071be02and headbe57050badeach pass every step, and head has no newfinding; its outputs equal
8eb071be02's apart from timing lines and line order. lint-imports keeps 4 contracts,0 broken. commitlint passes all 23 commits.
Operational note
Traffic at the five new addresses is reported under the transaction names of
openedx.core.djangoapps.user_api.v2.authn_views.*, not theuser_authn.views.*names of the older addresses.Dashboards and alerts on sign-in or registration failure rates that key on the old names see only the older
addresses' share as callers move.
Follow-ups
/api/user/v3/): serializers and the standardized error envelope (0025,0029), JSON in place of Django's HTML CSRF page, the 200-with-error-body case above, and a sign-in form
description naming the version 2 fields.
account family), and adding the tag later is an addition, never a removal.
docs/lms-openapi.yamlis not regenerated here. It carries none of the version 2 work of this stack.