diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2acb624b..b6dd55de 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,7 +33,32 @@ Unreleased ---------- .. scriv-insert-here -.. _changelog-3.7.0: +.. _changelog-3.9.0: + +[3.9.0] - 2026-08-04 +-------------------- + +Added +~~~~~ + +* Added new ``authentication`` architecture subdomain + +* Added six new authentication-related filters: + + * ``LogistrationViewContextGenerated`` + * ``AuthnMFEContextGenerated`` + * ``LoginAltRedirectURLRequested`` + * ``LoginFormGenerated`` + * ``RegistrationFormGenerated`` + * ``LogistrationViewRenderCompleted`` + +* Added three new structural types used by several of the above filters: + + * ``FormDescriptionProtocol`` - declares the minimal surface of the form + description that the form filters pass to pipeline steps. + * ``ProviderConfigProtocol`` - declares the minimal surface of the third-party + auth provider configuration that the form filters pass to pipeline steps. + * ``RunningPipeline`` - authentication pipeline state. [3.8.0] - 2026-07-07 --------------------- diff --git a/MANIFEST.in b/MANIFEST.in index 6e9e1200..1bbc4b2d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,5 @@ include LICENSE.txt include README.rst include requirements/base.in recursive-include openedx_filters *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.py +include openedx_filters/py.typed include requirements/constraints.txt diff --git a/docs/conf.py b/docs/conf.py index 52911979..9a6a12dd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,6 +37,7 @@ 'sphinx_copybutton', 'sphinx.ext.graphviz', 'sphinxcontrib.mermaid', + 'myst_parser', 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.intersphinx', @@ -44,6 +45,11 @@ 'sphinx.ext.linkcode', ] +# Render fenced ```mermaid code blocks in Markdown (MyST) sources through the +# sphinxcontrib.mermaid directive, so the same fences render both on GitHub and +# in the Sphinx-built docs. +myst_fence_as_directive = ["mermaid"] + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/decisions/0008-authentication-subdomain.md b/docs/decisions/0008-authentication-subdomain.md new file mode 100644 index 00000000..758ed973 --- /dev/null +++ b/docs/decisions/0008-authentication-subdomain.md @@ -0,0 +1,100 @@ +# 8. The authentication architecture subdomain + +## Status + +Accepted + +## Context + +Filters are grouped by architecture subdomain (see +[ADR-4](0004-filters-naming-and-versioning.rst)). +Until now, the filters covering how users sign in and register — +`StudentLoginRequested` and `StudentRegistrationRequested` — have lived in the +`learning` subdomain, because that was the only user-facing subdomain available +at the time. + +A new set of filters covering the login and registration *user experience* is +being introduced: hooks around the login/registration page context and render +lifecycle, the login and registration form descriptions, the authentication MFE +(`frontend-app-authn`) context, and the post-login redirect. These needed a +home, and `learning` is a poor fit: authentication gates access for *every* user +of the platform — learners and content authors alike — so it is a distinct +bounded context rather than a learning activity. + +## Decision + +We will introduce a new "Authentication" architecture subdomain, implemented in +`openedx_filters/authentication/` with filter types under +`org.openedx.authentication.*`. From now onward, new authentication-related +filters should be added here. + +Docs: This new subdomain will be added to the [Architecture Subdomains Reference](../reference/architecture-subdomains.rst) +alongside "Learning" and "Content Authoring". + +The following filters are added to it: + +- `LogistrationViewContextGenerated` — enrich the legacy (server-rendered) login/registration page context. +- `LogistrationViewRenderCompleted` — modify the rendered legacy login/registration page response. +- `AuthnMFEContextGenerated` — enrich the authentication MFE context. +- `LoginFormGenerated` — augment the generated login form description. +- `RegistrationFormGenerated` — augment the generated registration form description. +- `LoginAltRedirectURLRequested` — choose an alternative post-login redirect. + +The diagram below shows when each authn-related filter fires within the authn flow. + +```mermaid +flowchart TD + classDef filter fill:#e8f0fe,stroke:#1a73e8,color:#0a3069 + + ROUTES["/login and /register routes"] + LEGACY["Legacy logistration view
(server-rendered)"] + MFE["Authn MFE
(frontend-app-authn)"] + + LogistrationViewContextGenerated["LogistrationViewContextGenerated
enrich legacy page context"]:::filter + LogistrationViewRenderCompleted["LogistrationViewRenderCompleted
post-render response hook,
useful for setting extra cookies
"]:::filter + AuthnMFEContextGenerated["AuthnMFEContextGenerated
enrich MFE context"]:::filter + LoginFormGenerated["LoginFormGenerated
augment login form fields"]:::filter + RegistrationFormGenerated["RegistrationFormGenerated
augment registration form fields"]:::filter + StudentRegistrationRequested["StudentRegistrationRequested
hook for blocking registration"]:::filter + StudentLoginRequested["StudentLoginRequested
hook for blocking login"]:::filter + LoginAltRedirectURLRequested["LoginAltRedirectURLRequested
force alternative post-login redirect"]:::filter + + RENDERED["Legacy page rendered"] + FORMS["Login/registration
FormDescription generation"] + LOGINPOST["Login POST endpoint"] + REGPOST["Registration POST endpoint"] + DEST["Post-auth destination,
URL possibly overridden by filter"] + + ROUTES -- "AuthN MFE disabled for flow" --> LEGACY + ROUTES -- "AuthN MFE enabled for flow" --> MFE + LEGACY --> LogistrationViewContextGenerated --> RENDERED --> LogistrationViewRenderCompleted + LogistrationViewRenderCompleted -- "Get FormDescription via python API" --> FORMS + MFE --> AuthnMFEContextGenerated + AuthnMFEContextGenerated -- "Get FormDescription via REST API" --> FORMS + %% invisible edge: pin AuthnMFEContextGenerated to the same rank as + %% LogistrationViewContextGenerated so both *ContextGenerated filters render on the same level + AuthnMFEContextGenerated ~~~ RENDERED + FORMS -- "is /login route" --> LoginFormGenerated + FORMS -- "is /register route" --> RegistrationFormGenerated + %% invisible edge: pin LoginFormGenerated to the same rank as + %% RegistrationFormGenerated so both *FormGenerated filters render on the same level + LoginFormGenerated ~~~ REGPOST + LoginFormGenerated -- "submit login" --> LOGINPOST + RegistrationFormGenerated -- "submit registration" --> REGPOST + REGPOST --> StudentRegistrationRequested + LOGINPOST --> StudentLoginRequested + StudentLoginRequested -- "AuthN MFE enabled and the request is for first-party auth" --> LoginAltRedirectURLRequested + StudentLoginRequested -- "otherwise" --> DEST + LoginAltRedirectURLRequested --> DEST + StudentRegistrationRequested -- "account created and logged in" --> DEST +``` + +## Consequences + +- Future authentication-related filters have a clear, accurately named home and + no longer need to borrow the "Learning" subdomain. +- The pre-existing authn filters `StudentLoginRequested` and + `StudentRegistrationRequested` filters **remain in the `learning` subdomain**. + They are released, versioned public contracts, and re-homing them would be a + breaking change for existing consumers. A future major version could migrate + the two longstanding filters. diff --git a/docs/decisions/index.rst b/docs/decisions/index.rst index 4f59ce25..86543390 100644 --- a/docs/decisions/index.rst +++ b/docs/decisions/index.rst @@ -14,3 +14,4 @@ Decisions 0005-filters-payload 0006-filter-debug-tooling 0007-filter-design-practices + 0008-authentication-subdomain diff --git a/docs/reference/architecture-subdomains.rst b/docs/reference/architecture-subdomains.rst index e216e9f9..431247c7 100644 --- a/docs/reference/architecture-subdomains.rst +++ b/docs/reference/architecture-subdomains.rst @@ -8,6 +8,8 @@ Currently, these are the `architecture subdomains`_ used by the Open edX Filters +-------------------+----------------------------------------------------------------------------------------------------+ | Subdomain Name | Description | +===================+====================================================================================================+ +| Authentication | Handles how users sign in, register, and are routed through login/registration flows. | ++-------------------+----------------------------------------------------------------------------------------------------+ | Content Authoring | Allows educators to create, modify, package, annotate (tag), and share learning content. | +-------------------+----------------------------------------------------------------------------------------------------+ | Learning | Allows learners to consume content and perform actions in a learning activity on the platform. | diff --git a/docs/reference/filters.rst b/docs/reference/filters.rst index 8b83ce14..7f0be90e 100644 --- a/docs/reference/filters.rst +++ b/docs/reference/filters.rst @@ -20,6 +20,18 @@ Course Authoring Subdomain .. automodule:: openedx_filters.course_authoring.filters :members: +Authentication Subdomain +************************* + +.. automodule:: openedx_filters.authentication.filters + :members: + +Some of these filters hand their pipeline steps payloads whose shape is declared as a +structural type, so that steps do not couple to a concrete platform implementation: + +.. automodule:: openedx_filters.authentication.types + :members: + **Maintenance chart** +--------------+-------------------------------+----------------+--------------------------------+ diff --git a/openedx_filters/__init__.py b/openedx_filters/__init__.py index 0cd28ccf..9a58d1c6 100644 --- a/openedx_filters/__init__.py +++ b/openedx_filters/__init__.py @@ -6,7 +6,7 @@ from openedx_filters.filters import * -__version__ = "3.8.0" +__version__ = "3.9.0" if sys.version_info < (3, 12): # pragma: no cover warnings.warn( diff --git a/openedx_filters/authentication/__init__.py b/openedx_filters/authentication/__init__.py new file mode 100644 index 00000000..c812d895 --- /dev/null +++ b/openedx_filters/authentication/__init__.py @@ -0,0 +1,6 @@ +""" +Filters related to the authentication subdomain. + +The authentication subdomain covers how users sign in, register, and are routed through +login and registration flows. +""" diff --git a/openedx_filters/authentication/filters.py b/openedx_filters/authentication/filters.py new file mode 100644 index 00000000..2806b292 --- /dev/null +++ b/openedx_filters/authentication/filters.py @@ -0,0 +1,291 @@ +""" +Package where filters related to the authentication architectural subdomain are implemented. +""" + +from typing import Any + +from openedx_filters.authentication.types import FormDescriptionProtocol, ProviderConfigProtocol, RunningPipeline +from openedx_filters.tooling import OpenEdxPublicFilter + + +class LogistrationViewContextGenerated(OpenEdxPublicFilter): + """ + Filter used to enrich or modify the combined login-and-registration page context. + + Purpose: + This filter hooks into the legacy (server-rendered) login/registration flow. It is + triggered just after the combined login/registration page context has been generated + and just before the page is rendered, allowing pipeline steps to modify the context + dict (e.g. alter sidebar content) based on external conditions. + + Filter Type: + org.openedx.authentication.logistration_view.context.generated.v1 + + Trigger: + - Repository: openedx/edx-platform + - Path: openedx/core/djangoapps/user_authn/views/login_form.py + - Function or Method: login_and_registration_form + """ + + filter_type = "org.openedx.authentication.logistration_view.context.generated.v1" + + @classmethod + def run_filter(cls, context: dict) -> dict: + """ + Process the context through the configured pipeline steps. + + Arguments: + context (dict): the template context dict for the login/registration page. + + Returns: + dict: the (possibly modified) context. + """ + data = super().run_pipeline(context=context) + return data["context"] + + +class AuthnMFEContextGenerated(OpenEdxPublicFilter): + """ + Filter used to enrich or modify the authentication MFE context. + + Purpose: + This filter hooks into the modern authentication MFE (frontend-app-authn) flow. It is + triggered just after the context served to the authentication micro-frontend has been + generated, allowing pipeline steps to add or modify entries served to the MFE (e.g. + branding data) based on external conditions. It is the MFE counterpart to + LogistrationViewContextGenerated, which enriches the legacy server-rendered page's + nested context. + + The context is split across two arguments because the caller may only know how to + serve the entries it declares itself: pipeline steps modify entries the caller + already declares through ``context``, and contribute entries the caller does not + declare through ``extra_context``. + + Filter Type: + org.openedx.authentication.mfe.context.generated.v1 + + Trigger: + - Repository: openedx/edx-platform + - Path: openedx/core/djangoapps/user_authn/views/utils.py + - Function or Method: get_mfe_context + """ + + filter_type = "org.openedx.authentication.mfe.context.generated.v1" + + @classmethod + def run_filter(cls, context: dict, extra_context: dict) -> tuple[dict, dict]: + """ + Process the context through the configured pipeline steps. + + Arguments: + context (dict): the context dict served to the authentication MFE. Pipeline + steps modify the entries the caller declares itself. + extra_context (dict): additional entries to serve to the authentication MFE. + Pipeline steps add entries the caller does not declare itself. The caller + decides how these are merged into what it serves. + + Returns: + tuple[dict, dict]: + dict: the (possibly modified) context. + dict: the (possibly populated) extra context. + """ + data = super().run_pipeline(context=context, extra_context=extra_context) + return (data["context"], data["extra_context"]) + + +class LoginAltRedirectURLRequested(OpenEdxPublicFilter): + """ + Filter used to determine an alternative redirect URL after a successful login. + + Purpose: + This filter is triggered after a user has been authenticated, before the final redirect + is issued. Any pipeline step may return an alternative redirect URL to send the user + through additional post-login flows (e.g. an account-selection page). + + Filter Type: + org.openedx.authentication.login.alt_redirect_url.requested.v1 + + Trigger: + - Repository: openedx/edx-platform + - Path: openedx/core/djangoapps/user_authn/views/login.py + - Function or Method: login_user + """ + + filter_type = "org.openedx.authentication.login.alt_redirect_url.requested.v1" + + @classmethod + def run_filter(cls, redirect_url: str, user: Any) -> tuple[str, Any]: + """ + Process the redirect URL through the configured pipeline steps. + + Arguments: + redirect_url (str): the destination the caller intends to send the user to. A + pipeline step that redirects elsewhere may attempt to preserve this URL by + nesting it within another ``/?next=`` layer to create a chain of URLs. + user (User): the authenticated Django user. + + Returns: + tuple[str, User]: the (possibly modified) redirect URL and the user. + """ + data = super().run_pipeline(redirect_url=redirect_url, user=user) + return data["redirect_url"], data["user"] + + +class LoginFormGenerated(OpenEdxPublicFilter): + """ + Filter used to modify the login form description after it has been generated. + + Purpose: + This filter is triggered for every login form build, before the form fields are + added. Pipeline steps may override field properties (e.g. defaults, visibility, + restrictions). Field property overrides take effect when the fields are subsequently + added, so steps run before field construction. + + Pipeline steps can pass field overrides (enabling dynamic field hiding), but + cannot add fields of their own. At the time of this writing, there is no supported + mechanism for adding custom login fields. The registration form, however, does + support custom fields via the ``PROFILE_EXTENSION_FORM`` setting (or the + deprecated ``REGISTRATION_EXTENSION_FORM``) in platform. + + The third-party auth state of the request is passed alongside the form description so + that pipeline steps can tailor the form to the provider the user is authenticating + with, without having to resolve that state themselves. + + Filter Type: + org.openedx.authentication.login.form.generated.v1 + + Trigger: + - Repository: openedx/edx-platform + - Path: openedx/core/djangoapps/user_authn/views/login_form.py + - Function or Method: get_login_session_form + """ + + filter_type = "org.openedx.authentication.login.form.generated.v1" + + @classmethod + def run_filter( + cls, + form_desc: FormDescriptionProtocol, + running_pipeline: RunningPipeline | None, + current_provider: ProviderConfigProtocol | None, + ) -> tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]: + """ + Process the login form description through the configured pipeline steps. + + Arguments: + form_desc (FormDescriptionProtocol): the login form description. + running_pipeline (RunningPipeline): the third-party auth pipeline running for the + request, or None when third-party auth is disabled or no pipeline is running. + current_provider (ProviderConfigProtocol): the provider associated with the running + pipeline, or None when there is no running pipeline or the provider could not + be determined. + + Returns: + tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]: + the (possibly modified) form description, the running pipeline, and the current + provider. + """ + data = super().run_pipeline( + form_desc=form_desc, + running_pipeline=running_pipeline, + current_provider=current_provider, + ) + return data["form_desc"], data["running_pipeline"], data["current_provider"] + + +class RegistrationFormGenerated(OpenEdxPublicFilter): + """ + Filter used to modify the registration form description after it has been generated. + + Purpose: + This filter is triggered for every registration form build, before the form fields + are added. Pipeline steps may override field properties (e.g. defaults, visibility, + restrictions). Field property overrides take effect when the fields are subsequently + added, so steps run before field construction. + + Pipeline steps can pass field overrides (enabling dynamic field hiding), but + cannot add fields of their own. If you need to add fields instead, use the + ``PROFILE_EXTENSION_FORM`` setting (or the deprecated + ``REGISTRATION_EXTENSION_FORM``) in platform. + + The third-party auth state of the request is passed alongside the form description so + that pipeline steps can tailor the form to the provider the user is registering + through, without having to resolve that state themselves. + + Filter Type: + org.openedx.authentication.registration.form.generated.v1 + + Trigger: + - Repository: openedx/edx-platform + - Path: openedx/core/djangoapps/user_authn/views/registration_form.py + - Function or Method: RegistrationFormFactory.get_registration_form + """ + + filter_type = "org.openedx.authentication.registration.form.generated.v1" + + @classmethod + def run_filter( + cls, + form_desc: FormDescriptionProtocol, + running_pipeline: RunningPipeline | None, + current_provider: ProviderConfigProtocol | None, + ) -> tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]: + """ + Process the registration form description through the configured pipeline steps. + + Arguments: + form_desc (FormDescriptionProtocol): the registration form description. + running_pipeline (RunningPipeline): the third-party auth pipeline running for the + request, or None when third-party auth is disabled or no pipeline is running. + current_provider (ProviderConfigProtocol): the provider associated with the running + pipeline, or None when there is no running pipeline or the provider could not + be determined. + + Returns: + tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]: + the (possibly modified) form description, the running pipeline, and the current + provider. + """ + data = super().run_pipeline( + form_desc=form_desc, + running_pipeline=running_pipeline, + current_provider=current_provider, + ) + return data["form_desc"], data["running_pipeline"], data["current_provider"] + + +class LogistrationViewRenderCompleted(OpenEdxPublicFilter): + """ + Filter used to modify the rendered login/registration page response. + + Purpose: + This filter hooks into the legacy (server-rendered) login/registration flow. It is + triggered right after the combined login/registration page has been rendered, allowing + pipeline steps to modify the response (e.g. set or delete cookies, add headers) using + the final page context. + + Filter Type: + org.openedx.authentication.logistration_view.render.completed.v1 + + Trigger: + - Repository: openedx/edx-platform + - Path: openedx/core/djangoapps/user_authn/views/login_form.py + - Function or Method: login_and_registration_form + """ + + filter_type = "org.openedx.authentication.logistration_view.render.completed.v1" + + @classmethod + def run_filter(cls, response: Any, context: dict) -> tuple[Any, dict]: + """ + Process the response and context through the configured pipeline steps. + + Arguments: + response (HttpResponse): the rendered login/registration page response. + context (dict): the template context dict used to render the page. + + Returns: + tuple[HttpResponse, dict]: the (possibly modified) response and the context. + """ + data = super().run_pipeline(response=response, context=context) + return data["response"], data["context"] diff --git a/openedx_filters/authentication/tests/__init__.py b/openedx_filters/authentication/tests/__init__.py new file mode 100644 index 00000000..1b052bc2 --- /dev/null +++ b/openedx_filters/authentication/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Unit tests for authentication subdomain filters. +""" diff --git a/openedx_filters/authentication/tests/test_filters.py b/openedx_filters/authentication/tests/test_filters.py new file mode 100644 index 00000000..a0f9df2b --- /dev/null +++ b/openedx_filters/authentication/tests/test_filters.py @@ -0,0 +1,162 @@ +""" +Tests for authentication subdomain filters. +""" +from unittest.mock import Mock + +from django.test import TestCase + +from openedx_filters.authentication.filters import ( + AuthnMFEContextGenerated, + LoginAltRedirectURLRequested, + LoginFormGenerated, + LogistrationViewContextGenerated, + LogistrationViewRenderCompleted, + RegistrationFormGenerated, +) +from openedx_filters.authentication.types import RunningPipeline + + +class TestLogistrationViewContextGeneratedFilter(TestCase): + """ + Tests for the LogistrationViewContextGenerated filter. + """ + + def test_filter_type(self): + assert LogistrationViewContextGenerated.filter_type == \ + "org.openedx.authentication.logistration_view.context.generated.v1" + + def test_run_filter_passes_through_context(self): + context = {"data": {}} + + returned_context = LogistrationViewContextGenerated.run_filter(context) + + assert returned_context is context + + +class TestAuthnMFEContextGeneratedFilter(TestCase): + """ + Tests for the AuthnMFEContextGenerated filter. + """ + + def test_filter_type(self): + assert AuthnMFEContextGenerated.filter_type == "org.openedx.authentication.mfe.context.generated.v1" + + def test_run_filter_passes_through_all_arguments(self): + context = {"countryCode": "US"} + extra_context = {} + + returned_context, returned_extra_context = AuthnMFEContextGenerated.run_filter(context, extra_context) + + assert returned_context is context + assert returned_extra_context is extra_context + + +class TestLoginAltRedirectURLRequestedFilter(TestCase): + """ + Tests for the LoginAltRedirectURLRequested filter. + """ + + def test_filter_type(self): + assert LoginAltRedirectURLRequested.filter_type == \ + "org.openedx.authentication.login.alt_redirect_url.requested.v1" + + def test_run_filter_passes_through_all_arguments(self): + user = Mock() + + returned_url, returned_user = LoginAltRedirectURLRequested.run_filter("/dashboard", user) + + assert returned_url == "/dashboard" + assert returned_user is user + + +class TestLoginFormGeneratedFilter(TestCase): + """ + Tests for the LoginFormGenerated filter. + """ + + def test_filter_type(self): + assert LoginFormGenerated.filter_type == \ + "org.openedx.authentication.login.form.generated.v1" + + def test_run_filter_passes_through_all_arguments(self) -> None: + form_desc = Mock() + running_pipeline: RunningPipeline = { + "kwargs": {"details": {}, "response": {}}, + "backend": "tpa-saml", + } + current_provider = Mock() + + returned_form_desc, returned_pipeline, returned_provider = LoginFormGenerated.run_filter( + form_desc, running_pipeline, current_provider, + ) + + assert returned_form_desc is form_desc + assert returned_pipeline is running_pipeline + assert returned_provider is current_provider + + def test_run_filter_passes_through_absent_third_party_auth_state(self): + form_desc = Mock() + + returned_form_desc, returned_pipeline, returned_provider = LoginFormGenerated.run_filter( + form_desc, None, None, + ) + + assert returned_form_desc is form_desc + assert returned_pipeline is None + assert returned_provider is None + + +class TestRegistrationFormGeneratedFilter(TestCase): + """ + Tests for the RegistrationFormGenerated filter. + """ + + def test_filter_type(self): + assert RegistrationFormGenerated.filter_type == \ + "org.openedx.authentication.registration.form.generated.v1" + + def test_run_filter_passes_through_all_arguments(self) -> None: + form_desc = Mock() + running_pipeline: RunningPipeline = { + "kwargs": {"details": {}, "response": {}}, + "backend": "tpa-saml", + } + current_provider = Mock() + + returned_form_desc, returned_pipeline, returned_provider = RegistrationFormGenerated.run_filter( + form_desc, running_pipeline, current_provider, + ) + + assert returned_form_desc is form_desc + assert returned_pipeline is running_pipeline + assert returned_provider is current_provider + + def test_run_filter_passes_through_absent_third_party_auth_state(self): + form_desc = Mock() + + returned_form_desc, returned_pipeline, returned_provider = RegistrationFormGenerated.run_filter( + form_desc, None, None, + ) + + assert returned_form_desc is form_desc + assert returned_pipeline is None + assert returned_provider is None + + +class TestLogistrationViewRenderCompletedFilter(TestCase): + """ + Tests for the LogistrationViewRenderCompleted filter. + """ + + def test_filter_type(self): + assert LogistrationViewRenderCompleted.filter_type == \ + "org.openedx.authentication.logistration_view.render.completed.v1" + + def test_run_filter_passes_through_all_arguments(self): + response = Mock() + context = {"enable_sidebar": False} + + returned_response, returned_context = LogistrationViewRenderCompleted.run_filter(response, context) + + assert returned_response is response + assert returned_context is context diff --git a/openedx_filters/authentication/types.py b/openedx_filters/authentication/types.py new file mode 100644 index 00000000..29905f2b --- /dev/null +++ b/openedx_filters/authentication/types.py @@ -0,0 +1,99 @@ +""" +Structural types shared by filters in the authentication subdomain. + +These declare the shape of the payloads that filters in this subdomain pass to their pipeline +steps, so that both the caller producing them and the pipeline step consuming them can be +checked against a single shared definition: + +* the ``TypedDict`` declarations describe the mappings the filters pass along, and +* the structural (PEP 544) ``Protocol`` declarations describe the minimal surface of the + objects the filters pass along, without coupling the filters to any concrete platform + implementation. +""" + +from typing import Any, Protocol, Required, TypedDict + + +class RunningPipelineKwargs(TypedDict, total=False): + """ + Partial shape of a paused authentication pipeline's accumulated keyword arguments. + + ``details`` and ``response`` are the only two keys that may be accessed + unconditionally; the rest should be read with ``.get()``, because their values depend + on how far the pipeline had progressed + """ + + details: Required[dict[str, Any]] + response: Required[dict[str, Any]] + username: str | None + uid: str | None + is_new: bool + new_association: bool + auth_entry: str + user: Any + social: Any + + +class RunningPipeline(TypedDict): + """ + Shape of the authentication pipeline state for a request. + + This is the payload that the login and registration form filters pass to their pipeline + steps to describe the authentication attempt in flight, so that steps can tailor the form + to the provider the user is authenticating with. + + Unlike its ``kwargs`` member, this mapping is built by its caller as a complete literal + with exactly these two keys, so it is declared with both of them required: adding, + omitting or misspelling a key is an error on the caller's side. + + ``backend`` names the authentication backend driving the attempt, and ``kwargs`` holds the + keyword arguments the pipeline has accumulated so far. + """ + + kwargs: RunningPipelineKwargs + backend: str + + +class FormDescriptionProtocol(Protocol): + """ + Structural interface of the FormDescription object passed to the form-override filters. + + Only the minimal surface consumed by pipeline steps is declared here. Pipeline steps + should not rely on anything beyond this protocol. + + This protocol is deliberately limited to overriding the properties of fields the + caller already defines. Adding new registration fields is instead the job of the + ``PROFILE_EXTENSION_FORM`` setting (or the deprecated ``REGISTRATION_EXTENSION_FORM``) + in platform settings, which covers ingestion, persistence, and ordering. Custom login + fields, however, cannot be added at the time of this writing. + """ + + def override_field_properties( + self, + field_name: str, + /, + *, + default: Any = ..., + field_type: str = ..., + label: str = ..., + instructions: str = ..., + restrictions: dict = ..., + ) -> None: + """Override the given properties of the named form field.""" + ... # pylint: disable=unnecessary-ellipsis + + +class ProviderConfigProtocol(Protocol): + """ + Structural interface of the third-party auth provider configuration. + + Only the minimal surface consumed by pipeline steps is declared here. Pipeline steps + should not rely on anything beyond this protocol. + """ + + provider_id: str + skip_registration_form: bool + + def get_register_form_data(self, pipeline_kwargs: RunningPipelineKwargs, /) -> dict: + """Return the registration form field values prefilled by the provider.""" + ... # pylint: disable=unnecessary-ellipsis diff --git a/openedx_filters/filters.py b/openedx_filters/filters.py index 3a38c586..54e1e3b2 100644 --- a/openedx_filters/filters.py +++ b/openedx_filters/filters.py @@ -3,6 +3,7 @@ """ from abc import abstractmethod from logging import getLogger +from typing import Any log = getLogger(__name__) @@ -54,13 +55,21 @@ def __init__(self, filter_type, running_pipeline, **extra_config): self.extra_config = extra_config @abstractmethod - def run_filter(self, **kwargs): + # The explicit ``return None`` below is required by static type checkers, which treat an + # implicit fall-through as a missing return whenever a return type is annotated. + def run_filter( # pylint: disable=useless-return + self, *args: Any, **kwargs: Any, + ) -> dict[str, Any] | None: """ Abstract pipeline step runner. Used to implement custom code that'll be executed by OpenEdxPublicFilter's pipeline runner. It must be implemented by child classes. + The signature is intentionally declared in its most permissive (gradual) form so that + subclasses may narrow the accepted keyword arguments to those of the filter they + implement without static type checkers reporting an incompatible override. + By design, the pipeline expects either of three (3) types of returns: 1. A dictionary with the arguments the method received. They can be modified in the process. @@ -81,3 +90,4 @@ def run_filter(self, **kwargs): "3. An object different from a dict. Returning this will stop the pipeline execution. " "The accumulated output until this moment will be returned.\n" ) + return None diff --git a/openedx_filters/py.typed b/openedx_filters/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/openedx_filters/tests/test_tooling.py b/openedx_filters/tests/test_tooling.py index 3cffbad2..df591f3f 100644 --- a/openedx_filters/tests/test_tooling.py +++ b/openedx_filters/tests/test_tooling.py @@ -33,7 +33,7 @@ class FirstPipelineStep(PipelineStep): Utility function used when getting steps for pipeline. """ - def run_filter(self, **kwargs): + def run_filter(self, **kwargs): # pylint: disable=arguments-differ return {} @@ -42,7 +42,7 @@ class SecondPipelineStep(PipelineStep): Utility class used when getting steps for pipeline. """ - def run_filter(self, **kwargs): + def run_filter(self, **kwargs): # pylint: disable=arguments-differ return {} diff --git a/requirements/doc.in b/requirements/doc.in index 19c4c84f..d6c6e884 100644 --- a/requirements/doc.in +++ b/requirements/doc.in @@ -11,4 +11,5 @@ sphinx-book-theme sphinx-copybutton sphinx-autobuild sphinxcontrib-mermaid +myst-parser # Markdown (MyST) support for Sphinx docs diff --git a/requirements/doc.txt b/requirements/doc.txt index f97771fd..9d2ccd0e 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -115,11 +115,16 @@ librt==0.11.0 # -r requirements/test.txt # mypy markdown-it-py==4.2.0 - # via rich + # via + # mdit-py-plugins + # myst-parser + # rich markupsafe==3.0.3 # via # -r requirements/test.txt # jinja2 +mdit-py-plugins==0.6.1 + # via myst-parser mdurl==0.1.2 # via markdown-it-py more-itertools==11.0.2 @@ -132,6 +137,8 @@ mypy-extensions==1.1.0 # via # -r requirements/test.txt # mypy +myst-parser==5.1.0 + # via -r requirements/doc.in nh3==0.3.5 # via readme-renderer packaging==26.2 diff --git a/setup.py b/setup.py index 68da97f2..f9ee1957 100644 --- a/setup.py +++ b/setup.py @@ -145,6 +145,7 @@ def is_requirement(line): packages=[ "openedx_filters", ], + package_data={"openedx_filters": ["py.typed"]}, include_package_data=True, install_requires=load_requirements("requirements/base.in"), python_requires=">=3.11",