diff --git a/.github/workflows/tethys.yml b/.github/workflows/tethys.yml index b11b5c9968..0899fda398 100644 --- a/.github/workflows/tethys.yml +++ b/.github/workflows/tethys.yml @@ -189,7 +189,9 @@ jobs: conda activate tethys conda list tethys db start - pip install coveralls reactpy_django pytest pytest-django pytest-cov + # pytest-django 4.13 dropped Django 4.2 support. + # Remove this pin when we drop support for Django 4.2 in Tethys. + pip install coveralls reactpy_django pytest "pytest-django<4.13" pytest-cov # Test Tethys - name: Test Tethys run: | diff --git a/.gitignore b/.gitignore index 615baae503..6799f1edf8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ build/ dist/ docs/_build +*.env *~ tethys_gizmos/static/tethys_gizmos/less/bower_components/* node_modules diff --git a/docs/conf.py b/docs/conf.py index 47f62539eb..74224ee3d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -71,6 +71,8 @@ "docker", "docker.types", "docker.errors", + "encrypted_fields", + "encrypted_fields.fields", "guardian", "guardian.admin", "guardian.models", @@ -93,7 +95,6 @@ "sqlalchemy", "sqlalchemy.orm", "tethys_apps.harvester", - "tethys_apps.models", # Mocked to prevent issues with loading apps during docs build. "tethys_apps.admin", # Mocked to prevent issues with loading models during docs build. "yaml", ] diff --git a/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png b/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png new file mode 100644 index 0000000000..56b2c80679 --- /dev/null +++ b/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cbef96d7b11298281208279a21eb67f963b9b665cff08eb3bbfdc50a03dc8d6 +size 1057771 diff --git a/docs/tethys_portal/admin_pages/service_settings.rst b/docs/tethys_portal/admin_pages/service_settings.rst index 464d4ea76b..2358b5c2bf 100644 --- a/docs/tethys_portal/admin_pages/service_settings.rst +++ b/docs/tethys_portal/admin_pages/service_settings.rst @@ -4,7 +4,7 @@ Service Settings **************** -There are several different types of Service Settings including: ``Persistent Store Connection Settings``, ``Persistent Store Database Settings``, ``Dataset Service Settings``, ``Spatial Dataset Service Settings``, and ``Web Processing Service Settings`` (see Figure 1). These settings specify the types of services that the apps require. Use the drop down next to each Service Setting to assign a pre-registered ``Tethys Service`` to that app or use the *plus* button to create a new one. +There are several different types of Service Settings including: ``Persistent Store Connection Settings``, ``Persistent Store Database Settings``, ``Dataset Service Settings``, ``Spatial Dataset Service Settings``, ``Web Processing Service Settings``, and ``Secure Map Service Settings`` (see Figure 1). These settings specify the types of services that the apps require. Use the drop down next to each Service Setting to assign a pre-registered ``Tethys Service`` to that app or use the *plus* button to create a new one. .. figure:: ../../images/site_admin/service_settings.png :width: 675px diff --git a/docs/tethys_portal/admin_pages/tethys_services.rst b/docs/tethys_portal/admin_pages/tethys_services.rst index bb16900d84..5129c65349 100644 --- a/docs/tethys_portal/admin_pages/tethys_services.rst +++ b/docs/tethys_portal/admin_pages/tethys_services.rst @@ -4,7 +4,7 @@ Tethys Services *************** -The links under the ``TETHYS SERVICES`` heading can be used to register external services with Tethys Platform for use by apps and extensions. Use the ``Spatial Dataset Services`` link to register your Tethys Portal to GeoServer, the ``Dataset Services`` link to register to CKAN or HydroShare instances, the ``Web Processing Services`` link to register to WPS instances, or the ``PostgreSQL Persistent Store Services`` or ``SQLite Persistent Store Services`` link to register a database. +The links under the ``TETHYS SERVICES`` heading can be used to register external services with Tethys Platform for use by apps and extensions. Use the ``Spatial Dataset Services`` link to register your Tethys Portal to GeoServer, the ``Dataset Services`` link to register to CKAN or HydroShare instances, the ``Web Processing Services`` link to register to WPS instances, the ``Secure Map Services`` link to register map services that require an API key or OAuth2 access token, or the ``PostgreSQL Persistent Store Services`` or ``SQLite Persistent Store Services`` link to register a database. .. tip:: @@ -13,6 +13,7 @@ The links under the ``TETHYS SERVICES`` heading can be used to register external * :doc:`../tethys_sdk/tethys_services/spatial_dataset_services` * :doc:`../tethys_sdk/tethys_services/dataset_services` * :doc:`../tethys_sdk/tethys_services/web_processing_services` + * :doc:`../tethys_sdk/tethys_services/secure_map_services` * :doc:`../tethys_sdk/tethys_services/persistent_store` * :doc:`../tethys_sdk/tethys_services/spatial_persistent_store` * :ref:`tethys_portal_service_settings` diff --git a/docs/tethys_portal/configuration.rst b/docs/tethys_portal/configuration.rst index bb92c30d34..fa7b291a97 100644 --- a/docs/tethys_portal/configuration.rst +++ b/docs/tethys_portal/configuration.rst @@ -70,6 +70,8 @@ CONTEXT_PROCESSORS_OVERRIDE override for ``CONTEXT_PROCES RESOURCE_QUOTA_HANDLERS a list of Tethys ``ResourceQuotaHandler`` classes to load (see: :ref:`sdk_quotas_api`). For convenience, any quota handlers listed here will be appended to the default list of quota handlerss. To override ``RESOURCE_QUOTA_HANDLERS`` completely, use the ``RESOURCE_QUOTA_HANDLERS_OVERRIDE`` setting. RESOURCE_QUOTA_HANDLERS_OVERRIDE override for ``RESOURCE_QUOTA_HANDLERS`` setting. CAUTION: improper use of this setting can break the Tethys Portal. USE_OLD_WORKSPACES_API a temporary setting that maintains backward compatibility for the :ref:`tethys_workspaces_api` when True. When False the the new :ref:`tethys_paths_api` functionality will apply. Defaults to True. Will be removed in 5.0. +SALT_KEY the key used to encrypt sensitive values that are stored in the database, such as ``SecureMapService`` API keys (see: :ref:`secure_map_services_api`). Automatically generated by ``tethys gen portal_config``. Generate a new one at any time with ``tethys settings --generate-salt-key``. +OAUTH_REQUIREMENTS a mapping of app package to the name of a social auth provider that users must be linked to before they can access that app (e.g. ``{"my_first_app": "my_provider"}``). Users who have not linked an account are redirected to their user settings page. See :ref:`secure_map_services_oauth`. ================================================== ================================================================================ .. _tethys_portal_config_settings: diff --git a/docs/tethys_sdk/app_class.rst b/docs/tethys_sdk/app_class.rst index b9d48759af..cc0580842c 100644 --- a/docs/tethys_sdk/app_class.rst +++ b/docs/tethys_sdk/app_class.rst @@ -47,6 +47,9 @@ Override these methods (add them to your app class) to define objects that are u .. automethod:: tethys_sdk.base.TethysAppBase.web_processing_service_settings :noindex: +.. automethod:: tethys_sdk.base.TethysAppBase.secure_map_service_settings + :noindex: + .. automethod:: tethys_sdk.base.TethysAppBase.scheduler_settings :noindex: @@ -86,6 +89,12 @@ Class Methods .. automethod:: tethys_sdk.base.TethysAppBase.get_web_processing_service :noindex: +.. automethod:: tethys_sdk.base.TethysAppBase.get_secure_map_service + :noindex: + +.. automethod:: tethys_sdk.base.TethysAppBase.update_secure_map_service_params + :noindex: + .. automethod:: tethys_sdk.base.TethysAppBase.get_handoff_manager :noindex: diff --git a/docs/tethys_sdk/app_settings.rst b/docs/tethys_sdk/app_settings.rst index 9926520b6b..4163d9998d 100644 --- a/docs/tethys_sdk/app_settings.rst +++ b/docs/tethys_sdk/app_settings.rst @@ -108,6 +108,30 @@ To retrieve a connection to a Web Processing Service, import your :term:`app cla See the :doc:`./tethys_services/web_processing_services` for more details on how to use Dataset Services in your apps. +.. _app_settings_secure_map_service_settings: + +Secure Map Service Settings +=========================== + +Secure Map Service Settings are used to request map services that require credentials to access (e.g. a WMS service authenticated with an API key or an OAuth2 access token). Create Secure Map Service Settings by implementing the ``secure_map_service_settings()`` method in your :term:`app class`. This method should return a list of SecureMapServiceSetting_ objects: + +.. automethod:: tethys_apps.base.TethysAppBase.secure_map_service_settings + :noindex: + +To retrieve a Secure Map Service, import your :term:`app class` and call the ``get_secure_map_service()`` class method: + +.. automethod:: tethys_apps.base.TethysAppBase.get_secure_map_service + :noindex: + +To update the parameters of the service assigned to a setting, call the ``update_secure_map_service_params()`` class method: + +.. automethod:: tethys_apps.base.TethysAppBase.update_secure_map_service_params + :noindex: + +.. tip:: + + See the :doc:`./tethys_services/secure_map_services` for more details on how to use Secure Map Services in your apps. + .. _app_settings_scheduler_settings: Scheduler Settings @@ -163,6 +187,10 @@ Settings Objects .. autoclass:: tethys_sdk.app_settings.WebProcessingServiceSetting +.. _SecureMapServiceSetting: + +.. autoclass:: tethys_sdk.app_settings.SecureMapServiceSetting + .. _SchedulerSetting: .. autoclass:: tethys_sdk.app_settings.SchedulerSetting @@ -180,6 +208,8 @@ Settings Declaration Methods .. automethod:: tethys_apps.base.TethysAppBase.web_processing_service_settings +.. automethod:: tethys_apps.base.TethysAppBase.secure_map_service_settings + .. automethod:: tethys_apps.base.TethysAppBase.scheduler_settings @@ -198,4 +228,6 @@ Settings Getter Methods .. automethod:: tethys_apps.base.TethysAppBase.get_web_processing_service +.. automethod:: tethys_apps.base.TethysAppBase.get_secure_map_service + .. automethod:: tethys_apps.base.TethysAppBase.get_scheduler diff --git a/docs/tethys_sdk/layouts/map_layout.rst b/docs/tethys_sdk/layouts/map_layout.rst index 7a8beb9d69..e3b5b0e2c5 100644 --- a/docs/tethys_sdk/layouts/map_layout.rst +++ b/docs/tethys_sdk/layouts/map_layout.rst @@ -60,7 +60,7 @@ Add Layers To add layers to the map in a ``MapLayout``, override the :ref:`compose_layers ` method. The ``MapLayout`` view uses the :ref:`MapView Gizmo ` under the covers and it is given to the ``compose_layers()`` method via the ``map_view`` argument. Use the ``map_view`` argument to add new :ref:`MVLayers ` to the ``MapView``. -While the ``MapView`` Gizmo will be able to accept any ``MVLayer`` object, the ``MapLayout`` needs layers to have additional metadata attached for them to be recognized by the layers in the Layer Tree, legend, and other features of ``MapLayout``. Several helper methods are provided by ``MapLayout`` to assist with building ``MVLayer`` objects in the correct way: :ref:`build_wms_layer() `, :ref:`build_geojson_layer() `, and :ref:`build_arc_gis_layer() `. +While the ``MapView`` Gizmo will be able to accept any ``MVLayer`` object, the ``MapLayout`` needs layers to have additional metadata attached for them to be recognized by the layers in the Layer Tree, legend, and other features of ``MapLayout``. Several helper methods are provided by ``MapLayout`` to assist with building ``MVLayer`` objects in the correct way: :ref:`build_wms_layer() `, :ref:`build_geojson_layer() `, :ref:`build_arc_gis_layer() `, and :ref:`build_gml_layer() `. In addition, the ``compose_layers()`` method needs to return a ``list`` of at least one Layer Group. A Layer Group contains a list of layers and is used by the Layer Tree of ``MapLayout`` to organize layers. In addition, a control type is specified for each Layer Group (``'check' or 'radio'``), and can be used to control whether all the layers in a Layer Group can be viewed simultaneously (``'check'``) or only one at a time (``'radio'``). Create Layer Groups using the :ref:`map_layout_build_layer_group` helper method. @@ -838,6 +838,13 @@ build_arc_gis_layer .. automethod:: tethys_layouts.views.map_layout.MapLayout.build_arc_gis_layer +.. _map_layout_build_gml_layer: + +build_gml_layer +^^^^^^^^^^^^^^^ + +.. automethod:: tethys_layouts.views.map_layout.MapLayout.build_gml_layer + .. _map_layout_build_layer_group: build_layer_group diff --git a/docs/tethys_sdk/tethys_services.rst b/docs/tethys_sdk/tethys_services.rst index ec8f64b05c..d249ff1601 100644 --- a/docs/tethys_sdk/tethys_services.rst +++ b/docs/tethys_sdk/tethys_services.rst @@ -4,9 +4,9 @@ Tethys Services APIs ******************** -**Last Updated:** May 2017 +**Last Updated:** August 2026 -Tethys Services consists of several APIs that can be used to work with external data and processing services. Use the Persistent Store Services APIs to connect to SQL databases. Use the Dataset Services to consume file dataset services like CKAN or HydroShare. The Spatial Dataset Services can be used to connect to map servers like GeoServer and the Web Processing Services can be used to consume processing services such as those hosted by 52 North installations.1 +Tethys Services consists of several APIs that can be used to work with external data and processing services. Use the Persistent Store Services APIs to connect to SQL databases. Use the Dataset Services to consume file dataset services like CKAN or HydroShare. The Spatial Dataset Services can be used to connect to map servers like GeoServer and the Web Processing Services can be used to consume processing services such as those hosted by 52 North installations. Use the Secure Map Services to securely consume map services that require an API key or an OAuth2 access token. .. toctree:: :maxdepth: 1 @@ -15,4 +15,5 @@ Tethys Services consists of several APIs that can be used to work with external tethys_services/spatial_persistent_store tethys_services/dataset_services tethys_services/spatial_dataset_services + tethys_services/secure_map_services tethys_services/web_processing_services \ No newline at end of file diff --git a/docs/tethys_sdk/tethys_services/secure_map_services.rst b/docs/tethys_sdk/tethys_services/secure_map_services.rst new file mode 100644 index 0000000000..3a290c011d --- /dev/null +++ b/docs/tethys_sdk/tethys_services/secure_map_services.rst @@ -0,0 +1,307 @@ +.. _secure_map_services_api: + +************************ +Secure Map Services API +************************ + +**Last Updated:** August 2026 + +Secure map services are map services (e.g.: OGC WMS endpoints) that require credentials to access. The Secure Map Services API allows a developer to register the endpoint and credentials of such a service once, and then allows apps to consume that service without ever storing or handling the credentials themselves. + +In addition, requests can optionally be routed through a proxy endpoint provided by Tethys Portal so that credentials are never sent to the browser. See :ref:`secure_map_services_proxy` for more details. + +.. tip:: + + For a step-by-step tutorial on using secure map services in your app, see the :ref:`Secure Map Services Tutorial `. + +Two authentication methods are supported: + +* **API Key**: a key is stored (encrypted) with the service and added to each request as a query parameter. +* **OAuth**: an OAuth2 access token is retrieved from the requesting user's linked social auth account and added to each request as a ``Bearer`` token. + +.. important:: + + API keys assigned to a ``SecureMapService`` are encrypted before they are stored in the database. This requires a ``SALT_KEY`` to be set in your :file:`portal_config.yml`. If you have not already done so, generate one as follows: + + .. code-block:: bash + + tethys gen portal_config + tethys settings --generate-salt-key + +.. _secure_map_service_settings: + +Secure Map Service Settings +=========================== + +Using secure map services in your app is accomplished by adding the ``secure_map_service_settings()`` method to your :term:`app class`, which is located in your :term:`app configuration file` (:file:`app.py`). This method should return a list or tuple of ``SecureMapServiceSetting`` objects. For example: + +:: + + from tethys_sdk.app_settings import SecureMapServiceSetting + + class App(TethysAppBase): + """ + Tethys App Class for My First App. + """ + ... + def secure_map_service_settings(self): + """ + Example secure_map_service_settings method. + """ + secure_map_service_settings = ( + SecureMapServiceSetting( + name='primary_secure_map_service', + description='Secure map service for app to use.', + required=True, + ), + ) + + return secure_map_service_settings + +.. caution:: + + The ellipsis in the code block above indicates code that is not shown for brevity. **DO NOT COPY VERBATIM**. + +Unlike other Tethys Service settings, a ``SecureMapServiceSetting`` does not specify an engine. The type of service and how it is authenticated are properties of the ``SecureMapService`` that is assigned to the setting, not of the setting itself. + +.. _register_secure_map_service: + +Register a Secure Map Service +============================= + +Before a ``SecureMapService`` can be assigned to a setting, it must be registered in the Admin Interface of Tethys Portal: + +1. Access the Admin interface of Tethys Portal by clicking on the drop down menu next to your user name and selecting the "Site Admin" option. + +2. Scroll down to the **Tethys Services** section of the Admin Interface and select the link titled **Secure Map Services**. + +3. Click on the **Add Secure Map Service** button. + +4. Fill in the connection information for the service (see the table below). + +5. Press the **Save** button to save the new ``SecureMapService``. + +The following fields are available on a ``SecureMapService``: + +========================== ========================================================================================================================================================================== +Field Description +========================== ========================================================================================================================================================================== +**Name** Unique name used to identify the service. +**Endpoint** The URL of the map service. +**Legend Title** The title to use for the legend when the service is added to a map as a layer. +**Authentication Method** One of **API Key** or **OAuth**. The fields that apply to the other method are hidden in the admin form. +**API Key** The API key to use when the authentication method is **API Key**. The value is encrypted before it is stored in the database. +**OAuth Provider** The social auth backend to retrieve the access token from when the authentication method is **OAuth**. The options are populated from the authentication backends that are enabled for the portal (see :ref:`single_sign_on_config`). +**Service Type** The type of service: **WMS** (``ImageWMS``), **GML**, **GeoJSON**, or **REST/JSON API** (``REST``). For the map service types, the stored value is used as the ``source`` of the ``MVLayer`` that is created when the service is retrieved with ``as_layer=True``. Use **REST/JSON API** for services that are consumed with ``as_response=True`` or with ``as_endpoint=True`` rather than rendered as a map layer. +**Use Proxy for Requests** When checked, requests are routed through a Tethys Portal proxy endpoint so that credentials are never exposed to the browser. See :ref:`secure_map_services_proxy`. +**Parameters** A JSON object of additional query parameters to include with each request to the service. See :ref:`secure_map_services_params`. +========================== ========================================================================================================================================================================== + +.. tip:: + + You do not need to create a new ``SecureMapService`` for each ``SecureMapServiceSetting`` or each app. Apps and ``SecureMapServiceSettings`` can share ``SecureMapServices``. + +.. _assign_secure_map_service: + +Assign Secure Map Service +========================= + +The ``SecureMapServiceSetting`` can be thought of as a socket for a connection to a ``SecureMapService``. Before we can do anything with the ``SecureMapServiceSetting`` we need to "plug in" or assign a ``SecureMapService`` to the setting. Assigning a ``SecureMapService`` is done through the Admin Interface of Tethys Portal as follows: + +1. Navigate to App Settings Page + + a. Return to the Home page of the Admin Interface using the **Home** link in the breadcrumbs. + + b. Scroll to the **Tethys Apps** section of the Admin Interface and select the **Installed Apps** link. + + c. Select the link for your app from the list of installed apps. + +2. Assign ``SecureMapService`` to the appropriate ``SecureMapServiceSetting`` + + a. Scroll to the **Secure Map Service Settings** section and locate the ``SecureMapServiceSetting``. + + .. note:: + + If you don't see the ``SecureMapServiceSetting`` in the list, uninstall the app and reinstall it again. + + b. Assign the appropriate ``SecureMapService`` to your ``SecureMapServiceSetting`` using the drop down menu in the **Secure Map Service** column. + + c. Press the **Save** button at the bottom of the page to save your changes. + +.. note:: + + During development you will assign the ``SecureMapService`` setting yourself. However, when the app is installed in production, this step is performed by the portal administrator upon installing your app, which may or may not be yourself. + +Working with Secure Map Services +================================ + +After a secure map service has been assigned to a setting, use the ``get_secure_map_service()`` method of the app class to retrieve it. The value that is returned depends on which of the ``as_`` arguments is provided. + +Get the Service +--------------- + +Called with only a name, ``get_secure_map_service()`` returns the ``SecureMapService`` object that is assigned to the setting: + +.. code-block:: python + + from .app import App + + service = App.get_secure_map_service('primary_secure_map_service') + +Get an Endpoint +--------------- + +Pass ``as_endpoint=True`` to get the URL of the service with all of its parameters (and the API key, if applicable) applied: + +.. code-block:: python + + from .app import App + + endpoint = App.get_secure_map_service('primary_secure_map_service', as_endpoint=True) + +.. note:: + + A lazy string is returned when ``as_endpoint`` is ``True``. This ensures the endpoint reflects the current state of the service's settings every time it is used, and allows the endpoint to be referenced at import time (e.g.: as a ``MapLayout`` basemap) before the app URLs have been registered. + +Get a Map Layer +--------------- + +Pass ``as_layer=True`` to get an :ref:`MVLayer ` for the service that can be added to a ``MapView`` or ``MapLayout``. The ``source`` of the layer is the **Service Type** of the service and the ``legend_title`` is the **Legend Title** of the service: + +.. code-block:: python + + from .app import App + + layer = App.get_secure_map_service('primary_secure_map_service', as_layer=True) + +If the service uses OAuth authentication and is not proxied, the access token must be retrieved from the user making the request, so the ``request_user`` argument is required: + +.. code-block:: python + + layer = App.get_secure_map_service( + 'primary_secure_map_service', + as_layer=True, + request_user=request.user, + ) + +Get a Response +-------------- + +Pass ``as_response=True`` to perform the request server-side and get the resulting ``requests.Response`` object. This is useful for services that return data to be processed by the app rather than rendered as a map layer: + +.. code-block:: python + + from .app import App + + response = App.get_secure_map_service( + 'primary_secure_map_service', + as_response=True, + request_user=request.user, + ) + data = response.json() + +.. note:: + + As with ``as_layer``, ``request_user`` is required when the service uses OAuth authentication. An exception is raised if the request is not successful. + +.. _secure_map_services_params: + +Service Parameters +================== + +The **Parameters** field of a ``SecureMapService`` is a JSON object of query parameters that are added to every request made to the service. For example, the following parameters could be used for a WMS service: + +.. code-block:: json + + { + "service": "WMS", + "request": "GetMap", + "layers": "my:layer", + "format": "image/png", + "transparent": true + } + +String values may contain placeholders that reference fields of the ``SecureMapService``, using Python's ``string.Template`` syntax. This is most commonly used to place the API key in a parameter with a name other than ``api_key``: + +.. code-block:: json + + { + "connectId": "${api_key}" + } + +.. note:: + + If the API key is not referenced by any parameter, it is added automatically as a parameter named ``api_key``. + +Overriding Parameters Per Request +--------------------------------- + +Pass a ``param_overrides`` dictionary to ``get_secure_map_service()`` to add to or replace the parameters of the service for a single request. This does not modify the ``SecureMapService``: + +.. code-block:: python + + from .app import App + + response = App.get_secure_map_service( + 'primary_secure_map_service', + as_response=True, + param_overrides={'layers': 'my:other_layer'}, + request_user=request.user, + ) + +Updating Parameters Permanently +------------------------------- + +Use the ``update_secure_map_service_params()`` method of the app class to permanently update the parameters of the ``SecureMapService`` that is assigned to a setting. The given parameters are merged with the existing parameters and saved: + +.. code-block:: python + + from .app import App + + App.update_secure_map_service_params( + 'primary_secure_map_service', + {'layers': 'my:other_layer'}, + ) + +.. caution:: + + ``SecureMapServices`` can be shared by multiple settings and apps, so updating the parameters of a service affects every app that uses it. + +.. _secure_map_services_proxy: + +Proxying Requests +================= + +When the **Use Proxy for Requests** option is enabled on a ``SecureMapService``, the endpoint that is returned by ``get_secure_map_service()`` is not the endpoint of the map service, but rather a URL to a proxy view provided by Tethys Portal (``secure-map-proxy//``). The browser makes its requests against the proxy, which then adds the credentials and parameters of the service server-side and streams the response back. As a result, the API key or access token is never sent to the browser. + +The proxy view requires the user to be logged in, and for OAuth services it uses the access token of the logged in user making the request. + +.. tip:: + + Enable **Use Proxy for Requests** for any service whose credentials should not be visible to end users. Without it, the API key is included in the URL of the tile requests that the browser makes, where it can be read from the browser's developer tools. + +.. _secure_map_services_oauth: + +Requiring an OAuth Provider +=========================== + +Services that use OAuth authentication require the user to have linked the corresponding social auth account to their Tethys Portal account. To require users to link an account before they can access an app, add the app package and provider name to the ``OAUTH_REQUIREMENTS`` portal setting: + +.. code-block:: bash + + tethys settings --set OAUTH_REQUIREMENTS.my_first_app my_provider + +Users who access the app without having linked an account for the given provider are redirected to their user settings page with a message prompting them to link it. + +.. note:: + + The provider must also be configured as an authentication backend for the portal. See :ref:`single_sign_on_config` for more details. + +API Documentation +================= + +.. automethod:: tethys_apps.base.TethysAppBase.secure_map_service_settings + +.. automethod:: tethys_apps.base.TethysAppBase.get_secure_map_service + +.. automethod:: tethys_apps.base.TethysAppBase.update_secure_map_service_params diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 6e4f008798..01cec8d091 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -65,3 +65,4 @@ Complete these tutorials to learn about other Tethys Platform features. tutorials/websockets tutorials/bokeh tutorials/quotas + tutorials/secure_map_services diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst new file mode 100644 index 0000000000..29e5dae140 --- /dev/null +++ b/docs/tutorials/secure_map_services.rst @@ -0,0 +1,809 @@ +.. _secure_map_services_tutorial: + +******************* +Secure Map Services +******************* + +**Last Updated:** August 2026 + +This tutorial demonstrates how to use **Secure Map Services** in a Tethys app. A Secure Map Service +lets app developers store the connection information and credentials for map services that +require authentication — an API key or an OAuth2 access token — and then lets an app consume that +service without ever handling the credentials itself or exposing them to the user/browser. + +The following topics are covered: + +* Registering a Secure Map Service in the Tethys Portal admin +* Declaring ``SecureMapServiceSetting`` in your :term:`app class` +* Adding a secure service to a Map Layout as a layer and as a basemap +* Proxying requests so credentials never reach the browser +* Changing service parameters at runtime +* Requiring users to link an OAuth account before being able to access the app. +* Utilizing a Secure Map Service as a response to retrieve data from a service and format it for use in your app. +* Utilizing a Secure Map Service as an endpoint to make requests to a service from your app using JavaScript. + +For more information on Secure Map Services, see the :ref:`Secure Map Services API Documentation `. + +0. Prerequisites +================ + +For this tutorial, we'll be utilizing two services that require authentication to use their data in your app: + +* **GEGD** - a WMS imagery service authenticated with an API key. +* **GRiD** - an OGC/WFS imagery service and REST API authenticated with an OAuth2 access token. + +Before beginning this tutorial, make sure you have created an account with each service and have retrieved an API key from the GEGD page. + +1. Scaffold a New App +===================== + +To generate a new app using the scaffold, open a terminal, :ref:`activate_environment`, and execute the following commands: + +.. code-block:: bash + + tethys scaffold secure_map_tutorial + +You will be prompted to enter metadata about your app such as, proper name, version, author, and description. All of these metadata are optional. You can accept the default value that is shown in the square brackets by pressing enter. + +You'll then need to install your app by running these commands: + +.. code-block:: bash + + cd tethysapp-secure_map_tutorial + tethys install -d + + +2. Setup portal_config +====================== + +Next, in order to use the secure encryption features of a SecureMapService, you'll need a generated portal_config file. You can generate it using the following command: + +.. code-block:: bash + + tethys gen portal_config + +If you've already generated a portal_config file, you may still need to generate a salt key in that file. You can do so by running the following command: + +.. code-block:: bash + + tethys settings --generate-salt-key + + +3. Add a MapLayout +================== + +We'll be using a MapLayout for this application, so we'll begin by adding a MapLayout controller to your app. Open your ``controllers.py`` file and replace the contents with the following code: + +.. code-block:: python + + from tethys_sdk.layouts import MapLayout + from tethys_sdk.routing import controller + from .app import App + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + app = App + base_template = f'{App.package}/base.html' + template_name = f'{App.package}/home.html' + map_title = 'Secure Map Services Tutorial' + +Next open the ``home.html`` in your ``templates/secure_map_services_tutorial`` directory and replace the contents with the following code: + +.. code-block:: html+django + + {% extends "tethys_layouts/map_layout/map_layout.html" %} + {% load static tethys %} + +Now go ahead and open your app at localhost:8000 and you should see a fully interactive map like this one: + +.. figure:: ../images/tutorial/secure_map_services/secure-map-service-initial-map.png + :width: 650px + +4. Add a Basemap +================ +Now, you'll be setting up your first SecureMapService that you'll be using as a basemap. You'll want to make sure you have created your GEGD account and have your API key ready. + +First, open your ``app.py`` file and add the following import to the top of your file: + +.. code-block:: python + + from tethys_sdk.app_settings import SecureMapServiceSetting + + +Then add this to your main App class: + +.. code-block:: python + :emphasize-lines: 16, 18-31 + + class App(TethysAppBase): + """ + Tethys app class for Secure Map App. + """ + name = 'Secure Map App' + description = '' + package = 'secure_map_tutorial' # WARNING: Do not change this value + index = 'home' + icon = f'{package}/images/icon.gif' + root_url = 'secure-map-app' + color = '#5f27cd' + tags = '' + enable_feedback = False + feedback_emails = [] + + GEGD_SECURE_MAP_SERVICE_NAME = "gegd_secure_map_service" + + def secure_map_service_settings(self): + """ + Returns the settings for the secure map service. + """ + + secure_map_service_settings = ( + SecureMapServiceSetting( + name=self.GEGD_SECURE_MAP_SERVICE_NAME, + description="Secure Map Service for the app to use with GEGD", + required=True + ), + ) + + return secure_map_service_settings + +Now, you'll need to open your app and go to the app settings and look for the Secure Map Service Settings section. You should see a setting for the GEGD service. Click on the dropdown and select "Add New Secure Map Service" to add your GEGD service. + +Use the following configurations for your new Secure Map Service: + +- **Name:** GEGD Secure Map Service +- **Endpoint:** https://pro.gegd.com/streaming/v1/ogc/wms +- **Legend Title:** GEGD +- **Authentication Method:** API Key +- **API Key:** [YOUR API KEY] +- **Service Type:** WMS +- **Use Proxy for Requests:** True +- **Parameters:** + +.. code-block:: json + + { + "maxar_api_key": "${api_key}", + "layers": "Maxar:Imagery", + "version": "1.3.0" + } + +Then save your new Secure Map Service and assign it to the GEGD Secure Map Service setting, then save your app settings. + +Now we'll be adding the GEGD service as a basemap to your MapLayout. Open your ``controllers.py`` and add the following to your MapLayout class: + +.. code-block:: python + :emphasize-lines: 7-12 + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + app = App + base_template = f'{App.package}/base.html' + template_name = f'{App.package}/home.html' + map_title = 'Secure Map Services Tutorial' + basemaps = [ + {"WMS": { + "url": App.get_secure_map_service(App.GEGD_SECURE_MAP_SERVICE_NAME, as_endpoint=True), + "control_label": "GEGD Map" + }} + ] + +Now reopen your app and you should see the GEGD imagery on your map. + +Notice that if you look at the network traffic in your browser, you will see that the requests to the GEGD service are being proxied through your Tethys Portal and the API key is not visible in the request. + +5. Configure for OAuth2 with GRiD +================================= +Next, we want to add a map layer using the GRiD service. Before we can authenticate with OAuth2 to do that, we need to configure the Tethys Portal to use GRiD as an OAuth2 provider. + +Start by running this command: + +.. code-block:: bash + + tethys settings --set AUTHENTICATION_BACKENDS "['tethys_services.backends.grid.GRiDOAuth2']" + +Then configure your portal to require users to link their GRiD account before being able to access the app by running this command: + +.. code-block:: bash + + tethys settings --set OAUTH_REQUIREMENTS.secure_map_tutorial grid + +The last step required to configure your application to work with GRiD is to register your application with GRiD to get a client ID and client secret. You can do this by going to the GRiD developer portal and setting up a new application with the following settings: + +#. Navigate to the `GRiD application list `_. + +#. Click **Create new application**. + +#. Fill out the form: + + :Application Name: Your application's name + :Redirect URIs: ``http://localhost:8000/oauth2/complete/grid/`` + +.. note:: + + Below the Redirect URIs field, you will see instructions to add a second redirect URI required + for the GRiD service to work with Tethys. Add it to the same field, separated from the first + by a space. You can update this list later when deploying to a production server. + +#. Copy the generated **client ID** and **client secret** and store them somewhere secure. + +#. Submit the form. + +Once you've registered your application, you'll need to add the client ID and client secret to your Tethys Portal settings. You can do this by running the following commands: + +.. code-block:: bash + + tethys settings --set OAUTH_CONFIG.SOCIAL_AUTH_GRID_KEY [YOUR CLIENT ID] + tethys settings --set OAUTH_CONFIG.SOCIAL_AUTH_GRID_SECRET [YOUR CLIENT SECRET] + + +Now when you try to open your app you will be redirected to your account settings because you haven't linked your account. Scroll down until you find the "Single Sign On" section. Then click on "connect grid". This will redirect you to log in with your GRiD account and bring you back to the account settings. Once you've linked your account, you can go back to the app and you should be able to access it. + +6. Add a Map Layer +================== + +Next, you'll be setting up your second SecureMapService that you'll be using as a map layer. You'll want to make sure you have created your GRiD account since we'll be using that service for this layer. + +Begin by adding a new SecureMapServiceSetting to your app class in ``app.py``: + +.. code-block:: python + :emphasize-lines: 17, 30-34 + + class App(TethysAppBase): + """ + Tethys app class for Secure Map App. + """ + name = 'Secure Map App' + description = '' + package = 'secure_map_tutorial' # WARNING: Do not change this value + index = 'home' + icon = f'{package}/images/icon.gif' + root_url = 'secure-map-app' + color = '#5f27cd' + tags = '' + enable_feedback = False + feedback_emails = [] + + GEGD_SECURE_MAP_SERVICE_NAME = "gegd_secure_map_service" + GRID_SECURE_MAP_SERVICE_NAME = 'grid_secure_map_service' + + def secure_map_service_settings(self): + """ + Returns the settings for the secure map service. + """ + + secure_map_service_settings = ( + SecureMapServiceSetting( + name=self.GEGD_SECURE_MAP_SERVICE_NAME, + description="Secure Map Service for the app to use with GEGD", + required=True + ), + SecureMapServiceSetting( + name=self.GRID_SECURE_MAP_SERVICE_NAME, + description='Secure Map Service for app to use with GRiD', + required=True, + ), + ) + + return secure_map_service_settings + +Now let's configure this new Secure Map Service in the Tethys Portal. Open your app and go to the app settings and scroll down to the Secure Map Service Settings section. You should see a setting for the GRiD service. Click on the dropdown and select "Add New Secure Map Service" to add your GRiD service. + +Use the following configurations for your new Secure Map Service: + +- **Name:** GRiD Secure Map Service +- **Endpoint:** https://grid.nga.mil/grid/api/ogcservices +- **Legend Title:** GRiD +- **Authentication Method:** OAuth +- **OAuth Provider:** grid +- **Service Type:** GML +- **Use Proxy for Requests:** True +- **Parameters:** + +.. code-block:: json + + { + "service": "wfs", + "version": "1.1.0", + "request": "getfeature", + "typename": "ms:gridws_raster", + "maxfeatures": "200" + } + +Then save your new Secure Map Service and assign it to the GRiD Secure Map Service setting, then save your app settings. + +Our next step will be to add a new map layer to our MapLayout using the GRiD service. Open your ``controllers.py`` and add the following to your MapLayout class: + +.. code-block:: python + :emphasize-lines: 5-19 + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + ... + + def compose_layers(self, request, map_view, *args, **kwargs): + grid_layer = App.get_secure_map_service( + App.GRID_SECURE_MAP_SERVICE_NAME, + as_layer=True, + request_user=request.user + ) + + layer_groups = [ + self.build_layer_group( + id='grid_layer_group', + display_name='GRiD Layer Group', + layers=[grid_layer] + ) + ] + return layer_groups + +.. caution:: + + The ellipsis in the code block above indicates code that is not shown for brevity. **DO NOT COPY VERBATIM**. + +Now just go ahead and refresh your app and you should see the GRiD layer on your map. You can toggle the visibility of the layer using the layers control in the top right corner of the map. + +7. Update Service Parameters +============================ +Now that you have data from GRiD displaying on your map in the form of a layer, you may want to change the parameters of the service to display different data. You can do this by going into the service settings and manually updating the parameters field. But you can also do this in your app dynamically using the ``update_secure_map_service_params()`` method in your app code. + +In order to demonstrate how this can be done dynamically in your app, we'll add a form to the app that will allow the user to select which GRiD layer they want to display on the map. We'll then use the ``update_secure_map_service_params()`` method to update the parameters of the GRiD service based on the user's selection. + +We'll begin by adding a custom map tab to your MapLayout that will contain this form. Open ``home.html`` and add the following code: + +.. code-block:: html+django + + {% block custom_map_tabs %} + + {% endblock %} + + {% block custom_map_tab_panels %} +
+
+ {% csrf_token %} + {% gizmo grid_type %} + {% gizmo update_grid_button %} +
+
+ {% endblock %} + +Now we'll need to add the gizmos for the form to your MapLayout class in ``controllers.py``. Add the following code: + +.. code-block:: python + :emphasize-lines: 1, 7-28 + + from tethys_sdk.gizmos import Button, SelectInput + ... + + class SecureMapServiceMapLayout(MapLayout): + ... + + def get_context(self, request, context, *args, **kwargs): + context = super().get_context(request, context, *args, **kwargs) + grid_type = SelectInput( + name="grid_type", + display_text="Select GRID Layer", + options=[ + ("Point Cloud", "pointcloud"), + ("Raster", "raster"), + ], + initial="pointcloud", + ) + + update_grid_button = Button( + name="update_grid", + display_text="Update GRID Layer", + submit=True, + ) + + context["grid_type"] = grid_type + context["update_grid_button"] = update_grid_button + + return context + +Now if you refresh your app, you should see a new tab on the left that you can switch to with a select input and a button. Right now if you click the "Update GRID Layer" button, nothing will happen. We'll need to add a ``post()`` method to your MapLayout class to handle the form submission and update the GRiD service parameters. + +To add that functionality, first add the following imports to the top of ``controllers.py``: + +.. code-block:: python + + from django.http import HttpResponse + from django.shortcuts import redirect + +Then add the following ``post()`` method to your MapLayout class: + +.. code-block:: python + :emphasize-lines: 4-16 + + class SecureMapServiceMapLayout(MapLayout): + ... + + def post(self, request, *args, **kwargs): + grid_type = request.POST.get("grid_type") + if grid_type not in ["pointcloud", "raster"]: + return HttpResponse("Invalid GRID layer type selected.", status=400) + + App.update_secure_map_service_params( + App.GRID_SECURE_MAP_SERVICE_NAME, + params={ + "typename": f"ms:gridws_{grid_type}", + }, + ) + + return redirect(request.path) + +Now go ahead and try selecting a different GRiD layer from the select input and clicking the "Update GRID Layer" button. The app will refresh and you should see the layer on the map update to reflect your selection. You can even go in and look at the service settings and see that the parameters have been updated to reflect your selection. + +8. Using a Secure Map Service as a Response +=========================================== + +You can access a SecureMapService as a response in order to work directly with the data returned from the service in your Python code. We'll be using this to retrieve spatial data from the GRiD API and format it into GeoJSON to display AOIs on the map. + +First, let's add a new SecureMapServiceSetting to your app class in ``app.py`` for the GRiD AOI service. This service will be used to both display existing AOIs on the map, and to submit new AOIs to the GRiD service. Add the following code to your app class: + +.. code-block:: python + :emphasize-lines: 18, 36-40 + + class App(TethysAppBase): + """ + Tethys app class for Secure Map App. + """ + name = 'Secure Map App' + description = '' + package = 'secure_map_tutorial' # WARNING: Do not change this value + index = 'home' + icon = f'{package}/images/icon.gif' + root_url = 'secure-map-app' + color = '#5f27cd' + tags = '' + enable_feedback = False + feedback_emails = [] + + GEGD_SECURE_MAP_SERVICE_NAME = "gegd_secure_map_service" + GRID_SECURE_MAP_SERVICE_NAME = 'grid_secure_map_service' + GRID_SECURE_AOI_MAP_SERVICE_NAME = "grid_secure_aoi_map_service" + + def secure_map_service_settings(self): + """ + Returns the settings for the secure map service. + """ + + secure_map_service_settings = ( + SecureMapServiceSetting( + name=self.GEGD_SECURE_MAP_SERVICE_NAME, + description="Secure Map Service for the app to use with GEGD", + required=True + ), + SecureMapServiceSetting( + name=self.GRID_SECURE_MAP_SERVICE_NAME, + description='Secure Map Service for app to use with GRiD', + required=True, + ), + SecureMapServiceSetting( + name=self.GRID_SECURE_AOI_MAP_SERVICE_NAME, + description='Secure Map Service for app to use with GRiD for interacting with AOIs', + required=True + ), + ) + + return secure_map_service_settings + +Use these configurations for the new Secure Map Service: + +- **Name:** GRiD AOI Secure Map Service +- **Endpoint:** https://grid.nga.mil/grid/api/v3/aois +- **Legend Title:** GRiD AOIs +- **Authentication Method:** OAuth +- **OAuth Provider:** grid +- **Service Type:** REST/JSON API +- **Use Proxy for Requests:** True +- **Parameters:** + +.. code-block:: json + + { + "intersections": "false", + "intersection_geoms": "false", + "export_full": "false", + "sort": "pk" + } + +Save your new Secure Map Service and assign it to the GRiD AOI Secure Map Service setting, then save your app settings. + +Next, you'll add a new layer to your MapLayout class in ``controllers.py`` that will display the existing AOIs on the map. + +For that you'll need to first add the following packages to the dependencies of your application. Open your ``install.yml`` and edit the requirements block like so: + +.. code-block:: yaml + :emphasize-lines: 6, 8 + + requirements: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - shapely + +Then you'll need to actually install the shapely package into your environment. You can do this by running the following command: + +.. code-block:: bash + + conda install -c conda-forge shapely + +Next, add the following imports to ``controllers.py``: + +.. code-block:: python + :emphasize-lines: 3, 6-7 + + from tethys_sdk.layouts import MapLayout + from tethys_sdk.routing import controller + from tethys_sdk.gizmos import SelectInput, Button, MVLayer + from django.http import HttpResponse + from django.shortcuts import redirect + from shapely import wkt + from shapely.geometry import mapping + from .app import App + +Now add the following helper function to ``controllers.py``. This function will help format the AOI data returned from the GRiD service into a GeoJSON format that can be used to create a new MVLayer: + +.. code-block:: python + + def format_aoi_geojson(raw_aoi_data): + features = [] + for aoi in raw_aoi_data.get("aois", []): + wkt_str = aoi.get("geom") + if not wkt_str: + continue + try: + geom = wkt.loads(wkt_str) + except Exception: + continue + features.append( + { + "type": "Feature", + "geometry": mapping(geom), + "properties": { + "pk": aoi.get("pk"), + "name": aoi.get("name"), + "user": aoi.get("user"), + "created_at": aoi.get("created_at"), + "area": aoi.get("area"), + "notes": aoi.get("notes"), + "subscribed": aoi.get("subscribed"), + "export_count": len(aoi.get("exports") or []), + }, + } + ) + + return { + "type": "FeatureCollection", + "crs": {"type": "name", "properties": {"name": "EPSG:4326"}}, + "features": features, + } + +Now update your ``compose_layers()`` method in your MapLayout class to create a new MVLayer for the AOIs: + +.. code-block:: python + :emphasize-lines: 8-12, 14, 16-34, 40 + + def compose_layers(self, request, map_view, *args, **kwargs): + grid_layer = App.get_secure_map_service( + App.GRID_SECURE_MAP_SERVICE_NAME, + as_layer=True, + request_user=request.user + ) + + grid_aoi_response = App.get_secure_map_service( + App.GRID_SECURE_AOI_MAP_SERVICE_NAME, + as_response=True, + request_user=request.user, + ).json() + + aois_geojson = format_aoi_geojson(grid_aoi_response) + + aoi_layer = MVLayer( + source='GeoJSON', + options=aois_geojson, + legend_title='GRiD AOIs', + layer_options={ + 'style': {'ol.style.Style': { + 'stroke': {'ol.style.Stroke': { + 'color': '#ff7800', + 'width': 2, + }}, + 'fill': {'ol.style.Fill': { + 'color': 'rgba(255,120,0,0.15)', + }}, + }}, + "visible": True, + }, + data={'layer_id': 'grid_aois', 'layer_name': 'grid_aois', "show_legend": True,}, + feature_selection=True, + ) + + layer_groups = [ + self.build_layer_group( + id='grid_layer_group', + display_name='GRiD Layer Group', + layers=[grid_layer, aoi_layer] + ) + ] + return layer_groups + +Go ahead and refresh your app and you should see the AOIs displayed on the map as a new layer. You can toggle the visibility of the AOI layer using the layers control in the top right corner of the map. + +9. Using a Secure Map Service as an Endpoint +============================================ + +The last feature we'll be adding to our app is the ability to use a SecureMapService as an endpoint that can be used to make requests to the service from your app. You'll be using the GRiD service for this example, allowing you to draw AOIs on the map and submitting them to the GRiD service to create new AOIs using the GRiD REST API. + +We'll be adding a new form to the custom map tab that will allow the user to set a name for their new AOI. + +First, we'll need to make some updates to your ``controllers.py`` file. + +To start, update your imports: + +.. code-block:: python + :emphasize-lines: 8 + + from tethys_sdk.layouts import MapLayout + from tethys_sdk.routing import controller + from tethys_sdk.gizmos import SelectInput, Button, MVLayer, MVDraw, TextInput + from django.http import HttpResponse + from django.shortcuts import redirect + from shapely import wkt + from shapely.geometry import mapping + from .app import App + +Next, you need to add drawing capabilities to your map so that users can draw AOIs on the map that they would like to submit to the GRiD service. We'll be using the MVDraw gizmo for this. + +Begin by adding the MVDraw gizmo to your class: + +.. code-block:: python + :emphasize-lines: 13-17 + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + app = App + base_template = f'{App.package}/base.html' + template_name = f'{App.package}/home.html' + map_title = 'Secure Map Services Tutorial' + basemaps = [ + {"WMS": { + "url": App.get_secure_map_service(App.GEGD_SECURE_MAP_SERVICE_NAME, as_endpoint=True), + "control_label": "GEGD Map" + }} + ] + draw = MVDraw( + controls=["Modify", "Delete", "Move", "Polygon", "Box"], + initial="Move", + output_format="WKT", + ) + + +Next, let's add the gizmos you'll need for your new AOI form. Add the following code to your ``get_context`` method in ``controllers.py``: + +.. code-block:: python + :emphasize-lines: 19-23, 25-27, 29-34, 38-39 + + def get_context(self, request, context, *args, **kwargs): + context = super().get_context(request, context, *args, **kwargs) + grid_type = SelectInput( + name="grid_type", + display_text="Select GRID Layer", + options=[ + ("Point Cloud", "pointcloud"), + ("Raster", "raster"), + ], + initial="pointcloud", + ) + + update_grid_button = Button( + name="update_grid", + display_text="Update GRID Layer", + submit=True, + ) + + aoi_name = TextInput( + name="aoi_name", + display_text="AOI Name", + placeholder="Enter AOI Name", + ) + + aoi_proxy_endpoint = App.get_secure_map_service( + App.GRID_SECURE_AOI_MAP_SERVICE_NAME, as_endpoint=True + ) + + create_aoi_button = Button( + name="create_aoi", + display_text="Create AOI", + submit=False, + attributes= {"data-proxy-url": aoi_proxy_endpoint, "id": "create-aoi-button"} + ) + + context["grid_type"] = grid_type + context["update_grid_button"] = update_grid_button + context["aoi_name"] = aoi_name + context["create_aoi_button"] = create_aoi_button + + return context + +Now you need to add the new gizmos to a form in ``home.html``. + +.. code-block:: html+django + :emphasize-lines: 8-12 + + {% block custom_map_tab_panels %} +
+
+ {% csrf_token %} + {% gizmo grid_type %} + {% gizmo update_grid_button %} +
+
+ {% csrf_token %} + {% gizmo aoi_name %} + {% gizmo create_aoi_button %} +
+
+ {% endblock %} + +Next, we need to add some custom JavaScript to handle the AOI form submission and send the request. + +Open the ``public/js`` folder and create a new file named ``aoi.js`` and add the following code: + +.. code-block:: javascript + + $(document).ready(function() { + $("#create-aoi-button").click(function() { + var aoi_name = $("#aoi_name").val(); + var geometry = JSON.parse($("#map_view_geometry").val()).geometries[0].wkt; + + var proxyUrl = $(this).data("proxy-url"); + $.ajax({ + url: proxyUrl, + type: "POST", + data: { + name: aoi_name, + geom: geometry + }, + headers: { + "X-CSRFToken": $("input[name='csrfmiddlewaretoken']").val() + }, + success: function(response) { + console.log("AOI created successfully:", response); + }, + error: function(xhr, status, error) { + console.error("Error creating AOI:", error); + } + }) + }) + }); + +Now just include the new JavaScript file in your ``home.html`` file by adding this code block: + +.. code-block:: html+django + + {% block scripts %} + {{ block.super }} + + {% endblock %} + +Now just refresh the page and you should be able to draw an AOI on the map, enter a name for it, and click the "Create AOI" button to submit it to the GRiD service. You can check your GRiD account to see if the new AOI was created successfully, or just refresh the page and the AOI should be there with the other already existing AOIs. + +That's it! You've now successfully created a Tethys app that uses Secure Map Services to display data from GEGD and GRiD, and allows users to create new AOIs on the map using the GRiD service. + +For more information, see the :ref:`Secure Map Services API Documentation `. + + +10. Solution +============ +This concludes the tutorial. You can view the solution code for this tutorial in the `tethysapp-secure_map_tutorial `__ repository on GitHub. You can clone the repository using the following command: + +.. code-block:: bash + + git clone https://github.com/tethysplatform/tethysapp-secure_map_tutorial diff --git a/environment.yml b/environment.yml index 3f1ea2dc5f..09277f9b27 100644 --- a/environment.yml +++ b/environment.yml @@ -70,6 +70,7 @@ dependencies: - django-json-widget # enable json widget for app settings - djangorestframework # enable REST API framework - djangorestframework_simplejwt # JWT authentication for REST API + - django-fernet-encrypted-fields # enable encrypted fields for models # Map Layout - PyShp diff --git a/pyproject.toml b/pyproject.toml index d1c551c263..571a711aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,10 +43,13 @@ dependencies = [ "django-model-utils", "django-guardian", "djangorestframework_simplejwt", + "django-fernet-encrypted-fields", ] [project.optional-dependencies] -test = ["pytest", "pytest-django", "pytest-cov", "requests_mock"] +# pytest-django 4.13 dropped Django 4.2 support. +# Unpin once we drop support for Django 4.2 in Tethys. +test = ["pytest", "pytest-django<4.13", "pytest-cov", "requests_mock"] lint = ["flake8", "black"] [project.urls] diff --git a/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py b/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py index 04bde99726..84588c18da 100644 --- a/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py +++ b/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py @@ -7,6 +7,7 @@ PersistentStoreConnectionSetting, DatasetServiceSetting, SpatialDatasetServiceSetting, + SecureMapServiceSetting, WebProcessingServiceSetting, SchedulerSetting, ) @@ -178,6 +179,20 @@ def spatial_dataset_service_settings(self): return sds_settings + def secure_map_service_settings(self): + """ + Example secure_map_service_settings method. + """ + secure_map_service_settings = ( + SecureMapServiceSetting( + name="secure_map_service", + description="Secure map service for app to use", + required=True, + ), + ) + + return secure_map_service_settings + def scheduler_settings(self): """ Example scheduler_settings method. diff --git a/tests/unit_tests/test_tethys_apps/test_admin.py b/tests/unit_tests/test_tethys_apps/test_admin.py index ac92323757..c9fd281f47 100644 --- a/tests/unit_tests/test_tethys_apps/test_admin.py +++ b/tests/unit_tests/test_tethys_apps/test_admin.py @@ -13,6 +13,7 @@ JSONCustomSettingInline, DatasetServiceSettingInline, SpatialDatasetServiceSettingInline, + SecureMapServiceSettingInline, WebProcessingServiceSettingInline, PersistentStoreConnectionSettingInline, PersistentStoreConnectionSettingForm, @@ -514,6 +515,7 @@ def test_TethysAppAdmin(self): PersistentStoreDatabaseSettingInline, DatasetServiceSettingInline, SpatialDatasetServiceSettingInline, + SecureMapServiceSettingInline, WebProcessingServiceSettingInline, SchedulerSettingInline, TethysAppQuotasSettingInline, diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index b486721bf8..f0e0339f79 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -1424,6 +1424,87 @@ def test_persistent_store_exists_object_does_not_exist(self, mock_ta, mock_ite): # Check if result False self.assertFalse(result) + @mock.patch("tethys_apps.base.app_base.TethysAppBase._resolve_secure_map_service") + def test_get_secure_map_service(self, mock_rms): + result = TethysAppChild.get_secure_map_service(name=self.fake_name) + mock_rms.assert_called_once_with( + self.fake_name, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None, + ) + self.assertEqual(mock_rms(), result) + + @mock.patch("tethys_apps.base.app_base.TethysAppBase._resolve_secure_map_service") + def test_get_secure_map_service_as_endpoint(self, mock_rms): + mock_rms.return_value = "/apps/test-app/map-service/fake_name/" + result = TethysAppChild.get_secure_map_service( + name=self.fake_name, as_endpoint=True + ) + mock_rms.assert_not_called() + self.assertEqual("/apps/test-app/map-service/fake_name/", str(result)) + mock_rms.assert_called_once_with( + self.fake_name, + as_endpoint=True, + param_overrides=None, + ) + + @mock.patch("tethys_apps.models.TethysApp") + def test__resolve_secure_map_service(self, mock_ta): + mock_get_value = ( + mock_ta.objects.get().secure_map_service_settings.get().get_value + ) + mock_get_value.return_value = "test_secure_map_service" + + result = TethysAppChild._resolve_secure_map_service(name=self.fake_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + mock_ta.objects.get().secure_map_service_settings.get.assert_called_with( + name=self.fake_name + ) + self.assertEqual("test_secure_map_service", result) + + @mock.patch("tethys_apps.models.TethysApp") + def test__resolve_secure_map_service_object_does_not_exist(self, mock_ta): + mock_get = mock_ta.objects.get().secure_map_service_settings.get + mock_get.side_effect = ObjectDoesNotExist + + self.assertRaises( + TethysAppSettingDoesNotExist, + TethysAppChild._resolve_secure_map_service, + name=self.fake_name, + ) + + @mock.patch("tethys_apps.models.SecureMapServiceSetting.update_params") + @mock.patch("tethys_apps.models.TethysApp") + def test_update_secure_map_service_params(self, mock_ta, mock_update_params): + mock_setting = ( + mock_ta.objects.get().secure_map_service_settings.get.return_value + ) + + fake_params = {"param1": "value1"} + TethysAppChild.update_secure_map_service_params( + name=self.fake_name, params=fake_params + ) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + mock_ta.objects.get().secure_map_service_settings.get.assert_called_with( + name=self.fake_name + ) + mock_setting.update_params.assert_called_with(fake_params) + + @mock.patch("tethys_apps.models.TethysApp") + def test_update_secure_map_service_params_object_does_not_exist(self, mock_ta): + mock_get = mock_ta.objects.get().secure_map_service_settings.get + mock_get.side_effect = ObjectDoesNotExist + + self.assertRaises( + TethysAppSettingDoesNotExist, + TethysAppChild.update_secure_map_service_params, + name=self.fake_name, + params={"param1": "value1"}, + ) + @mock.patch("tethys_apps.base.app_base.files") @mock.patch("tethys_apps.base.app_base.TethysPath") @mock.patch("tethys_apps.base.app_base.sync_cookies_from_yaml") @@ -1472,7 +1553,7 @@ def test_sync_with_tethys_db( self.assertTrue(mock_ta().save.call_count == 2) # Check if add_settings is called 6 times - self.assertTrue(mock_ta().sync_settings.call_count == 6) + self.assertTrue(mock_ta().sync_settings.call_count == 7) mock_sync_cookies.assert_called_once_with( mock_path().path.__truediv__(), "p", "n" ) @@ -1492,7 +1573,7 @@ def test_sync_with_tethys_db_in_db(self, mock_ta, mock_ds): self.assertTrue(mock_app.save.call_count == 2) # Check if add_settings is called 6 times - self.assertTrue(mock_app.sync_settings.call_count == 6) + self.assertTrue(mock_app.sync_settings.call_count == 7) @mock.patch("django.conf.settings") @mock.patch("tethys_apps.models.TethysApp") diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py new file mode 100644 index 0000000000..37d7c41f70 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py @@ -0,0 +1,662 @@ +import requests +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, SecureMapServiceSetting +from tethys_sdk.gizmos import MVLayer +from tethys_services.models import SecureMapService +from django.core.exceptions import ValidationError +from tethys_apps.exceptions import TethysAppSettingNotAssigned +from django.utils.functional import Promise +from django.utils.encoding import force_str +from unittest import mock + + +class SecureMapServiceSettingTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package="test_app") + self.test_user = self.create_test_user( + username="testuser", password="testpassword" + ) + + self.params_with_api_key = {"param1": "value1", "api_key": "${api_key}"} + self.params_without_api_key = {"param1": "value1"} + + self.map_service_with_api_key_no_proxy = SecureMapService( + name="api_key_no_proxy", + legend_title="Map Service with API Token No Proxy", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + service_type="WMS", + params=self.params_with_api_key, + use_proxy=False, + ) + self.map_service_with_api_key_no_proxy.save() + + self.map_service_without_api_key_param_no_proxy = SecureMapService( + name="no_api_key_params_no_proxy", + legend_title="Map Service without API Token Param No Proxy", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + service_type="WMS", + params=self.params_without_api_key, + use_proxy=False, + ) + self.map_service_without_api_key_param_no_proxy.save() + + self.map_service_with_api_key_with_proxy = SecureMapService( + name="api_key_with_proxy", + legend_title="Map Service with API Token With Proxy", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + service_type="GML", + params=self.params_with_api_key, + use_proxy=True, + ) + self.map_service_with_api_key_with_proxy.save() + + self.map_service_with_oauth_no_proxy = SecureMapService( + name="oauth_no_proxy", + legend_title="Map Service with OAuth No Proxy", + endpoint="https://example.com/map_service", + authentication_method="oauth", + oauth_provider="test_oauth_provider", + service_type="GML", + params=self.params_without_api_key, + use_proxy=False, + ) + self.map_service_with_oauth_no_proxy.save() + + self.map_service_with_oauth_with_proxy = SecureMapService( + name="oauth_with_proxy", + legend_title="Map Service with OAuth With Proxy", + endpoint="https://example.com/map_service", + authentication_method="oauth", + oauth_provider="test_oauth_provider", + service_type="WMS", + params=self.params_without_api_key, + use_proxy=True, + ) + self.map_service_with_oauth_with_proxy.save() + + def tear_down(self): + self.map_service_with_api_key_no_proxy.delete() + self.map_service_with_api_key_with_proxy.delete() + self.map_service_without_api_key_param_no_proxy.delete() + self.map_service_with_oauth_no_proxy.delete() + self.map_service_with_oauth_with_proxy.delete() + + def test_clean_validation_error(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = None + setting.save() + self.assertRaises( + ValidationError, + self.test_app.settings_set.select_subclasses() + .get(name="secure_map_service") + .clean, + ) + + def test__generate_request_none(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request, + ) + + def test__generate_request_with_proxy(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_with_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request() + + self.assertIsInstance(url, Promise) + self.assertEqual( + f"/secure-map-proxy/{setting.secure_map_service.pk}/", force_str(url) + ) + + def test__generate_request_without_proxy_with_api_key(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request() + + self.assertEqual( + url, "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test__generate_request_without_proxy_without_api_key_param(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_without_api_key_param_no_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request() + + self.assertEqual( + url, "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test__generate_request_param_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request( + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual( + url, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + + def test__build_layer_none(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer, + ) + + def test__build_layer_with_api_key(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer() + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + + def test__build_layer_without_api_key_param(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_without_api_key_param_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer() + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + + def test__build_layer_with_proxy(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_with_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer() + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertIsInstance(layer["options"]["url"], Promise) + self.assertEqual( + force_str(layer["options"]["url"]), + f"/secure-map-proxy/{setting.secure_map_service.pk}/", + ) + + def test__build_layer_param_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer( + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + + @mock.patch( + "tethys_services.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) + def test__build_Layer_oauth(self, mock_got): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer(request_user=self.test_user) + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], "https://example.com/map_service?param1=value1" + ) + self.assertEqual(layer["options"]["token"], "test_oauth_token") + + def test__build_Layer_oauth_no_request_user(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + self.assertRaises( + ValueError, + SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer, + request_user=None, + ) + + def test__fetch_response_none(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response, + ) + + def test__fetch_response_no_request_user(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + self.assertRaises( + ValueError, + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response, + request_user=None, + ) + + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_api_key(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response() + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + self.assertEqual(response.status_code, 200) + + @mock.patch( + "tethys_apps.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_oauth_header(self, mock_get, mock_got): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response(request_user=self.test_user) + + mock_got.assert_called_once_with(self.test_user) + + self.assertEqual(response.url, "https://example.com/map_service?param1=value1") + self.assertEqual(response.status_code, 200) + + @mock.patch("tethys_apps.models.log") + @mock.patch( + "tethys_apps.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_oauth_response_not_ok( + self, mock_get, mock_got, mock_log + ): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=403, + ok=False, + url="https://example.com/map_service?param1=value1", + text="Forbidden access message", + raise_for_status=mock.MagicMock( + side_effect=requests.HTTPError("403 Forbidden") + ), + ) + mock_get.return_value = mock_response + + with self.assertRaises(requests.HTTPError): + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response(request_user=self.test_user) + + mock_got.assert_called_once_with(self.test_user) + mock_log.error.assert_called_once() + logged = mock_log.error.call_args.args[0] + self.assertIn( + f"SecureMapService with name {setting.secure_map_service.name} request failed", + logged, + ) + self.assertIn("status_code: 403", logged) + self.assertIn("Forbidden access message", logged) + + @mock.patch( + "tethys_apps.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_oauth_good_response(self, mock_get, mock_got): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response(request_user=self.test_user) + + mock_got.assert_called_once_with(self.test_user) + + self.assertEqual(response, mock_response) + self.assertEqual(response.status_code, 200) + + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_api_key_with_overrides(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response( + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + self.assertEqual(response.status_code, 200) + + def test_get_value_none(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get(name="secure_map_service").get_value, + ) + + def test_get_value_as_endpoint(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + endpoint = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value(as_endpoint=True) + + self.assertEqual( + endpoint, + "https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + + def test_get_value_as_endpoint_with_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + endpoint = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value( + as_endpoint=True, + param_overrides={"param1": "overridden_value", "param2": "value2"}, + ) + + self.assertEqual( + endpoint, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + + def test_get_value_as_layer(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value(as_layer=True) + + self.assertIsInstance(layer, MVLayer) + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + + def test_get_value_as_layer_with_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value( + as_layer=True, + param_overrides={"param1": "overridden_value", "param2": "value2"}, + ) + + self.assertIsInstance(layer, MVLayer) + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + + @mock.patch("tethys_apps.models.requests.get") + def test_get_value_as_response(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value(as_response=True) + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + self.assertEqual(response.status_code, 200) + + @mock.patch("tethys_apps.models.requests.get") + def test_get_value_as_response_with_overrides(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value( + as_response=True, + param_overrides={"param1": "overridden_value", "param2": "value2"}, + ) + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + self.assertEqual(response.status_code, 200) + + def test_get_value_get_service(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + service = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value() + + self.assertEqual(service, setting.secure_map_service) + + def test_update_params_none(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).update_params, + {"param1": "new_value"}, + ) + + def test_update_params(self): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + new_params = {"param1": "new_value", "param3": "value3"} + SecureMapServiceSetting.objects.get(name="secure_map_service").update_params( + new_params + ) + + updated_service = SecureMapService.objects.get(pk=setting.secure_map_service.pk) + expected_params = { + "param1": "new_value", + "api_key": "${api_key}", + "param3": "value3", + } + self.assertEqual(updated_service.params, expected_params) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py index 034d5bac94..7b446d01d7 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py @@ -19,6 +19,7 @@ SpatialDatasetService, DatasetService, WebProcessingService, + SecureMapService, ) @@ -66,6 +67,15 @@ def set_up(self): ) self.ss.save() + self.ms = SecureMapService( + name="test_ms", + legend_title="Test Map Service", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + ) + self.ms.save() + def tear_down(self): self.wps.delete() self.ps.delete() @@ -156,7 +166,7 @@ def test_sync_settings_remove_only_setting(self): def test_settings_prop(self): ret = self.test_app.settings - self.assertEqual(21, len(ret)) + self.assertEqual(22, len(ret)) for r in ret: self.assertIsInstance(r, TethysAppSetting) @@ -352,6 +362,12 @@ def test_configured_prop_required_and_set(self): ps_db_setting.persistent_store_service = self.ps ps_db_setting.save() + secure_map_setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + secure_map_setting.secure_map_service = self.ms + secure_map_setting.save() + ret = self.test_app.configured self.assertTrue(ret) diff --git a/tests/unit_tests/test_tethys_apps/test_views.py b/tests/unit_tests/test_tethys_apps/test_views.py index a15be38317..47d8e705b9 100644 --- a/tests/unit_tests/test_tethys_apps/test_views.py +++ b/tests/unit_tests/test_tethys_apps/test_views.py @@ -3,11 +3,13 @@ from unittest import mock from tethys_apps.models import ProxyApp, TethysApp +from tethys_services.models import SecureMapService from tethys_apps.views import ( library, handoff_capabilities, handoff, send_beta_feedback_email, + secure_map_proxy, ) @@ -291,3 +293,166 @@ def test_send_beta_feedback_email_send_mail_exception( mock_json_response.assert_called_once_with( {"success": False, "error": "Failed to send email: foo_error"} ) + + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_noneexistent_service(self, mock_get): + mock_request = mock.MagicMock() + mock_setting_id = 9999 + mock_get.side_effect = SecureMapService.DoesNotExist + + ret = secure_map_proxy(mock_request, mock_setting_id) + + self.assertEqual(404, ret.status_code) + self.assertEqual(b"Service setting not found.", ret.content) + + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_no_auth_token(self, mock_get): + mock_request = mock.MagicMock() + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.get_oauth_token.return_value = None + mock_get.return_value = mock_service + + ret = secure_map_proxy(mock_request, mock_setting_id) + + assert ret.status_code == 500 + assert ret.content == b"Failed to retrieve OAuth token." + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_empty_304(self, mock_get, mock_request_func): + mock_request = mock.MagicMock() + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.get_oauth_token.return_value = "test_oauth_token123" + mock_get.return_value = mock_service + + mock_request_func.return_value = mock.MagicMock(status_code=304, content=b"") + + ret = secure_map_proxy(mock_request, mock_setting_id) + + assert ret.status_code == 304 + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_oauth_no_extra_headers(self, mock_get, mock_request_func): + mock_request = mock.MagicMock(method="POST", body=b"test_body") + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.endpoint = "http://example.com/service" + mock_service.get_oauth_token.return_value = "test_oauth_token123" + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["headers"]["Authorization"] == "Bearer test_oauth_token123" + assert kwargs["data"] == mock_request.body + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_api_key_no_extra_headers( + self, mock_get, mock_request_func + ): + mock_request = mock.MagicMock(method="GET", body=None) + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "api_key" + mock_service.endpoint = "http://example.com/service" + mock_service.get_resolved_params.return_value = {"api_key": "test_api_key"} + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["params"]["api_key"] == "test_api_key" + assert kwargs["data"] is None + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_oauth_with_extra_headers( + self, mock_get, mock_request_func + ): + mock_request = mock.MagicMock(method="GET", body=None) + mock_request.headers = { + "If-None-Match": "test_value", + "If-Modified-Since": "test_date", + } + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.endpoint = "http://example.com/service" + mock_service.get_oauth_token.return_value = "test_oauth_token123" + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_response.headers = { + "Cache-Control": "max-age=3600", + "Expires": "test_expire_date", + } + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["headers"]["Authorization"] == "Bearer test_oauth_token123" + assert kwargs["headers"]["If-None-Match"] == "test_value" + assert kwargs["headers"]["If-Modified-Since"] == "test_date" + assert kwargs["data"] is None + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" + assert ret.headers["Cache-Control"] == "max-age=3600" + assert ret.headers["Expires"] == "test_expire_date" + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_api_key_content_type(self, mock_get, mock_request_func): + mock_request = mock.MagicMock(method="POST", body=b"test_body") + mock_request.content_type = "application/json" + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "api_key" + mock_service.endpoint = "http://example.com/service" + mock_service.get_resolved_params.return_value = {"api_key": "test_api_key"} + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["params"]["api_key"] == "test_api_key" + assert kwargs["headers"]["Content-Type"] == "application/json" + assert kwargs["data"] == b"test_body" + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" diff --git a/tests/unit_tests/test_tethys_cli/test_settings_commands.py b/tests/unit_tests/test_tethys_cli/test_settings_commands.py index 10e17d8d80..07c7b44924 100644 --- a/tests/unit_tests/test_tethys_cli/test_settings_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_settings_commands.py @@ -127,3 +127,119 @@ def test_settings_command_rm(self, _, mock_remove_setting): mock_args = mock.MagicMock(set_kwargs=None, get_key=None, rm_key=rm_key) cmds.settings_command(mock_args) mock_remove_setting.assert_called_with({}, rm_key[0]) + + @mock.patch("tethys_cli.settings_commands.generate_salt_key_setting") + @mock.patch( + "tethys_cli.settings_commands.read_settings", + return_value={"SALT_KEY": "existing_salt_key"}, + ) + def test_settings_command_gsk(self, _, mock_gsk): + mock_args = mock.MagicMock( + set_kwargs=None, + get_key=None, + rm_key=None, + generate_salt_key=True, + overwrite=True, + ) + cmds.settings_command(mock_args) + mock_gsk.assert_called_with({"SALT_KEY": "existing_salt_key"}, True) + + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_no_salt_key( + self, mock_write_settings, mock_write_success, mock_gsk + ): + tethys_settings = {} + mock_gsk.return_value = "mock_salt_key123" + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_existing_salt_key_overwrite_true( + self, mock_write_settings, mock_write_success, mock_gsk + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_gsk.return_value = "mock_salt_key123" + cmds.generate_salt_key_setting(tethys_settings, overwrite=True) + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch( + "tethys_cli.settings_commands.write_warning" + ) # Mocked to avoid actual warning output during test + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_existing_salt_key_invalid_then_yes( + self, mock_write_settings, mock_ww, mock_write_success, mock_gsk, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_gsk.return_value = "mock_salt_key123" + mock_input.side_effect = ["invalid", "maybe", "y"] + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_input.assert_has_calls( + [ + mock.call("Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + ] + ) + self.assertEqual(mock_input.call_count, 3) + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.write_settings") + @mock.patch("tethys_cli.settings_commands.write_warning") + def test_generate_salt_key_setting_existing_salt_key_no( + self, mock_ww, mock_ws, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_input.return_value = "n" + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_ww.assert_called_with("Generation of SALT_KEY cancelled.") + mock_input.assert_called_once_with("Overwrite? (y/n): ") + mock_ws.assert_not_called() + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch( + "tethys_cli.settings_commands.write_warning" + ) # Mocked to avoid actual warning output during test + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_existing_salt_key_yes( + self, mock_write_settings, mock_ww, mock_write_success, mock_gsk, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_gsk.return_value = "mock_salt_key123" + mock_input.return_value = "y" + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_input.assert_called_once_with("Overwrite? (y/n): ") + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.write_settings") + @mock.patch("tethys_cli.settings_commands.write_warning") + def test_generate_salt_key_setting_existing_salt_key_invalid_then_no( + self, mock_ww, mock_ws, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_input.side_effect = ["invalid", "maybe", "n"] + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_input.assert_has_calls( + [ + mock.call("Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + ] + ) + self.assertEqual(mock_input.call_count, 3) + mock_ww.assert_called_with("Generation of SALT_KEY cancelled.") + mock_ws.assert_not_called() diff --git a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py index 2d847478fd..4eb5fdd05c 100644 --- a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py +++ b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py @@ -6,6 +6,8 @@ from django.template import base from django.template import TemplateSyntaxError from django.template import Context +from django.utils.functional import Promise +from django.utils.translation import gettext_lazy from importlib import reload from pathlib import Path import sys @@ -134,19 +136,27 @@ def test_return_item_none(self): # Check Result self.assertFalse(result) - def test_json_date_handler(self): - result = gizmos_templatetags.json_date_handler(datetime(2018, 1, 1)) + def test_json_data_handler(self): + result = gizmos_templatetags.json_data_handler(datetime(2018, 1, 1)) # Timestamp should be 1514764800 expected = 1514790000000.0 if sys.platform == "win32" else 1514764800000.0 self.assertEqual(expected, result) - def test_json_date_handler_no_datetime(self): - result = gizmos_templatetags.json_date_handler("2018") + def test_json_data_handler_no_datetime(self): + result = gizmos_templatetags.json_data_handler("2018") # Check Result self.assertEqual("2018", result) + def test_json_data_handler_promise(self): + promise = gettext_lazy("test") + result = gizmos_templatetags.json_data_handler(promise) + + self.assertNotIsInstance(result, Promise) + self.assertIsInstance(result, str) + self.assertEqual(result, "test") + def test_jsonify(self): data = ["foo", {"bar": ("baz", None, 1.0, 2)}] diff --git a/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py b/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py index 47998e75ab..9132bbcfac 100644 --- a/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py +++ b/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py @@ -1271,6 +1271,40 @@ class CustomMapLayoutThing(MapLayoutMixin): }, ) + def test_build_gml_layer_default(self): + class CustomMapLayoutThing(MapLayoutMixin): + map_extent = [-65.69, 23.81, -129.17, 49.38] + + ret = CustomMapLayoutThing.build_gml_layer( + endpoint="http://example.com/geoserver/wfs", + layer_name="foo:bar", + layer_title="Foo Bar", + layer_variable="baz", + ) + + self.assertIsInstance(ret, MVLayer) + self.assertEqual(ret.source, "GML") + self.assertEqual(ret.legend_title, "Foo Bar") + self.assertDictEqual( + ret.layer_options, + {"visible": True, "show_download": False}, + ) + self.assertDictEqual( + ret.data, + { + "layer_id": "foo:bar", + "layer_name": "foo:bar", + "popup_title": "Foo Bar", + "layer_variable": "baz", + "toggle_status": True, + "excluded_properties": ["id", "type", "layer_name"], + "removable": False, + "renamable": False, + "show_legend": True, + "legend_url": None, + }, + ) + def test_build_custom_layer_geoserver_wms(self): class CustomMapLayoutThing(MapLayoutMixin): map_extent = [-65.69, 23.81, -129.17, 49.38] diff --git a/tests/unit_tests/test_tethys_portal/test_middleware.py b/tests/unit_tests/test_tethys_portal/test_middleware.py index f99fbbdc37..55f44bdbce 100644 --- a/tests/unit_tests/test_tethys_portal/test_middleware.py +++ b/tests/unit_tests/test_tethys_portal/test_middleware.py @@ -5,6 +5,7 @@ TethysSocialAuthExceptionMiddleware, TethysAppAccessMiddleware, TethysMfaRequiredMiddleware, + TethysOauthRequiredMiddleware, ) from django.core.exceptions import PermissionDenied @@ -843,3 +844,114 @@ def test_mfa_required_all_true__invalid_token__staff_user( # required for all users mock_redirect.assert_called_once_with("mfa_home") + + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_init(self, mock_settings): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_key": "test_value"} + obj = TethysOauthRequiredMiddleware(mock_get_response) + self.assertEqual(obj.get_response, mock_get_response) + self.assertEqual(obj.requirements, mock_settings.OAUTH_REQUIREMENTS) + + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_requirements(self, mock_settings): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {} + mock_request = mock.MagicMock() + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_active_app(self, mock_settings, mock_gap): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_key": "test_value"} + mock_gap.return_value = None + mock_request = mock.MagicMock() + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_requirements_for_app(self, mock_settings, mock_gap): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_key": "test_value"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_has_provider(self, mock_settings, mock_gap): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_package": "test_value"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + mock_request.user.social_auth.filter.return_value.exists.return_value = True + + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + mock_request.user.social_auth.filter.assert_called_once_with( + provider="test_value" + ) + + @mock.patch("tethys_portal.middleware.urlencode") + @mock.patch("tethys_portal.middleware.reverse") + @mock.patch("tethys_portal.middleware.messages.info") + @mock.patch("tethys_portal.middleware.redirect") + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_provider( + self, + mock_settings, + mock_gap, + mock_redirect, + mock_messages, + mock_reverse, + mock_urlencode, + ): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_package": "test_provider"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + mock_request.get_full_path.return_value = "/apps/test_package/test_path" + mock_request.user.social_auth.filter.return_value.exists.return_value = False + mock_reverse.return_value = "/user/settings/" + mock_urlencode.return_value = "next=/apps/test_package/test_path" + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_messages.assert_called_once_with( + mock_request, + "This application requires authenticating with test_provider. Please link your test_provider account.", + ) + mock_reverse.assert_called_once_with("user:settings") + mock_redirect.assert_called_once_with( + f"{mock_reverse.return_value}?next=/apps/test_package/test_path" + ) + + @mock.patch("tethys_portal.middleware.urlencode") + @mock.patch("tethys_portal.middleware.reverse") + @mock.patch("tethys_portal.middleware.redirect") + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_user_not_authenticated( + self, + mock_settings, + mock_gap, + mock_redirect, + mock_reverse, + mock_urlencode, + ): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_package": "test_provider"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + mock_request.get_full_path.return_value = "/apps/test_package/test_path" + mock_request.user.is_authenticated = False + mock_reverse.return_value = "/accounts/login/" + mock_urlencode.return_value = "next=/apps/test_package/test_path" + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_reverse.assert_called_once_with("accounts:login") + mock_redirect.assert_called_once_with( + f"{mock_reverse.return_value}?next=/apps/test_package/test_path" + ) diff --git a/tests/unit_tests/test_tethys_portal/test_urls.py b/tests/unit_tests/test_tethys_portal/test_urls.py index c7a252987c..cfacdd0794 100644 --- a/tests/unit_tests/test_tethys_portal/test_urls.py +++ b/tests/unit_tests/test_tethys_portal/test_urls.py @@ -164,6 +164,12 @@ def test_urlpatterns_update_dask_job_status(self): resolver._func_path, ) + def test_urlpatterns_secure_map_proxy(self): + url = reverse("secure_map_proxy", kwargs={"setting_id": "15"}) + resolver = resolve(url) + self.assertEqual("/secure-map-proxy/15/", url) + self.assertEqual("tethys_apps.views.secure_map_proxy", resolver._func_path) + @override_settings(REGISTER_CONTROLLER="test") @mock.patch("django.urls.re_path") @mock.patch("tethys_apps.base.function_extractor.TethysFunctionExtractor") diff --git a/tests/unit_tests/test_tethys_services/test_admin.py b/tests/unit_tests/test_tethys_services/test_admin.py index 6ed0e78491..607c10b18a 100644 --- a/tests/unit_tests/test_tethys_services/test_admin.py +++ b/tests/unit_tests/test_tethys_services/test_admin.py @@ -1,12 +1,14 @@ import unittest from unittest import mock +from django.test.utils import override_settings from django.utils.translation import gettext_lazy as _ from tethys_services.models import ( DatasetService, SpatialDatasetService, WebProcessingService, PostgresPersistentStoreService, + SecureMapService, ) from tethys_services.admin import ( DatasetServiceForm, @@ -17,6 +19,7 @@ SpatialDatasetServiceAdmin, WebProcessingServiceAdmin, PostgresPersistentStoreServiceAdmin, + SecureMapServiceForm, ) @@ -88,6 +91,50 @@ def test_PostgresPersistentStoreServiceForm(self): self.assertEqual(expected_fields, ret.Meta.fields) self.assertTrue("password" in ret.Meta.widgets) + @override_settings(AUTHENTICATION_BACKENDS=[]) + def test_SecureMapServiceForm_no_authentication_backends(self): + mock_args = mock.MagicMock() + + ret = SecureMapServiceForm(mock_args) + self.assertEqual(SecureMapService, ret.Meta.model) + self.assertEqual("__all__", ret.Meta.fields) + self.assertTrue("api_key" in ret.Meta.widgets) + + oauth_provider_field = ret.fields.get("oauth_provider") + self.assertEqual([], oauth_provider_field.choices) + + @override_settings(AUTHENTICATION_BACKENDS=["this_is_a_backend"]) + @mock.patch("tethys_services.admin.import_string") + def test_SecureMapServiceForm_with_authentication_backends_no_name(self, mock_is): + mock_args = mock.MagicMock() + # Mock an object withou a name attribute + mock_is.return_value = object() + + ret = SecureMapServiceForm(mock_args) + self.assertEqual(SecureMapService, ret.Meta.model) + self.assertEqual("__all__", ret.Meta.fields) + self.assertTrue("api_key" in ret.Meta.widgets) + + oauth_provider_field = ret.fields.get("oauth_provider") + self.assertEqual([], oauth_provider_field.choices) + + @override_settings(AUTHENTICATION_BACKENDS=["this_is_a_backend"]) + @mock.patch("tethys_services.admin.import_string") + def test_SecureMapServiceForm_with_authentication_backends(self, mock_is): + mock_args = mock.MagicMock() + mock_is.return_value = mock.MagicMock() + mock_is.return_value.name = "fake_backend_name" + + ret = SecureMapServiceForm(mock_args) + self.assertEqual(SecureMapService, ret.Meta.model) + self.assertEqual("__all__", ret.Meta.fields) + self.assertTrue("api_key" in ret.Meta.widgets) + + oauth_provider_field = ret.fields.get("oauth_provider") + self.assertEqual( + [("fake_backend_name", "fake_backend_name")], oauth_provider_field.choices + ) + def test_DatasetServiceAdmin(self): mock_args = mock.MagicMock() expected_fields = ( diff --git a/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py new file mode 100644 index 0000000000..83b39d13b5 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py @@ -0,0 +1,198 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_services.models import SecureMapService +from unittest import mock +from django.core.exceptions import ObjectDoesNotExist + + +class SecureMapServiceTests(TethysTestCase): + def test_str(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", endpoint="http://example.com" + ) + self.assertEqual("test_secure_map_service", str(secure_map_service)) + + def test_get_authentication_method_options(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", endpoint="http://example.com" + ) + + expected_options = ["api_key", "oauth"] + + actual_options = secure_map_service.get_authentication_method_options() + for option in expected_options: + self.assertIn(option, actual_options) + + def test_get_oauth_token_api_key(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + ) + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=None) + self.assertEqual( + str(context.exception), + "Authentication method must be 'oauth' to retrieve an OAuth token.", + ) + + def test_get_oauth_token_no_provider(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth", + ) + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=None) + self.assertEqual( + str(context.exception), + "OAuth provider must be specified to retrieve an OAuth token.", + ) + + def test_get_oauth_token_user_not_linked(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth", + oauth_provider="test_provider", + ) + + mock_user = mock.MagicMock() + mock_user.social_auth.get.side_effect = ObjectDoesNotExist + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=mock_user) + self.assertEqual(str(context.exception), "User not linked to test_provider.") + + def test_get_oauth_token_no_token(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth", + oauth_provider="test_provider", + ) + + mock_user = mock.MagicMock() + mock_user.social_auth.get.return_value.extra_data = {} + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=mock_user) + self.assertEqual(str(context.exception), "No access token found for user.") + + def test_get_oauth_token_success(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth", + oauth_provider="test_provider", + ) + + mock_user = mock.MagicMock() + mock_user.social_auth.get.return_value.extra_data = { + "access_token": "access_token12345" + } + + token = secure_map_service.get_oauth_token(user=mock_user) + self.assertEqual(token, "access_token12345") + + def test_get_resolved_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params=None, + ) + + result = secure_map_service.get_resolved_params() + self.assertEqual(result, {}) + + def test_get_resolved_params_with_api_key(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params={ + "param1": "value1", + "param2": "value2", + "test_api_key": "${api_key}", + }, + api_key="api_key_12345", + ) + + resolved_params = secure_map_service.get_resolved_params() + self.assertEqual( + resolved_params, + {"param1": "value1", "param2": "value2", "test_api_key": "api_key_12345"}, + ) + + def test_get_resolved_params_with_missing_api_key(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params={ + "param1": "value1", + "param2": "value2", + "test_api_key": "${api_key}", + }, + api_key=None, + ) + + resolved_params = secure_map_service.get_resolved_params() + self.assertEqual( + resolved_params, + {"param1": "value1", "param2": "value2", "test_api_key": ""}, + ) + + def test_get_resolved_params_with_multiple_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params={ + "param1": "value1", + "name": "${name}", + "test_api_key": "${api_key}", + }, + api_key="api_key_12345", + ) + + resolved_params = secure_map_service.get_resolved_params() + self.assertEqual( + resolved_params, + { + "param1": "value1", + "name": "test_secure_map_service", + "test_api_key": "api_key_12345", + }, + ) + + def test_update_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + params={"param1": "value1", "param2": "value2"}, + ) + secure_map_service.save() + + secure_map_service.update_params({"param2": "new_value", "param3": "value3"}) + + updated_service = SecureMapService.objects.get(pk=secure_map_service.pk) + self.assertEqual( + updated_service.params, + {"param1": "value1", "param2": "new_value", "param3": "value3"}, + ) + + def test_update_params_no_existing_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + params=None, + ) + secure_map_service.save() + + secure_map_service.update_params({"param1": "value1"}) + + updated_service = SecureMapService.objects.get(pk=secure_map_service.pk) + self.assertEqual(updated_service.params, {"param1": "value1"}) diff --git a/tethys_apps/admin.py b/tethys_apps/admin.py index 3a6b265f98..bbf93387c8 100644 --- a/tethys_apps/admin.py +++ b/tethys_apps/admin.py @@ -37,6 +37,7 @@ SpatialDatasetServiceSetting, WebProcessingServiceSetting, SchedulerSetting, + SecureMapServiceSetting, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, ProxyApp, @@ -299,6 +300,12 @@ def get_queryset(self, request): ) # Custom form for PersistentStoreDatabaseSetting +class SecureMapServiceSettingInline(TethysAppSettingInline): + readonly_fields = ("name", "description", "required") + fields = ("name", "description", "secure_map_service", "required") + model = SecureMapServiceSetting + + class TethysAppAdmin(GuardedModelAdmin): obj_perms_manage_template = "tethys_apps/guardian/extend_obj_perms_manage.html" readonly_fields = ( @@ -329,6 +336,7 @@ class TethysAppAdmin(GuardedModelAdmin): PersistentStoreDatabaseSettingInline, DatasetServiceSettingInline, SpatialDatasetServiceSettingInline, + SecureMapServiceSettingInline, WebProcessingServiceSettingInline, SchedulerSettingInline, TethysAppQuotasSettingInline, diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 6c502d1cdc..f0d63109d3 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -14,7 +14,7 @@ from django.db.utils import ProgrammingError from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.urls import re_path -from django.utils.functional import classproperty +from django.utils.functional import classproperty, lazy from django.shortcuts import render, redirect, reverse from django.template.loader import render_to_string @@ -846,6 +846,37 @@ def web_processing_service_settings(self): """ return None + def secure_map_service_settings(self): + """ + Override this method to define secure map service connections for use in your app. + + Returns: + iterable: A list or tuple of ``SecureMapServiceSetting`` objects. + + **Example:** + + :: + + from tethys_sdk.app_settings import SecureMapServiceSetting + + class MyFirstApp(TethysAppBase): + + def secure_map_service_settings(self): + \""" + Example secure_map_service_settings method. + \""" + secure_map_services = ( + SecureMapServiceSetting( + name='primary_secure_map_service', + description='Secure Map Service for app to use', + required=True, + ), + ) + + return secure_map_services + """ + return None + def scheduler_settings(self): """ Override this method to define HTCondor and Dask scheduler services for use in your app. @@ -1861,6 +1892,127 @@ def persistent_store_exists(cls, name): ps_database_setting.persistent_store_database_exists() return True + @classmethod + def get_secure_map_service( + cls, + name, + as_endpoint=False, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None, + ): + """ + Retrieves secure map service assigned to named SecureMapServiceSetting for the app. + + Args: + name(str): name of the SecureMapServiceSetting as defined in the app.py. + as_endpoint(bool): Returns endpoint url string if True, Defaults to False. + as_layer(bool): Returns GeoServerLayer object if True, Defaults to False. + as_response(bool): Returns requests.Response object if True, Defaults to False. + param_overrides(dict): Dictionary of parameters to override for the map service request. Defaults to None. + request_user(User): Django User object to use for the request. Defaults to None. + + Returns: + SecureMapService: SecureMapService assigned to setting. + + **NOTE:** When ``as_endpoint`` is True a lazy string is returned. This ensures the + endpoint reflects the current state of the map service's settings every time it is used. + + """ + if as_endpoint: + return lazy(cls._resolve_secure_map_service, str)( + name, + as_endpoint=True, + param_overrides=param_overrides, + ) + + return cls._resolve_secure_map_service( + name, + as_layer=as_layer, + as_response=as_response, + param_overrides=param_overrides, + request_user=request_user, + ) + + @classmethod + def _resolve_secure_map_service( + cls, + name, + as_endpoint=False, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None, + ): + """ + Resolve the named SecureMapServiceSetting. + + This function is kept seperate from ``get_secure_map_service`` to allow for lazy evaluation of the endpoint url. + + Args: + name(str): name of the SecureMapServiceSetting as defined in the app.py. + as_endpoint(bool): Returns endpoint url string if True, Defaults to False. + as_layer(bool): Returns GeoServerLayer object if True, Defaults to False. + as_response(bool): Returns requests.Response object if True, Defaults to False. + param_overrides(dict): Dictionary of parameters to override for the map service request. Defaults to None. + request_user(User): Django User object to use for the request. Defaults to None. + + Returns: + SecureMapService: when no 'as' option is specified + str: lazy endpoint url when ``as_endpoint`` is True + MVLayer: map layer when ``as_layer`` is True + requests.Response: response object when ``as_response`` is True + + """ + from tethys_apps.models import TethysApp + + db_app = TethysApp.objects.get(package=cls.package) + secure_map_service_settings = db_app.secure_map_service_settings + + try: + secure_map_service_setting = secure_map_service_settings.get(name=name) + + except ObjectDoesNotExist: + raise TethysAppSettingDoesNotExist( + "SecureMapServiceSetting", name, cls.name + ) + + return secure_map_service_setting.get_value( + as_endpoint=as_endpoint, + as_layer=as_layer, + as_response=as_response, + param_overrides=param_overrides, + request_user=request_user, + ) + + @classmethod + def update_secure_map_service_params(cls, name, params): + """ + Update the params for a given SecureMapServiceSetting's assigned SecureMapService. + + **NOTE**: This method will not update the params for the SecureMapServiceSetting itself. + It will only update the params for the assigned SecureMapService. + + Args: + name(str): name of the SecureMapServiceSetting as defined in the app.py. + params(dict): dictionary of params to update for the service. + """ + from tethys_apps.models import TethysApp + + db_app = TethysApp.objects.get(package=cls.package) + secure_map_service_settings = db_app.secure_map_service_settings + + try: + secure_map_service_setting = secure_map_service_settings.get(name=name) + + except ObjectDoesNotExist: + raise TethysAppSettingDoesNotExist( + "SecureMapServiceSetting", name, cls.name + ) + + secure_map_service_setting.update_params(params) + def sync_all_settings(self, db_app): # custom settings db_app.sync_settings(self.custom_settings(), db_app.custom_settings) @@ -1883,6 +2035,10 @@ def sync_all_settings(self, db_app): list(db_app.persistent_store_connection_settings) + list(db_app.persistent_store_database_settings), ) + # secure map service settings + db_app.sync_settings( + self.secure_map_service_settings(), db_app.secure_map_service_settings + ) # scheduler settings db_app.sync_settings(self.scheduler_settings(), db_app.scheduler_settings) @@ -1917,7 +2073,6 @@ def sync_with_tethys_db(self): show_in_apps_library=self.show_in_apps_library, ) db_app.save() - self.sync_all_settings(db_app) # If the app is in the database, update developer priority attributes @@ -1925,7 +2080,6 @@ def sync_with_tethys_db(self): db_app = db_apps[0] db_app.index = self.index db_app.root_url = self.root_url - self.sync_all_settings(db_app) # In debug mode, update all fields, not just developer priority attributes diff --git a/tethys_apps/migrations/0009_securemapservicesetting.py b/tethys_apps/migrations/0009_securemapservicesetting.py new file mode 100644 index 0000000000..ede15f6272 --- /dev/null +++ b/tethys_apps/migrations/0009_securemapservicesetting.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.14 on 2026-05-28 07:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "tethys_apps", + "0008_remove_persistentstoreconnectionsetting_persistent_store_service_and_more", + ), + ("tethys_services", "0003_securemapservice_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SecureMapServiceSetting", + fields=[ + ( + "tethysappsetting_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="tethys_apps.tethysappsetting", + ), + ), + ( + "secure_map_service", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="tethys_services.securemapservice", + ), + ), + ], + bases=("tethys_apps.tethysappsetting",), + ), + ] diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 2fe79c10f5..90f8a0d362 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -8,20 +8,25 @@ ******************************************************************************** """ +from urllib.parse import urlencode + from django.dispatch import receiver import logging import uuid import json +import requests from django.db import models from django.core.exceptions import ValidationError from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey +from django.urls import reverse_lazy from model_utils.managers import InheritanceManager from tethys_apps.exceptions import ( TethysAppSettingNotAssigned, PersistentStoreInitializerError, ) +from tethys_sdk.gizmos import MVLayer from tethys_apps.base.mixins import TethysBaseMixin from tethys_compute.models.condor.condor_scheduler import CondorScheduler from tethys_compute.models.dask.dask_scheduler import DaskScheduler @@ -48,6 +53,7 @@ DatasetService, SpatialDatasetService, WebProcessingService, + SecureMapService, ) except RuntimeError: # pragma: no cover log.exception("An error occurred while trying to import tethys service models.") @@ -165,6 +171,12 @@ def persistent_store_database_settings(self): persistentstoredatabasesetting__isnull=True ).select_subclasses("persistentstoredatabasesetting") + @property + def secure_map_service_settings(self): + return self.settings_set.exclude( + securemapservicesetting__isnull=True + ).select_subclasses("securemapservicesetting") + @property def configured(self): required_settings = [s for s in self.settings if s.required] @@ -1104,6 +1116,189 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False self.save() +class SecureMapServiceSetting(TethysAppSetting): + """ + Used to define a Secure Map Service Setting. + + Attributes: + name(str): Unique name used to identify the setting. + legend_title(str): The title to use for the legend when this service is added as a layer on a map. + endpoint(str): The endpoint URL for the secure map service. + authentication_method(str): The method used for authentication (e.g., "API Key", "OAuth"). + api_key(str): The API key used for authentication with the secure map service. + oauth_provider(str): The OAuth provider to use for authentication if the secure map service uses OAuth2 for authentication. + service_type(str): The type of map service (e.g., "WMS", "GML"). + params(dict): Additional parameters to include in requests to the secure map service. + use_proxy(bool): Whether to route requests through a proxy endpoint to handle authentication instead of sending credentials directly from the client. + description(str): Short description of the setting. + required(bool): A value will be required if True. + + **Example:** + + :: + + from tethys_sdk.app_settings import SecureMapServiceSetting + + secure_map_service_setting = SecureMapServiceSetting( + name='secure_map_service', + description='Secure Map service for app to use', + required=True, + ) + + """ + + secure_map_service = models.ForeignKey( + SecureMapService, on_delete=models.CASCADE, blank=True, null=True + ) + + def clean(self): + """ + Validate prior to saving changes. + """ + if not self.secure_map_service and self.required: + raise ValidationError("Required.") + + def _generate_request(self, param_overrides=None): + """ + Generate a request to the secure map service, including any necessary authentication headers or parameters. + """ + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot generate request for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + + service = self.secure_map_service + if service.use_proxy: + # Use lazy URL resolution so the proxy endpoint is resolved at + # runtime. This allows the url to be referenced before apps are + # fully loaded and the urls are registered. This helps in cases + # like using a map service for a MapLayout basemap + endpoint = reverse_lazy( + "secure_map_proxy", kwargs={"setting_id": service.pk} + ) + return endpoint + endpoint = service.endpoint + params = service.get_resolved_params() + if param_overrides: + params.update(param_overrides) + + # If the API key is not already included in the params, add it + # This allows for the API key to be included in the params with a placeholder (e.g. ${api_key}) + # or to be assigned to a different parameter name if the service expects it that way + if ( + service.authentication_method == "api_key" + and service.api_key not in params.values() + ): + params["api_key"] = service.api_key + + query_string = urlencode(params) + url = f"{endpoint}?{query_string}" if query_string else endpoint + return url + + def _build_layer(self, param_overrides=None, request_user=None): + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot build layer for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + + endpoint = self._generate_request(param_overrides=param_overrides) + service = self.secure_map_service + options = {"url": endpoint} + if not service.use_proxy and service.authentication_method == "oauth": + if not request_user: + raise ValueError( + "Request user must be provided to build layer for OAuth authenticated service." + ) + + options["token"] = service.get_oauth_token(request_user) + + return MVLayer( + source=service.service_type, + layer_options={"visible": True}, + options=options, + legend_title=service.legend_title, + data={"show_legend": True, "layer_id": service.pk}, + ) + + def _fetch_response(self, param_overrides=None, request_user=None): + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot fetch response for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + service = self.secure_map_service + params = dict(service.get_resolved_params() or {}) + if param_overrides: + params.update(param_overrides) + + headers = {} + if service.authentication_method == "oauth": + if not request_user: + raise ValueError( + "Request user must be provided to fetch response for OAuth authenticated service." + ) + headers["Authorization"] = f"Bearer {service.get_oauth_token(request_user)}" + + resp = requests.get(service.endpoint, params=params, headers=headers) + if not resp.ok: + log.error( + f"SecureMapService with name {service.name} request failed: \n" + f"url: {service.endpoint}\n" + f"params: {params}\n" + f"headers: {headers}\n" + f"status_code: {resp.status_code}\n" + f"response_text: {resp.text}" + ) + resp.raise_for_status() + return resp + + def get_value( + self, + as_endpoint=False, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None, + ): + secure_map_service = None + if self.secure_map_service: + secure_map_service = self.secure_map_service + + if secure_map_service is None: + if self.required: + raise TethysAppSettingNotAssigned( + f'The required setting "{self.name}" for app "{self.tethys_app.package}":' + f"has not been assigned." + ) + + if as_endpoint: + return self._generate_request(param_overrides=param_overrides) + elif as_layer: + return self._build_layer( + param_overrides=param_overrides, request_user=request_user + ) + elif as_response: + return self._fetch_response( + param_overrides=param_overrides, request_user=request_user + ) + + return secure_map_service + + def update_params(self, new_params): + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot update params for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + self.secure_map_service.update_params(new_params) + + class SchedulerSetting(TethysAppSetting): """ Used to define a Scheduler setting for Job processing services like HTCondor and Dask. diff --git a/tethys_apps/views.py b/tethys_apps/views.py index e777c39662..45f1c20fd7 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -9,9 +9,10 @@ """ import logging +import requests from django.shortcuts import render -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse, JsonResponse, StreamingHttpResponse from django.core.mail import send_mail from tethys_config.models import get_custom_template @@ -23,6 +24,21 @@ logger = logging.getLogger("tethys." + __name__) +# Forwarded so the browser's cached-copy check reaches the service and it can +# reply 304 instead of resending the whole body. +PROXY_FORWARDED_REQUEST_HEADERS = ("If-None-Match", "If-Modified-Since") + +# Forwarded so the browser can cache proxied responses instead of making a new request +# on every pan and zoom. +PROXY_FORWARDED_RESPONSE_HEADERS = ( + "Cache-Control", + "ETag", + "Expires", + "Last-Modified", + "Vary", + "Age", +) + @login_required() def library(request): @@ -150,3 +166,61 @@ def send_beta_feedback_email(request): json = {"success": True, "result": "Emails sent to specified developers"} return JsonResponse(json) + + +@login_required() +def secure_map_proxy(request, setting_id): + """ + Proxy view for securely accessing map services with credentials stored in Tethys Services or OAuth. + """ + from tethys_services.models import SecureMapService + + try: + service = SecureMapService.objects.get(id=setting_id) + except SecureMapService.DoesNotExist: + return HttpResponse("Service setting not found.", status=404) + + browser_params = {key: value for key, value in request.GET.items()} + service_params = service.get_resolved_params() + params = {**service_params, **browser_params} + + headers = {} + if service.authentication_method == "oauth": + access_token = service.get_oauth_token(request.user) + if not access_token: + return HttpResponse("Failed to retrieve OAuth token.", status=500) + headers["Authorization"] = f"Bearer {access_token}" + + if request.content_type: + headers["Content-Type"] = request.content_type + + for header in PROXY_FORWARDED_REQUEST_HEADERS: + value = request.headers.get(header) + if value: + headers[header] = value + + resp = requests.request( + method=request.method, + url=service.endpoint, + params=params, + headers=headers, + data=request.body if request.body else None, + stream=True, + ) + + # A 304 has no body, so return it directly rather than returning an empty response + if resp.status_code == 304: + proxy_response = HttpResponse(status=304) + else: + proxy_response = StreamingHttpResponse( + resp.iter_content(chunk_size=8192), + status=resp.status_code, + content_type=resp.headers.get("Content-Type", "application/octet-stream"), + ) + + for header in PROXY_FORWARDED_RESPONSE_HEADERS: + value = resp.headers.get(header) + if value: + proxy_response[header] = value + + return proxy_response diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index c8593aece7..da86a12d33 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -15,6 +15,7 @@ from os import environ from datetime import datetime from pathlib import Path +import secrets from subprocess import call, run from jinja2 import Template @@ -256,6 +257,10 @@ def generate_secret_key(): ) +def generate_salt_key(): + return secrets.token_hex(32) + + def empty_context(args): context = {} return context @@ -338,7 +343,13 @@ def gen_portal_yaml(args): tethys_portal_settings.setdefault("version", 2.0) tethys_portal_settings.setdefault("name", "") tethys_portal_settings.setdefault("apps", {}) - tethys_portal_settings.setdefault("settings", {"SECRET_KEY": generate_secret_key()}) + tethys_portal_settings.setdefault( + "settings", + { + "SECRET_KEY": generate_secret_key(), + "SALT_KEY": generate_salt_key(), + }, + ) tethys_portal_settings.setdefault( "site_settings", {category: {} for category in SITE_SETTING_CATEGORIES} ) diff --git a/tethys_cli/settings_commands.py b/tethys_cli/settings_commands.py index a743167449..c5ccb81d63 100644 --- a/tethys_cli/settings_commands.py +++ b/tethys_cli/settings_commands.py @@ -14,8 +14,13 @@ import yaml -from .gen_commands import generate_command -from tethys_cli.cli_colors import write_info, write_warning, write_error +from .gen_commands import generate_command, generate_salt_key +from tethys_cli.cli_colors import ( + write_info, + write_warning, + write_error, + write_success, +) from tethys_apps.utilities import get_tethys_home_dir from django.conf import settings @@ -54,6 +59,18 @@ def add_settings_parser(subparsers): "dot notation. (e.g. DATABASES.default.NAME)", nargs=1, ) + settings_parser.add_argument( + "--generate-salt-key", + dest="generate_salt_key", + action="store_true", + help="Generate a new SALT_KEY and write it to the settings in the portal_config.yml file.", + ) + settings_parser.add_argument( + "--overwrite", + dest="overwrite", + action="store_true", + help="Overwrite an existing SALT_KEY without prompting. Use with --generate-salt-key.", + ) settings_parser.set_defaults( func=settings_command, ) @@ -119,6 +136,33 @@ def get_setting(tethys_settings, key): write_info(f"{key}: {pformat(d[k])}") +def generate_salt_key_setting(tethys_settings, overwrite=False): + # Rotating the SALT_KEY makes values encrypted with the previous key + # unrecoverable, so confirm before replacing + if tethys_settings.get("SALT_KEY"): + if not overwrite: + valid_inputs = ("y", "n", "yes", "no") + no_inputs = ("n", "no") + + write_warning( + "WARNING: A SALT_KEY already exists in the portal_config.yml file. " + "Replacing it will make any values encrypted with the existing key " + "unrecoverable." + ) + overwrite_input = input("Overwrite? (y/n): ").lower() + + while overwrite_input not in valid_inputs: + overwrite_input = input("Invalid option. Overwrite? (y/n): ").lower() + + if overwrite_input in no_inputs: + write_warning("Generation of SALT_KEY cancelled.") + return + + tethys_settings["SALT_KEY"] = generate_salt_key() + write_settings(tethys_settings) + write_success("Successfully generated a new SALT_KEY.") + + def remove_setting(tethys_settings, key): result = _get_dict_key_handle(tethys_settings, key) if result is not None: @@ -162,3 +206,5 @@ def settings_command(args): get_setting(tethys_settings, args.get_key) elif args.rm_key: remove_setting(tethys_settings, args.rm_key[0]) + elif args.generate_salt_key: + generate_salt_key_setting(tethys_settings, args.overwrite) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index ad5c7b3ec4..ff58ff89c9 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -150,7 +150,8 @@ var map_clicked, set_map_on_click, clear_clicked_point, highlight_clicked_point; var update_field; // Utility Methods -var is_defined, in_array, string_to_function, build_ol_objects, add_default_base_map_layer; +var is_defined, in_array, string_to_function, build_ol_objects, add_default_base_map_layer, + get_token_headers, remove_token, load_tile_token, load_image_token; // Class Declarations var DrawingControl, DragFeatureInteraction, DeleteFeatureInteraction; @@ -236,6 +237,13 @@ ol_base_map_init = function() default_source_options: {}, label_property: null, }, + 'WMS': { + source_class: function(options) { + return new ol.source.TileWMS(options); + }, + default_source_options: {}, + label_property: null, + }, } if (is_defined(m_disable_base_map) && m_disable_base_map) { @@ -792,7 +800,8 @@ ol_layers_init = function() { // Constants var GEOJSON = 'GeoJSON', - KML = 'KML'; + KML = 'KML', + GML = 'GML'; var TILE_SOURCES = ['TileDebug', 'TileUTFGrid', 'UrlTile', 'TileImage', 'VectorTile', 'BingMaps', 'TileArcGISRest', 'TileJSON', 'TileWMS', 'WMTS', 'XYZ', 'Zoomify', 'CartoDB', 'OSM']; @@ -800,7 +809,7 @@ ol_layers_init = function() var IMAGE_SOURCES = ['ImageArcGISRest', 'ImageCanvas', 'ImageMapGuide', 'ImageStatic', 'ImageWMS', 'ImageVector', 'Raster']; - var VECTOR_SOURCES = ['GeoJSON', 'KML', 'Vector', 'Cluster']; + var VECTOR_SOURCES = ['GeoJSON', 'KML', 'GML', 'Vector', 'Cluster']; var STYLE_MAP = { 'fill' : ol.style.Fill, @@ -911,9 +920,15 @@ ol_layers_init = function() // Tile layer case if (in_array(current_layer.source, TILE_SOURCES)) { - var resolutions, source_options, tile_grid; + var resolutions, source_options, tile_grid, tile_token; + + tile_token = current_layer.options ? current_layer.options.token : null; + source_options = remove_token(current_layer.options); - source_options = current_layer.options; + // Load the tiles with an Authorization header when a token is given + if (tile_token) { + source_options['tileLoadFunction'] = load_tile_token(tile_token); + } if (source_options && 'tileGrid' in source_options) { source_options['tileGrid'] = new ol.tilegrid.TileGrid(source_options['tileGrid']); @@ -960,8 +975,16 @@ ol_layers_init = function() // Image layer case else if (in_array(current_layer.source, IMAGE_SOURCES)) { + let image_token = current_layer.options ? current_layer.options.token : null; + let image_source_options = remove_token(current_layer.options); + + // Load the images with an Authorization header when a token is given + if (image_token) { + image_source_options['imageLoadFunction'] = load_image_token(image_token); + } + Source = string_to_function('ol.source.' + current_layer.source); - current_layer_layer_options['source'] = new Source(current_layer.options); + current_layer_layer_options['source'] = new Source(image_source_options); layer = new ol.layer.Image(current_layer_layer_options); } @@ -997,11 +1020,44 @@ ol_layers_init = function() else if (current_layer.source === KML){ // From URL case if (current_layer.options.hasOwnProperty('url')) { - current_layer_layer_options['source'] = new ol.source.Vector({ - url: current_layer.options.url, - format: new ol.format.KML(), - projection: new ol.proj.get(DEFAULT_PROJECTION) - }); + let kml_url = current_layer.options.url; + let kml_token = current_layer.options.token; + + // Load the KML with an Authorization header when a token is given + if (kml_token) { + let kml_format = new ol.format.KML(); + + let kml_url_source = new ol.source.Vector({ + format: kml_format, + strategy: ol.loadingstrategy.all, + projection: new ol.proj.get(DEFAULT_PROJECTION), + loader: function(extent, resolution, projection, success, failer) { + fetch(kml_url, {credentials: 'same-origin', headers: get_token_headers(kml_token)}) + .then(r => r.text()) + .then(text => { + let features = kml_format.readFeatures(text, { + featureProjection: DEFAULT_PROJECTION, + }); + kml_url_source.addFeatures(features); + if (success) { success(features); } + }) + .catch(err => { + console.error('KML load failed: ', err); + kml_url_source.removeLoadedExtent(extent); + if (failer) { failer(); } + }) + }, + }); + + current_layer_layer_options['source'] = kml_url_source; + } else { + current_layer_layer_options['source'] = new ol.source.Vector({ + url: kml_url, + format: new ol.format.KML(), + projection: new ol.proj.get(DEFAULT_PROJECTION) + }); + } + layer = new ol.layer.Vector(current_layer_layer_options); } @@ -1017,12 +1073,101 @@ ol_layers_init = function() current_layer_layer_options['source'] = kml_source; layer = new ol.layer.Vector(current_layer_layer_options); } + } else if (current_layer.source === GML) { + // TODO look into different GML formats + let gmlFormat = new ol.format.WFS({ + version: '1.1.0', + gmlFormat: new ol.format.GML3(), + }); + + if (current_layer.options.hasOwnProperty('url')) { + let baseUrl = current_layer.options.url; + let token = current_layer.options.token; + + let gmlSource = new ol.source.Vector({ + format: gmlFormat, + strategy: ol.loadingstrategy.bbox, + loader: function(extent, resolution, projection, success, failer) { + let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; + let url = baseUrl + sep + 'bbox=' + extent.join(',') + ',EPSG:3857'; + + fetch(url, {credentials: 'same-origin', headers: get_token_headers(token)}) + .then(r => r.text()) + .then(text => { + let features = gmlFormat.readFeatures(text, { + dataProjection: 'EPSG:4326', + featureProjection: DEFAULT_PROJECTION, + }); + gmlSource.addFeatures(features); + if (success) { success(features); } + }) + .catch(err => { + console.error('GML load failed: ', err); + gmlSource.removeLoadedExtent(extent); + if (failer) { failer(); } + }) + } + }) + + current_layer_layer_options['source'] = gmlSource; + layer = new ol.layer.Vector(current_layer_layer_options); + } + + else if (current_layer.options.hasOwnProperty('gml')) { + let gmlSource = new ol.source.Vector({ + features: gmlFormat.readFeatures(current_layer.options.gml, { + dataProjection: 'EPSG:4326', + featureProjection: DEFAULT_PROJECTION, + }), + }); + current_layer_layer_options['source'] = gmlSource; + layer = new ol.layer.Vector(current_layer_layer_options); + } } // Generic vector case else { - Source = string_to_function('ol.source.' + current_layer.source); - current_layer_layer_options['source'] = new Source(current_layer.options); + let token = current_layer.options ? current_layer.options.token: null; + let baseUrl = current_layer.options ? current_layer.options.url: null; + + if (token && baseUrl) { + let format_name = current_layer.options.format || 'GeoJSON'; + let VectorFormat = string_to_function('ol.format.' + format_name); + let vector_format = new VectorFormat(); + let use_bbox = current_layer.options.strategy === 'bbox'; + + let vector_source = new ol.source.Vector({ + format: vector_format, + strategy: use_bbox ? ol.loadingstrategy.bbox : ol.loadingstrategy.all, + loader: function(extent, resolution, projection, success, failer) { + let url = baseUrl; + if (use_bbox) { + let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; + url += sep + 'bbox=' + extent.join(',') + ',' + DEFAULT_PROJECTION; + } + fetch(url, {credentials: 'same-origin', headers: get_token_headers(token)}) + .then(r => r.text()) + .then(text => { + let features = vector_format.readFeatures(text, { + dataProjection: 'EPSG:4326', + featureProjection: DEFAULT_PROJECTION, + }); + vector_source.addFeatures(features); + if (success) { success(features); } + }) + .catch(err => { + console.error('Vector load failed: ', err); + vector_source.removeLoadedExtent(extent); + if (failer) { failer(); } + }) + }, + }); + current_layer_layer_options['source'] = vector_source; + } else { + Source = string_to_function('ol.source.' + current_layer.source); + current_layer_layer_options['source'] = new Source(remove_token(current_layer.options)); + } + layer = new ol.layer.Vector(current_layer_layer_options); } } @@ -2313,6 +2458,72 @@ is_defined = function(variable) return !!(typeof variable !== typeof undefined && variable !== false && variable !== null); }; +// Build request headers with a token for authentication +get_token_headers = function(token) { + let headers = {}; + + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } + + return headers; +}; + +// Remove the token from the options object +remove_token = function(options) { + if (!options) { return options; } + + let stripped_options = Object.assign({}, options); + delete stripped_options.token; + return stripped_options; +}; + +// Load a tile with an authorization header +load_tile_token = function(token) { + return function(tile, src) { + fetch(src, {credentials: 'same-origin', headers: get_token_headers(token)}) + .then(r => { + if (!r.ok) { throw new Error('HTTP ' + r.status); } + return r.blob(); + }) + .then(blob => { + let object_url = URL.createObjectURL(blob); + let image = tile.getImage(); + image.onload = function() { URL.revokeObjectURL(object_url); }; + image.src = object_url; + }) + .catch(err => { + console.error('Tile load failed: ', err); + if (is_defined(ol.TileState) && tile.setState) { + tile.setState(ol.TileState.ERROR); + } + }); + }; +}; + +// Load an image with an authorization header +load_image_token = function(token) { + return function(image, src) { + fetch(src, {credentials: 'same-origin', headers: get_token_headers(token)}) + .then(r => { + if (!r.ok) { throw new Error('HTTP ' + r.status); } + return r.blob(); + }) + .then(blob => { + let object_url = URL.createObjectURL(blob); + let img = image.getImage(); + img.onload = function() { URL.revokeObjectURL(object_url); }; + img.src = object_url; + }) + .catch(err => { + console.error('Image load failed: ', err); + if (is_defined(ol.ImageState) && image.setState) { + image.setState(ol.ImageState.ERROR); + } + }); + }; +}; + // Instantiate a function from a string // credits: http://stackoverflow.com/questions/1366127/instantiate-a-javascript-object-using-a-string-to-define-the-class-name string_to_function = function(str) { diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index f43c12e575..1ff3b7166c 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -19,6 +19,8 @@ from django.template import TemplateSyntaxError from django.templatetags.static import static from django.core.serializers.json import DjangoJSONEncoder +from django.utils.encoding import force_str +from django.utils.functional import Promise from tethys_apps.harvester import SingletonHarvester @@ -147,9 +149,12 @@ def return_item(container, i): return None -def json_date_handler(obj): +def json_data_handler(obj): if isinstance(obj, datetime): return time.mktime(obj.timetuple()) * 1000 + elif isinstance(obj, Promise): + # Resolve lazy objects like lazy urls + return force_str(obj) else: return obj @@ -159,7 +164,7 @@ def jsonify(data): """ Convert python data structures into a JSON string """ - return json.dumps(data, default=json_date_handler) + return json.dumps(data, default=json_data_handler) @register.filter diff --git a/tethys_layouts/mixins/map_layout.py b/tethys_layouts/mixins/map_layout.py index c50c519726..f4f7cbf3f8 100644 --- a/tethys_layouts/mixins/map_layout.py +++ b/tethys_layouts/mixins/map_layout.py @@ -869,6 +869,62 @@ def build_arc_gis_layer( return mv_layer + @classmethod + def build_gml_layer( + cls, + endpoint, + layer_name, + layer_title, + layer_variable, + layer_id=None, + visible=True, + selectable=False, + extent=None, + public=True, + renamable=False, + removable=False, + show_legend=True, + legend_url=None, + ): + """ + Build a GML Map Server MVLayer object with supplied arguments. + + Args: + endpoint(str): Full ArcGIS REST URL for the layer (e.g.: "https://sampleserver1.arcgisonline.com/ArcGIS/rest/services/Specialty/ESRI_StateCityHighway_USA/MapServer"). + layer_name(str): Programmatic name of the layer (e.g.: "ESRI_StateCityHighway_USA"). + layer_title(str): Title of layer to display in Layer Picker (e.g.: "ESRI Highways"). + layer_variable(str): Variable type/class of the layer (e.g.: "highways"). + layer_id(UUID, int, str): layer_id for non geoserver layer where layer_name may not be unique. + visible(bool): Layer is visible when True. Defaults to True. + public(bool): Layer is publicly accessible when app is running in Open Portal Mode if True. Defaults to True. + extent(list): Extent for the layer. Optional. + renamable(bool): Show Rename option in layer context menu when True. Must implement the appropriate method to persist the change. Defaults to False. + removable(bool): Show Remove option in layer context menu when True. Must implement the appropriate method to persist the change. Defaults to False. + show_legend(bool): Show the legend for this layer when True and legends are enabled. Defaults to True. + legend_url(str): URL of a legend image to display for the layer when legends are enabled. + """ + + mv_layer = cls._build_mv_layer( + layer_id=layer_id, + layer_name=layer_name, + layer_source="GML", + layer_title=layer_title, + layer_variable=layer_variable, + options={ + "url": endpoint, + }, + extent=extent, + visible=visible, + public=public, + selectable=selectable, + renamable=renamable, + removable=removable, + show_legend=show_legend, + legend_url=legend_url, + ) + + return mv_layer + @classmethod def build_custom_layer( cls, diff --git a/tethys_layouts/views/map_layout.py b/tethys_layouts/views/map_layout.py index 04f05a7a69..223456c39a 100644 --- a/tethys_layouts/views/map_layout.py +++ b/tethys_layouts/views/map_layout.py @@ -54,12 +54,14 @@ class MapLayout(TethysLayout, MapLayoutMixin): cesium_ion_token (str): Cesium Ion API token. Required if map_type is "cesium_map_view". See: https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/ default_disable_basemap (bool) Set to True to disable the basemap. default_map_extent = The default BBOX extent for the map. Defaults to [-65.69, 23.81, -129.17, 49.38]. + draw (MVDraw): An MVDraw object enabling the drawing toolbar on the map. Defaults to None (no drawing). enforce_permissions (bool): Enables permissions checks when True. Defaults to False. geocode_api_key (str): An Open Cage Geocoding API key. Required to enable address search/geocoding feature. See: https://opencagedata.com/api#quickstart geocode_extent (4-list): Bounding box defining search area for address search feature (e.g.: [-65.69, 23.81, -129.17, 49.38]). Alternatively, set to 'map-extent' to use map extent. geoserver_workspace (str): Name of the GeoServer workspace of layers if applicable. Defaults to None. feature_selection_multiselect (bool): Set to True to enable multi-selection when feature selection is enabled. Defaults to False. feature_selection_sensitivity (int): Feature selection sensitivity/relative search radius. Defaults to 4. + form_id (str): The id of a form on the page to which the hidden geometry text field will be linked. layer_tab_name (str) Name of the "Layers" tab. Defaults to "Layers". map_subtitle (str): The subtitle to display on the MapLayout view. map_title (str): The title to display on the MapLayout view. @@ -95,12 +97,14 @@ class MapLayout(TethysLayout, MapLayoutMixin): cesium_ion_token = None default_disable_basemap = False default_map_extent = [-65.69, 23.81, -129.17, 49.38] # USA EPSG:4326 + draw = None geocode_api_key = None enforce_permissions = False geocode_extent = None geoserver_workspace = "" feature_selection_multiselect = False feature_selection_sensitivity = 4 + form_id = None layer_tab_name = "Layers" map_type = "tethys_map_view" max_zoom = 28 @@ -476,6 +480,8 @@ def _build_map_view(self, request, view, extent, *args, **kwargs): basemap=self.basemaps, legend=False, show_clicks=self.show_map_clicks, + draw=self.draw, + form_id=self.form_id, ) # Configure initial basemap visibility diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 52fce23d84..a8cbfcd02f 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -12,9 +12,11 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied from django.shortcuts import redirect +from django.urls import reverse from tethys_cli.cli_colors import pretty_output, FG_WHITE from tethys_apps.utilities import get_active_app, user_can_access_app from tethys_portal.views.error import handler_404 +from urllib.parse import urlencode from tethys_portal.optional_dependencies import optional_import, has_module @@ -155,3 +157,40 @@ def __call__(self, request): response = self.get_response(request) return response + + +class TethysOauthRequiredMiddleware: + def __init__(self, get_response): + self.get_response = get_response + self.requirements = getattr(settings, "OAUTH_REQUIREMENTS", {}) + + def __call__(self, request): + if not self.requirements: + return self.get_response(request) + + app = get_active_app(request) + if app is None: + return self.get_response(request) + + app_name = app.package + required_provider = self.requirements.get(app_name) + if not required_provider: + return self.get_response(request) + + # If the user is trying to access an app and there is a required OAuth provider for that app, check if the user is authenticated. + if not request.user.is_authenticated: + next_param = urlencode({"next": request.get_full_path()}) + login_url = reverse("accounts:login") + return redirect(f"{login_url}?{next_param}") + + if request.user.social_auth.filter(provider=required_provider).exists(): + return self.get_response(request) + + messages.info( + request, + f"This application requires authenticating with {required_provider}. Please link your {required_provider} account.", + ) + next_param = urlencode({"next": request.get_full_path()}) + + settings_url = reverse("user:settings") + return redirect(f"{settings_url}?{next_param}") diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 8037934b68..0ff3cc6d4b 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -35,7 +35,7 @@ from tethys_apps.utilities import relative_to_tethys_home from tethys_utils import deprecation_warning -from tethys_cli.gen_commands import generate_secret_key +from tethys_cli.gen_commands import generate_secret_key, generate_salt_key from tethys_portal.optional_dependencies import optional_import, has_module # optional imports @@ -66,6 +66,9 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = portal_config_settings.pop("SECRET_KEY", generate_secret_key()) +# SECURITY WARNING: keep the salt key used in production secret! +SALT_KEY = portal_config_settings.pop("SALT_KEY", generate_salt_key()) + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = portal_config_settings.pop("DEBUG", True) @@ -285,6 +288,7 @@ "tethys_portal.middleware.TethysMfaRequiredMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "tethys_portal.middleware.TethysAppAccessMiddleware", + "tethys_portal.middleware.TethysOauthRequiredMiddleware", ], ) if has_module("corsheaders"): @@ -591,6 +595,10 @@ "OAUTH2_PROVIDER", portal_config_settings.get("OAUTH2_PROVIDER", {}) ).pop("URL_NAMESPACE", "o") +# Mapping of app package to the social auth provider users must be linked to +# before they can access that app (e.g. {"my_first_app": "my_provider"}) +OAUTH_REQUIREMENTS = portal_config_settings.pop("OAUTH_REQUIREMENTS", {}) + # MFA Settings # See: https://github.com/mkalioby/django-mfa2 # Methods that shouldn't be allowed for the user, U2F, FIDO2, TOTP, Trusted_Devices, Email diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index 14c8c05de2..1fef022e89 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -248,6 +248,11 @@ name="update_dask_job_status", ), re_path(r"^api/", include((api_urls, "api"), namespace="api")), + re_path( + r"^secure-map-proxy/(?P\d+)/$", + tethys_apps_views.secure_map_proxy, + name="secure_map_proxy", + ), *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ] diff --git a/tethys_sdk/app_settings.py b/tethys_sdk/app_settings.py index 8f31391219..0126ad04e1 100644 --- a/tethys_sdk/app_settings.py +++ b/tethys_sdk/app_settings.py @@ -20,5 +20,6 @@ SchedulerSetting, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, + SecureMapServiceSetting, TethysAppSettingNotAssigned, ) diff --git a/tethys_services/admin.py b/tethys_services/admin.py index 93d2d6137f..f6bd22e150 100644 --- a/tethys_services/admin.py +++ b/tethys_services/admin.py @@ -8,16 +8,27 @@ ******************************************************************************** """ +from django.conf import settings from django.contrib import admin +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from .models import ( DatasetService, + SecureMapService, SpatialDatasetService, WebProcessingService, PostgresPersistentStoreService, SQLitePersistentStoreService, ) -from django.forms import ModelForm, PasswordInput +from django.forms import ModelForm, PasswordInput, ChoiceField +from tethys_portal.optional_dependencies import ( + optional_import, + has_module, +) + +JSONEditorWidget = optional_import( + "JSONEditorWidget", from_module="django_json_widget.widgets" +) class DatasetServiceForm(ModelForm): @@ -81,6 +92,50 @@ class Meta: fields = ("name", "engine", "dir_path") +class SecureMapServiceForm(ModelForm): + class Meta: + model = SecureMapService + fields = "__all__" + labels = { + "name": _("Name"), + "endpoint": _("Endpoint"), + "api_key": _("API Key"), + "oauth_provider": _("OAuth Provider"), + "params": _("Parameters"), + "legend_title": _("Legend Title"), + "service_type": _("Service Type"), + "use_proxy": _("Use Proxy for Requests"), + } + + widgets = { + "api_key": PasswordInput(render_value=True), + } + + options_default = { + "modes": ["code", "text"], + "search": False, + "navigationBar": False, + } + + if has_module("django_json_widget"): + widgets["params"] = JSONEditorWidget( + width="60%", height="300px", options=options_default + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + choices = [] + if settings.AUTHENTICATION_BACKENDS: + for backend in settings.AUTHENTICATION_BACKENDS: + backend_class = import_string(backend) + if hasattr(backend_class, "name"): + choices.append((backend_class.name, backend_class.name)) + self.fields["oauth_provider"] = ChoiceField( + choices=choices, + required=False, + ) + + class DatasetServiceAdmin(admin.ModelAdmin): """ Admin model for Web Processing Service Model @@ -142,8 +197,31 @@ class SQLitePersistentStoreServiceAdmin(admin.ModelAdmin): fields = ("name", "engine", "dir_path") +class SecureMapServiceAdmin(admin.ModelAdmin): + """ + Admin model for Secure Map Service Model + """ + + form = SecureMapServiceForm + fields = ( + "name", + "endpoint", + "legend_title", + "authentication_method", + "api_key", + "oauth_provider", + "service_type", + "use_proxy", + "params", + ) + + class Media: + js = ("tethys_services/js/secure_map_service_admin.js",) + + admin.site.register(DatasetService, DatasetServiceAdmin) admin.site.register(SpatialDatasetService, SpatialDatasetServiceAdmin) admin.site.register(WebProcessingService, WebProcessingServiceAdmin) admin.site.register(PostgresPersistentStoreService, PostgresPersistentStoreServiceAdmin) admin.site.register(SQLitePersistentStoreService, SQLitePersistentStoreServiceAdmin) +admin.site.register(SecureMapService, SecureMapServiceAdmin) diff --git a/tethys_services/backends/grid.py b/tethys_services/backends/grid.py new file mode 100644 index 0000000000..ffda295751 --- /dev/null +++ b/tethys_services/backends/grid.py @@ -0,0 +1,33 @@ +from social_core.backends.oauth import BaseOAuth2 + + +class GRiDOAuth2(BaseOAuth2): + """ + GRiD OAuth2 authentication backend. + """ + + auth_server_hostname = "grid.nga.mil" + http_scheme = "https" + name = "grid" + + auth_server_full_url = "{0}://{1}".format(http_scheme, auth_server_hostname) + AUTHORIZATION_URL = "{0}/grid/api/authorize".format(auth_server_full_url) + ACCESS_TOKEN_URL = "{0}/grid/api/token".format(auth_server_full_url) + ACCESS_TOKEN_METHOD = "POST" + + REDIRECT_STATE = False + + DEFAULT_SCOPE = ["api"] + + SCOPE_SEPARATOR = "," + + def user_data(self, access_token, *args, **kwargs): + return {"access_token": access_token} + + def get_user_details(self, response): + return { + "username": response.get("username", ""), + "email": response.get("email", ""), + "first_name": response.get("first_name", ""), + "last_name": response.get("last_name", ""), + } diff --git a/tethys_services/migrations/0003_securemapservice_and_more.py b/tethys_services/migrations/0003_securemapservice_and_more.py new file mode 100644 index 0000000000..33c1c0247d --- /dev/null +++ b/tethys_services/migrations/0003_securemapservice_and_more.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.14 on 2026-05-28 07:46 + +import encrypted_fields.fields +import tethys_services.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tethys_services", "0002_postgrespersistentstoreservice_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SecureMapService", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=30, unique=True)), + ("legend_title", models.CharField(max_length=100, unique=True)), + ( + "endpoint", + models.CharField( + max_length=1024, + validators=[tethys_services.models.validate_url], + ), + ), + ( + "authentication_method", + models.CharField( + blank=True, + choices=[("api_key", "API Key"), ("oauth", "OAuth")], + max_length=100, + ), + ), + ( + "api_key", + encrypted_fields.fields.EncryptedTextField(blank=True, null=True), + ), + ("oauth_provider", models.CharField(blank=True, max_length=100)), + ( + "service_type", + models.CharField( + choices=[ + ("ImageWMS", "WMS"), + ("GML", "GML"), + ("geojson", "GeoJSON"), + ], + default="ImageWMS", + max_length=50, + ), + ), + ("params", models.JSONField(blank=True, default=dict, null=True)), + ("use_proxy", models.BooleanField(default=False)), + ], + options={ + "verbose_name": "Secure Map Service", + "verbose_name_plural": "Secure Map Services", + }, + ), + migrations.AlterField( + model_name="postgrespersistentstoreservice", + name="engine", + field=models.CharField( + choices=[("postgresql", "PostgreSQL")], + default="postgresql", + max_length=50, + ), + ), + migrations.AlterField( + model_name="sqlitepersistentstoreservice", + name="engine", + field=models.CharField( + choices=[("sqlite", "SQLite")], default="sqlite", max_length=50 + ), + ), + ] diff --git a/tethys_services/migrations/0004_alter_securemapservice_service_type.py b/tethys_services/migrations/0004_alter_securemapservice_service_type.py new file mode 100644 index 0000000000..02914c7484 --- /dev/null +++ b/tethys_services/migrations/0004_alter_securemapservice_service_type.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.9 on 2026-08-07 22:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tethys_services", "0003_securemapservice_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="securemapservice", + name="service_type", + field=models.CharField( + choices=[("ImageWMS", "WMS"), ("GML", "GML")], + default="ImageWMS", + max_length=50, + ), + ), + ] diff --git a/tethys_services/migrations/0005_alter_securemapservice_legend_title_and_more.py b/tethys_services/migrations/0005_alter_securemapservice_legend_title_and_more.py new file mode 100644 index 0000000000..e72f63ddd4 --- /dev/null +++ b/tethys_services/migrations/0005_alter_securemapservice_legend_title_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.15 on 2026-08-13 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tethys_services", "0004_alter_securemapservice_service_type"), + ] + + operations = [ + migrations.AlterField( + model_name="securemapservice", + name="legend_title", + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name="securemapservice", + name="service_type", + field=models.CharField( + choices=[ + ("ImageWMS", "WMS"), + ("GML", "GML"), + ("GeoJSON", "GeoJSON"), + ("REST", "REST/JSON API"), + ], + default="ImageWMS", + max_length=50, + ), + ), + ] diff --git a/tethys_services/models.py b/tethys_services/models.py index 759dc4b4a2..bd21ea9dad 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -12,6 +12,8 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from urllib.error import HTTPError, URLError import os +from encrypted_fields.fields import EncryptedTextField +from string import Template from tethys_portal.optional_dependencies import optional_import, has_module @@ -402,3 +404,112 @@ def get_engine(self, spatial=False): def get_url(self): db_file = os.path.join(self.dir_path, f"{self.database}.sqlite") return f"sqlite:///{db_file}" + + +class SecureMapService(models.Model): + """ + ORM for Secure Map Service settings. + """ + + name = models.CharField(max_length=30, unique=True) + legend_title = models.CharField(max_length=100, blank=True) + endpoint = models.CharField(max_length=1024, validators=[validate_url]) + authentication_method = models.CharField( + max_length=100, blank=True, choices=[("api_key", "API Key"), ("oauth", "OAuth")] + ) + api_key = EncryptedTextField(blank=True, null=True) + oauth_provider = models.CharField(max_length=100, blank=True) + service_type = models.CharField( + max_length=50, + choices=[ + ("ImageWMS", "WMS"), + ("GML", "GML"), + ("GeoJSON", "GeoJSON"), + ("REST", "REST/JSON API"), + ], + default="ImageWMS", + ) + params = models.JSONField(blank=True, null=True, default=dict) + use_proxy = models.BooleanField(default=False) # Hide API key in requests + + class Meta: + verbose_name = "Secure Map Service" + verbose_name_plural = "Secure Map Services" + + def __str__(self): + return self.name + + @classmethod + def get_authentication_method_options(cls): + """ + Get the available authentication method options for the SecureMapService model. + This method is used for populating the choices in the admin form. + """ + return [ + value for value, _ in cls._meta.get_field("authentication_method").choices + ] + + def get_oauth_token(self, user): + """ + Retrieve the OAuth token for the given user. + Args: + user (User): The user for whom to retrieve the OAuth token. + + Returns: + str: The OAuth token for the user. + """ + if self.authentication_method != "oauth": + raise ValueError( + "Authentication method must be 'oauth' to retrieve an OAuth token." + ) + if not self.oauth_provider: + raise ValueError( + "OAuth provider must be specified to retrieve an OAuth token." + ) + + try: + auth = user.social_auth.get(provider=self.oauth_provider) + except ObjectDoesNotExist: + raise ValueError(f"User not linked to {self.oauth_provider}.") + + access_token = auth.extra_data.get("access_token") + if not access_token: + raise ValueError("No access token found for user.") + + return access_token + + def get_resolved_params(self): + """ + Resolve template variables in the service parameters using the model's attributes. + + Returns: + dict: A dictionary of resolved parameters. + """ + if not self.params: + return {} + + safe_attribute_names = { + f.name: str(getattr(self, f.name, "") or "") for f in self._meta.fields + } + + resolved_params = {} + for key, value in self.params.items(): + if isinstance(value, str): + value = Template(value).safe_substitute(safe_attribute_names) + resolved_params[key] = value + return resolved_params + + def update_params(self, new_params): + """ + Merge the given parameters into the parameters of the service and save. + + Note that a SecureMapService may be shared by multiple settings and apps, + so updating its parameters affects every app that uses it. + + Args: + new_params (dict): The parameters to merge into the service parameters. + """ + params = self.params or {} + params.update(new_params) + self.params = params + self.save() diff --git a/tethys_services/static/tethys_services/js/secure_map_service_admin.js b/tethys_services/static/tethys_services/js/secure_map_service_admin.js new file mode 100644 index 0000000000..21650c93ac --- /dev/null +++ b/tethys_services/static/tethys_services/js/secure_map_service_admin.js @@ -0,0 +1,25 @@ +document.addEventListener("DOMContentLoaded", function () { + const authMethodField = document.getElementById("id_authentication_method") + const apiKeyRow = document.querySelector(".form-row.field-api_key") + const oauthProviderRow = document.querySelector(".form-row.field-oauth_provider") + function updateFields() { + const method = authMethodField.value + + if (method === "api_key") { + console.log("API Key authentication selected") + apiKeyRow.style.display = "" + oauthProviderRow.style.display = "none" + } else if (method === "oauth") { + console.log("OAuth authentication selected") + oauthProviderRow.style.display = "" + apiKeyRow.style.display = "none" + } else { + console.log("No authentication selected") + apiKeyRow.style.display = "none" + oauthProviderRow.style.display = "none" + } + } + + authMethodField.addEventListener("change", updateFields) + updateFields() +}) \ No newline at end of file